Skill: tikitackr/kdp-publisher-de (version 1.0.0)

--- name: kdp-publisher description: Baut KDP-fertige PDFs aus Markdown-Kapiteln im professionellen OpenClaw-Buchstil (Inter-Font, 6x9 inch, Typst-basiert). Verwendung: "Bau mir ein PDF aus [Dateiname]" oder "Kompiliere Kapitel 3". --- # kdp-publisher — KDP PDF Builder Dieser Skill wandelt Markdown-Kapitel in KDP-fertige PDFs um — im Buchstil von "OpenClaw – Agentic Authorship". ## Voraussetzungen prüfen ```bash # Python-Abhängigkeiten installieren (einmalig): pip install requests qrcode # Skill-Verzeichnis: ls $OPENCLAW_WORKSPACE/skills/kdp-publisher/scripts/ ``` ## Verwendung **Ein Kapitel:** ```bash cd $OPENCLAW_WORKSPACE/skills/kdp-publisher/scripts python3 build-book.py --chapter /pfad/zu/kapitel.md /pfad/output/kapitel.pdf ``` **Ganzes Buch (alle Kapitel in der konfigurierten Reihenfolge):** ```bash cd $OPENCLAW_WORKSPACE/skills/kdp-publisher/scripts python3 build-book.py /pfad/output/buch.pdf ``` ## Was passiert im Hintergrund 1. `build-book.py` orchestriert den Build 2. `md2typ.py` konvertiert Markdown → Typst 3. `compile.py` generiert QR-Codes + sendet an TypeTex API → PDF ## Markdown-Format | Element | Syntax | |---|---| | Kapitelüberschrift | `# Titel` | | Abschnitt | `## Titel` | | Tipp-Box | `> **TIPP:** Text` | | Warn-Box | `> **WARNUNG:** Text` | | Hinweis-Box | `> **HINWEIS:** Text` | | Erfolg-Box | `> **ERFOLG:** Text` | | Zitat | `> Text` | | QR-Code | `[QR-CODE: ID – Label]` | | QR mit Hinweis | `[QR-CODE: ID – Label \| Hinweistext]` | ## QR-Codes konfigurieren QR-Code-URLs werden aus einer `links.json` geladen. Standardpfad: ``` $OPENCLAW_WORKSPACE/links.json ``` Format: ```json { "eigene_projekte": { "DASHBOARD_URL": "https://deine-domain.de" }, "qr_codes": { "MEIN-QR": { "target": "{{DASHBOARD_URL}}/seite", "print_url": "deine-domain.de/seite" } } } ``` ## Häufige Probleme - **QR nicht gefunden:** ID in `links.json` prüfen, kein `_`-Präfix - **API-Timeout:** Kapitel einzeln kompilieren statt ganzes Buch - **Font fehlt:** TypeTex API stellt Inter und Liberation Mono bereit — kein lokales Install nötig

path: _meta.json
content: {
  "name": "kdp-publisher-de",
  "version": "1.0.0",
  "author": "Tikitackr",
  "description": "Baut KDP-fertige PDFs aus Markdown-Kapiteln im professionellen OpenClaw-Buchstil. Typst-basiert, Inter-Font, 6x9 inch, mit QR-Code-Integration.",
  "language": "de",
  "license": "MIT",
  "tags": [
    "kdp",
    "pdf",
    "amazon",
    "buch",
    "typst",
    "markdown",
    "publishing"
  ],
  "commands": [
    "Bau mir ein PDF aus [Dateiname]",
    "Kompiliere Kapitel [N]",
    "Bau das komplette Buch als PDF",
    "Erstelle ein PDF"
  ],
  "requires": {
    "runtime": "python >= 3.9",
    "network": true
  },
  "repository": "https://clawhub.ai/Tikitackr/kdp-publisher-de"
}


path: README.md
content: # kdp-publisher

OpenClaw-Skill der Markdown-Kapitel in KDP-fertige PDFs umwandelt — im professionellen Buchstil von "OpenClaw – Agentic Authorship".

## Installation

In deinem OpenClaw-Chat:

```
Installiere den Skill Tikitackr/kdp-publisher
```

## Verwendung

```
Bau mir ein PDF aus kapitel-01.md
```

```
Kompiliere das komplette Buch als PDF
```

## Was der Skill liefert

- KDP-konformes PDF (6×9 inch, mit Gutter)
- Inter-Font, professioneller Buchsatz
- Kapiteltrennseiten, Boxen, QR-Codes
- Titelseite + Copyrightseite automatisch generiert

## Voraussetzungen

- OpenClaw auf einem VPS installiert
- Python 3.9+
- `pip install requests qrcode`

## Lizenz

MIT


path: scripts/kdp-book.typ.txt
content: // ============================================================
// KDP Book Template — Typst
// Amazon KDP-konform: Trim-Größen, Bleed, Gutter, Typografie
// Layout-Qualität: Hurenkinder/Waisen, sticky headings, recto-Kapitel
// ============================================================

