Skill: kralsamwise/kdp-publisher (version 1.0.0)

--- name: kdp-publisher description: Helps agents create, format, and publish children's books and activity books to Amazon KDP. Covers the full pipeline from story prompt through PDF assembly, cover creation, and upload guidance. Triggers on KDP publishing, children's book creation, activity book generation, book formatting, Amazon self-publishing, and book cover design. --- # kdp-publisher Helps agents create, format, and publish children's books and activity books to Amazon Kindle Direct Publishing (KDP). Covers the full pipeline from story generation through KDP upload. ## Triggers Load this skill when the user mentions: - KDP, Kindle Direct Publishing, Amazon self-publishing - Children's book creation, picture book, storybook - Activity book, coloring book, workbook, puzzle book - Book formatting, PDF for print, cover design, book cover wrap - KDP upload, manuscript upload, interior PDF --- ## Supported Book Types | Type | Trim Size | Interior | Notes | |------|-----------|----------|-------| | Children's picture book | 8.5×8.5" | Full color | Illustration + text spreads | | Activity book | 8.5×11" | Color or B&W | I Spy, dot-to-dot, etc. | | Coloring book | 8.5×11" | B&W | Thick clean lines, no shading | | Math workbook | 8.5×11" | B&W | Practice problems + answer key | | Puzzle book | 8.5×11" | B&W | Word search, sudoku, crossword | --- ## Core Workflow ``` prompt → story generation → illustration → interior PDF → cover PDF → metadata → KDP upload ``` ### Step 1 — Generate story and plan Use `scripts/generate-book.py` for picture books. For workbooks/puzzles, use a puzzle generator tool (BookBolt, custom scripts). ```bash python3 scripts/generate-book.py "a curious octopus who learns to share" \ --type picture-book \ --age-group 3-5 \ --style watercolor \ --output-dir output/ ``` **Story requirements:** - Title ≤ 60 characters - 12 spreads → 24 story pages → ~28 pages total (title + dedication + The End + author bio) - Ages 3-5: 10-20 words per spread, simple/repetitive sentences - Ages 5-8: 20-40 words per spread, clear story arc - Always include moral/lesson, dedication, back-cover description, 7 keywords ### Step 2 — Generate illustrations **Critical image rules:** - ALWAYS add "no text, no words, no letters, no writing" to every image prompt - Define character reference (colors, features, accessories) before generating spread #1 - Reference first image style in all subsequent prompts: "match the watercolor style of page 1" - Use 1:1 aspect ratio for 8.5×8.5 picture books; use 3:4 for 8.5×11 activity books - Minimum 300 DPI for print — Imagen 1024px = ~3.4" at 300 DPI, which is fine for a scaled-up 8.5×8.5 page - Inspect every image for AI text artifacts before embedding in PDF ### Step 3 — Assemble interior PDF See `references/kdp-specs.md` for exact dimensions. **8.5×8.5 No Bleed (picture books):** - Page size: 612×612 pt - Content area: 576×576 pt (0.25" margins on all sides) - Images MUST fit within the content area — do not let image touch trim edge on a no-bleed file - Layout: odd pages = illustration, even pages = text (or full-spread alternate) **Text rules:** - Embed fonts (ReportLab: register TTFont before use) - Font size ≥ 14pt for ages 3-5; use centered text, generous leading - No text inside AI images — overlay programmatically after generation ### Step 4 — Create cover Use `scripts/create-cover.py` for the full wrap PDF (front + spine + back + bleed): ```bash python3 scripts/create-cover.py \ --title "The Curious Octopus" \ --author "Your Name" \ --pages 28 \ --paper white \ --front-image output/curious-octopus/images/cover.png \ --back-text "Follow Otto the octopus as he discovers the joy of sharing..." \ --output output/curious-octopus/cover.pdf ``` **Spine width formulas (KDP standard):** - White paper B&W: pages × 0.002252 inches - Cream paper B&W: pages × 0.0025 inches - White paper color: pages × 0.002347 inches **Cover dimensions (8.5×8.5, white color, 28 pages):** - Spine width: 28 × 0.002347 = 0.0657" - Total cover width: 8.5 + 0.0657 + 8.5 + 0.25" bleed = 17.316" - Total cover height: 8.5 + 0.25" bleed = 8.75" **Cover design rules:** - Put title + author text as a PDF overlay — never bake text into the AI cover image - Leave bottom-right 2"×1.5" of back cover clear for ISBN barcode - Spine text is optional for books < 100 pages (too narrow to read) - KDP will overlay its own barcode on the back cover — leave space ### Step 5 — Write metadata **Description rules:** - Write from the finished story, NOT from the prompt or outline - Use character names exactly as they appear in the final text - 2-3 sentences: hook + plot + moral/appeal - Include age range in description text ("Perfect for ages 3-6") - Do not keyword-stuff; Amazon indexes naturally **KDP metadata fields:** - Title + subtitle (subtitle is optional but good for search) - Author: pen name - 7 backend keywords (use all 7 slots; 50 chars each; use phrases not single words) - 2 categories (research BSR before choosing) - Language, publication date - AI-generated content disclosure: required if text or images are AI-generated ### Step 6 — Validate before upload ```bash python3 scripts/validate-book.py \ --interior output/curious-octopus/interior.pdf \ --cover output/curious-octopus/cover.pdf \ --metadata output/curious-octopus/metadata.json ``` Checks: page count, trim dimensions, image resolution, no-text-in-images, description/story consistency. ### Step 7 — Upload to KDP **Browser automation rules (critical):** - Do NOT use Playwright `type` on KDP form fields — focus shifts and input breaks - Use JavaScript `evaluate` with element IDs: ```js document.getElementById('data-print-book-title').value = 'The Curious Octopus'; document.getElementById('data-print-book-title').dispatchEvent(new Event('input', {bubbles:true})); ``` - File uploads require CDP `DOM.setFileInputFiles` + change event (Playwright upload action doesn't work): ```js // After DOM.setFileInputFiles: document.querySelector('input[type=file]').dispatchEvent(new Event('change', {bubbles:true})); ``` - Wait for `#data-print-book-interior-processing-status` to show COMPLETED before proceeding - Reload KDP page before each upload session — stale form state causes silent failures **Category selection:** - Use the cascade-select dropdowns in the category modal - After selecting all levels, JS-click the placement checkbox (don't Playwright-click) --- ## Activity Books & Workbooks For non-narrative books, skip the story generation step. Instead: 1. Plan content structure (sections, problem counts, difficulty levels) 2. Generate content programmatically or with a puzzle generator 3. Layout with ReportLab or a worksheet template 4. Apply the same cover + metadata workflow **Math workbook rules:** - Do NOT add answer lines below equations — kids write directly below the problem line - Use ≥ 14pt font for problem numbers; ≥ 20pt for the math itself - Include answer key as last section - 8.5×11", 1" margins, B&W interior **Coloring book rules:** - Image prompts: "simple black and white coloring page, thick lines, no shading, age-appropriate" - Do not use grayscale images — must be pure black line art on white background - Leave blank back sides of pages to prevent marker bleed-through --- ## Pricing Quick Reference | Book Type | Paperback Price | Expected Royalty/Sale | |-----------|----------------|-----------------------| | Kids picture book | $12.99 | ~$5.00 | | Activity book (color) | $8.99-$10.99 | ~$3.10-$4.50 | | Math/puzzle workbook | $9.99-$12.99 | ~$4.30-$6.00 | | Coloring book | $7.99-$9.99 | ~$3.30-$4.50 | Use 70% royalty tier: price must be $2.99-$9.99 for ebooks. Paperbacks always use 60% tier. --- ## Amazon Ads Quick-Start See `references/ads-quickstart.md` for full setup. Summary: - Start with Automatic Targeting, $5/day - After 2 weeks, harvest converting search terms → Manual Exact Match campaign - Target ACoS: 30-45% - Kill keywords: 8+ clicks, 0 sales → reduce bid to $0.02 - Scale: increase bids 20% on keywords with ACoS < 20% --- ## Key Lessons These were learned through production and would not be obvious from KDP documentation: 1. **KDP form automation**: Use JS evaluate + element IDs. Never use browser `type` action — fields shift focus mid-input. 2. **File upload via CDP**: Standard Playwright upload fails on KDP. Use `DOM.setFileInputFiles` + dispatch change event. See TOOLS.md for full script path. 3. **Spine width formula**: White paper B&W = `pages × 0.002252`. White paper color = `pages × 0.002347`. This is what KDP Cover Calculator gives you. 4. **8.5×8.5 No Bleed margin**: Images must fit within 576×576 pt content area on a 612×612 pt page. Images touching the trim edge will fail KDP preflight. 5. **No text in AI illustrations**: Every image prompt must include "no text, no words, no letters". Text baked into AI images is often garbled. Always overlay text programmatically. 6. **Descriptions from finished content**: Never write the description from the original prompt. Write it after the story is final — use actual character names and plot. 7. **KDP categories**: Select via cascade dropdowns in the modal, then JS-click the placement checkbox. Don't rely on Playwright click for the final checkbox. 8. **Math workbooks**: No answer lines below equations. Kids write below the problem line. 9. **Stale form state**: Reload the KDP page before each upload session. Uploading a second file to a page already mid-process fails silently. 10. **AI disclosure**: Amazon requires disclosure if text OR images are AI-generated. Select Yes during upload. Non-disclosure can result in account action. --- ## References - `references/kdp-specs.md` — Trim sizes, margins, bleed, spine width tables, file requirements - `references/qa-checklist.md` — Pre-upload quality checklist - `references/niche-guide.md` — Profitable niches with pricing and competition data - `references/ads-quickstart.md` — Amazon Ads campaign setup and optimization ## Scripts - `scripts/generate-book.py` — Full book generation pipeline (story + illustrations + interior PDF) - `scripts/create-cover.py` — Standalone cover wrap PDF generator - `scripts/validate-book.py` — Pre-upload validator ## Dependencies ```bash pip install reportlab Pillow google-genai PyPDF2 # Optional for validation: pip install pytesseract # OCR-based text-in-image detection ``` API key: Set `GOOGLE_AI_API_KEY` env var, or pass `--api-key`, or create `~/.clawdbot/credentials/google_ai.json` with `{"api_key": "..."}`.

path: references/ads-quickstart.md
content: # Amazon Ads Quick-Start Guide

Set up your first KDP Sponsored Products campaign in under 20 minutes.

---

## Why Ads Are Non-Negotiable

Organic discovery on Amazon in 2026 is essentially zero for new books without reviews. Ads:
- Put your book in search results immediately
- Generate initial sales, which improve ranking
- Provide keyword data you can't get any other way
- Are the fastest path to the Amazon flywheel (sales → ranking → more sales)

**Budget mindset:** Treat early ad spend as market research, not pure marketing cost.

---

## Ad Types to Use

### Sponsored Products (Use This First — 80% of Budget)
- Appears in search results and on product pages
- Cost-per-click model (you only pay when someone clicks)
- Two targeting modes: Automatic (start here) and Manual

### Sponsored Brands (Use Later — 20% of Budget)
- Banner ads at top of search results
- Requires at least 3 books in your catalog
- Good for series promotion

Skip Lockscreen Ads until you have profitable books with reviews.

---

## Step 1: Set Up Your First Campaign

### Access
1. Go to KDP dashboard
2. Click "Promote and Advertise" on your published book
3. Or go directly to: advertising.amazon.com

### Campaign Settings
- **Campaign type:** Sponsored Products
- **Campaign name:** `[Book Title] - Auto Launch` (be descriptive)
- **Start date:** Today
- **End date:** No end date (run indefinitely until you pause)
- **Daily budget:** $5.00/day (start here for every new book)
- **Targeting:** Automatic targeting (Amazon discovers keywords for you)
- **Default bid:** $0.35 (adjust after first week)

### Ad Group Settings
- **Ad group name:** Auto
- **Product:** Select your book

Click **Launch Campaign**.

---

## Step 2: Week 1-2 (Let It Run)

Do not touch the campaign for 7-14 days. It needs data to optimize.

