How I built it · Physical products

    How I went from two KDP rejections to seven books live

    The dropdown that ate two sessions, the question that found it, and the checks that got six more books through KDP and Lulu with no rejections.

    By Molly Shelestak · 4 min read

    The story

    Two KDP rejections. Two lost sessions. Six wrong guesses, then two more. The bug was a dropdown.

    I was turning my friend Sean Levon Nash's paintings into coloring books and publishing the paperbacks through KDP. The first book got rejected at least twice. The previewer also flagged 20 interior pages as outside the margins, and when we measured them, nothing was wrong.

    Claude diagnosed it from my screenshots and reached six wrong conclusions in a row, then two more. Two full sessions went to layout edits. Then I asked one question: is 8.5 x 11 the right trim size for this book? The dashboard was set to 8×10. KDP had been cropping 0.5 in off every edge of a file that was fine.

    That day we turned the mess into a Claude skill: read the dashboard back before touching a layout, measure the PDF instead of a screenshot, and run a script on every cover before upload. Then six more books went through KDP and Lulu with no rejection. Each round still taught me something: a QR code pointing at a dead link, a canvas still carrying the last book's spine, an ISBN block Claude put in the wrong place.

    Now the split is clean. I design in Canva, sign in, attach the files and make every publish call. Claude computes the spine, measures every file, runs the checks and fills the KDP and Lulu forms. All 7 books are live.

    This is fine meme: a dog sits calmly in a room on fire. Top: Session two of layout edits. Bottom: this is fine.
    Scooby Doo reveal meme. Fred pulls the mask off a villain labelled broken layout: Let us see who you really are. It was the trim-size dropdown.

    The moving parts

    In the order things flow.

    1. 1

      KDP dashboard

      Trim size, page count, paper, bleed and ink. Read back before anything else.

    2. 2

      Interior PDF

      Every page the same trim, fonts embedded, page count final.

    3. 3

      Cover geometry

      Spine width from the page count and paper; cover width from the spine.

    4. 4

      Design tool

      The cover, designed with guides and exported as CMYK with crop marks off.

    5. 5

      verify_kdp_cover.py

      Measures the exported cover and fails on real problems.

    6. 6

      KDP previewer

      The last check. Read its literal error text.

    Tools I used

    • Amazon KDP · The dashboard where trim, paper, ink and bleed get set, and the previewer that has the final say
    • Canva · Designs the cover, with guides kept in one group; Claude also edits it through the Canva API
    • Claude Code · Runs the skill, measures the PDFs and explains every failure
    • Python · verify_kdp_cover.py, built on pypdf and Pillow
    • pdftoppm · Renders the cover so the script can measure real ink, not a screenshot
    • Ghostscript · The one I stopped using for colour: it dulled my reds
    Panik Kalm Panik meme. Panik: KDP crops 0.5 in off every edge. Kalm: the front cover text survives. Panik: the back cover text had 0.37 in of margin.

    Build your own

    1. Lock the interior page count

      Page count drives spine width, and spine width drives cover width. A two-page change moved one of my covers from 17.5833 to 17.5878 in. Don't start the cover until the count is final.

    2. Set the dashboard, then read it back

      Set trim, paper, ink and bleed in KDP, then check each against the interior file. KDP renders your cover against the trim size selected in the dashboard, not the one your PDF was built for.

      Prompt for Claude

      Here is my interior PDF. Read the trim size from the first page's mediabox, count the pages, and check that every page is the same size. Then list what I should see in the KDP dashboard for trim size, page count and bleed so I can compare them one by one.
    3. Check the interior

      Every page the same trim, fonts fully embedded or outlined, at least 24 pages, and an inside margin that fits the page count: 0.375 in up to 150 pages, 0.5 in for 151–300, 0.625 in for 301–500. A page-count change can quietly break the margin.

    4. Compute the cover from the final count

      Spine width is pages × a paper multiplier. White paper is 0.002252 in per page, which I confirmed against KDP: 150 pages gives 0.3378 in. The script also carries multipliers for other papers, but I haven't verified those. Check KDP's cover template generator before trusting them. Give Canva the canvas size in inches: a pixel width can't always be set exactly.

    5. Design and export without the traps

      Keep your guides in one group. Send the group to the front while you design and to the back before you export, instead of deleting them. Export as PDF Print with CMYK on and crop marks and bleed off. That setting adds about 0.2367 in to every edge, a second bleed on top of the one your canvas already has. If you reuse a design from another book, it still has that book's spine. Re-check the width.

    6. Run the script

      python verify_kdp_cover.py cover.pdf --pages 150 --trim 8.5x11 --paper white. Fix anything it fails. It takes a minute or two on a large cover, mostly decoding the image.

      Prompt for Claude

      Run verify_kdp_cover.py on cover.pdf with my page count, trim size and paper. For every FAIL, tell me what it measured, which KDP rule it breaks, and the smallest change in my design tool that fixes it.
    7. Upload, then read the previewer

      Upload both files and open the previewer. Make sure it's showing the file you just uploaded: a visible change in the newest version is the fastest tell. If it flags anything, copy the literal error text before you change a thing.

    Drake meme. Drake turns away from: Shrink the artwork to be safe. Drake approves: Read the dashboard back and check it against the file.

    Where it bit me

    • The dropdown did it

      The crop math: lost per edge = (your cover width − KDP's expected cover width) / 2. Mine was 0.5 in. Front-cover text had 0.94 in of margin and survived; back-cover text had 0.37 in and didn't. The 20 flagged interior pages were the same mismatch, which is why nothing was wrong when I checked locally.
    • Read the cut like a clue

      Which edge gets cut tells you which setting is wrong. Page count only changes the spine, so a wrong count shifts things sideways. Clipping at the top or bottom means the trim height is wrong: that's the trim-size dropdown, not the page count.
    • Screenshots lie

      Diagnosing from screenshots gave six wrong conclusions in a row, then two more the next session. A screenshot has unknown crop and zoom, and a title that looks sliced is usually the browser edge. Measure the PDF itself.
    • Your ruler is measuring itself

      Every pixel measurement I wrote was wrong on its first run, and each one looked plausible. An early version of my script failed a known-good cover at 0.010 in when the nearest text was 0.938 in away: it was measuring artwork, which is supposed to touch the trim. If a number lands on a boundary, a round number or the edge of your scan window, it's measuring your window.
    • Ghostscript ate my reds

      Ghostscript's RGB to CMYK conversion visibly dulled saturated reds. Export CMYK from the design tool instead, and after any PDF post-processing, check that the image stream's hash didn't change.
    • The barcode box, twice

      KDP prints its own barcode, so leave a 2.0 × 1.2 in area clear in the back cover's bottom-right corner, 0.25 in from the bottom and from the spine-side trim. On book three, Claude put the white block in the wrong place and made it 1.98 × 1.19 in. I caught it after submitting. Measure it after you place it.
    • Bleed won't save a wrong spine

      One book's canvas was 0.185 in too wide because it still had the previous book's spine. I asked why the bleed couldn't just absorb it. Bleed absorbs error at the outer edges, not at the spine fold, so the fix goes into the spine.
    Is this a pigeon meme. The man is labelled me, the butterfly is a sliced-looking title in a screenshot, and he asks: Is this a KDP problem?
    It was the edge of the browser.
    The full-wrap cover of Raven Steals the Sun: back cover with sample pages, a bio and a white box in the bottom-right corner, the spine, and the front with a formline raven over an orange sun.
    Book one's final wrap. The white box bottom-right is kept clear for KDP's barcode.

    Steal this

    verify_kdp_cover.py

    script · python

    #!/usr/bin/env python3
    """Measure a KDP full-wrap cover PDF against KDP's print requirements.
    
    Reports facts, not opinions: page geometry, image colour space and true DPI
    (recursing into Form XObjects), leftover design guides, bleed coverage, and the
    distance from every piece of ink to the trim line.
    
    Usage:
        python verify_kdp_cover.py cover.pdf --pages 150
        python verify_kdp_cover.py cover.pdf --pages 150 --trim 8.5x11 --paper white
    
    Requires: pypdf, Pillow. Raster checks additionally need `pdftoppm` (poppler);
    they are skipped with a notice if it is absent.
    """
    from __future__ import annotations
    
    import argparse
    import shutil
    import subprocess
    import sys
    import tempfile
    from dataclasses import dataclass
    from pathlib import Path
    
    # KDP constants. Verified against KDP's published paperback specification.
    SPINE_PER_PAGE = {
        "white": 0.002252,
        "cream": 0.0025,
        "groundwood": 0.00235,
        "color": 0.002347,
    }
    BLEED = 0.125           # per outer edge, included in the cover sheet size
    SAFE_INSET = 0.25       # text/important art must stay this far inside trim
    SPINE_TEXT_MIN_PAGES = 79
    SPINE_TEXT_INSET = 0.0625   # spine text must stay this far inside each fold
    BARCODE_W, BARCODE_H = 2.0, 1.2
    BARCODE_INSET = 0.25    # from the back cover's bottom and right trim
    MIN_DPI = 300
    RASTER_DPI = 96         # 1 px == 1/96 in, keeps the arithmetic readable
    
    # Neon colours conventionally used for on-canvas KDP guides. KDP bans these.
    GUIDE_COLOURS = {
        "cyan (trim)": (0x00, 0xE5, 0xFF),
        "magenta (safe)": (0xFF, 0x00, 0xA8),
        "yellow (spine fold)": (0xFF, 0xD4, 0x00),
        "green (spine safe)": (0x00, 0xFF, 0x66),
        "red (barcode)": (0xFF, 0x00, 0x00),
    }
    
    
    @dataclass(frozen=True)
    class Geometry:
        """Derived KDP cover geometry, all values in inches."""
    
        pages: int
        trim_w: float
        trim_h: float
        paper: str
        spine: float
        cover_w: float
        cover_h: float
        fold_left: float
        fold_right: float
    
    
    def compute_geometry(pages: int, trim_w: float, trim_h: float, paper: str) -> Geometry:
        """Derive full-wrap cover dimensions from trim size and page count."""
        if paper not in SPINE_PER_PAGE:
            raise ValueError(f"unknown paper {paper!r}; expected one of {sorted(SPINE_PER_PAGE)}")
        spine = pages * SPINE_PER_PAGE[paper]
        cover_w = 2 * trim_w + spine + 2 * BLEED
        cover_h = trim_h + 2 * BLEED
        fold_left = BLEED + trim_w
        return Geometry(
            pages=pages, trim_w=trim_w, trim_h=trim_h, paper=paper, spine=spine,
            cover_w=cover_w, cover_h=cover_h,
            fold_left=fold_left, fold_right=fold_left + spine,
        )
    
    
    class Report:
        """Collects PASS/FAIL/INFO lines and tracks whether anything failed."""
    
        def __init__(self) -> None:
            self.failed = False
            self.lines: list[str] = []
    
        def ok(self, label: str, detail: str) -> None:
            self.lines.append(f"  [PASS] {label}: {detail}")
    
        def bad(self, label: str, detail: str) -> None:
            self.failed = True
            self.lines.append(f"  [FAIL] {label}: {detail}")
    
        def info(self, label: str, detail: str) -> None:
            self.lines.append(f"  [INFO] {label}: {detail}")
    
        def section(self, title: str) -> None:
            self.lines.append(f"\n{title}")
    
        def render(self) -> str:
            return "\n".join(self.lines)
    
    
    def iter_images(resources, seen=None):
        """Yield (name, image_object), descending into Form XObjects.
    
        Canva and most design tools nest every placed image inside a Form XObject.
        A DPI check that does not recurse sees no images at all and silently
        falls back to nonsense.
        """
        seen = set() if seen is None else seen
        xobjects = (resources or {}).get("/XObject")
        if not xobjects:
            return
        for name, ref in xobjects.items():
            try:
                obj = ref.get_object()
            except Exception:
                continue
            key = id(obj)
            if key in seen:
                continue
            seen.add(key)
            subtype = obj.get("/Subtype")
            if subtype == "/Image":
                yield str(name), obj
            elif subtype == "/Form":
                yield from iter_images(obj.get("/Resources"), seen)
    
    
    def check_geometry(page, geo: Geometry, rep: Report) -> None:
        """Page size, rotation, and stale box entries."""
        box = page.mediabox
        w, h = float(box.width) / 72, float(box.height) / 72
        tol = 0.001
        detail = f"{w:.4f} x {h:.4f} in (need {geo.cover_w:.4f} x {geo.cover_h:.4f})"
        if abs(w - geo.cover_w) <= tol and abs(h - geo.cover_h) <= tol:
            rep.ok("page size", detail)
        else:
            rep.bad("page size", detail)
            extra_w, extra_h = w - geo.cover_w, h - geo.cover_h
            if extra_w > 0.05 and abs(extra_w - extra_h) < 0.02:
                rep.info("page size", f"+{extra_w / 2:.4f} in on every edge — "
                                      "looks like 'crop marks and bleed' was left on at export")
    
        rotate = page.get("/Rotate")
        (rep.ok if not rotate else rep.bad)("rotation", f"/Rotate = {rotate!r}")
    
    
    def check_content(page, rep: Report) -> None:
        """Fonts and placed-image colour space / resolution."""
        resources = page.get("/Resources") or {}
        fonts = list((resources.get("/Font") or {}).keys())
        if fonts:
            rep.info("fonts", f"{len(fonts)} present — each must be fully embedded")
        else:
            rep.ok("fonts", "none — text is outlined to vector paths")
    
        page_w_in = float(page.mediabox.width) / 72
        images = list(iter_images(resources))
        if not images:
            rep.info("images", "none found")
            return
        for name, img in images:
            width = int(img.get("/Width") or 0)
            cs = img.get("/ColorSpace")
            cs = cs if isinstance(cs, str) else (cs[0] if isinstance(cs, list) else str(cs))
            dpi = width / page_w_in if page_w_in else 0
            label = f"image {name}"
            note = f"{width}x{img.get('/Height')} {cs} ~{dpi:.0f} DPI across the full wrap"
            if dpi + 0.5 < MIN_DPI:
                rep.bad(label, note)
            else:
                rep.ok(label, note)
            if cs and "RGB" in str(cs):
                rep.info(label, "RGB — KDP prefers CMYK; export CMYK from the design tool, "
                                "never convert with ghostscript (it crushes saturated reds)")
    
    
    def rasterize(pdf: Path, out_dir: Path):
        """Render page 1 at RASTER_DPI. Returns a PIL image, or None if unavailable."""
        if not shutil.which("pdftoppm"):
            return None
        stem = out_dir / "page"
        subprocess.run(
            ["pdftoppm", "-png", "-r", str(RASTER_DPI), "-singlefile", str(pdf), str(stem)],
            check=True, capture_output=True,
        )
        from PIL import Image
        return Image.open(f"{stem}.png").convert("RGB")
    
    
    def check_guides(arr, rep: Report) -> None:
        """Any leftover neon guide shapes would print as bright lines."""
        import numpy as np
    
        present = {}
        for name, g in GUIDE_COLOURS.items():
            dist = np.abs(arr - np.array(g, dtype=np.int16)).sum(axis=2)
            count = int((dist < 60).sum())
            if count > 80:
                present[name] = count
        if present:
            rep.bad("design guides", f"leftover guide pixels {present} — delete them and re-export")
        else:
            rep.ok("design guides", "none detected")
    
    
    def check_bleed(arr, rep: Report) -> None:
        """White along an outer edge means the art stops short of the bleed."""
        import numpy as np
    
        white = (arr > 248).all(axis=2)
        edges = {
            "left": white[:, 0], "right": white[:, -1],
            "top": white[0, :], "bottom": white[-1, :],
        }
        worst = [f"{n} {int(e.mean() * 100)}% white" for n, e in edges.items() if e.mean() > 0.02]
        if worst:
            rep.bad("bleed", f"{', '.join(worst)} — art does not reach the edge")
        else:
            rep.ok("bleed", "art covers all four outer edges")
    
    
    def check_ink_margins(arr, geo: Geometry, rep: Report) -> None:
        """Distance from the trim line to the nearest non-background ink, per edge.
    
        Background is sampled from the page's own corners, so this works whether
        the cover sits on cream, white, or a dark field.
    
        A foreground run must be at least MIN_RUN px thick to count. Without that,
        a single antialiased pixel at a panel boundary reads as "ink at the trim
        line" and the whole measurement is wrong -- the exact false positive that
        makes eyeballed diagnoses unreliable.
        """
        import numpy as np
    
        h, w, _ = arr.shape
        d = int(round(BLEED * RASTER_DPI))
        corners = [arr[1, 1], arr[1, w - 2], arr[h - 2, 1], arr[h - 2, w - 2]]
        bg = np.zeros((h, w), dtype=bool)
        for c in corners:
            bg |= np.abs(arr - c.astype(np.int16)).sum(axis=2) < 45
        fg = ~bg
        fg[:d, :] = fg[-d:, :] = False           # ignore the bleed band itself
        fg[:, :d] = fg[:, -d:] = False
    
        MIN_RUN = 3
        col_counts, row_counts = fg.sum(axis=0), fg.sum(axis=1)
        cols = np.flatnonzero(col_counts >= MIN_RUN)
        rows = np.flatnonzero(row_counts >= MIN_RUN)
        if cols.size == 0 or rows.size == 0:
            rep.info("ink margins", "no distinct foreground found; inspect visually")
            return
    
        found = {
            "left": (cols[0] - d) / RASTER_DPI,
            "right": ((w - d) - cols[-1]) / RASTER_DPI,
            "top": (rows[0] - d) / RASTER_DPI,
            "bottom": ((h - d) - rows[-1]) / RASTER_DPI,
        }
        # Deliberately INFO, never FAIL. This finds the nearest non-background pixel,
        # which on a full-bleed cover is the artwork -- and artwork reaching the trim
        # is correct. Only a human (or a text-aware check) can say whether the thing
        # near the trim is art that should bleed or type that must stay inside.
        for edge, inches in sorted(found.items(), key=lambda kv: kv[1]):
            rep.info(f"nearest content {edge}", f"{inches:.3f} in inside trim")
        rep.info(
            "reading these",
            f"artwork at ~0.000 is correct on a bleed cover; confirm no TEXT is "
            f"closer than {SAFE_INSET} in by eye or by measuring the text colour only",
        )
    
    
    def main() -> int:
        ap = argparse.ArgumentParser(description="Verify a KDP full-wrap cover PDF.")
        ap.add_argument("pdf", type=Path)
        ap.add_argument("--pages", type=int, required=True, help="interior page count")
        ap.add_argument("--trim", default="8.5x11", help="trim size, e.g. 8.5x11 or 6x9")
        ap.add_argument("--paper", default="white", choices=sorted(SPINE_PER_PAGE))
        args = ap.parse_args()
    
        trim_w, trim_h = (float(v) for v in args.trim.lower().split("x"))
        geo = compute_geometry(args.pages, trim_w, trim_h, args.paper)
    
        print(f"KDP cover check — {args.pdf.name}")
        print(f"  {args.pages} pp, {trim_w}x{trim_h} in, {args.paper} paper")
        print(f"  spine {geo.spine:.4f} in; cover sheet {geo.cover_w:.4f} x {geo.cover_h:.4f} in")
        print(f"  spine folds at x = {geo.fold_left:.4f} and {geo.fold_right:.4f} in")
        if args.pages < SPINE_TEXT_MIN_PAGES:
            print(f"  NOTE: under {SPINE_TEXT_MIN_PAGES} pp — KDP does not allow spine text")
    
        from pypdf import PdfReader
        reader = PdfReader(str(args.pdf))
        rep = Report()
    
        rep.section("Structure")
        if len(reader.pages) != 1:
            rep.bad("page count", f"{len(reader.pages)} pages — a cover must be exactly 1")
        else:
            rep.ok("page count", "1")
        page = reader.pages[0]
        check_geometry(page, geo, rep)
        check_content(page, rep)
    
        rep.section("Rendered ink")
        with tempfile.TemporaryDirectory() as td:
            img = rasterize(args.pdf, Path(td))
            if img is None:
                rep.info("raster checks", "skipped — pdftoppm (poppler) not on PATH")
            else:
                import numpy as np
                arr = np.asarray(img).astype(np.int16)
                check_guides(arr, rep)
                check_bleed(arr, rep)
                check_ink_margins(arr, geo, rep)
    
        print(rep.render())
        print("\n" + ("RESULT: problems found" if rep.failed else "RESULT: all checks passed"))
        return 1 if rep.failed else 0
    
    
    if __name__ == "__main__":
        sys.exit(main())
    

    Needs Python 3 with pypdf and Pillow (pip install pypdf pillow). The raster checks also need pdftoppm from poppler; without it they're skipped with a notice.

    Who did what

    Claude

    • Computes the spine from the page count
    • Measures every PDF and runs the cover and interior checks
    • Fills the KDP and Lulu forms in the browser
    • Edits cover elements through the Canva API

    Me

    • Asked the trim-size question that found the dropdown
    • Designs the covers in Canva and exports them
    • Signs in, attaches the files and makes every publish call
    Galaxy brain meme, four panels of a brain glowing brighter: Edit the layout. Edit the layout again. Guess from screenshots. Ask if the trim size is right.

    See the books

    All seven of Sean Levon Nash's coloring books are on Amazon, and together on his book page.

    Next: How I turned Sean Levon Nash's paintings into seven coloring books

    All free guides