#let trim-sizes = (
  "6x9":      (width: 6in,    height: 9in),
  "5.5x8.5":  (width: 5.5in,  height: 8.5in),
  "5x8":      (width: 5in,    height: 8in),
  "7x10":     (width: 7in,    height: 10in),
  "8.5x11":   (width: 8.5in,  height: 11in),
  "8.5x8.5":  (width: 8.5in,  height: 8.5in),
  "5.06x7.81":(width: 5.06in, height: 7.81in),
)

#let get-gutter(page-count-estimate) = {
  if page-count-estimate <= 150      { 0.375in }
  else if page-count-estimate <= 300 { 0.5in }
  else if page-count-estimate <= 500 { 0.625in }
  else if page-count-estimate <= 700 { 0.75in }
  else                               { 0.875in }
}

#let kdp-setup(
  title: "Buchtitel",
  author: "Autorenname",
  subtitle: none,
  language: "de",
  trim: "6x9",
  page-count-estimate: 200,
  bleed: false,
  body-font: "Inter",
  heading-font: "Inter",
  body-size: 11pt,
  line-height: 1.4,
  outside-margin: 0.5in,
  top-margin: 0.6in,
  bottom-margin: 0.7in,
  show-page-numbers: true,
  running-header: true,
  chapters-on-recto: true,
  isbn: none,
  doc,
) = {

  let trim-dim = trim-sizes.at(trim, default: trim-sizes.at("6x9"))
  let gutter = get-gutter(page-count-estimate)
  let bleed-size = if bleed { 0.125in } else { 0in }
  let page-width  = trim-dim.width  + bleed-size
  let page-height = trim-dim.height + (bleed-size * 2)
  let inner-margin = gutter
  let outer-margin = outside-margin

  set text(
    font: body-font,
    size: body-size,
    lang: language,
    hyphenate: true,
    costs: (
      widow:      100%,
      orphan:     100%,
      runt:        60%,
      hyphenation: 40%,
    ),
  )

  set par(
    justify: true,
    leading: line-height * body-size - body-size,
    first-line-indent: 0em,
    spacing: body-size * 1.4,
  )

  show raw.where(block: true): it => {
    v(0.3cm)
    block(
      width: 100%,
      fill: rgb("#0f172a"),
      stroke: (left: 3pt + rgb("#f59e0b")),
      inset: (left: 1em, right: 1em, top: 0.7em, bottom: 0.7em),
      breakable: false,
    )[
      #set text(font: "Liberation Mono", size: 9pt, fill: rgb("#e2e8f0"))
      #set par(justify: false, first-line-indent: 0em, leading: 0.4em)
      #it
    ]
    v(0.3cm)
  }

  show raw.where(block: false): it => {
    box(
      fill: rgb("#f1f5f9"),
      inset: (x: 4pt, y: 1pt),
      radius: 2pt,
    )[
      #set text(font: "Liberation Mono", size: 9.5pt, fill: rgb("#0f172a"))
      #it
    ]
  }

  set document(title: title, author: author)

  set page(
    width: page-width,
    height: page-height,
    margin: (
      inside:  inner-margin,
      outside: outer-margin,
      top:     top-margin,
      bottom:  bottom-margin,
    ),
    header: if running-header {
      context {
        let p = counter(page).get().first()
        let headings-on-page = query(heading.where(level: 1))
          .filter(h => h.location().page() == here().page())
        if p > 2 and headings-on-page.len() == 0 {
          set text(font: heading-font, size: 8pt, fill: rgb("#94a3b8"))
          set par(first-line-indent: 0em)
          stack(
            dir: ttb,
            if calc.odd(p) { align(right)[#title] }
            else           { align(left)[#title] },
            v(4pt),
            line(length: 100%, stroke: 0.5pt + rgb("#e2e8f0")),
          )
        }
      }
    },
    footer: if show-page-numbers {
      context {
        let p = counter(page).get().first()
        let headings-on-page = query(heading.where(level: 1))
          .filter(h => h.location().page() == here().page())
        if p > 2 and headings-on-page.len() == 0 {
          set text(size: 9pt)
          if calc.odd(p) { align(right)[#p] }
          else           { align(left)[#p] }
        }
      }
    },
  )

  show heading.where(level: 1): it => {
    page(
      width: page-width,
      height: page-height,
      margin: (top: 0pt, bottom: 0pt, left: 0pt, right: 0pt),
      header: none,
      footer: none,
    )[
      #v(1.8in)
      #rect(width: 100%, height: 6pt, fill: rgb("#f59e0b"))
      #pad(left: inner-margin, right: outer-margin)[
        #v(0.5in)
        #block[
          #set text(font: heading-font, size: 13pt, fill: rgb("#94a3b8"), tracking: 2pt)
          #set par(justify: false, first-line-indent: 0em)
          #upper[Kapitel #context counter(heading.where(level: 1)).display()]
        ]
        #v(14pt)
        #block[
          #set text(font: heading-font, size: 26pt, weight: "bold", fill: rgb("#d97706"))
          #set par(justify: false, first-line-indent: 0em, leading: 0.3em)
          #it.body
        ]
        #v(18pt)
        #rect(width: 60pt, height: 2pt, fill: rgb("#f59e0b"))
      ]
    ]
  }

  show heading.where(level: 2): it => {
    v(0.8cm)
    block(width: 100%, sticky: true)[
      #set text(font: heading-font, size: body-size * 1.25, weight: "bold")
      #set par(first-line-indent: 0em, justify: false)
      #it.body
    ]
    v(0.4cm)
  }

  show heading.where(level: 3): it => {
    v(0.5cm)
    block(width: 100%, sticky: true)[
      #set text(font: heading-font, size: body-size * 1.1, weight: "bold", style: "italic")
      #set par(first-line-indent: 0em, justify: false)
      #it.body
    ]
    v(0.25cm)
  }

  // Titelseite
  page(margin: (x: 1.5in, y: 1.5in))[
    #v(1fr)
    #align(center)[
      #set text(font: heading-font)
      #text(size: 20pt, weight: "bold")[#title]
      #if subtitle != none {
        v(0.5cm)
        text(size: 16pt, style: "italic")[#subtitle]
      }
      #v(1.5cm)
      #line(length: 60%, stroke: 0.5pt)
      #v(0.8cm)
      #text(size: 14pt)[#author]
    ]
    #v(1fr)
  ]

  // Copyright-Seite
  page(margin: (x: 1.2in, y: 1in))[
    #v(1fr)
    #set text(size: 8.5pt)
    #set par(justify: false, first-line-indent: 0em)
    #align(left)[
      *#title* \
      #if subtitle != none [#subtitle \ ]
      #v(0.3cm)
      Von #author \
      #v(0.5cm)
      Copyright © 2026 #author \
      Alle Rechte vorbehalten. \
      #v(0.3cm)
      Kein Teil dieses Buches darf ohne schriftliche Genehmigung \
      des Autors reproduziert oder verwendet werden. \
      #v(0.5cm)
      Erstveröffentlichung: 2026 \
      #if isbn != none [ISBN: #isbn \ ]
      Printed in Germany \
    ]
  ]

  counter(page).update(1)
  doc
}

// --- Hilfsfunktionen ---

#let epigraph(body, source: none) = {
  v(0.5cm)
  block(width: 80%, inset: (left: 1.5em, right: 1em, top: 0.5em, bottom: 0.5em), breakable: false)[
    #set text(style: "italic", size: 10pt)
    #set par(justify: false, first-line-indent: 0em)
    #body
    #if source != none [
      #v(0.2cm)
      #align(right)[#set text(style: "normal", size: 9pt); — #source]
    ]
  ]
  v(0.5cm)
}

#let _hint-box(label, border-color, bg-color, label-color, content) = {
  v(0.4cm)
  block(
    width: 100%,
    stroke: (left: 3pt + rgb(border-color)),
    inset: (left: 1em, right: 1em, top: 0.6em, bottom: 0.6em),
    fill: rgb(bg-color),
    breakable: true,
    sticky: true,
  )[
    #block[
      #set text(weight: "bold", size: 10pt, fill: rgb(label-color))
      #set par(first-line-indent: 0em)
      #label
    ]
    #v(0.15cm)
    #set text(size: 10.5pt)
    #set par(first-line-indent: 0em)
    #content
  ]
  v(0.4cm)
}