What to monitor:
- Impressions (at least 100+ per day means it's running)
- Clicks (expect 10-50 clicks per week at start)
- Orders (expect 0-5 in first week — that's normal)
- Spend (should be near your $5/day budget if relevant)

---

## Step 3: First Optimization (After 2+ Weeks)

### Access Search Term Report
1. Reports → Advertising Reports → Sponsored Products Search Term
2. Download CSV, filter by your campaign
3. Look for search terms with: clicks ≥ 5 and orders ≥ 1

### Kill Bleeders
Any search term with 8+ clicks and 0 sales:
1. Go to campaign → Negative Keywords
2. Add as Negative Exact
3. This stops wasting spend on irrelevant searches

### Create Manual Campaign with Winners
1. Create new campaign: `[Book Title] - Manual Exact`
2. Add converting search terms as Exact Match keywords
3. Set bids 10-20% higher than your auto campaign bid
4. This maximizes spend on proven converters

---

## Ongoing Optimization (Weekly, ~30 Minutes)

Run through these in order every week:

**Step 1 — Kill bleeders**
- Filter: clicks ≥ 8, orders = 0
- Add to Negative Exact in auto campaign
- Add to Negative Exact in manual campaign

**Step 2 — Reduce high ACoS bids**
- ACoS > 100%: reduce bid by 50%
- ACoS 45-100%: reduce bid by 25%

**Step 3 — Increase low ACoS bids**
- ACoS < 20%: increase bid by 20%
- These keywords are highly profitable — capture more traffic

**Step 4 — Harvest new keywords**
- From auto campaign search term report
- Any term with clicks ≥ 3 and orders ≥ 1 → add to manual campaign

**Step 5 — Product targeting (Month 2+)**
- Target competitor ASINs (books similar to yours)
- Find competitors in your category with many reviews
- Add their ASINs as product targets in a separate ad group

---

## Key Metrics

| Metric | Definition | Target |
|--------|------------|--------|
| ACoS | Ad spend ÷ Ad revenue | 30-45% |
| CTR | Clicks ÷ Impressions | 0.3-0.8% |
| CPC | Cost per click | $0.20-$0.60 |
| CVR | Orders ÷ Clicks | 5-15% |

### Calculate Your Breakeven ACoS
```
Breakeven ACoS = Royalty per sale ÷ List price × 100

Example:
  Paperback price: $12.99
  Printing cost:   $3.50
  KDP fee (40%):   $5.20
  Royalty:         $4.29
  Breakeven ACoS:  $4.29 ÷ $12.99 = 33%
```

You can run above breakeven ACoS during launch (to build rank), then optimize down.

---

## Budget Scaling

### Per-Book Budget by Phase
| Phase | Daily Budget | Monthly |
|-------|-------------|---------|
| Launch (weeks 1-4) | $5/day | $150 |
| Early optimization (month 2) | $5-10/day | $150-300 |
| Scale winner (month 3+) | $10-20/day | $300-600 |
| Pause underperformer | $0.50/day | $15 (keep some data) |

### Decision Rules
- **Pause if:** < 3 sales/month after 60 days with ads
- **Increase if:** ACoS < 30% consistently for 2+ weeks
- **Scale aggressively if:** ACoS < 20% with 10+ sales/month

---

## Targeting Strategy by Phase

### Launch (Weeks 1-4): Automatic Only
- Amazon's algorithm discovers what searches lead to your book
- Provides keyword data you couldn't guess in advance
- Run at $5/day, don't touch bids

### Build (Weeks 5-12): Auto + Manual
- Keep auto campaign running (it keeps finding new keywords)
- Add Manual Exact Match campaign with proven keywords
- Manual Phrase Match for longer-tail variations
- Start product targeting: pick 5-10 competitor ASINs

### Scale (Month 3+): Full Structure
- Auto campaign: $3/day (discovery)
- Manual Exact: $5-15/day (proven keywords)
- Product targeting: $3-5/day (competitor pages)
- Sponsored Brands: $2-3/day (if 3+ books)

---

## Example Campaign Structure (Month 3)

```
Campaign 1: "Brave Bear - Auto Discovery"
  Budget: $3/day, Auto targeting, Default bid: $0.30
  Negative: [all non-converters from first 2 months]

Campaign 2: "Brave Bear - Manual Exact Winners"
  Budget: $8/day, Manual Exact Match
  Keywords: "picture book courage", "brave bear kids book", "character book preschool"
  Bids: $0.40-$0.60 (adjust based on ACoS)

Campaign 3: "Brave Bear - Product Targeting"
  Budget: $3/day
  Targets: [competitor ASINs with 50-500 reviews in same category]
  Bid: $0.25-$0.40
```

---

## Common Mistakes

**Mistake 1: Turning off campaigns too early**
New campaigns need 2-4 weeks to gather data. Don't evaluate before then.

**Mistake 2: Setting bids too high or too low**
- Too high: Spend money on irrelevant clicks
- Too low: Never get impressions
- Start at $0.30-$0.40, adjust weekly

**Mistake 3: Not adding negative keywords**
Without negatives, auto campaigns will waste spend on irrelevant searches. Review weekly.

**Mistake 4: Optimizing every day**
Over-optimization prevents the algorithm from learning. Weekly reviews are sufficient.

**Mistake 5: Giving up on good books**
Most books take 3-4 months to reach their natural performance level. Be patient.

---

## Tracking Spreadsheet Template

Keep a simple spreadsheet per book per month:

| Month | Gross Sales | Ad Spend | ACoS | Net Royalty | Action |
|-------|------------|---------|------|-------------|--------|
| 1 | $45 | $150 | 333% | -$105 | Normal launch loss |
| 2 | $180 | $150 | 83% | $30 | Break-even, continue |
| 3 | $380 | $200 | 53% | $180 | Improving, increase bid on winners |
| 4 | $600 | $250 | 42% | $350 | At target, scale |

---

## Resources

- Amazon Ads console: advertising.amazon.com
- KDP marketing resources: kdp.amazon.com/en_US/help/topic/G200735480
- ACoS calculator: Use the formula above or Publisher Rocket's estimator


path: references/kdp-specs.md
content: # KDP Publishing Specifications

Reference for all KDP file requirements, dimensions, and constraints.

---

## Trim Sizes

### Children's & Activity Books
| Size | Use Case | Bleed Option |
|------|----------|--------------|
| 8.5" × 8.5" | Children's picture books (square) | No Bleed or Bleed |
| 8.5" × 11" | Activity books, workbooks, coloring books | No Bleed or Bleed |
| 6" × 9" | Chapter books, journals | No Bleed or Bleed |
| 5.5" × 8.5" | Small workbooks, pocket guides | No Bleed or Bleed |
| 7" × 10" | Larger workbooks | No Bleed or Bleed |

**Most popular for each type:**
- Picture books: 8.5×8.5 (full color, square)
- Workbooks/activity books: 8.5×11 (standard page)
- Journals: 6×9

---

## Margins

### No Bleed (recommended for picture books)
| Dimension | Minimum |
|-----------|---------|
| Outside margin (top, bottom, outside edge) | 0.25" |
| Inside/gutter margin (binding edge) | 0.375" for 24-150 pages |
| Inside/gutter margin | 0.5" for 151-300 pages |
| Inside/gutter margin | 0.625" for 301+ pages |

**For 8.5×8.5 No Bleed:**
- Page size in PDF: 612 × 612 pt (8.5" × 8.5" exactly)
- Safe content area: 576 × 576 pt (images and text must stay within this)
- Images touching the trim edge = **preflight failure**

### With Bleed
| Dimension | Value |
|-----------|-------|
| Bleed | 0.125" on all sides |
| Outside margin (within bleed) | 0.25" from trim edge |
| Gutter margin (within bleed) | 0.375"+ from trim edge |

**For 8.5×8.5 With Bleed:**
- PDF page size: 8.75" × 8.75" (8.5 + 0.125 each side)
- In points: 630 × 630 pt

---

## Spine Width Formulas

KDP calculates spine based on page count and paper type:

| Paper Type | Formula | Example (28 pages) |
|------------|---------|-------------------|
| White, B&W interior | pages × 0.002252" | 28 × 0.002252 = 0.063" |
| Cream, B&W interior | pages × 0.002500" | 28 × 0.002500 = 0.070" |
| White, Color interior | pages × 0.002347" | 28 × 0.002347 = 0.066" |

**Minimum spine for text: 0.25"**  
(Text on spine is only legible above ~100 pages on most trim sizes)

Use the [KDP Cover Calculator](https://kdp.amazon.com/en_US/cover-calculator) to confirm exact dimensions before creating your cover file.

---

## Cover File Specifications

### Full Wrap Cover Dimensions
```
Total Width  = Back Cover Width + Spine Width + Front Cover Width + 2 × Bleed
Total Height = Trim Height + 2 × Bleed
```

**Example: 8.5×8.5, 28 pages, white color paper:**
```
Spine        = 28 × 0.002347 = 0.0657"
Total Width  = 8.5 + 0.0657 + 8.5 + 0.25  = 17.316"
Total Height = 8.5 + 0.25                  = 8.75"
```

### Cover File Requirements
- Format: PDF (preferred) or JPG/TIFF
- Color mode: RGB (not CMYK — KDP converts to CMYK internally)
- Resolution: 300 DPI minimum at final print size
- Bleed: 0.125" on all edges
- Safety margin: Keep all important content 0.125" inside trim lines
- ISBN barcode area: Leave 2" × 1.5" clear on bottom-right of back cover
- KDP overlays its own barcode — do not add a final barcode yourself

---

## Interior File Specifications

### PDF Requirements
- Format: PDF (PDF/X-1a for best results, standard PDF accepted)
- No crop marks or printer marks
- Page size must match trim size exactly (no-bleed files) or trim+bleed (bleed files)
- All fonts embedded
- Color mode: RGB for color interiors; grayscale or B&W for B&W interiors
- Resolution: 300 DPI minimum for images at final print size

### Image Resolution Guide
| Image Size in Book | Min Pixel Width |
|--------------------|----------------|
| Full page 8.5×8.5 at 300 DPI | 2550 × 2550 px |
| Full page 8.5×11 at 300 DPI | 2550 × 3300 px |
| Half page at 300 DPI | 1275+ px wide |
| Imagen default output (1024px) | ~3.4" at 300 DPI; scales acceptably to 8" |

> **Note:** Imagen 1024×1024 output is technically 128 DPI when scaled to fill an 8" page, but KDP preflight usually accepts it. For best results, upscale images to 2550px before embedding (use Pillow `resize` with `LANCZOS`).

### Page Count Rules
- **Minimum:** 24 pages (color); 48 pages (B&W)
- **Maximum:** 828 pages
- KDP prefers even page counts
- Children's picture books: typically 24-32 pages
- Activity/workbooks: 40-200 pages

---

## Interior Types & Pricing Impact

| Interior Type | Paper Color | Printing Cost (approx.) |
|--------------|-------------|------------------------|
| Black & White | White | Lower (~$0.015/page) |
| Black & White | Cream | Similar to B&W white |
| Standard Color | White | Higher (~$0.035/page) |
| Premium Color | White | Highest (~$0.065/page) |

**Printing cost example:**
- 28-page color interior, 8.5×8.5: ~28 × $0.035 = ~$0.98 base + fixed cost
- Actual KDP printing cost for 28-page color 8.5×8.5: approximately $3.00-$4.50

---

## Ebook Specifications (EPUB/Mobi)

Not typically used for children's picture books or activity books (print-only).

- Format: EPUB 3 (preferred), MOBI, or DOCX
- Images: RGB, 72-96 DPI acceptable for ebook
- Cover image: Min 1600 px wide, max 10,000 px; JPG or PNG
- For illustrated books: Use fixed-layout EPUB to preserve image positioning

---

## Royalty Calculation

### Paperback (all prices)
```
Royalty = 0.60 × (List Price - Printing Cost - Amazon Fee)
Amazon Fee = 0.15 × List Price  (included in 60% calc)
Net Royalty ≈ 0.60 × List Price - Printing Cost
```

### Ebook — 70% Royalty (requires $2.99-$9.99 price)
```
Royalty = (List Price × 0.70) - Delivery Fee
Delivery Fee ≈ $0.06/MB average
```

### Ebook — 35% Royalty (any price)
```
Royalty = List Price × 0.35
```

**Optimal pricing for picture books:**
- Paperback: $12.99-$14.99
- Ebook: $3.99-$4.99 (if offered)

---

## File Upload Process

1. Log into KDP → Create New Title → Paperback
2. Enter metadata (Title, Author, Description, Keywords, Categories)
3. **Upload interior:** Upload interior PDF → wait for "COMPLETED" status
4. **Upload cover:** Upload cover PDF (full wrap) or use Cover Creator
5. Preview with KDP Previewer (3D + interior viewer)
6. Set pricing for each marketplace
7. Disclose AI-generated content (text and/or images)
8. Submit for review (typical: 12-72 hours)

---

## ISBN

| Option | Cost | Notes |
|--------|------|-------|
| KDP Free ISBN | Free | KDP is publisher of record |
| Own ISBN | $125 (single) / $295 (10-pack) | You are publisher of record |

For starting out, KDP free ISBN is recommended. Purchase your own when expanding to other retailers (Ingram Spark, Barnes & Noble, etc.).

---

## AI Content Disclosure (Required)

Amazon KDP requires disclosure of AI-generated content since 2023:

- **Disclose:** AI-generated text (even if heavily edited), AI-generated images
- **No disclosure needed:** AI grammar/spelling assistance, AI outlining/brainstorming, AI proofreading
- **Location:** During book creation/editing in KDP dashboard, under "Content" section
- **Field:** "Does this book contain AI-generated content?" → Yes, and specify (text/images)

Non-compliance risks: title removal or account suspension.

---

## Quick Reference Card

```
8.5×8.5 picture book, no bleed, 28 pages, white color:
  Interior PDF : 612 × 612 pt (8.5" × 8.5"), content within 576×576 pt
  Cover PDF    : 17.316" × 8.75" (spine = 0.0657")
  Min pages    : 24
  DPI          : 300 min

8.5×11 workbook, no bleed, 100 pages, white B&W:
  Interior PDF : 612 × 792 pt (8.5" × 11"), 0.5"+ margins
  Cover PDF    : 17.225" × 11.25" (spine = 0.2252")
  Min pages    : 48
```


path: references/niche-guide.md
content: # KDP Profitable Niches Guide

Top niches for KDP publishing with pricing, competition, and production notes.
Based on 2025-2026 market data.

---

## Tier 1: Primary Focus (Highest ROI)

### 1. Math Workbooks (Educational)
- **Target:** Grades K-6, skill-specific (addition, multiplication, fractions)
- **Trim size:** 8.5×11"
- **Page count:** 80-150 pages (B&W)
- **Pricing:** $9.99-$12.99 paperback, $6.99-$8.99 ebook
- **Expected royalty/sale:** $4.30-$6.00
- **Monthly revenue per book:** $100-$400 (mature)
- **Competition:** Medium (quality content differentiates)
- **Series potential:** Very high — Grade K, 1, 2, 3, 4, 5, 6 for each skill
- **Key search terms:** "grade [X] math workbook", "[skill] practice worksheets", "homeschool math"
- **Production notes:**
  - Use worksheet generators for problem sets (BookBolt, MathDrills API)
  - No answer lines below equations — kids write directly on the problem line
  - Must include answer key
  - Black and white interior keeps printing cost low

### 2. Activity Books for Kids (Ages 3-8)
- **Types:** I Spy, dot-to-dot, mazes, scissors skills, color-by-number, spot-the-difference
- **Trim size:** 8.5×11" or 8.5×8.5" (for younger kids)
- **Page count:** 50-80 pages
- **Pricing:** $8.99-$10.99 paperback (color), $6.99-$7.99 (B&W)
- **Expected royalty/sale:** $3.10-$4.50
- **Monthly revenue per book:** $80-$300
- **Competition:** Medium-High (quality cover critical)
- **Series potential:** High — themed by animal, vehicle, season, holiday
- **Key search terms:** "activity book ages [X]", "i spy [theme]", "kids activity book [theme]"
- **Production notes:**
  - Color interiors sell better for ages 3-5
  - B&W acceptable for activity/puzzle content ages 5+
  - Series: "I Spy on the Farm", "I Spy in the Ocean", "I Spy in Space"

### 3. Puzzle Books (Word Search, Sudoku, Crossword, Mazes)
- **Sub-niches:** Large print for seniors, themed (animals, food, holidays), difficulty-specific
- **Trim size:** 8.5×11"
- **Page count:** 100-200 puzzles
- **Pricing:** $9.99-$12.99 paperback, $5.99-$7.99 ebook
- **Expected royalty/sale:** $4.50-$6.00
- **Monthly revenue per book:** $150-$500
- **Competition:** Medium (themed puzzles have lower competition)
- **Series potential:** Very high — "Easy Sudoku Vol. 1, 2, 3..."
- **Key search terms:** "[puzzle type] large print", "[puzzle type] seniors", "[puzzle type] [theme]"
- **Production notes:**
  - Use puzzle generators (BookBolt, PuzzleMaker, Instant Puzzle Generator)
  - "Large print" and "seniors" keywords dramatically lower competition
  - Include solutions section

### 4. Children's Picture Books (Illustrated)
- **Target:** Ages 3-8, 24-32 pages
- **Trim size:** 8.5×8.5" (square is standard for picture books)
- **Pricing:** $12.99-$14.99 paperback, $3.99-$4.99 ebook
- **Expected royalty/sale:** $5.00-$6.50 paperback
- **Monthly revenue per book:** $100-$600
- **Competition:** High (brand and cover quality matter a lot)
- **Series potential:** High — same character in different adventures
- **Key search terms:** "picture book [theme]", "[moral/lesson] book for kids", "bedtime story [theme]"
- **Production notes:**
  - AI illustration pipeline (Imagen/Midjourney) + ReportLab PDF
  - Character consistency is critical — define reference sheet first
  - Never put text in AI images — overlay programmatically
  - Write description from finished story, not the prompt

---

## Tier 2: Secondary Focus (Scalable)

### 5. Coloring Books (Themed)
- **Focus:** Niche themes (trucks, unicorns, dinosaurs, mandalas, specific holidays)
- **Trim size:** 8.5×11" (standard) or 8.5×8.5" (toddler)
- **Page count:** 40-60 pages (B&W line art)
- **Pricing:** $7.99-$9.99 paperback (print only — coloring books don't sell as ebooks)
- **Expected royalty/sale:** $3.30-$4.50
- **Monthly revenue per book:** $50-$200
- **Competition:** Very High (must niche down — "truck coloring book toddlers age 2-4")
- **Series potential:** High by theme
- **Key search terms:** "[theme] coloring book [age range]", "toddler coloring book [theme]"
- **Production notes:**
  - AI prompt: "simple black and white coloring page, thick clean lines, no shading, no gray, white background"
  - Must be pure black line art — not grayscale
  - Blank back pages prevent marker bleed-through

### 6. Telling Time Workbooks
- **Target:** Ages 5-10
- **Trim size:** 8.5×11"
- **Pricing:** $8.99-$9.99 paperback
- **Monthly revenue per book:** $80-$250
- **Competition:** Medium-Low
- **Key search terms:** "telling time worksheet", "clock worksheet grade [X]", "learn to tell time"

### 7. Spot the Difference Books
- **Target:** All ages, themed (cars, animals, nature)
- **Trim size:** 8.5×11"
- **Pricing:** $7.99-$9.99 paperback (color)
- **Monthly revenue per book:** $100-$300
- **Competition:** Medium-Low (AI pairs hard to create = natural barrier)
- **Production notes:**
  - Generate base AI image, then modify with Pillow for differences
  - Mark differences in answer key by number/letter

### 8. Journals & Planners
- **Types:** Guided gratitude journals, habit trackers, meal planners, teacher planners
- **Trim size:** 6×9" (standard)
- **Pricing:** $12.99-$16.99 paperback
- **Monthly revenue per book:** $60-$250
- **Competition:** High (must be very specific niche + professional design)
- **Key search terms:** "gratitude journal women", "habit tracker 2026", "[niche] planner"

---

## Pricing Strategy

### Optimal Price Points
| Book Type | Paperback | Ebook | Royalty Target |
|-----------|-----------|-------|----------------|
| Kids picture book | $12.99 | $3.99 | $5+ |
| Activity book (color) | $9.99-$10.99 | N/A | $4+ |
| Workbook (B&W) | $9.99-$12.99 | $6.99-$8.99 | $4.50+ |
| Puzzle book (B&W) | $10.99-$12.99 | $7.99 | $5+ |
| Coloring book | $7.99-$9.99 | N/A | $3.50+ |
| Journal | $12.99-$16.99 | $7.99 | $6+ |

### Royalty Breakdown
- **70% ebook royalty tier:** Price must be $2.99-$9.99
- **Paperback:** ~60% × (price - printing cost) — printing cost $2-5 typical
- Calculate your breakeven ACoS before setting ad budgets

---

## Series Strategy

**The most reliable path to consistent revenue is series publishing.**

1. Publish Book 1 in a niche (e.g., "Grade 1 Addition Workbook")
2. Track performance for 4-6 weeks
3. If > 50 sales/month: publish Book 2 (Grade 2), Book 3 (Grade 3)
4. If < 20 sales/month: cut losses, test new niche

**Proven series formulas:**
- Math: Grade K, 1, 2, 3, 4, 5, 6 (7 books)
- Activity: Farm, Ocean, Space, Zoo, Holidays, Vehicles (6+ books)
- Puzzle: Vol. 1, 2, 3 (infinite) + Easy/Medium/Hard tiers
- Picture books: Same character, new lesson each book

---

## Keyword Research

**Backend keywords (7 slots, 50 chars each):**
- Use long-tail phrases, not single words ("grade 1 math addition workbook" not "math")
- Don't repeat keywords from your title (Amazon already indexes those)
- Think like a parent: "workbook for first graders", "homeschool math practice"
- Seasonal/niche variants: "christmas coloring book toddlers", "summer activity book kids"

**Category research:**
- Check BSR (Best Sellers Rank) in subcategories — anything under #5,000 indicates healthy sales
- Two categories maximum per book — choose the most specific subcategory available
- Kids activity books: Children's Books > Activities, Crafts & Games > Activity Books
- Workbooks: Books > Education & Teaching > Schools > Elementary Education

---

## Competition Analysis

**Before committing to a niche:**
1. Search your main keyword on Amazon
2. Look at the top 10 results: What are their BSRs? (Under 50,000 = healthy market)
3. Check review counts: Can you realistically compete? (Under 50 reviews = opportunity)
4. Check prices: What's the going rate? Don't undercut — match premium pricing
5. Look at covers: What color schemes and styles dominate? Design to stand out

**Red flags:**
- Top 10 all have 200+ reviews and 100+ BSR
- Category BSR is above 100,000 (low demand)
- Multiple publishers with identical designs (race to the bottom)

**Green flags:**
- Mix of 0-review and 50-review books in top 10
- BSR range 1,000-10,000 in category
- Room to differentiate with better cover or more specific sub-niche

---

## Revenue Benchmarks (Realistic)

| Performance Tier | Books (% of catalog) | Monthly/Book |
|-----------------|----------------------|--------------|
| Duds | ~50% | $0-$50 |
| Mid performers | ~35% | $50-$200 |
| Strong | ~10% | $200-$500 |
| Winners | ~5% | $500-$2,000+ |

**Blended average with advertising:** $100-$150/book/month

**Rule of 80/20:** 80% of revenue comes from 20% of books. Publish broad, scale winners.

---

## AI Disclosure Requirement

Amazon requires disclosure for AI-generated content (2023, still in effect):

| Content Type | Disclosure Required |
|-------------|---------------------|
| AI-written story text | Yes |
| AI-generated illustrations | Yes |
| AI grammar/spell check | No |
| AI-assisted brainstorming | No |
| Human-written, AI-edited | No |

Select "Yes — AI-generated content" during KDP upload. Specify text, images, or both.


path: references/qa-checklist.md
content: # KDP Quality Assurance Checklist

Run this checklist before uploading any book to KDP. The goal: no AI gibberish, no mismatched content, no silent preflight failures.

---

## Before Generation

### Story / Content Planning
- [ ] Prompt is specific enough to produce consistent characters and setting
- [ ] Age group and tone defined (ages 3-5 = ultra simple; ages 5-8 = slightly more complex)
- [ ] Character reference written down: name, appearance, colors, defining features
- [ ] Book type confirmed: picture book / activity book / coloring book / workbook
- [ ] Page count target set (24-32 for picture books; 50-80 for activity; 80-150 for workbooks)

### Image Prompts
- [ ] Every image prompt includes: `no text, no words, no letters, no writing`
- [ ] Style established on first image; subsequent prompts reference it explicitly
- [ ] Character description is consistent in all illustration prompts

---

## After Story Generation (Before Images)

- [ ] Read entire story aloud (or TTS) — catch awkward phrasing
- [ ] Spell check all text
- [ ] Character names are consistent throughout (no draft leftovers like "Character_Name")
- [ ] Word count per page is appropriate for age group
- [ ] Moral/lesson is clear and age-appropriate
- [ ] Dedication text finalized
- [ ] Back-cover description written from the **finished story** (not the original prompt)

---

## After Image Generation

### Each Image — Visual Inspection Required
- [ ] No AI text artifacts (gibberish, misspelled words, stray letters)
- [ ] No watermarks or logos
- [ ] Character looks consistent across all pages (same colors, features, proportions)
- [ ] Scene matches the story text on that spread
- [ ] Image is not blurry or corrupted
- [ ] Image is clean — no unexpected objects, disturbing content, inappropriate themes

### Run Automated Check (Optional)
```bash
python3 validate-book.py --images output/my-book/images/ --check-text-in-images
```

---

## Interior PDF Checks

- [ ] Open PDF in a viewer (Preview, Adobe Reader) at 100% zoom
- [ ] Check first page (title page): title, subtitle, author name all correct
- [ ] Check dedication page
- [ ] Every spread has its illustration
- [ ] Every spread has its story text — no spreads missing
- [ ] Text is readable — no encoding issues with special characters
- [ ] No blank pages where there shouldn't be
- [ ] "The End" page present
- [ ] About the Author page (optional but professional)
- [ ] Page count is even and within range (24-32 for picture books)
- [ ] All images are sharp, no compression artifacts

```bash
# Validate automatically:
python3 validate-book.py --interior output/my-book/interior.pdf --book-type picture-book
```

---

## Cover Checks

- [ ] Open cover PDF at 100% zoom
- [ ] Title is correct and readable
- [ ] Author name is correct
- [ ] Cover image has no AI text artifacts
- [ ] Spine (if visible): text is correct and readable
- [ ] Back cover description matches the actual story
- [ ] Bottom-right of back cover: ISBN area is clear (2"×1.5" white space)
- [ ] Bleed extends 0.125" beyond trim on all edges
- [ ] All important content is 0.125" inside trim lines

```bash
# Validate dimensions:
python3 validate-book.py --cover output/my-book/cover.pdf --pages 28 --trim 8.5x8.5
```

---

## Metadata Checks

- [ ] Title exactly matches cover
- [ ] Subtitle (if any) correct
- [ ] Author name matches pen name
- [ ] Description: 150-400 characters, written from final story, no spoilers
- [ ] 7 keywords filled — all relevant, 50 chars max each, use phrases not single words
- [ ] 2 categories chosen (research BSR rankings first)
- [ ] Age range accurate
- [ ] AI disclosure marked: Yes (if text or images are AI-generated)
- [ ] Language: English (or correct language)

---

## KDP Upload Checks

- [ ] Reload the KDP page before starting upload (avoid stale form state)
- [ ] Interior uploaded successfully → status shows "COMPLETED"
- [ ] Cover uploaded successfully → no error messages
- [ ] KDP Previewer: check first page, a middle spread, last page
- [ ] KDP Previewer: 3D view looks correct (title on cover, spine readable)
- [ ] Pricing set for all target marketplaces (US, UK, CA, AU minimum)
- [ ] AI content disclosure completed

---

## Book-Specific Notes

### Picture Books (8.5×8.5)
- Images must fit within 576×576 pt content area on 612×612 pt page (no-bleed)
- If images are bleeding, use Ghostscript to add margins: `gs -sDEVICE=pdfwrite -o fixed.pdf -c "<<BeginPage>> 0.94 0.94 scale 18 18 translate <<EndPage>>" -f interior.pdf`

### Math Workbooks
- **Never add a separate answer line below equations** — kids write directly below the math line
- Include section headings, clear problem numbering, and answer key at back
- Check that all problems have correct difficulty for target grade

### Coloring Books
- Confirm images are pure black line art on white — not grayscale
- If any gray tones appear, regenerate or convert: `convert page.png -threshold 50% bw_page.png`
- Leave back sides of pages blank (prevent marker bleed-through)

### Activity Books
- Test each puzzle/activity yourself: can a child actually complete it?
- Ensure answer key matches every puzzle
- Check that difficulty progression makes sense (easy → harder)

---

## Final Sign-Off

- [ ] Book title confirms correct  
- [ ] All pages verified (spread count, page count, no blanks)
- [ ] All images inspected for AI artifacts
- [ ] Description verified against final story
- [ ] Metadata complete and accurate
- [ ] AI disclosure done
- [ ] Cover dimensions validated
- [ ] KDP Previewer reviewed

**→ Ready to publish.**

---

*Encode this process into scripts wherever possible. Human review is still required for image inspection and description verification.*


path: scripts/create-cover.py
content: #!/usr/bin/env python3
"""
KDP Cover Generator — create-cover.py
Creates a KDP-ready full-wrap cover PDF (front + spine + back + bleed).

The output is a single-page PDF at the exact dimensions KDP requires.
Text overlays are added programmatically (never baked into AI images).

Usage:
  python3 create-cover.py --title "My Book" --author "Jane Doe" --pages 28
  python3 create-cover.py --title "My Book" --author "Jane Doe" --pages 28 \\
      --front-image output/my-book/images/cover.png \\
      --back-text "A heartwarming tale about..." \\
      --trim 8.5x8.5 --paper white-color
  python3 create-cover.py --help
"""

import argparse
import json
import sys
import io
from pathlib import Path

try:
    from reportlab.lib.pagesizes import inch as INCH
    from reportlab.pdfgen import canvas as pdf_canvas
    from reportlab.lib.colors import HexColor, white, black, Color
    from reportlab.lib.utils import ImageReader
    from PIL import Image
except ImportError as e:
    print(f"❌ Missing dependency: {e}")
    print("   Run: pip install reportlab Pillow")
    sys.exit(1)


# ─── Spine Width Formulas ─────────────────────────────────────────────────────
# Source: KDP Cover Calculator (kdp.amazon.com/en_US/cover-calculator)

SPINE_WIDTH_PER_PAGE = {
    "white-bw":    0.002252,   # White paper, B&W interior
    "cream-bw":    0.002500,   # Cream paper, B&W interior
    "white-color": 0.002347,   # White paper, color interior
}

TRIM_SIZES = {
    "8.5x8.5":  (8.5, 8.5),
    "8.5x11":   (8.5, 11.0),
    "6x9":      (6.0, 9.0),
    "5.5x8.5":  (5.5, 8.5),
    "7x10":     (7.0, 10.0),
}

BLEED = 0.125  # inches — standard KDP bleed


def calc_spine_width(pages: int, paper: str) -> float:
    """Return spine width in inches."""
    rate = SPINE_WIDTH_PER_PAGE.get(paper, SPINE_WIDTH_PER_PAGE["white-bw"])
    return pages * rate


def calc_cover_dimensions(trim_w: float, trim_h: float, pages: int, paper: str):
    """
    Return (total_width_in, total_height_in, spine_width_in, bleed_in).
    All in inches.
    """
    spine = calc_spine_width(pages, paper)
    total_w = trim_w + spine + trim_w + (2 * BLEED)
    total_h = trim_h + (2 * BLEED)
    return total_w, total_h, spine, BLEED


def load_image(path_str: str) -> Image.Image:
    """Load a PIL image from a file path."""
    p = Path(path_str)
    if not p.exists():
        print(f"❌ Image not found: {path_str}")
        sys.exit(1)
    return Image.open(p).convert("RGB")


def make_placeholder_image(label: str, width: int = 1024, height: int = 1024,
                            color: str = "#3A6EA5") -> Image.Image:
    """Create a simple colored placeholder image."""
    from PIL import ImageDraw
    img = Image.new("RGB", (width, height), color)
    draw = ImageDraw.Draw(img)
    draw.rectangle([20, 20, width - 20, height - 20], outline="#FFFFFF", width=4)
    draw.text((width // 2, height // 2), label[:30], fill="white", anchor="mm")
    return img


def image_to_reader(img: Image.Image) -> ImageReader:
    """Convert PIL Image to ReportLab ImageReader."""
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    buf.seek(0)
    return ImageReader(buf)


def wrap_text(c, text: str, max_width: float, font: str, size: int):
    """Word-wrap text to fit max_width. Returns list of lines."""
    words = text.split()
    lines, current = [], []
    for word in words:
        test = " ".join(current + [word])
        if c.stringWidth(test, font, size) <= max_width:
            current.append(word)
        else:
            if current:
                lines.append(" ".join(current))
            current = [word]
    if current:
        lines.append(" ".join(current))
    return lines


def create_cover(
    title: str,
    author: str,
    pages: int,
    paper: str = "white-color",
    trim_key: str = "8.5x8.5",
    front_image_path: str = None,
    back_image_path: str = None,
    back_text: str = "",
    spine_color: str = "#2C5F8A",
    title_color: str = "#1A1A2E",
    output_path: str = None,
    subtitle: str = "",
) -> Path:
    """Generate the full-wrap KDP cover PDF."""

    # Dimensions
    trim_w, trim_h = TRIM_SIZES.get(trim_key, (8.5, 8.5))
    total_w_in, total_h_in, spine_in, bleed_in = calc_cover_dimensions(
        trim_w, trim_h, pages, paper
    )

    # Convert to points (1 inch = 72 pt)
    total_w = total_w_in * INCH
    total_h = total_h_in * INCH
    bleed = bleed_in * INCH
    spine_w = spine_in * INCH
    trim_w_pt = trim_w * INCH
    trim_h_pt = trim_h * INCH

    # Coordinate references (from left edge of PDF)
    back_x = bleed                              # Back cover left edge
    spine_x = bleed + trim_w_pt                # Spine left edge
    front_x = spine_x + spine_w                # Front cover left edge
    cover_bottom = bleed                        # Bottom edge of cover area
    cover_top = bleed + trim_h_pt              # Top edge of cover area

    print(f"\n📐 Cover Dimensions")
    print(f"   Trim size   : {trim_w}\" × {trim_h}\"")
    print(f"   Pages       : {pages}")
    print(f"   Paper       : {paper}")
    print(f"   Spine width : {spine_in:.4f}\" ({spine_w:.2f} pt)")
    print(f"   Total size  : {total_w_in:.3f}\" × {total_h_in:.3f}\"")
    print(f"   ({total_w:.1f} × {total_h:.1f} pt)")

    # Output path
    if output_path is None:
        slug = title.lower().replace(" ", "-")[:40]
        output_path = f"cover-{slug}.pdf"
    out = Path(output_path)
    out.parent.mkdir(parents=True, exist_ok=True)

    # ─── Draw PDF ─────────────────────────────────────────────────────────────
    c = pdf_canvas.Canvas(str(out), pagesize=(total_w, total_h))

    # ── Background ────────────────────────────────────────────────────────────
    c.setFillColor(HexColor("#F0F4F8"))
    c.rect(0, 0, total_w, total_h, fill=1, stroke=0)

    # ── Front Cover ───────────────────────────────────────────────────────────
    # Background gradient simulation (solid color)
    c.setFillColor(HexColor("#2C5F8A"))
    c.rect(front_x, cover_bottom, trim_w_pt, trim_h_pt, fill=1, stroke=0)

    # Front cover image (fills most of the front, leaving room for title bar)
    title_bar_h = 1.8 * INCH
    img_area_h = trim_h_pt - title_bar_h - 0.25 * INCH
    img_area_w = trim_w_pt - 0.5 * INCH

    if front_image_path:
        front_img = load_image(front_image_path)
    else:
        front_img = make_placeholder_image("FRONT COVER IMAGE", 1024, 1024)

    c.drawImage(
        image_to_reader(front_img),
        front_x + 0.25 * INCH,
        cover_bottom + title_bar_h,
        img_area_w,
        img_area_h,
        preserveAspectRatio=True,
        anchor="c",
    )

    # Title bar on front cover
    c.setFillColor(HexColor(title_color))
    c.rect(front_x, cover_bottom, trim_w_pt, title_bar_h, fill=1, stroke=0)

    # Title text
    c.setFillColor(white)
    title_font_size = 32 if len(title) <= 25 else (24 if len(title) <= 40 else 18)
    c.setFont("Helvetica-Bold", title_font_size)
    title_lines = wrap_text(c, title, trim_w_pt - 0.5 * INCH, "Helvetica-Bold", title_font_size)
    y = cover_bottom + title_bar_h - 0.4 * INCH
    for line in title_lines:
        c.drawCentredString(front_x + trim_w_pt / 2, y, line)
        y -= (title_font_size + 6) / 72 * INCH

    # Subtitle
    if subtitle:
        c.setFont("Helvetica-Oblique", 14)
        c.setFillColor(HexColor("#CCDDEE"))
        c.drawCentredString(front_x + trim_w_pt / 2, cover_bottom + 0.85 * INCH, subtitle)

    # Author name on front cover
    c.setFont("Helvetica", 16)
    c.setFillColor(HexColor("#AACCEE"))
    c.drawCentredString(front_x + trim_w_pt / 2, cover_bottom + 0.35 * INCH, f"by {author}")

    # ── Back Cover ────────────────────────────────────────────────────────────
    c.setFillColor(HexColor("#1A3A5C"))
    c.rect(back_x, cover_bottom, trim_w_pt, trim_h_pt, fill=1, stroke=0)

    # Back cover image (optional, smaller)
    if back_image_path:
        back_img = load_image(back_image_path)
        c.drawImage(
            image_to_reader(back_img),
            back_x + 0.5 * INCH,
            cover_bottom + trim_h_pt * 0.45,
            trim_w_pt * 0.4,
            trim_h_pt * 0.35,
            preserveAspectRatio=True,
            anchor="c",
        )

    # Back cover title
    c.setFillColor(white)
    c.setFont("Helvetica-Bold", 18)
    c.drawCentredString(back_x + trim_w_pt / 2, cover_bottom + trim_h_pt - 1.0 * INCH, title)

    # Back cover description
    if back_text:
        c.setFont("Helvetica", 12)
        c.setFillColor(HexColor("#CCDDEE"))
        desc_lines = wrap_text(c, back_text, trim_w_pt - 1.0 * INCH, "Helvetica", 12)
        y = cover_bottom + trim_h_pt - 1.6 * INCH
        for line in desc_lines:
            c.drawCentredString(back_x + trim_w_pt / 2, y, line)
            y -= 18

    # Author name on back
    c.setFont("Helvetica-Oblique", 12)
    c.setFillColor(HexColor("#AABBCC"))
    c.drawCentredString(back_x + trim_w_pt / 2, cover_bottom + 2.0 * INCH, f"by {author}")

    # ISBN barcode placeholder (bottom right of back cover)
    # KDP will overlay its own barcode here — leave 2" × 1.5" clear
    barcode_w = 2.0 * INCH
    barcode_h = 1.2 * INCH
    barcode_x = back_x + trim_w_pt - barcode_w - 0.3 * INCH
    barcode_y = cover_bottom + 0.4 * INCH
    c.setFillColor(white)
    c.rect(barcode_x, barcode_y, barcode_w, barcode_h, fill=1, stroke=0)
    c.setFillColor(HexColor("#999999"))
    c.setFont("Helvetica", 7)
    c.drawCentredString(barcode_x + barcode_w / 2, barcode_y + barcode_h / 2 - 4, "ISBN BARCODE")

    # ── Spine ─────────────────────────────────────────────────────────────────
    c.setFillColor(HexColor(spine_color))
    c.rect(spine_x, 0, spine_w, total_h, fill=1, stroke=0)

    # Spine text — only if spine is wide enough (≥ 0.25")
    if spine_in >= 0.25:
        c.saveState()
        c.translate(spine_x + spine_w / 2, total_h / 2)
        c.rotate(90)
        max_spine_text_w = trim_h_pt - 0.5 * INCH
        spine_font_size = min(14, int(spine_w * 72 * 0.6))
        spine_font_size = max(7, spine_font_size)
        c.setFont("Helvetica-Bold", spine_font_size)
        c.setFillColor(white)
        spine_text = f"{title}  ·  {author}"
        if c.stringWidth(spine_text, "Helvetica-Bold", spine_font_size) > max_spine_text_w:
            spine_text = title[:30]
        c.drawCentredString(0, -spine_font_size / 2, spine_text)
        c.restoreState()

    # ── Bleed / Safe Zone Guides (as comments in output, not visible) ─────────
    # Front cover safe zone: front_x + 0.125" from all edges
    # Back cover safe zone: back_x + 0.125" from all edges

    c.save()
    print(f"\n✅ Cover PDF created: {out}")
    print(f"   Upload this file as your KDP cover (Custom Cover > Upload PDF).")
    print(f"   KDP will automatically add the barcode over the white rectangle.")
    return out


# ─── CLI ─────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="KDP Cover Generator — create full-wrap cover PDFs",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 create-cover.py --title "The Curious Octopus" --author "Jane Doe" --pages 28
  python3 create-cover.py \\
      --title "My Activity Book" --author "Jane Doe" --pages 60 \\
      --trim 8.5x11 --paper white-bw \\
      --front-image output/my-book/images/cover.png \\
      --back-text "50 fun activities for curious kids!" \\
      --output output/my-book/cover.pdf

Paper options:
  white-color  — White paper, color interior (default)
  white-bw     — White paper, B&W interior
  cream-bw     — Cream paper, B&W interior

Spine width formula:
  white-color: pages × 0.002347 inches
  white-bw:    pages × 0.002252 inches
  cream-bw:    pages × 0.002500 inches
""",
    )
    parser.add_argument("--title", required=True, help="Book title")
    parser.add_argument("--author", required=True, help="Author name")
    parser.add_argument("--pages", required=True, type=int, help="Interior page count")
    parser.add_argument(
        "--subtitle", default="", help="Optional subtitle text"
    )
    parser.add_argument(
        "--trim",
        default="8.5x8.5",
        choices=list(TRIM_SIZES.keys()),
        help="Trim size (default: 8.5x8.5)",
    )
    parser.add_argument(
        "--paper",
        default="white-color",
        choices=list(SPINE_WIDTH_PER_PAGE.keys()),
        help="Paper type — affects spine width (default: white-color)",
    )
    parser.add_argument(
        "--front-image", metavar="PATH", help="Path to front cover image (PNG/JPG)"
    )
    parser.add_argument(
        "--back-image", metavar="PATH", help="Path to back cover image (PNG/JPG, optional)"
    )
    parser.add_argument(
        "--back-text", default="", help="Back cover description text"
    )
    parser.add_argument(
        "--spine-color", default="#2C5F8A", help="Spine background hex color (default: #2C5F8A)"
    )
    parser.add_argument(
        "--title-color", default="#1A1A2E", help="Title bar hex color (default: #1A1A2E)"
    )
    parser.add_argument(
        "--output", "-o", default=None, help="Output PDF path (default: cover-<slug>.pdf)"
    )
    parser.add_argument(
        "--from-metadata", metavar="PATH",
        help="Load title/author/pages from a metadata.json file (overrides individual flags)"
    )

    args = parser.parse_args()

    # Load from metadata if provided
    title, author, pages, subtitle = args.title, args.author, args.pages, args.subtitle
    if args.from_metadata:
        meta_path = Path(args.from_metadata)
        if not meta_path.exists():
            print(f"❌ Metadata file not found: {meta_path}")
            sys.exit(1)
        with open(meta_path) as f:
            meta = json.load(f)
        title = meta.get("title", title)
        author = meta.get("author", author)
        pages = meta.get("page_count", pages)
        subtitle = meta.get("subtitle", subtitle)
        print(f"✓ Loaded from metadata: {title} by {author}, {pages} pages")

    create_cover(
        title=title,
        author=author,
        pages=pages,
        paper=args.paper,
        trim_key=args.trim,
        front_image_path=args.front_image,
        back_image_path=args.back_image,
        back_text=args.back_text,
        spine_color=args.spine_color,
        title_color=args.title_color,
        output_path=args.output,
        subtitle=subtitle,
    )


if __name__ == "__main__":
    main()


path: scripts/generate-book.py
content: #!/usr/bin/env python3
"""
KDP Book Generator — generate-book.py
Generates KDP-ready print books using Google Gemini (text) and Imagen (illustrations).

Supported book types:
  picture-book  — Children's illustrated story (8.5x8.5, full color)
  activity-book — Kids activity pages (8.5x11, color)
  coloring-book — Line art coloring pages (8.5x11, B&W)
  workbook      — Math/practice workbook (8.5x11, B&W)

Usage:
  python3 generate-book.py "a brave fox who learns kindness" --type picture-book
  python3 generate-book.py "addition facts 1-20" --type workbook --grade 1
  python3 generate-book.py --help
"""

import os
import sys
import json
import time
import argparse
import re
import io
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional


# ─── Dependency check ────────────────────────────────────────────────────────

def check_deps():
    missing = []
    try:
        from reportlab.lib.pagesizes import inch
    except ImportError:
        missing.append("reportlab")
    try:
        from PIL import Image
    except ImportError:
        missing.append("Pillow")
    try:
        import google.genai
    except ImportError:
        missing.append("google-genai")
    if missing:
        print(f"❌ Missing dependencies: {', '.join(missing)}")
        print(f"   Run: pip install {' '.join(missing)}")
        sys.exit(1)

check_deps()

from reportlab.lib.pagesizes import inch as INCH
from reportlab.pdfgen import canvas as pdf_canvas
from reportlab.lib.colors import HexColor, white, black
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from PIL import Image
import google.genai as genai


# ─── Constants ───────────────────────────────────────────────────────────────

BOOK_CONFIGS = {
    "picture-book": {
        "trim": (8.5 * INCH, 8.5 * INCH),
        "margin": 0.25 * INCH,
        "gutter": 0.625 * INCH,
        "color": True,
        "aspect_ratio": "1:1",
        "min_pages": 24,
        "max_pages": 32,
        "description": "Children's illustrated picture book (8.5×8.5, full color)",
    },
    "activity-book": {
        "trim": (8.5 * INCH, 11.0 * INCH),
        "margin": 0.5 * INCH,
        "gutter": 0.75 * INCH,
        "color": True,
        "aspect_ratio": "3:4",
        "min_pages": 50,
        "max_pages": 80,
        "description": "Kids activity book (8.5×11, color)",
    },
    "coloring-book": {
        "trim": (8.5 * INCH, 11.0 * INCH),
        "margin": 0.5 * INCH,
        "gutter": 0.75 * INCH,
        "color": False,
        "aspect_ratio": "3:4",
        "min_pages": 40,
        "max_pages": 60,
        "description": "Coloring book (8.5×11, B&W line art)",
    },
    "workbook": {
        "trim": (8.5 * INCH, 11.0 * INCH),
        "margin": 1.0 * INCH,
        "gutter": 1.0 * INCH,
        "color": False,
        "aspect_ratio": None,
        "min_pages": 80,
        "max_pages": 150,
        "description": "Math/practice workbook (8.5×11, B&W)",
    },
}

STYLE_PRESETS = {
    "watercolor": "children's book illustration, soft watercolor painting, gentle pastel colors, whimsical, no text, no words, no letters",
    "cartoon": "children's book illustration, bright bold cartoon style, vibrant colors, clean lines, no text, no words, no letters",
    "digital": "children's book illustration, smooth digital painting, modern clean style, no text, no words, no letters",
    "line-art": "simple black and white coloring book page, thick clean lines, no shading, no gray tones, white background, no text, no words",
}

AGE_CONFIGS = {
    "3-5": {"words_per_page": "10-20", "complexity": "very simple sentences, repetitive structure, one idea per page"},
    "5-8": {"words_per_page": "20-40", "complexity": "simple sentences, clear story arc, relatable situations"},
}

DEFAULT_AUTHOR = os.environ.get("KDP_AUTHOR_NAME", "Your Name")
IMAGE_SIZE = 1024


# ─── Utilities ───────────────────────────────────────────────────────────────

def load_api_key(api_key_arg: Optional[str]) -> str:
    """Load Google AI API key from arg, env var, or credentials file."""
    key = api_key_arg or os.environ.get("GOOGLE_AI_API_KEY")
    if key:
        return key
    creds_path = Path.home() / ".clawdbot" / "credentials" / "google_ai.json"
    if creds_path.exists():
        with open(creds_path) as f:
            return json.load(f).get("api_key", "")
    print("❌ No API key found.")
    print("   Provide --api-key, set GOOGLE_AI_API_KEY env var, or create")
    print("   ~/.clawdbot/credentials/google_ai.json with {\"api_key\": \"...\"}")
    sys.exit(1)


def slugify(text: str) -> str:
    """Convert text to filesystem-safe slug."""
    slug = re.sub(r"[^\w\s-]", "", text.lower())
    slug = re.sub(r"[-\s]+", "-", slug)
    return slug.strip("-")[:60]


def wrap_text(c, text: str, max_width: float, font_name: str, font_size: int) -> List[str]:
    """Word-wrap text to fit within max_width."""
    words = text.split()
    lines, current = [], []
    for word in words:
        test = " ".join(current + [word])
        if c.stringWidth(test, font_name, font_size) <= max_width:
            current.append(word)
        else:
            if current:
                lines.append(" ".join(current))
            current = [word]
    if current:
        lines.append(" ".join(current))
    return lines


def try_register_font(font_name: str, bold: bool = False) -> str:
    """Try to register a system TTF font; fall back to Helvetica."""
    candidates = [
        f"/usr/share/fonts/truetype/dejavu/DejaVuSans{'Bold' if bold else ''}.ttf",
        f"/usr/share/fonts/truetype/liberation/LiberationSans-{'Bold' if bold else 'Regular'}.ttf",
        f"/System/Library/Fonts/Helvetica.ttc",
    ]
    for path in candidates:
        if Path(path).exists():
            reg_name = f"CustomFont{'Bold' if bold else ''}"
            try:
                pdfmetrics.registerFont(TTFont(reg_name, path))
                return reg_name
            except Exception:
                pass
    return "Helvetica-Bold" if bold else "Helvetica"


# ─── Core Classes ─────────────────────────────────────────────────────────────

class StoryGenerator:
    """Generates story content via Google Gemini."""

    def __init__(self, client):
        self.client = client

    def generate(self, prompt: str, book_type: str, age_group: str = "3-5",
                 num_spreads: int = 12, grade: int = None) -> Dict:
        """Generate story/content structure for the given book type."""

        if book_type == "workbook":
            return self._generate_workbook(prompt, grade or 1)
        elif book_type == "coloring-book":
            return self._generate_coloring_book(prompt, num_spreads)
        elif book_type == "activity-book":
            return self._generate_activity_book(prompt, age_group, num_spreads)
        else:
            return self._generate_picture_book(prompt, age_group, num_spreads)

    def _generate_picture_book(self, prompt: str, age_group: str, num_spreads: int) -> Dict:
        age_cfg = AGE_CONFIGS.get(age_group, AGE_CONFIGS["3-5"])
        story_prompt = f"""Write a children's picture book for ages {age_group} about: {prompt}

Rules:
- Title: catchy, max 60 characters
- Exactly {num_spreads} spreads
- Each spread: {age_cfg['words_per_page']} words of story text
- Sentence style: {age_cfg['complexity']}
- Include a positive moral/lesson
- Content: kid-safe, no scary themes
- Each spread has a distinct, illustratable scene (NO text in scenes)

Respond ONLY with valid JSON (no markdown fences):
{{
  "title": "...",
  "subtitle": "...",
  "moral": "...",
  "dedication": "...",
  "description": "2-3 sentence back-cover description",
  "keywords": ["kw1","kw2","kw3","kw4","kw5","kw6","kw7"],
  "spreads": [
    {{"page": 1, "text": "...", "scene": "Illustration description, no text in image"}},
    ...
  ]
}}"""

        return self._call_gemini(story_prompt, f"picture book: {prompt}")

    def _generate_coloring_book(self, prompt: str, num_pages: int) -> Dict:
        cb_prompt = f"""Create a coloring book with {num_pages} pages themed around: {prompt}

Each page is a distinct scene or subject for coloring. Keep descriptions suitable for ages 4-10.

Respond ONLY with valid JSON:
{{
  "title": "...",
  "subtitle": "...",
  "description": "2-3 sentence description",
  "keywords": ["kw1","kw2","kw3","kw4","kw5","kw6","kw7"],
  "pages": [
    {{"page": 1, "scene": "Simple illustration description for line art, no text in image"}},
    ...
  ]
}}"""
        return self._call_gemini(cb_prompt, f"coloring book: {prompt}")

    def _generate_activity_book(self, prompt: str, age_group: str, num_pages: int) -> Dict:
        act_prompt = f"""Plan an activity book for ages {age_group} about: {prompt}
{num_pages} activity pages total. Variety: I Spy, matching, mazes, counting, coloring.

Respond ONLY with valid JSON:
{{
  "title": "...",
  "subtitle": "...",
  "description": "2-3 sentence description",
  "keywords": ["kw1","kw2","kw3","kw4","kw5","kw6","kw7"],
  "pages": [
    {{"page": 1, "activity_type": "i-spy", "title": "I Spy on the Farm!", "scene": "Detailed illustration description, no text in image", "instruction": "Can you find 5 chickens?"}},
    ...
  ]
}}"""
        return self._call_gemini(act_prompt, f"activity book: {prompt}")

    def _generate_workbook(self, prompt: str, grade: int) -> Dict:
        wb_prompt = f"""Plan a math workbook for Grade {grade} covering: {prompt}

Include sections with specific problem types. Problems must be appropriate for grade {grade}.

Respond ONLY with valid JSON:
{{
  "title": "Grade {grade} {prompt.title()} Workbook",
  "subtitle": "...",
  "description": "2-3 sentence description for parents and teachers",
  "keywords": ["kw1","kw2","kw3","kw4","kw5","kw6","kw7"],
  "sections": [
    {{
      "title": "Section 1: ...",
      "pages": 20,
      "problem_type": "addition",
      "difficulty": "easy",
      "sample_problems": ["3 + 4 = ___", "7 + 2 = ___", "5 + 5 = ___"]
    }},
    ...
  ],
  "has_answer_key": true
}}"""
        return self._call_gemini(wb_prompt, f"workbook: grade {grade} {prompt}")

    def _call_gemini(self, prompt: str, fallback_context: str) -> Dict:
        """Call Gemini and parse JSON response."""
        try:
            response = self.client.models.generate_content(
                model="gemini-2.5-flash",
                contents=prompt
            )
            text = response.text.strip()
            # Strip markdown fences if present
            if "```json" in text:
                text = text.split("```json")[1].split("```")[0].strip()
            elif "```" in text:
                text = text.split("```")[1].split("```")[0].strip()
            data = json.loads(text)
            print(f"✓ Content generated: '{data.get('title', 'Untitled')}'")
            return data
        except Exception as e:
            print(f"⚠ Content generation failed ({e}), using fallback structure")
            return self._fallback(fallback_context)

    def _fallback(self, context: str) -> Dict:
        return {
            "title": context.title()[:60],
            "subtitle": "",
            "moral": "Every day is a chance to grow.",
            "dedication": "For curious minds everywhere.",
            "description": f"A wonderful book about {context}.",
            "keywords": ["children", "kids", "picture book", "bedtime", "story", "educational", "fun"],
            "spreads": [
                {"page": i + 1, "text": f"Page {i + 1} of our story...", "scene": f"Scene {i + 1}"}
                for i in range(12)
            ],
        }


class ImageGenerator:
    """Generates illustrations using Google Imagen."""

    def __init__(self, client, model: str = "imagen-4.0-fast-generate-001"):
        self.client = client
        self.model = model

    def generate(self, scene: str, style: str = "watercolor",
                 character_ref: str = "", aspect_ratio: str = "1:1",
                 retries: int = 3) -> Optional[Image.Image]:
        """Generate a single illustration."""
        style_prefix = STYLE_PRESETS.get(style, STYLE_PRESETS["watercolor"])
        char_part = f"{character_ref}, " if character_ref else ""
        full_prompt = (
            f"{style_prefix}, {char_part}{scene}, "
            f"suitable for children, no text, no words, no writing, clean composition"
        )

        for attempt in range(retries):
            try:
                resp = self.client.models.generate_images(
                    model=self.model,
                    prompt=full_prompt,
                    config={"number_of_images": 1, "aspect_ratio": aspect_ratio},
                )
                if resp.generated_images:
                    data = resp.generated_images[0].image.image_bytes
                    img = Image.open(io.BytesIO(data)).convert("RGB")
                    print(f"     ✓ {img.size[0]}×{img.size[1]} px")
                    return img
            except Exception as e:
                err = str(e).lower()
                if "quota" in err or "rate" in err:
                    wait = (attempt + 1) * 10
                    print(f"     ⚠ Rate limit, waiting {wait}s...")
                    time.sleep(wait)
                else:
                    print(f"     ⚠ Error: {e}")
                    if attempt < retries - 1:
                        time.sleep(3)

        print("     ⚠ Using placeholder image")
        return self._placeholder(scene)

    def _placeholder(self, text: str) -> Image.Image:
        """Create a simple placeholder image."""
        from PIL import ImageDraw
        img = Image.new("RGB", (IMAGE_SIZE, IMAGE_SIZE), "#EEF4FB")
        draw = ImageDraw.Draw(img)
        draw.rectangle([20, 20, IMAGE_SIZE - 20, IMAGE_SIZE - 20], outline="#4A90E2", width=5)
        # Simple center text
        draw.text((IMAGE_SIZE // 2, IMAGE_SIZE // 2), text[:40], fill="#4A90E2", anchor="mm")
        return img


class PDFAssembler:
    """Assembles KDP-ready interior PDFs."""

    def __init__(self, book_type: str, author: str = DEFAULT_AUTHOR):
        self.config = BOOK_CONFIGS[book_type]
        self.book_type = book_type
        self.author = author
        self.width, self.height = self.config["trim"]
        self.margin = self.config["margin"]

    def assemble(self, content: Dict, images: List[Image.Image], out_path: Path) -> int:
        """Build the interior PDF. Returns final page count."""
        c = pdf_canvas.Canvas(str(out_path), pagesize=self.config["trim"])

        if self.book_type == "picture-book":
            return self._assemble_picture_book(c, content, images, out_path)
        elif self.book_type in ("activity-book", "coloring-book"):
            return self._assemble_image_book(c, content, images, out_path)
        elif self.book_type == "workbook":
            return self._assemble_workbook(c, content, out_path)
        else:
            c.save()
            return 0

    def _assemble_picture_book(self, c, content: Dict, images: List[Image.Image],
                                out_path: Path) -> int:
        w, h = self.width, self.height
        m = self.margin

        # Page 1: Title page
        c.setFillColor(HexColor("#2C5F8A"))
        c.rect(0, 0, w, h, fill=1, stroke=0)
        title = content.get("title", "Untitled")
        c.setFillColor(white)
        c.setFont("Helvetica-Bold", 36)
        title_lines = wrap_text(c, title, w - 2 * m, "Helvetica-Bold", 36)
        y = h * 0.65 + (len(title_lines) * 22)
        for line in title_lines:
            c.drawCentredString(w / 2, y, line)
            y -= 44
        if content.get("subtitle"):
            c.setFont("Helvetica", 22)
            c.drawCentredString(w / 2, h * 0.48, content["subtitle"])
        c.setFont("Helvetica", 18)
        c.drawCentredString(w / 2, h * 0.3, f"by {self.author}")
        c.showPage()

        # Page 2: Dedication
        c.setFillColor(white)
        c.rect(0, 0, w, h, fill=1, stroke=0)
        dedication = content.get("dedication", "For curious minds everywhere.")
        c.setFillColor(HexColor("#555555"))
        c.setFont("Helvetica-Oblique", 18)
        ded_lines = wrap_text(c, dedication, w - 2 * m, "Helvetica-Oblique", 18)
        y = h * 0.55 + (len(ded_lines) * 12)
        for line in ded_lines:
            c.drawCentredString(w / 2, y, line)
            y -= 26
        c.showPage()

        # Story spreads
        spreads = content.get("spreads", [])
        for idx, spread in enumerate(spreads):
            # Illustration page
            if idx < len(images):
                self._draw_image_page(c, images[idx])
            else:
                c.setFillColor(HexColor("#F0F8FF"))
                c.rect(0, 0, w, h, fill=1, stroke=0)
            c.showPage()

            # Text page
            c.setFillColor(white)
            c.rect(0, 0, w, h, fill=1, stroke=0)
            text = spread.get("text", "")
            c.setFillColor(black)
            c.setFont("Helvetica", 22)
            lines = wrap_text(c, text, w - 2 * m, "Helvetica", 22)
            total_h = len(lines) * 34
            y = h / 2 + total_h / 2
            for line in lines:
                c.drawCentredString(w / 2, y, line)
                y -= 34
            c.showPage()

        # "The End" page
        c.setFillColor(HexColor("#2C5F8A"))
        c.rect(0, 0, w, h, fill=1, stroke=0)
        c.setFillColor(white)
        c.setFont("Helvetica-Bold", 72)
        c.drawCentredString(w / 2, h / 2, "The End")
        c.showPage()

        # About the Author page
        c.setFillColor(white)
        c.rect(0, 0, w, h, fill=1, stroke=0)
        c.setFillColor(HexColor("#2C5F8A"))
        c.setFont("Helvetica-Bold", 22)
        c.drawCentredString(w / 2, h * 0.72, f"About the Author")
        c.setFillColor(HexColor("#444444"))
        c.setFont("Helvetica", 14)
        bio = (
            f"{self.author} writes stories that help children "
            "discover their inner strength and embrace who they are."
        )
        bio_lines = wrap_text(c, bio, w - 2 * m, "Helvetica", 14)
        y = h * 0.62
        for line in bio_lines:
            c.drawCentredString(w / 2, y, line)
            y -= 22

        # Review request
        c.setFont("Helvetica-Oblique", 11)
        c.setFillColor(HexColor("#888888"))
        c.drawCentredString(w / 2, h * 0.25, "If you enjoyed this book, please leave a review on Amazon!")
        c.showPage()

        # Pad to minimum page count
        page_count = c.getPageNumber() - 1
        min_pages = self.config["min_pages"]
        while page_count < min_pages:
            c.showPage()
            page_count += 1

        c.save()
        print(f"✓ Interior PDF: {out_path} ({page_count} pages)")
        return page_count

    def _assemble_image_book(self, c, content: Dict, images: List[Image.Image],
                              out_path: Path) -> int:
        w, h = self.width, self.height
        m = self.margin

        # Title page
        c.setFillColor(HexColor("#1A1A2E"))
        c.rect(0, 0, w, h, fill=1, stroke=0)
        c.setFillColor(white)
        c.setFont("Helvetica-Bold", 42)
        c.drawCentredString(w / 2, h * 0.6, content.get("title", "Untitled"))
        c.setFont("Helvetica", 20)
        c.drawCentredString(w / 2, h * 0.5, f"by {self.author}")
        c.showPage()

        pages = content.get("pages", [])
        for idx, page in enumerate(pages):
            c.setFillColor(white)
            c.rect(0, 0, w, h, fill=1, stroke=0)

            # Page title/instruction
            instruction = page.get("instruction") or page.get("title", "")
            if instruction:
                c.setFillColor(HexColor("#2C5F8A"))
                c.setFont("Helvetica-Bold", 18)
                inst_lines = wrap_text(c, instruction, w - 2 * m, "Helvetica-Bold", 18)
                y = h - m - 10
                for line in inst_lines:
                    c.drawCentredString(w / 2, y, line)
                    y -= 24

            # Illustration
            if idx < len(images):
                img_top = h - m - (len(inst_lines) * 24 + 20 if instruction else 0) - 10
                img_bottom = m
                img_h = img_top - img_bottom
                self._draw_image_region(c, images[idx], m, img_bottom, w - 2 * m, img_h)
            c.showPage()

        # Back matter
        self._add_back_matter(c, content)

        page_count = c.getPageNumber() - 1
        min_pages = self.config["min_pages"]
        while page_count < min_pages:
            c.showPage()
            page_count += 1

        c.save()
        print(f"✓ Interior PDF: {out_path} ({page_count} pages)")
        return page_count

    def _assemble_workbook(self, c, content: Dict, out_path: Path) -> int:
        w, h = self.width, self.height
        m = self.margin

        # Title page
        c.setFillColor(white)
        c.setFillColor(HexColor("#003366"))
        c.rect(0, 0, w, h, fill=1, stroke=0)
        c.setFillColor(white)
        c.setFont("Helvetica-Bold", 36)
        title_lines = wrap_text(c, content.get("title", "Workbook"), w - 2 * m, "Helvetica-Bold", 36)
        y = h * 0.65
        for line in title_lines:
            c.drawCentredString(w / 2, y, line)
            y -= 44
        c.setFont("Helvetica", 20)
        c.drawCentredString(w / 2, h * 0.42, f"by {self.author}")
        c.drawCentredString(w / 2, h * 0.35, "Name: _______________________________")
        c.showPage()

        # Sections
        for section in content.get("sections", []):
            # Section header page
            c.setFillColor(HexColor("#003366"))
            c.rect(0, 0, w, 1.5 * INCH, fill=1, stroke=0)
            c.setFillColor(white)
            c.setFont("Helvetica-Bold", 24)
            c.drawCentredString(w / 2, 1.5 * INCH - 28, section.get("title", "Section"))

            # Generate sample problem pages
            problems = section.get("sample_problems", [])
            if not problems:
                problems = [f"Problem {i + 1}" for i in range(10)]

            # Render problems in a grid
            c.setFillColor(black)
            cols = 2
            col_w = (w - 2 * m) / cols
            row_h = 1.0 * INCH
            x_starts = [m + i * col_w for i in range(cols)]
            y = h - 2.5 * INCH
            col = 0
            for i, prob in enumerate(problems):
                if y < m + row_h:
                    c.showPage()
                    y = h - m
                    col = 0

                x = x_starts[col]
                # Problem number
                c.setFont("Helvetica-Bold", 12)
                c.drawString(x, y, f"{i + 1}.")
                # Problem text
                c.setFont("Helvetica", 20)
                c.drawString(x + 0.25 * INCH, y, prob)
                # Answer line — DO NOT add a separate line below equations
                # (kids write directly below/on the existing line structure)
                y -= row_h
                col = (col + 1) % cols
                if col == 0:
                    pass  # y already decremented

            c.showPage()

        # Answer key
        if content.get("has_answer_key"):
            c.setFillColor(HexColor("#F0F0F0"))
            c.rect(0, 0, w, h, fill=1, stroke=0)
            c.setFillColor(black)
            c.setFont("Helvetica-Bold", 28)
            c.drawCentredString(w / 2, h * 0.9, "Answer Key")
            c.setFont("Helvetica", 12)
            c.drawCentredString(w / 2, h * 0.82, "(Answers will vary based on generated content)")
            c.showPage()

        page_count = c.getPageNumber() - 1
        min_pages = self.config["min_pages"]
        while page_count < min_pages:
            c.showPage()
            page_count += 1

        c.save()
        print(f"✓ Interior PDF: {out_path} ({page_count} pages)")
        return page_count

    def _draw_image_page(self, c, img: Image.Image):
        """Draw a full-page illustration within safe margins."""
        w, h = self.width, self.height
        m = self.margin
        self._draw_image_region(c, img, m, m, w - 2 * m, h - 2 * m)

    def _draw_image_region(self, c, img: Image.Image, x: float, y: float,
                            max_w: float, max_h: float):
        """Draw an image within a specified region, preserving aspect ratio."""
        buf = io.BytesIO()
        img.save(buf, format="PNG")
        buf.seek(0)
        reader = ImageReader(buf)
        c.drawImage(reader, x, y, max_w, max_h, preserveAspectRatio=True, anchor="c")

    def _add_back_matter(self, c, content: Dict):
        """Add a simple back matter / copyright page."""
        w, h = self.width, self.height
        m = self.margin
        c.setFillColor(white)
        c.rect(0, 0, w, h, fill=1, stroke=0)
        c.setFillColor(HexColor("#888888"))
        c.setFont("Helvetica", 10)
        lines = [
            content.get("title", ""),
            f"Copyright © {datetime.now().year} {self.author}",
            "All rights reserved.",
            "Printed in the United States of America",
        ]
        y = h / 2 + 30
        for line in lines:
            c.drawCentredString(w / 2, y, line)
            y -= 18
        c.showPage()


# ─── Main Pipeline ────────────────────────────────────────────────────────────

class BookGenerator:
    """Orchestrates the full book generation pipeline."""

    def __init__(self, api_key: str, output_dir: str = "output",
                 book_type: str = "picture-book", author: str = DEFAULT_AUTHOR):
        self.[REDACTED_SECRET]
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(parents=True, exist_ok=True)
        self.book_type = book_type
        self.author = author
        self.config = BOOK_CONFIGS[book_type]

        client = genai.Client([REDACTED_SECRET])
        self.story_gen = StoryGenerator(client)
        self.image_gen = ImageGenerator(client)
        self.pdf = PDFAssembler(book_type, author)

        print(f"✓ Book type: {self.config['description']}")

    def generate(self, prompt: str, age_group: str = "3-5", style: str = "watercolor",
                 num_spreads: int = 12, grade: int = 1, skip_images: bool = False) -> Path:
        """Run the full pipeline. Returns the book output directory."""
        print(f"\n{'='*60}")
        print("KDP BOOK GENERATOR")
        print(f"{'='*60}")
        print(f"Prompt : {prompt}")
        print(f"Type   : {self.book_type}")
        if self.book_type == "picture-book":
            print(f"Ages   : {age_group}  |  Style: {style}")
        if self.book_type == "workbook":
            print(f"Grade  : {grade}")

        # 1. Generate content structure
        print("\n📖 Generating content structure...")
        content = self.story_gen.generate(prompt, self.book_type, age_group, num_spreads, grade)

        # 2. Set up output directory
        slug = slugify(content.get("title", prompt))
        book_dir = self.output_dir / slug
        book_dir.mkdir(parents=True, exist_ok=True)
        images_dir = book_dir / "images"
        images_dir.mkdir(exist_ok=True)
        print(f"\n📁 Output: {book_dir}")

        # 3. Generate illustrations
        images = []
        if self.book_type != "workbook":
            pages_to_illustrate = content.get("spreads") or content.get("pages") or []
            total = len(pages_to_illustrate)
            aspect = self.config.get("aspect_ratio", "1:1")
            style_key = "line-art" if self.book_type == "coloring-book" else style

            if not skip_images and total > 0:
                print(f"\n🎨 Generating {total} illustrations...")
                char_ref = f"consistent character style for: {prompt}" if self.book_type == "picture-book" else ""

                for idx, page in enumerate(pages_to_illustrate):
                    scene = page.get("scene", f"Scene {idx + 1}")
                    print(f"\n  [{idx+1}/{total}] {scene[:60]}...")
                    img = self.image_gen.generate(scene, style_key, char_ref, aspect)
                    if img:
                        img_path = images_dir / f"page_{idx+1:02d}.png"
                        img.save(img_path, "PNG")
                        images.append(img)
                    if idx < total - 1:
                        time.sleep(2)  # Rate limiting

                # Cover illustration
                print(f"\n  [cover] Generating cover image...")
                cover_scene = f"book cover: {prompt}, inviting, exciting"
                cover_img = self.image_gen.generate(cover_scene, style_key, "", aspect)
                if cover_img:
                    cover_img.save(images_dir / "cover.png", "PNG")
            else:
                print("\n⚠ Skipping image generation (--skip-images)")
                for idx, page in enumerate(pages_to_illustrate):
                    scene = page.get("scene", f"Scene {idx + 1}")
                    img = self.image_gen._placeholder(scene)
                    img_path = images_dir / f"page_{idx+1:02d}.png"
                    img.save(img_path, "PNG")
                    images.append(img)

        # 4. Assemble interior PDF
        print("\n📄 Assembling interior PDF...")
        interior_path = book_dir / "interior.pdf"
        page_count = self.pdf.assemble(content, images, interior_path)

        # 5. Save metadata
        metadata = {
            "title": content.get("title", ""),
            "subtitle": content.get("subtitle", ""),
            "author": self.author,
            "description": content.get("description", ""),
            "keywords": content.get("keywords", [])[:7],
            "book_type": self.book_type,
            "page_count": page_count,
            "trim_size": f"{self.config['trim'][0]/INCH:.1f}x{self.config['trim'][1]/INCH:.1f}",
            "color_interior": self.config["color"],
            "age_group": age_group if self.book_type == "picture-book" else "",
            "grade": grade if self.book_type == "workbook" else None,
            "created_at": datetime.now().isoformat(),
            "ai_disclosure": True,
        }
        meta_path = book_dir / "metadata.json"
        with open(meta_path, "w") as f:
            json.dump(metadata, f, indent=2)

        # 6. Save story JSON for portal compatibility
        story_json = {
            "id": slug,
            "title": content.get("title", ""),
            "content": "\n\n".join(
                s.get("text", "") for s in content.get("spreads", [])
            ),
            "moral": content.get("moral", ""),
            "metadata": {
                "author": self.author,
                "page_count": page_count,
                "images": [f"images/page_{i+1:02d}.png" for i in range(len(images))],
            },
        }
        with open(book_dir / "story.json", "w") as f:
            json.dump(story_json, f, indent=2)

        print(f"\n{'='*60}")
        print("✅ BOOK GENERATION COMPLETE")
        print(f"{'='*60}")
        print(f"  Title      : {content.get('title')}")
        print(f"  Pages      : {page_count}")
        print(f"  Interior   : {interior_path.name}")
        print(f"  Metadata   : {meta_path.name}")
        print(f"  Images     : {len(images)}")
        print(f"\nNext step: python3 create-cover.py --title \"{content.get('title')}\" "
              f"--author \"{self.author}\" --pages {page_count}")
        print(f"Then:      python3 validate-book.py --interior {interior_path}")
        print(f"{'='*60}\n")

        return book_dir


# ─── CLI ─────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="KDP Book Generator — create KDP-ready books with AI content and illustrations",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 generate-book.py "a brave fox who learns kindness" --type picture-book --age-group 3-5
  python3 generate-book.py "ocean animals" --type coloring-book --spreads 40
  python3 generate-book.py "addition and subtraction" --type workbook --grade 1
  python3 generate-book.py "I Spy on the farm" --type activity-book --skip-images
""",
    )
    parser.add_argument("prompt", help="Book concept or topic")
    parser.add_argument(
        "--type",
        choices=list(BOOK_CONFIGS.keys()),
        default="picture-book",
        help="Book type (default: picture-book)",
    )
    parser.add_argument(
        "--age-group",
        choices=["3-5", "5-8"],
        default="3-5",
        help="Target age group for picture/activity books (default: 3-5)",
    )
    parser.add_argument(
        "--style",
        choices=list(STYLE_PRESETS.keys()),
        default="watercolor",
        help="Illustration style (default: watercolor)",
    )
    parser.add_argument(
        "--spreads",
        type=int,
        default=12,
        help="Number of story spreads / illustration pages (default: 12)",
    )
    parser.add_argument(
        "--grade",
        type=int,
        default=1,
        help="Grade level for workbooks (default: 1)",
    )
    parser.add_argument(
        "--author",
        default=os.environ.get("KDP_AUTHOR_NAME", DEFAULT_AUTHOR),
        help="Author name (default: from KDP_AUTHOR_NAME env var)",
    )
    parser.add_argument(
        "--output-dir",
        default="output",
        help="Output directory (default: output/)",
    )
    parser.add_argument(
        "--api-key",
        help="Google AI API key (or use GOOGLE_AI_API_KEY env var)",
    )
    parser.add_argument(
        "--skip-images",
        action="store_true",
        help="Skip image generation, use placeholder images",
    )

    args = parser.parse_args()
    [REDACTED_SECRET])

    gen = BookGenerator(
        [REDACTED_SECRET],
        output_dir=args.output_dir,
        book_type=args.type,
        author=args.author,
    )
    gen.generate(
        prompt=args.prompt,
        age_group=args.age_group,
        style=args.style,
        num_spreads=args.spreads,
        grade=args.grade,
        skip_images=args.skip_images,
    )


if __name__ == "__main__":
    main()


path: scripts/validate-book.py
content: #!/usr/bin/env python3
"""
KDP Book Validator — validate-book.py
Pre-upload quality checker for KDP book files.

Checks:
  - Interior PDF: page count, trim dimensions, image resolution
  - Cover PDF: dimensions match expected for page count + trim size
  - Metadata: required fields present
  - Images: checks for AI text artifacts (uses Gemini Vision if API key available)
  - Story consistency: description references match story content

Usage:
  python3 validate-book.py --interior output/my-book/interior.pdf
  python3 validate-book.py \\
      --interior output/my-book/interior.pdf \\
      --cover output/my-book/cover.pdf \\
      --metadata output/my-book/metadata.json \\
      --images output/my-book/images/ \\
      --check-text-in-images
  python3 validate-book.py --help
"""

import argparse
import json
import os
import sys
import re
from pathlib import Path
from typing import List, Dict, Optional, Tuple

# ─── Dependency check ─────────────────────────────────────────────────────────

def check_deps():
    missing = []
    try:
        import PyPDF2
    except ImportError:
        missing.append("PyPDF2")
    try:
        from PIL import Image
    except ImportError:
        missing.append("Pillow")
    if missing:
        print(f"❌ Missing dependencies: {', '.join(missing)}")
        print(f"   Run: pip install {' '.join(missing)}")
        sys.exit(1)

check_deps()

import PyPDF2
from PIL import Image


# ─── KDP Specs ───────────────────────────────────────────────────────────────

KDP_TRIM_SIZES = {
    "8.5x8.5":  (612.0, 612.0),    # 8.5" × 8.5" in points
    "8.5x11":   (612.0, 792.0),
    "6x9":      (432.0, 648.0),
    "5.5x8.5":  (396.0, 612.0),
    "7x10":     (504.0, 720.0),
}

SPINE_WIDTH_PER_PAGE = {
    "white-bw":    0.002252,
    "cream-bw":    0.002500,
    "white-color": 0.002347,
}

BLEED_IN = 0.125
MIN_IMAGE_DPI = 300
PAGE_COUNT_MIN = {
    "picture-book": 24,
    "activity-book": 40,
    "coloring-book": 30,
    "workbook": 60,
}
PAGE_COUNT_MAX = {
    "picture-book": 32,
    "activity-book": 100,
    "coloring-book": 80,
    "workbook": 200,
}


# ─── Validators ──────────────────────────────────────────────────────────────

class ValidationResult:
    def __init__(self):
        self.errors: List[str] = []
        self.warnings: List[str] = []
        self.passed: List[str] = []

    def error(self, msg: str):
        self.errors.append(msg)
        print(f"  ❌ {msg}")

    def warn(self, msg: str):
        self.warnings.append(msg)
        print(f"  ⚠  {msg}")

    def ok(self, msg: str):
        self.passed.append(msg)
        print(f"  ✓  {msg}")

    @property
    def is_valid(self) -> bool:
        return len(self.errors) == 0


def validate_interior_pdf(pdf_path: Path, book_type: str, trim_key: str,
                           result: ValidationResult) -> int:
    """Validate interior PDF. Returns page count."""
    print(f"\n📄 Interior PDF: {pdf_path.name}")

    if not pdf_path.exists():
        result.error(f"File not found: {pdf_path}")
        return 0

    try:
        with open(pdf_path, "rb") as f:
            reader = PyPDF2.PdfReader(f)
            page_count = len(reader.pages)

        result.ok(f"PDF opens successfully ({page_count} pages)")

        # Page count check
        min_p = PAGE_COUNT_MIN.get(book_type, 24)
        max_p = PAGE_COUNT_MAX.get(book_type, 32)
        if page_count < min_p:
            result.error(f"Page count {page_count} is below minimum {min_p} for {book_type}")
        elif page_count > max_p:
            result.warn(f"Page count {page_count} exceeds typical max {max_p} for {book_type} — ok if intentional")
        else:
            result.ok(f"Page count {page_count} within range ({min_p}-{max_p})")

        # Page count must be even (for spreads)
        if page_count % 2 != 0:
            result.warn(f"Page count {page_count} is odd — KDP prefers even page counts for print")

        # Dimension check (check first page)
        if page_count > 0:
            with open(pdf_path, "rb") as f:
                reader = PyPDF2.PdfReader(f)
                page = reader.pages[0]
                w = float(page.mediabox.width)
                h = float(page.mediabox.height)

            expected_w, expected_h = KDP_TRIM_SIZES.get(trim_key, (612.0, 612.0))
            tol = 2.0  # 2pt tolerance

            if abs(w - expected_w) <= tol and abs(h - expected_h) <= tol:
                result.ok(f"Page dimensions match {trim_key} ({w:.1f}×{h:.1f} pt)")
            else:
                result.error(
                    f"Page dimensions {w:.1f}×{h:.1f} pt don't match {trim_key} "
                    f"(expected {expected_w:.1f}×{expected_h:.1f} pt)"
                )

        return page_count

    except Exception as e:
        result.error(f"Could not read PDF: {e}")
        return 0


def validate_cover_pdf(cover_path: Path, page_count: int, trim_key: str,
                        paper: str, result: ValidationResult):
    """Validate cover PDF dimensions."""
    print(f"\n📕 Cover PDF: {cover_path.name}")

    if not cover_path.exists():
        result.error(f"File not found: {cover_path}")
        return

    try:
        with open(cover_path, "rb") as f:
            reader = PyPDF2.PdfReader(f)
            if len(reader.pages) != 1:
                result.warn(f"Cover PDF has {len(reader.pages)} pages (expected 1)")
            page = reader.pages[0]
            w_pt = float(page.mediabox.width)
            h_pt = float(page.mediabox.height)

        result.ok(f"Cover PDF dimensions: {w_pt:.2f}×{h_pt:.2f} pt")

        trim_w, trim_h = KDP_TRIM_SIZES.get(trim_key, (612.0, 612.0))
        trim_w_in = trim_w / 72
        trim_h_in = trim_h / 72

        # Calculate expected cover dimensions
        spine_in = page_count * SPINE_WIDTH_PER_PAGE.get(paper, SPINE_WIDTH_PER_PAGE["white-color"])
        expected_w_in = trim_w_in + spine_in + trim_w_in + (2 * BLEED_IN)
        expected_h_in = trim_h_in + (2 * BLEED_IN)
        expected_w_pt = expected_w_in * 72
        expected_h_pt = expected_h_in * 72

        tol = 5.0  # 5pt tolerance for cover
        w_ok = abs(w_pt - expected_w_pt) <= tol
        h_ok = abs(h_pt - expected_h_pt) <= tol

        if w_ok and h_ok:
            result.ok(
                f"Cover dimensions correct for {page_count} pages "
                f"({expected_w_in:.3f}\"×{expected_h_in:.3f}\")"
            )
        else:
            result.warn(
                f"Cover dimensions {w_pt/72:.3f}\"×{h_pt/72:.3f}\" differ from expected "
                f"{expected_w_in:.3f}\"×{expected_h_in:.3f}\" "
                f"(spine: {spine_in:.4f}\"). May be ok if you used KDP's calculator directly."
            )

    except Exception as e:
        result.error(f"Could not read cover PDF: {e}")


def validate_metadata(meta_path: Path, result: ValidationResult) -> Dict:
    """Validate metadata.json fields."""
    print(f"\n📋 Metadata: {meta_path.name}")

    if not meta_path.exists():
        result.warn("No metadata.json found — skipping metadata checks")
        return {}

    try:
        with open(meta_path) as f:
            meta = json.load(f)
    except Exception as e:
        result.error(f"Could not read metadata: {e}")
        return {}

    required = ["title", "author", "description", "keywords"]
    for field in required:
        if meta.get(field):
            result.ok(f"Field '{field}' present")
        else:
            result.error(f"Required field '{field}' is missing or empty")

    # Keywords check
    keywords = meta.get("keywords", [])
    if isinstance(keywords, list):
        if len(keywords) < 7:
            result.warn(f"Only {len(keywords)} keywords — KDP allows 7, use all slots")
        else:
            result.ok(f"7 keywords provided")
        for kw in keywords:
            if len(kw) > 50:
                result.warn(f"Keyword too long (>50 chars): '{kw[:30]}...'")

    # Description check
    desc = meta.get("description", "")
    if len(desc) < 50:
        result.warn("Description is very short — aim for 150-400 characters")
    elif len(desc) > 4000:
        result.error(f"Description too long ({len(desc)} chars) — KDP max is 4000")
    else:
        result.ok(f"Description length: {len(desc)} chars")

    # Title length
    title = meta.get("title", "")
    if len(title) > 200:
        result.error(f"Title too long ({len(title)} chars) — KDP max is 200")
    elif len(title) == 0:
        result.error("Title is empty")
    else:
        result.ok(f"Title: '{title}'")

    # AI disclosure check
    if meta.get("ai_disclosure") is None:
        result.warn("ai_disclosure not set in metadata — remember to disclose on KDP if content is AI-generated")

    return meta


def validate_images(images_dir: Path, result: ValidationResult) -> List[Path]:
    """Validate image files for resolution."""
    print(f"\n🎨 Images: {images_dir}")

    if not images_dir.exists():
        result.warn(f"Images directory not found: {images_dir}")
        return []

    image_files = sorted(
        list(images_dir.glob("*.png")) + list(images_dir.glob("*.jpg")) + list(images_dir.glob("*.jpeg"))
    )

    if not image_files:
        result.warn("No images found in images directory")
        return []

    result.ok(f"Found {len(image_files)} image files")

    low_res_count = 0
    for img_path in image_files:
        try:
            with Image.open(img_path) as img:
                w, h = img.size
                # For 8.5x8.5 at 300 DPI: need 2550×2550 px
                # For embedded at 8" at 300 DPI: need 2400 px
                # Imagen outputs 1024px — at 3.4" that's 300 DPI
                # When scaled to 8", that's 128 DPI — technically below spec
                # but KDP often accepts it for full-page images
                if w < 800 or h < 800:
                    result.warn(f"{img_path.name}: Low resolution {w}×{h} — may fail KDP preflight")
                    low_res_count += 1
                elif w < 1500 or h < 1500:
                    result.warn(
                        f"{img_path.name}: {w}×{h} px — acceptable for smaller images, "
                        "but 2550×2550+ recommended for full-page"
                    )
                else:
                    result.ok(f"{img_path.name}: {w}×{h} px")
        except Exception as e:
            result.error(f"Could not open image {img_path.name}: {e}")

    if low_res_count > 0:
        result.warn(f"{low_res_count} image(s) may be too low resolution for print")

    return image_files


def check_text_in_images(image_files: List[Path], api_key: str,
                          result: ValidationResult):
    """Use Gemini Vision to detect text artifacts in AI illustrations."""
    print(f"\n🔍 Checking for text in images (Gemini Vision)...")

    try:
        import google.genai as genai
    except ImportError:
        result.warn("google-genai not installed — skipping AI text detection")
        return

    if not [REDACTED_SECRET]"No API key — skipping AI text detection. Manually inspect all images.")
        return

    client = genai.Client([REDACTED_SECRET])

    # Check a sample (all if ≤10, otherwise every 3rd)
    to_check = image_files if len(image_files) <= 10 else image_files[::3]
    text_found = []

    for img_path in to_check:
        try:
            with open(img_path, "rb") as f:
                img_data = f.read()

            response = client.models.generate_content(
                model="gemini-2.5-flash",
                contents=[
                    {
                        "parts": [
                            {
                                "inline_data": {
                                    "mime_type": "image/png",
                                    "data": __import__("base64").b64encode(img_data).decode(),
                                }
                            },
                            {
                                "text": (
                                    "Does this image contain any text, letters, numbers, words, "
                                    "or writing of any kind? Answer YES or NO first, then if YES, "
                                    "describe exactly what text you see."
                                )
                            },
                        ]
                    }
                ],
            )
            answer = response.text.strip()
            if answer.upper().startswith("YES"):
                text_found.append((img_path.name, answer))
                result.error(
                    f"TEXT DETECTED in {img_path.name}: {answer[:100]}"
                )
            else:
                result.ok(f"No text in {img_path.name}")

        except Exception as e:
            result.warn(f"Could not check {img_path.name}: {e}")

    if not text_found:
        result.ok(f"No text artifacts found in {len(to_check)} images checked")


def check_description_consistency(meta: Dict, story_path: Path,
                                   result: ValidationResult):
    """Check that description mentions names/themes from the actual story."""
    print(f"\n📝 Description vs. Story Consistency")

    description = meta.get("description", "")
    if not description:
        result.warn("No description to check")
        return

    if not story_path or not story_path.exists():
        result.warn("No story.json found — cannot verify description consistency")
        return

    try:
        with open(story_path) as f:
            story = json.load(f)
    except Exception as e:
        result.warn(f"Could not read story.json: {e}")
        return

    story_content = story.get("content", "") + " " + story.get("title", "")
    story_words = set(re.findall(r"\b[A-Z][a-z]+\b", story_content))  # Proper nouns

    desc_words = set(re.findall(r"\b[A-Z][a-z]+\b", description))
    desc_lower = description.lower()

    # Check that description doesn't mention names not in story
    potential_mismatches = []
    for word in desc_words:
        if (
            len(word) > 3
            and word not in story_words
            and word not in {"This", "The", "A", "An", "In", "On", "With", "From", "For"}
        ):
            potential_mismatches.append(word)

    if potential_mismatches:
        result.warn(
            f"Description contains names/words not found in story: {', '.join(potential_mismatches[:5])} "
            f"— verify these are correct (may be fine if story uses them in different case)"
        )
    else:
        result.ok("Description names appear consistent with story content")

    # Check description isn't just the prompt
    title = meta.get("title", "")
    if title and title.lower() in description.lower() and len(description) < 80:
        result.warn(
            "Description appears to be a summary of the prompt rather than the finished story — rewrite from final text"
        )
    else:
        result.ok("Description appears to be written from finished content")


def check_blank_pages(pdf_path: Path, result: ValidationResult):
    """Render pages and detect all-white (blank) pages that shouldn't be blank."""
    print(f"\n🔲 Blank page detection...")
    # Note: This requires pdf2image / poppler. If not available, skip gracefully.
    try:
        from pdf2image import convert_from_path
        import numpy as np
    except ImportError:
        result.warn("pdf2image/numpy not installed — skipping blank page detection")
        result.warn("   Install with: pip install pdf2image numpy (+ apt install poppler-utils)")
        return

    try:
        pages = convert_from_path(str(pdf_path), dpi=72, first_page=1, last_page=10)
        blank_pages = []
        for i, page_img in enumerate(pages):
            arr = __import__("numpy").array(page_img.convert("L"))
            if arr.std() < 3.0:  # Near-zero variance = blank white page
                blank_pages.append(i + 1)

        if blank_pages:
            result.warn(f"Possible blank pages detected (first 10): pages {blank_pages}")
        else:
            result.ok("No unexpected blank pages in first 10 pages")
    except Exception as e:
        result.warn(f"Could not perform blank page check: {e}")


# ─── Main ─────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="KDP Book Validator — pre-upload quality checks",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Basic validation
  python3 validate-book.py --interior output/my-book/interior.pdf

  # Full validation with all checks
  python3 validate-book.py \\
      --interior output/my-book/interior.pdf \\
      --cover output/my-book/cover.pdf \\
      --metadata output/my-book/metadata.json \\
      --images output/my-book/images/ \\
      --check-text-in-images

  # Validate from book directory
  python3 validate-book.py --book-dir output/my-book/
""",
    )
    parser.add_argument("--interior", metavar="PATH", help="Interior PDF path")
    parser.add_argument("--cover", metavar="PATH", help="Cover PDF path")
    parser.add_argument("--metadata", metavar="PATH", help="metadata.json path")
    parser.add_argument("--images", metavar="DIR", help="Images directory path")
    parser.add_argument(
        "--book-dir", metavar="DIR",
        help="Book output directory (auto-discovers interior.pdf, cover.pdf, metadata.json, images/)"
    )
    parser.add_argument(
        "--book-type",
        choices=list(PAGE_COUNT_MIN.keys()),
        default="picture-book",
        help="Book type for page count validation (default: picture-book)",
    )
    parser.add_argument(
        "--trim",
        default="8.5x8.5",
        choices=list(KDP_TRIM_SIZES.keys()),
        help="Trim size (default: 8.5x8.5)",
    )
    parser.add_argument(
        "--paper",
        default="white-color",
        choices=list(SPINE_WIDTH_PER_PAGE.keys()),
        help="Paper type for cover dimension check (default: white-color)",
    )
    parser.add_argument(
        "--check-text-in-images",
        action="store_true",
        help="Use Gemini Vision to check for AI text artifacts in images",
    )
    parser.add_argument(
        "--api-key",
        help="Google AI API key (for --check-text-in-images)",
    )

    args = parser.parse_args()

    # Resolve paths
    interior_path = None
    cover_path = None
    metadata_path = None
    images_dir = None
    story_path = None

    if args.book_dir:
        bd = Path(args.book_dir)
        interior_path = bd / "interior.pdf"
        cover_path = bd / "cover.pdf"
        metadata_path = bd / "metadata.json"
        images_dir = bd / "images"
        story_path = bd / "story.json"
    else:
        if args.interior:
            interior_path = Path(args.interior)
            # Auto-discover siblings
            parent = interior_path.parent
            if not cover_path and (parent / "cover.pdf").exists():
                cover_path = parent / "cover.pdf"
            if not metadata_path and (parent / "metadata.json").exists():
                metadata_path = parent / "metadata.json"
            if not images_dir and (parent / "images").exists():
                images_dir = parent / "images"
            story_path = parent / "story.json"
        if args.cover:
            cover_path = Path(args.cover)
        if args.metadata:
            metadata_path = Path(args.metadata)
        if args.images:
            images_dir = Path(args.images)

    if not interior_path and not cover_path and not metadata_path:
        parser.print_help()
        print("\n❌ Provide at least --interior, --cover, or --book-dir")
        sys.exit(1)

    # Load API key for image text check
    [REDACTED_SECRET] or os.environ.get("GOOGLE_AI_API_KEY")
    if not [REDACTED_SECRET] = Path.home() / ".clawdbot" / "credentials" / "google_ai.json"
        if creds_path.exists():
            try:
                with open(creds_path) as f:
                    [REDACTED_SECRET]).get("api_key")
            except Exception:
                pass

    # Get book type from metadata if available
    book_type = args.book_type
    trim_key = args.trim
    paper = args.paper
    if metadata_path and metadata_path.exists():
        try:
            with open(metadata_path) as f:
                meta_quick = json.load(f)
            book_type = meta_quick.get("book_type", book_type)
            trim_raw = meta_quick.get("trim_size", "").replace(" x ", "x").replace(" ", "")
            if trim_raw in KDP_TRIM_SIZES:
                trim_key = trim_raw
        except Exception:
            pass

    print(f"\n{'='*60}")
    print("KDP BOOK VALIDATOR")
    print(f"{'='*60}")
    print(f"Book type : {book_type}")
    print(f"Trim size : {trim_key}")
    print(f"Paper     : {paper}")

    result = ValidationResult()

    # Run checks
    page_count = 0
    if interior_path:
        page_count = validate_interior_pdf(interior_path, book_type, trim_key, result)
        if interior_path.exists():
            check_blank_pages(interior_path, result)

    if cover_path:
        validate_cover_pdf(cover_path, page_count, trim_key, paper, result)

    meta = {}
    if metadata_path:
        meta = validate_metadata(metadata_path, result)

    image_files = []
    if images_dir:
        image_files = validate_images(images_dir, result)

    if args.check_text_in_images and image_files:
        check_text_in_images(image_files, api_key, result)
    elif image_files and not args.check_text_in_images:
        print(f"\n  ℹ  Run with --check-text-in-images to scan illustrations for AI text artifacts")

    if meta and story_path:
        check_description_consistency(meta, story_path, result)

    # Summary
    print(f"\n{'='*60}")
    print("VALIDATION SUMMARY")
    print(f"{'='*60}")
    print(f"  ✅ Passed  : {len(result.passed)}")
    print(f"  ⚠  Warnings: {len(result.warnings)}")
    print(f"  ❌ Errors  : {len(result.errors)}")

    if result.errors:
        print(f"\n🚫 ERRORS (must fix before upload):")
        for e in result.errors:
            print(f"   • {e}")

    if result.warnings:
        print(f"\n⚠  WARNINGS (review before upload):")
        for w in result.warnings:
            print(f"   • {w}")

    if result.is_valid:
        print(f"\n✅ Book passed validation — ready to upload to KDP!")
        sys.exit(0)
    else:
        print(f"\n❌ Fix {len(result.errors)} error(s) before uploading to KDP.")
        sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal: suspicious | SkillSpector: suspicious (HIGH)