aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-05 09:36:29 +0200
committerDanilo M. <danix@danix.xyz>2026-10-05 09:36:29 +0200
commit517d1e69b1b30c4a647840417d896884fa9fcea1 (patch)
treea7829522ef63143a0e16b0fa51bd3ea6ee9b3d4a
parent8240bd84331db6d06d50876288d360e6cba45686 (diff)
downloadfanfictioner-517d1e69b1b30c4a647840417d896884fa9fcea1.tar.gz
fanfictioner-517d1e69b1b30c4a647840417d896884fa9fcea1.zip
Parse and validate plan.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rwxr-xr-xfanfictioner112
1 files changed, 112 insertions, 0 deletions
diff --git a/fanfictioner b/fanfictioner
index a3715c4..43691b9 100755
--- a/fanfictioner
+++ b/fanfictioner
@@ -64,7 +64,119 @@ def keep(src, dst):
return dst
+def parse_plan(text):
+ plan = {"title": "", "series": "", "book": "", "style": "", "characters": {}, "pages": []}
+ section = cur = None
+ for line in text.splitlines():
+ s = line.strip()
+ if s.startswith("# ") and not plan["title"]:
+ plan["title"] = s[2:].strip()
+ elif s.startswith("## "):
+ section, cur = s[3:].strip().lower(), None
+ elif s.startswith("### "):
+ head = s[4:].strip()
+ if section == "characters":
+ cur = plan["characters"].setdefault(head, [])
+ elif section == "pages":
+ m = re.match(r"(\d+)\.\s*(.*)", head)
+ cur = {"n": int(m[1]) if m else 0, "title": m[2] if m else head, "text": [],
+ "characters": [], "pose": "", "framing": "", "model": ""}
+ plan["pages"].append(cur)
+ elif section is None and (m := re.match(r"(Series|Book|Style):\s*(.*)", s)):
+ plan[m[1].lower()] = m[2].strip()
+ elif section == "pages" and cur is not None and \
+ (m := re.match(r"(Characters|Pose|Framing|Model):\s*(.*)", s)):
+ key, val = m[1].lower(), m[2].strip()
+ if key == "characters":
+ val = [] if val.lower() == "none" else [c.strip() for c in val.split(",") if c.strip()]
+ elif key == "model":
+ val = val.split()[0].lower() if val else ""
+ cur[key] = val
+ elif cur is not None and s:
+ (cur if isinstance(cur, list) else cur["text"]).append(s)
+ plan["characters"] = {k: " ".join(v) for k, v in plan["characters"].items()}
+ for p in plan["pages"]:
+ p["text"] = " ".join(p["text"])
+ return plan
+
+
+def validate_plan(plan):
+ errs = [f"missing {k}" for k in ("title", "series", "book") if not plan[k]]
+ if not plan["characters"]:
+ errs.append("no characters")
+ if not plan["pages"]:
+ errs.append("no pages")
+ ns = [p["n"] for p in plan["pages"]]
+ if len(set(ns)) != len(ns) or 0 in ns:
+ errs.append("page numbers must be unique and start at 1")
+ for p in plan["pages"]:
+ errs += [f"page {p['n']}: unknown character {c}" for c in p["characters"]
+ if c not in plan["characters"]]
+ if p["characters"] and not p["pose"]:
+ errs.append(f"page {p['n']}: Pose missing")
+ if p["model"] and p["model"] not in ("zimage", "krea2"):
+ errs.append(f"page {p['n']}: unknown model {p['model']}")
+ return errs
+
+
+SAMPLE_PLAN = """# Rooftop
+Series: Test Series
+Book: Book One
+Style: soft watercolor, muted colors
+
+## Characters
+
+### Mara
+Young woman, long red hair,
+green eyes, grey coat.
+
+### Jon
+Tall man, short beard, blue jacket.
+
+### Kit
+Small girl, black bob, yellow raincoat.
+
+## Pages
+
+### 1. Arrival
+They reach the roof at dusk.
+Characters: Mara, Jon, Kit
+Pose: Mara left facing right, Jon center facing camera, Kit right sitting.
+Framing: wide shot, eye level, landscape
+
+### 2. Empty roof
+Nobody is there any more.
+Characters: none
+Framing: wide shot, portrait
+Model: krea2
+"""
+
+
def selftest():
+ plan = parse_plan(SAMPLE_PLAN)
+ assert (plan["title"], plan["series"], plan["book"]) == ("Rooftop", "Test Series", "Book One"), plan
+ assert plan["style"] == "soft watercolor, muted colors"
+ assert list(plan["characters"]) == ["Mara", "Jon", "Kit"]
+ assert plan["characters"]["Mara"] == "Young woman, long red hair, green eyes, grey coat."
+ p1, p2 = plan["pages"]
+ assert (p1["n"], p1["title"], p1["characters"]) == (1, "Arrival", ["Mara", "Jon", "Kit"])
+ assert p1["text"] == "They reach the roof at dusk." and p1["pose"].startswith("Mara left")
+ assert p1["model"] == "" and p2["model"] == "krea2" and p2["characters"] == []
+ assert validate_plan(plan) == []
+
+ bad = parse_plan(SAMPLE_PLAN.replace("Book: Book One\n", "")
+ .replace("Characters: none", "Characters: Zed")
+ .replace("Model: krea2", "Model: sdxl")
+ .replace("### 2.", "### 1."))
+ errs = validate_plan(bad)
+ assert "missing book" in errs, errs
+ assert "page 1: unknown character Zed" in errs, errs
+ assert "page 1: Pose missing" in errs, errs
+ assert "page 1: unknown model sdxl" in errs, errs
+ assert "page numbers must be unique and start at 1" in errs, errs
+ assert validate_plan(parse_plan("")) == ["missing title", "missing series", "missing book",
+ "no characters", "no pages"]
+
with tempfile.TemporaryDirectory() as t:
t = Path(t)
(t / "c.conf").write_text('# comment\nLIBRARY="/data/my lib"\nPORT=8642\n')