#let box-tip(content)     = _hint-box("Tipp",     "#3b82f6", "#eff6ff", "#3b82f6", content)
#let box-warning(content) = _hint-box("Warnung",  "#ef4444", "#fef2f2", "#ef4444", content)
#let box-success(content) = _hint-box("Erledigt", "#10b981", "#f0fdf4", "#10b981", content)
#let box-note(content)    = _hint-box("Hinweis",  "#f59e0b", "#fffbeb", "#d97706", content)

#let infobox(title: none, content) = _hint-box(
  if title != none { title } else { "Hinweis" },
  "#f59e0b", "#fffbeb", "#d97706", content
)

#let chapter-intro(content) = {
  v(0.3cm)
  block(width: 90%, inset: (left: 1.5em), sticky: true)[
    #set text(style: "italic", size: 10.5pt)
    #set par(first-line-indent: 0em)
    #content
  ]
  v(0.5cm)
}

#let dedication(content) = {
  pagebreak(to: "odd", weak: true)
  v(1fr)
  align(center)[
    #set text(style: "italic", size: 11pt)
    #set par(justify: false, first-line-indent: 0em)
    #content
  ]
  v(1fr)
  pagebreak()
}

#let scene-break() = {
  v(0.5cm)
  align(center)[#text(size: 14pt)[⁂]]
  v(0.5cm)
}

#let soft-break()    = pagebreak(weak: true)
#let chapter-start() = pagebreak(to: "odd", weak: false)

#let qr-block(qr-img, label, url, hint: "Am PC oder Tablet siehst du das volle interaktive Dashboard.") = {
  v(0.4cm)
  block(breakable: false, width: 100%)[
    #set par(first-line-indent: 0em)
    #grid(
      columns: (3cm, 1fr),
      gutter: 0.8em,
      qr-img,
      align(horizon)[
        #set text(size: 9pt)
        #text(weight: "bold")[#label] \
        #text(style: "italic")[#url] \
        #text(size: 8pt, fill: rgb("#555555"))[#hint]
      ]
    )
  ]
  v(0.4cm)
}


path: scripts/md2typ.py
content: #!/usr/bin/env python3
"""
md2typ.py — Markdown → Typst für OpenClaw-Buchkapitel
Verwendung: python md2typ.py <input.md> [output.typ]
            python md2typ.py <input.md>        # stdout
"""
import re
import sys
import os


# ── Inline-Konverter ──────────────────────────────────────────────────────────

def convert_inline(text):
    """Markdown-Inline-Syntax → Typst. Code-Spans werden nicht angefasst."""
    parts = []
    last = 0
    for m in re.finditer(r'`[^`]+`', text):
        parts.append(('text', text[last:m.start()]))
        parts.append(('code', m.group(0)))
        last = m.end()
    parts.append(('text', text[last:]))

    result = []
    for kind, chunk in parts:
        if kind == 'code':
            result.append(chunk)
        else:
            # Literal # und @ zuerst mit Platzhaltern schützen (bevor wir #strong etc. erzeugen)
            chunk = chunk.replace('#', '\x05')
            chunk = chunk.replace('@', '\x06')
            chunk = chunk.replace('$', '\x07')

            # Platzhalter für Bold/Italic, damit Regex-Reihenfolge keine Rolle spielt
            # Bold+Italic: ***text*** → Platzhalter
            chunk = re.sub(r'\*\*\*(.+?)\*\*\*', lambda m: f'\x01{m.group(1)}\x02', chunk)
            # Bold: **text** → Platzhalter
            chunk = re.sub(r'\*\*(.+?)\*\*', lambda m: f'\x03{m.group(1)}\x04', chunk)
            # Italic: *text* → _text_
            chunk = re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'_\1_', chunk)
            # Bold-Platzhalter auflösen — #strong[...] vermeidet Delimiter-Fehler bei /*, -*, etc.
            chunk = re.sub(r'\x01(.+?)\x02', lambda m: f'#strong[_{m.group(1)}_]', chunk)
            chunk = re.sub(r'\x03(.+?)\x04', lambda m: f'#strong[{m.group(1)}]', chunk)
            # Markdown-Links: [text](url) → #link("url")[text]
            chunk = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'#link("\2")[\1]', chunk)
            # Literal-Platzhalter zu Typst-Escapes auflösen
            chunk = chunk.replace('\x05', r'\#')
            chunk = chunk.replace('\x06', r'\@')
            chunk = chunk.replace('\x07', r'\$')
            result.append(chunk)

    return ''.join(result)


# ── Blockquote → Hint-Box ─────────────────────────────────────────────────────

_HINT_PATTERNS = [
    (r'^(?:💡\s*)?\*\*TIPP:\*\*\s*(.*)',     'box-tip'),
    (r'^(?:⚠️\s*)?\*\*WARNUNG:\*\*\s*(.*)',   'box-warning'),
    (r'^(?:✅\s*)?\*\*ERFOLG:\*\*\s*(.*)',    'box-success'),
    (r'^(?:📌\s*)?\*\*HINWEIS:\*\*\s*(.*)',   'box-note'),
    (r'^\*\*TIPP:\*\*\s*(.*)',                 'box-tip'),
    (r'^\*\*WARNUNG:\*\*\s*(.*)',              'box-warning'),
    (r'^\*\*ERFOLG:\*\*\s*(.*)',               'box-success'),
    (r'^\*\*HINWEIS:\*\*\s*(.*)',              'box-note'),
]

def convert_blockquote(lines):
    content_parts = []
    for line in lines:
        stripped = re.sub(r'^>\s?', '', line).strip()
        if stripped:
            content_parts.append(stripped)
    if not content_parts:
        return ''
    full = ' '.join(content_parts)

    for pattern, func in _HINT_PATTERNS:
        m = re.match(pattern, full, re.DOTALL)
        if m:
            inner = convert_inline(m.group(1).strip())
            return f'#{func}[\n{inner}\n]'

    # Einfaches Blockquote → Epigraph
    return f'#epigraph[\n{convert_inline(full)}\n]'


# ── Tabelle ───────────────────────────────────────────────────────────────────

def convert_table(lines):
    rows = []
    for line in lines:
        stripped = line.strip()
        if re.match(r'^\|[-:| ]+\|$', stripped):
            continue
        cells = [c.strip() for c in stripped.strip('|').split('|')]
        rows.append(cells)
    if not rows:
        return ''

    num_cols = len(rows[0])
    col_spec = ', '.join(['1fr'] * num_cols)
    header_cells = ', '.join(f'[{convert_inline(c)}]' for c in rows[0])

    out = [f'#table(\n  columns: ({col_spec}),\n  table.header({header_cells}),']
    for row in rows[1:]:
        # Pad/trim row to expected column count
        while len(row) < num_cols:
            row.append('')
        row = row[:num_cols]
        cells = ', '.join(f'[{convert_inline(c)}]' for c in row)
        out.append(f'  {cells},')
    out.append(')')
    return '\n'.join(out)


# ── QR-Code ───────────────────────────────────────────────────────────────────

def convert_qr(line):
    m = re.match(r'\[QR-CODE:\s*([A-Z0-9_-]+)\s*[–—-]\s*(.+?)\s*\]', line.strip())
    if m:
        qr_id = m.group(1)
        rest = m.group(2).strip()
        if '|' in rest:
            label, hint = [x.strip() for x in rest.split('|', 1)]
            return f'#qr-block(image("qr-{qr_id}.svg", width: 3cm), "{label}", "", hint: "{hint}")'
        return f'#qr-block(image("qr-{qr_id}.svg", width: 3cm), "{rest}", "")'
    return None


# ── Haupt-Konverter ───────────────────────────────────────────────────────────

def md2typ(md_text):
    lines = md_text.splitlines()
    out = []
    i = 0

    while i < len(lines):
        line = lines[i]

        # Code-Block
        if line.strip().startswith('```'):
            lang = line.strip()[3:].strip()
            code_lines = []
            i += 1
            while i < len(lines) and not lines[i].strip().startswith('```'):
                code_lines.append(lines[i])
                i += 1
            fence = f'```{lang}' if lang else '```'
            out.append(f'{fence}\n' + '\n'.join(code_lines) + '\n```')
            i += 1
            continue

        # Tabelle
        if (line.strip().startswith('|')
                and i + 1 < len(lines)
                and re.match(r'^\|[-:| ]+\|', lines[i + 1].strip())):
            table_lines = []
            while i < len(lines) and lines[i].strip().startswith('|'):
                table_lines.append(lines[i])
                i += 1
            out.append(convert_table(table_lines))
            continue

        # Blockquote
        if line.startswith('>'):
            quote_lines = []
            while i < len(lines) and lines[i].startswith('>'):
                quote_lines.append(lines[i])
                i += 1
            out.append(convert_blockquote(quote_lines))
            continue

        # Überschriften
        m = re.match(r'^(#{1,3})\s+(.*)', line)
        if m:
            level = len(m.group(1))
            title = convert_inline(m.group(2).strip())
            out.append('=' * level + ' ' + title)
            i += 1
            continue

        # Horizontale Linie → überspringen
        if re.match(r'^---+\s*$', line) or re.match(r'^\*\*\*+\s*$', line):
            i += 1
            continue

        # &nbsp;
        if line.strip() == '&nbsp;':
            out.append('')
            i += 1
            continue

        # QR-Code — konsekutive QR-Zeilen als nicht-trennbare Gruppe
        qr = convert_qr(line)
        if qr:
            group = [qr]
            i += 1
            while i < len(lines):
                next_qr = convert_qr(lines[i])
                if next_qr:
                    group.append(next_qr)
                    i += 1
                elif lines[i].strip() == '':
                    i += 1  # Leerzeilen innerhalb der Gruppe überspringen
                else:
                    break
            if len(group) > 1:
                out.append('#block(breakable: false)[\n' + '\n'.join(group) + '\n]')
            else:
                out.append(group[0])
            continue

        # Ungeordnete Liste
        if re.match(r'^[-*]\s+', line):
            while i < len(lines) and re.match(r'^[-*]\s+', lines[i]):
                text = re.sub(r'^[-*]\s+', '', lines[i])
                out.append('- ' + convert_inline(text))
                i += 1
            continue

        # Geordnete Liste
        if re.match(r'^\d+\.\s+', line):
            while i < len(lines) and re.match(r'^\d+\.\s+', lines[i]):
                text = re.sub(r'^\d+\.\s+', '', lines[i])
                out.append('+ ' + convert_inline(text))
                i += 1
            continue

        # Leerzeile
        if line.strip() == '':
            out.append('')
            i += 1
            continue

        # Normaler Absatz
        out.append(convert_inline(line))
        i += 1

    # Mehrfache Leerzeilen auf eine reduzieren
    result = []
    prev_blank = False
    for line in out:
        if line == '':
            if not prev_blank:
                result.append('')
            prev_blank = True
        else:
            result.append(line)
            prev_blank = False

    body = '\n'.join(result).strip()
    return '#import "kdp-book.typ": *\n\n' + body + '\n'


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

def main():
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(1)

    input_file = sys.argv[1]
    output_file = sys.argv[2] if len(sys.argv) > 2 else None

    if not os.path.exists(input_file):
        print(f'[md2typ] Fehler: {input_file} nicht gefunden')
        sys.exit(1)

    md_text = open(input_file, encoding='utf-8').read()
    typ_text = md2typ(md_text)

    if output_file:
        os.makedirs(os.path.dirname(os.path.abspath(output_file)), exist_ok=True)
        open(output_file, 'w', encoding='utf-8').write(typ_text)
        print(f'[md2typ] ✓ {input_file} → {output_file}')
    else:
        sys.stdout.write(typ_text)


if __name__ == '__main__':
    main()


path: scripts/compile.py
content: #!/usr/bin/env python3
"""
KDP PDF Compiler — kompiliert Typst zu KDP-fertigem PDF via TypeTex API.
Verwendung: python compile.py <main.typ> [output.pdf] [template.typ]
Beispiel:   python compile.py build/main.typ output/buch.pdf kdp-book.typ
"""
import requests, base64, json, os, sys, time, re, io

LINKS_JSON = os.path.expanduser(
    "~/Desktop/openclaw-projekt/agentic-authorship-dashboard/shared/links.json"
)

def _load_qr_urls():
    if not os.path.exists(LINKS_JSON):
        return {}, {}
    data = json.load(open(LINKS_JSON, encoding="utf-8"))
    base = data.get("eigene_projekte", {}).get("DASHBOARD_URL", "https://openclaw-buch.de")
    targets, print_urls = {}, {}
    for qr_id, entry in data.get("qr_codes", {}).items():
        if qr_id.startswith("_"):
            continue
        if isinstance(entry, dict) and "target" in entry:
            targets[qr_id] = entry["target"].replace("{{DASHBOARD_URL}}", base)
            if "print_url" in entry:
                print_urls[qr_id] = entry["print_url"]
    targets.setdefault("DISCORD-SETUP", f"{base}/?module=config-builder&preset=discord")
    targets.setdefault("HUMANIZER-INSTALL", "https://clawhub.ai/Tikitackr/humanizer-de")
    return targets, print_urls

QR_URLS, PRINT_URLS = _load_qr_urls()

def generate_qr_svg(url):
    import qrcode
    qr = qrcode.QRCode(border=1)
    qr.add_data(url)
    qr.make(fit=True)
    matrix = qr.get_matrix()
    n = len(matrix)
    cell = 10
    size = n * cell
    rects = []
    for r, row in enumerate(matrix):
        for c, val in enumerate(row):
            if val:
                x, y = c * cell, r * cell
                rects.append(f'<rect x="{x}" y="{y}" width="{cell}" height="{cell}"/>')
    return (
        f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}" '
        f'width="{size}" height="{size}">'
        f'<rect width="{size}" height="{size}" fill="white"/>'
        f'<g fill="black">{"".join(rects)}</g>'
        f'</svg>'
    )

def inject_qr_codes(main_content, aux):
    # Ersetzt vollständige qr-block-Aufrufe: Bild UND Anzeige-URL (mit oder ohne hint:)
    def replace_qr_block(m):
        qr_id = m.group(1)
        width = m.group(2)
        label = m.group(3)
        hint_part = m.group(4)  # '' oder ', hint: "..."'
        url = QR_URLS.get(qr_id, "https://openclaw-buch.de/")
        print_url = PRINT_URLS.get(qr_id, url.replace("https://", "").replace("http://", ""))
        print(f"[KDP] QR generieren: {qr_id} → {print_url}")
        svg = generate_qr_svg(url).replace("\\", "\\\\").replace('"', '\\"')
        return f'#qr-block(image.decode("{svg}", format: "svg", width: {width}), "{label}", "{print_url}"{hint_part})'
    main_content = re.sub(
        r'#qr-block\(\s*image\("qr-([A-Z0-9-]+)\.svg",\s*width:\s*([^)]+)\)\s*,\s*"([^"]*)"\s*,\s*"[^"]*"((?:,\s*hint:\s*"[^"]*")?)\s*\)',
        replace_qr_block,
        main_content
    )
    # Fallback: standalone image-Referenzen außerhalb von qr-block
    def replace_qr_image(m):
        qr_id = m.group(1)
        width = m.group(2)
        url = QR_URLS.get(qr_id, "https://openclaw-buch.de/")
        print(f"[KDP] QR generieren (standalone): {qr_id} → {url}")
        svg = generate_qr_svg(url).replace("\\", "\\\\").replace('"', '\\"')
        return f'image.decode("{svg}", format: "svg", width: {width})'
    main_content = re.sub(
        r'image\("qr-([A-Z0-9-]+)\.svg",\s*width:\s*([^)]+)\)',
        replace_qr_image,
        main_content
    )
    return main_content, aux

API_URL = os.environ.get(
    "TYPETEX_API_URL",
    "https://studio-intrinsic--typetex-compile-app.modal.run"
)

def health_check():
    try:
        r = requests.get(f"{API_URL}/public/compile/health", timeout=10)
        return r.json().get("status") == "ok"
    except Exception:
        return False

def compile_typst_to_pdf(main_content, auxiliary_files=None, output_path="output.pdf"):
    payload = {
        "content": main_content,
        "main_filename": "main.typ",
        "auxiliary_files": auxiliary_files or {}
    }
    print(f"[KDP] Sende an TypeTex API...")
    start = time.time()
    try:
        r = requests.post(f"{API_URL}/public/compile/typst", json=payload, timeout=120)
        elapsed = round(time.time() - start, 1)
        if r.status_code != 200:
            return {"success": False, "error": f"HTTP {r.status_code}: {r.text[:300]}"}
        result = r.json()
        if result.get("success"):
            pdf = base64.b64decode(result["pdf_base64"])
            os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
            open(output_path, "wb").write(pdf)
            size_kb = len(pdf) // 1024
            print(f"[KDP] ✓ PDF: {output_path} ({size_kb} KB, {elapsed}s)")
            return {"success": True, "pdf_path": os.path.abspath(output_path), "size_kb": size_kb}
        else:
            print(f"[KDP] ✗ Fehler: {result.get('error')}")
            print(result.get("log_output", "")[:800])
            return {"success": False, "error": result.get("error"), "log": result.get("log_output","")}
    except requests.Timeout:
        return {"success": False, "error": "Timeout — Buch zu groß. Kapitel einzeln kompilieren."}
    except requests.ConnectionError:
        return {"success": False, "error": f"API nicht erreichbar: {API_URL}"}
    except Exception as e:
        return {"success": False, "error": str(e)}

def main():
    if len(sys.argv) < 2:
        print(__doc__); sys.exit(1)
    main_file     = sys.argv[1]
    output_file   = sys.argv[2] if len(sys.argv) > 2 else main_file.replace(".typ", ".pdf")
    template_file = sys.argv[3] if len(sys.argv) > 3 else None

    if not os.path.exists(main_file):
        print(f"[KDP] Fehler: {main_file} nicht gefunden"); sys.exit(1)

    main_content = open(main_file, encoding="utf-8").read()
    aux = {}
    main_dir = os.path.dirname(os.path.abspath(main_file))

    # Alle #include "datei.typ" aus main automatisch hochladen
    for inc in re.findall(r'#include\s+"([^"]+)"', main_content):
        inc_path = os.path.join(main_dir, inc)
        if os.path.exists(inc_path):
            aux[inc] = open(inc_path, encoding="utf-8").read()
            print(f"[KDP] Include: {inc}")
        else:
            print(f"[KDP] Warnung: #include \"{inc}\" nicht gefunden ({inc_path})")

    candidates = [
        template_file,
        os.path.join(os.path.dirname(os.path.abspath(main_file)), "kdp-book.typ.txt"),
        os.path.join(os.path.dirname(os.path.abspath(main_file)), "kdp-book.typ"),
        os.path.join(os.getcwd(), "kdp-book.typ.txt"),
        os.path.join(os.getcwd(), "kdp-book.typ"),
    ]
    for candidate in candidates:
        if candidate and os.path.exists(candidate):
            aux["kdp-book.typ"] = open(candidate, encoding="utf-8").read()
            break
    else:
        print("[KDP] Warnung: kdp-book.typ nicht gefunden — bitte als 3. Argument übergeben.")

    main_content, aux = inject_qr_codes(main_content, aux)
    # QR-Injektion auch in aux-Dateien (z.B. #include-Kapitel)
    for fname in list(aux.keys()):
        if fname.endswith(".typ") and fname != "kdp-book.typ":
            aux[fname], _ = inject_qr_codes(aux[fname], {})

    if not health_check():
        print(f"[KDP] API nicht erreichbar — versuche lokales Typst...")
        import subprocess
        try:
            r = subprocess.run(["typst","compile",main_file,output_file],
                               capture_output=True, text=True, timeout=60)
            if r.returncode == 0:
                print(f"[KDP] ✓ Lokal: {output_file}")
            else:
                print(f"[KDP] ✗ {r.stderr}"); sys.exit(1)
        except FileNotFoundError:
            print("[KDP] Typst nicht installiert: https://github.com/typst/typst/releases")
            sys.exit(1)
        return

    result = compile_typst_to_pdf(main_content, aux, output_file)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    if not result["success"]: sys.exit(1)

if __name__ == "__main__":
    main()


path: scripts/build-book.py
content: #!/usr/bin/env python3
"""
build-book.py — Baut das komplette OpenClaw-Buch als KDP-PDF
Verwendung: python build-book.py [output.pdf]
            python build-book.py --chapter kapitel-01-final.md [output.pdf]
"""
import os
import sys
import json

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from md2typ import md2typ
from compile import compile_typst_to_pdf, inject_qr_codes, health_check

# ── Konfiguration ─────────────────────────────────────────────────────────────

KAPITEL_DIR = os.path.expanduser(
    "~/Desktop/openclaw-projekt/buch-review/kapitel"
)

CHAPTER_ORDER = [
    "vorwort-v3-final.md",
    "kapitel-01-final.md",
    "kapitel-02-final.md",
    "kapitel-03-final.md",
    "kapitel-04-final.md",
    "kapitel-05-final.md",
    "kapitel-06-final.md",
    "kapitel-07-final.md",
    "kapitel-08-final.md",
    "kapitel-09-final.md",
    "kapitel-10-final.md",
    "kapitel-11-final.md",
    "kapitel-12-final.md",
    "kapitel-13-final.md",
    "kapitel-13-teil2-final.md",
    "kapitel-13-teil3-final.md",
    "kapitel-14-final.md",
    "kapitel-15-final.md",
]

TEMPLATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "kdp-book.typ.txt")

BOOK_HEADER = """\
#import "kdp-book.typ": *

#show: kdp-setup.with(
  title: "OpenClaw — Agentic Authorship",
  author: "Thomas Iburg",
  subtitle: "Wie KI-Agenten dein Buch schreiben",
  language: "de",
  trim: "6x9",
  page-count-estimate: 300,
  bleed: false,
  body-font: "Inter",
  heading-font: "Inter",
  body-size: 11pt,
  chapters-on-recto: true,
)
"""

IMPORT_HEADER = '#import "kdp-book.typ": *\n\n'

# ── Hilfsfunktionen ───────────────────────────────────────────────────────────

def convert_chapter(md_path):
    """Konvertiert ein Markdown-Kapitel zu Typst (ohne Import-Header)."""
    md_text = open(md_path, encoding="utf-8").read()
    typ_text = md2typ(md_text)
    # Import-Header entfernen — wird einmalig im BOOK_HEADER gesetzt
    if typ_text.startswith(IMPORT_HEADER):
        typ_text = typ_text[len(IMPORT_HEADER):]
    return typ_text


def build_main_typ(chapters):
    """Erzeugt den vollständigen main.typ-Inhalt aus einer Liste von MD-Pfaden."""
    parts = [BOOK_HEADER]
    for md_path in chapters:
        name = os.path.basename(md_path)
        print(f"[build] Konvertiere: {name}")
        body = convert_chapter(md_path)
        parts.append(f"\n// ── {name} ──\n\n{body}")
    return "\n".join(parts)


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

def main():
    args = sys.argv[1:]

    # --chapter <datei> [output]
    if args and args[0] == "--chapter":
        if len(args) < 2:
            print("Verwendung: build-book.py --chapter <datei.md> [output.pdf]")
            sys.exit(1)
        md_file = args[1]
        if not os.path.isabs(md_file):
            md_file = os.path.join(KAPITEL_DIR, md_file)
        output_file = args[2] if len(args) > 2 else "output/chapter-test.pdf"
        chapters = [md_file]
        # Für einzelne Kapitel: mit BOOK_HEADER (inkl. kdp-setup)
        main_content = BOOK_HEADER + "\n" + convert_chapter(md_file)
    else:
        output_file = args[0] if args else "output/openclaw-buch.pdf"
        chapters = [os.path.join(KAPITEL_DIR, f) for f in CHAPTER_ORDER]
        for path in chapters:
            if not os.path.exists(path):
                print(f"[build] Fehler: {path} nicht gefunden")
                sys.exit(1)
        main_content = build_main_typ(chapters)

    # Template laden
    if not os.path.exists(TEMPLATE_PATH):
        print(f"[build] Fehler: {TEMPLATE_PATH} nicht gefunden")
        sys.exit(1)
    aux = {"kdp-book.typ": open(TEMPLATE_PATH, encoding="utf-8").read()}

    # QR-Codes injizieren
    print("[build] QR-Codes injizieren...")
    main_content, aux = inject_qr_codes(main_content, aux)

    # API-Check
    if not health_check():
        print("[build] Fehler: TypeTex API nicht erreichbar")
        sys.exit(1)

    # Kompilieren
    os.makedirs(os.path.dirname(os.path.abspath(output_file)), exist_ok=True)
    print(f"[build] Kompiliere → {output_file}")
    result = compile_typst_to_pdf(main_content, aux, output_file)

    print(json.dumps(result, ensure_ascii=False, indent=2))
    if not result["success"]:
        sys.exit(1)


if __name__ == "__main__":
    main()


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