ghostwriter-muse-pages 11.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/build_pages.py +183 -9
  2. package/kit.js +217 -11
  3. package/package.json +1 -1
package/build_pages.py CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Build the book's pages (Overview, Book Setup, Voice, Stories, Notes, Outline, Manuscript, Ghostwriter).
2
+ """Build the book's pages (Overview, Book Setup, Voice, Stories, Notes, Outline, Manuscript, Cover, Ghostwriter).
3
3
 
4
4
  Reads <book>/Dashboard/book.json the status and outline, written by the ghostwriter
5
5
  <book>/START-HERE.md only its "Method:" line, when book.json does not say which version is in use
@@ -8,23 +8,25 @@ Reads <book>/Dashboard/book.json the status and outline, written by t
8
8
  <book>/2-Plan/04-story-bank.md the Stories page, read as it is
9
9
  <book>/2-Plan/00-notes.md the notes board, read as it is
10
10
  <book>/5-Manuscript/*.md the chapters, read as they are
11
- Writes <book>/Dashboard/Overview.html, Setup.html, Voice.html, Stories.html, Notes.html, Outline.html, Manuscript.html, Ghostwriter.html
11
+ <book>/2-Plan/cover/cover.json the Cover page, with the picture it names if the author brought one
12
+ Writes <book>/Dashboard/Overview.html, Setup.html, Voice.html, Stories.html, Notes.html, Outline.html, Manuscript.html, Cover.html, Ghostwriter.html
13
+ <book>/Dashboard/cover-picture.* a copy of the author's own cover picture, for the pages in the folder
12
14
  <book>/Dashboard/_live/ only with --live: book-dashboard.html to publish, plus data/*.json to load into it
13
15
 
14
16
  Usage: python3 build_pages.py --book "/path/to/Book Folder" [--live]
15
17
  python3 build_pages.py --version
16
- Python 3 standard library only. This one file is the whole builder: the design of the pages is not in it.
18
+ Python 3 standard library only (a large cover picture is made smaller when the Pillow library happens to be installed). This one file is the whole builder: the design of the pages is not in it.
17
19
  Every page it writes is a few lines that load the page code, at one exact version, from the address in KIT_URL.
18
20
  """
19
21
  import argparse, json, os, re, sys
20
22
 
21
- BUILDER = "0.8.1" # this builder's own version
22
- KIT_VERSION = "11.0.0" # the exact version of the page code every page loads
23
+ BUILDER = "0.9.1" # this builder's own version
24
+ KIT_VERSION = "13.0.0" # the exact version of the page code every page loads
23
25
  KIT_URL = "https://cdn.jsdelivr.net/npm/ghostwriter-muse-pages@" + KIT_VERSION + "/kit.js"
24
26
  FONTS_URL = "https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Inter:wght@400;500;600&family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap"
25
27
  GHOSTWRITERS = {"lawrence", "kenji", "saira", "octavio", "zora", "boo", "imani", "indigo", "idris", "ryu", "marcus-aurelius", "frederick-douglass", "jane-austen",
26
28
  "li-bai", "mary-shelley", "murasaki-shikibu", "phillis-wheatley", "rumi", "sor-juana", "william-shakespeare"}
27
- PAGES = {"overview": "Overview.html", "setup": "Setup.html", "voice": "Voice.html", "stories": "Stories.html", "notes": "Notes.html", "outline": "Outline.html", "manuscript": "Manuscript.html", "ghostwriter": "Ghostwriter.html"}
29
+ PAGES = {"overview": "Overview.html", "setup": "Setup.html", "voice": "Voice.html", "stories": "Stories.html", "notes": "Notes.html", "outline": "Outline.html", "manuscript": "Manuscript.html", "cover": "Cover.html", "ghostwriter": "Ghostwriter.html"}
28
30
  STAGE_COUNT = 10
29
31
  PAGE_KIT = int(KIT_VERSION.split(".")[0]) # the page version written beside the live link in START-HERE.md
30
32
  STORY_FIELDS = {"home": "home", "role": "role", "when": "when", "narrative": "narrative", "how it ended": "ended", "teaching point": "point", "people": "people",
@@ -374,6 +376,157 @@ def read_stories(plan, problems):
374
376
  return {"status": status, "confirmed": confirmed, "items": items}
375
377
 
376
378
 
379
+ COVER_LAYOUTS = ("paper", "bold", "quiet")
380
+ COVER_FONTS = {"Fraunces", "Cormorant Garamond", "Playfair Display", "DM Serif Display", "Libre Baskerville", "Lora", "Archivo Black", "Archivo", "Bebas Neue", "Oswald", "Space Grotesk", "Inter"}
381
+ HEX = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
382
+ VIEWBOX = re.compile(r"^\s*-?\d+(?:\.\d+)?(?:[\s,]+-?\d+(?:\.\d+)?){3}\s*$")
383
+ COVER_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,40}$")
384
+ SVG_BAD = re.compile(r"<\s*(?:script|foreignObject|image|use|a|text|tspan|style|iframe|animate\w*|set|svg)\b|\bon[a-z]+\s*=|href\s*=|javascript:|<!|<\?", re.I)
385
+ PART = 180000 # letters of a picture carried by one document on the live page
386
+ PICTURE_MAX = 1500000 # bytes: the largest picture the pages carry
387
+
388
+
389
+ def picture_kind(data):
390
+ if data[:3] == b"\xff\xd8\xff":
391
+ return "image/jpeg", "jpg"
392
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
393
+ return "image/png", "png"
394
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
395
+ return "image/webp", "webp"
396
+ if data[:4] == b"GIF8":
397
+ return "image/gif", "gif"
398
+ return None, None
399
+
400
+
401
+ def shrink(data, mime):
402
+ """Make a large picture smaller, if this computer happens to have the Pillow library. Without it the picture is used as it is."""
403
+ if mime == "image/gif":
404
+ return data, mime
405
+ try:
406
+ import io
407
+ from PIL import Image, ImageOps
408
+ im = Image.open(io.BytesIO(data))
409
+ if max(im.size) <= 1500 and len(data) <= 350000:
410
+ return data, mime
411
+ im = ImageOps.exif_transpose(im)
412
+ im.thumbnail((1500, 1500))
413
+ buf = io.BytesIO()
414
+ im.convert("RGB").save(buf, "JPEG", quality=86, optimize=True)
415
+ return (buf.getvalue(), "image/jpeg") if len(buf.getvalue()) < len(data) else (data, mime)
416
+ except Exception:
417
+ return data, mime
418
+
419
+
420
+ def cover_lettering(src, where, problems):
421
+ """The typefaces, colors, and line breaks of one cover. Anything not on the lists is left out, and the page uses its own choice."""
422
+ out = {}
423
+ for k in ("titleFont", "textFont"):
424
+ if src.get(k):
425
+ if src[k] in COVER_FONTS:
426
+ out[k] = src[k]
427
+ else:
428
+ problems.append('cover.json: %s names the typeface "%s", which the pages do not carry. It is left out. Use one of: %s.' % (where, src[k], ", ".join(sorted(COVER_FONTS))))
429
+ if isinstance(src.get("titleLines"), list) and 1 <= len(src["titleLines"]) <= 6:
430
+ out["titleLines"] = [str(x) for x in src["titleLines"]]
431
+ if isinstance(src.get("caps"), bool):
432
+ out["caps"] = src["caps"]
433
+ return out
434
+
435
+
436
+ def read_cover(book_dir, dash, problems):
437
+ """The Cover page is built from 2-Plan/cover/cover.json, written by the ghostwriter, and from the picture it names, if the
438
+ author brought one. Returns (the cover as the folder pages carry it, the cover as the live page carries it, the picture's parts)."""
439
+ path = os.path.join(book_dir, "2-Plan", "cover", "cover.json")
440
+ if not os.path.exists(path):
441
+ return None, None, []
442
+ try:
443
+ raw = json.load(open(path, encoding="utf-8"))
444
+ except (ValueError, UnicodeDecodeError) as e:
445
+ problems.append("2-Plan/cover/cover.json is not valid JSON (%s), so the Cover page is not built. Fix the file and build again." % e)
446
+ return None, None, []
447
+ if not isinstance(raw, dict):
448
+ problems.append("2-Plan/cover/cover.json must be one object. See the cover reference.")
449
+ return None, None, []
450
+ out, seen = {"concepts": []}, set()
451
+ for c in raw.get("concepts") or []:
452
+ if not isinstance(c, dict) or not COVER_ID.match(str(c.get("id", ""))) or c.get("id") == "own" or not str(c.get("name", "")).strip():
453
+ problems.append('cover.json: each direction needs an id (small letters, digits, hyphens; not "own") and a name. One was left out.')
454
+ continue
455
+ if c["id"] in seen:
456
+ problems.append('cover.json: two directions share the id "%s". The second one was left out.' % c["id"])
457
+ continue
458
+ if len(out["concepts"]) == 4:
459
+ problems.append("cover.json: the Cover page shows at most four directions. The rest were left out.")
460
+ break
461
+ seen.add(c["id"])
462
+ where = 'the direction "%s"' % c["name"]
463
+ d = {"id": c["id"], "name": str(c["name"]).strip(), "why": str(c.get("why", "")).strip(), "layout": c.get("layout") if c.get("layout") in COVER_LAYOUTS else "paper"}
464
+ if c.get("layout") not in COVER_LAYOUTS:
465
+ problems.append("cover.json: %s has no layout, or one the pages do not know. It is drawn as \"paper\". Use one of: %s." % (where, ", ".join(COVER_LAYOUTS)))
466
+ colors = {k: v for k, v in (c.get("colors") or {}).items() if k in ("paper", "ink", "accent") and HEX.match(str(v))} if isinstance(c.get("colors"), dict) else {}
467
+ if len(colors) < 3:
468
+ problems.append("cover.json: %s needs three colors, paper, ink, and accent, each written like #1F3B2D. The page fills in the missing ones." % where)
469
+ d["colors"] = colors
470
+ d.update(cover_lettering(c, where, problems))
471
+ m = c.get("motif")
472
+ if isinstance(m, dict) and str(m.get("svg", "")).strip():
473
+ svg = str(m["svg"]).strip()
474
+ if len(svg) > 20000 or SVG_BAD.search(svg) or not VIEWBOX.match(str(m.get("viewBox", ""))):
475
+ problems.append("cover.json: the drawing for %s was left out. A drawing is plain shapes only (path, rect, circle, ellipse, line, polygon, polyline, g, and gradients), at most 20,000 letters, with a viewBox of four numbers, and no words, pictures, links, or scripts." % where)
476
+ else:
477
+ d["motif"] = {"viewBox": str(m["viewBox"]).strip(), "svg": svg}
478
+ out["concepts"].append(d)
479
+ out["prompts"] = [{"label": str(x.get("label", "")).strip(), "why": str(x.get("why", "")).strip(), "say": str(x["say"]).strip()}
480
+ for x in (raw.get("prompts") or []) if isinstance(x, dict) and str(x.get("say", "")).strip()][:6]
481
+ b = raw.get("back")
482
+ if isinstance(b, dict) and (b.get("headline") or b.get("text")):
483
+ text = b.get("text") if isinstance(b.get("text"), list) else [b.get("text")] if b.get("text") else []
484
+ status = str(b.get("status", "")).strip()
485
+ d = re.search(r"\d{4}-\d{2}-\d{2}", status)
486
+ out["back"] = {"status": "confirmed" if status.upper().startswith("CONFIRMED") else "draft", "confirmed": d.group(0) if d else "",
487
+ "headline": str(b.get("headline", "")).strip(), "text": [str(x).strip() for x in text if str(x).strip()][:8], "bio": str(b.get("bio", "")).strip()}
488
+ live, parts, own = dict(out), [], raw.get("own")
489
+ if isinstance(own, dict) and str(own.get("file", "")).strip():
490
+ rel = str(own["file"]).strip().replace("\\", "/")
491
+ src = os.path.normpath(os.path.join(book_dir, rel))
492
+ data = None
493
+ if not src.startswith(book_dir + os.sep) or not os.path.isfile(src):
494
+ problems.append('cover.json: the picture "%s" is not in the book folder, so the author\'s own cover is not shown. Give its place inside the book folder, for example 2-Plan/cover/my-cover.jpg.' % rel)
495
+ else:
496
+ data = open(src, "rb").read()
497
+ mime, ext = picture_kind(data)
498
+ if not mime:
499
+ problems.append('cover.json: "%s" is not a JPEG, PNG, WebP, or GIF picture, so it is not shown. Ask the author for one of those, or save a copy as JPEG.' % rel)
500
+ data = None
501
+ else:
502
+ data, mime = shrink(data, mime)
503
+ ext = picture_kind(data)[1]
504
+ if len(data) > PICTURE_MAX:
505
+ problems.append('cover.json: the picture "%s" is %.1f MB, which is more than the pages can carry (1.5 MB). Save a smaller copy beside it (1,500 pixels tall is plenty, as a JPEG), name that copy in own.file, and build again.' % (rel, len(data) / 1e6))
506
+ data = None
507
+ if data is not None:
508
+ import base64, hashlib
509
+ name = "cover-picture." + ext
510
+ open(os.path.join(dash, name), "wb").write(data)
511
+ b64, sig = base64.b64encode(data).decode("ascii"), hashlib.sha256(data).hexdigest()[:12]
512
+ chunks = [b64[i:i + PART] for i in range(0, len(b64), PART)]
513
+ parts = [{"sig": sig, "i": i, "of": len(chunks), "data": ch} for i, ch in enumerate(chunks)]
514
+ base = {"words": own.get("words") is not False, "note": str(own.get("note", "")).strip()}
515
+ if not base["words"]:
516
+ base["lettering"] = cover_lettering(own.get("lettering") if isinstance(own.get("lettering"), dict) else {}, "the author's own picture", problems)
517
+ ink = (own.get("lettering") or {}).get("ink") if isinstance(own.get("lettering"), dict) else None
518
+ if HEX.match(str(ink or "")):
519
+ base["lettering"]["ink"] = ink
520
+ out["own"] = dict(base, src=name)
521
+ live["own"] = dict(base, sig=sig, parts=len(chunks), type=mime, bytes=len(data))
522
+ chosen = str(raw.get("chosen", "") or "").strip()
523
+ if chosen and not (chosen in seen or (chosen == "own" and "own" in out)):
524
+ problems.append('cover.json: chosen is "%s", which is not one of the directions%s. No cover is marked as chosen.' % (chosen, "" if "own" in out else ", and there is no picture of the author's own"))
525
+ chosen = ""
526
+ out["chosen"] = live["chosen"] = chosen
527
+ return out, live, parts
528
+
529
+
377
530
  def read_version(book_dir, state, problems):
378
531
  """The version marker in the top bar of every page. The ghostwriter writes state.version at each refresh:
379
532
  {"method": "0.8.1", "plugin": "0.8.0", "checked": "2026-10-10"}. Anything that is not a plain version or date is dropped.
@@ -452,6 +605,7 @@ def main():
452
605
  board = read_notes(os.path.join(book_dir, "2-Plan", "00-notes.md"), notes)
453
606
  plan = os.path.join(book_dir, "2-Plan")
454
607
  setup, voice, stories = read_setup(plan), read_voice(plan), read_stories(plan, notes)
608
+ cover, cover_live, cover_parts = read_cover(book_dir, dash, notes)
455
609
  if OLD_STAGES:
456
610
  notes.append("book.json lists 11 steps. The Proposal is no longer a step: write state.stages with 10 values and state.stage from 1 to 10 (see references/pages.md). This build dropped the sixth value for you.")
457
611
 
@@ -461,7 +615,7 @@ def main():
461
615
  title = state["title"].replace("&", "&amp;").replace("<", "").replace(">", "")
462
616
 
463
617
  # 1. The pages for the book folder: one small file per page, carrying the book's data with it.
464
- snap = {"state": state, "outline": outline, "manuscript": manuscript, "notes": board, "setup": setup, "voice": voice, "stories": stories}
618
+ snap = {"state": state, "outline": outline, "manuscript": manuscript, "notes": board, "setup": setup, "voice": voice, "stories": stories, "cover": cover}
465
619
  for key, fname in PAGES.items():
466
620
  doc = ('<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
467
621
  "<style>img{max-width:100%}[hidden]{display:none!important}</style></head><body>\n"
@@ -471,7 +625,7 @@ def main():
471
625
  # 2. The live page and its data, for sessions that can publish it.
472
626
  live = os.path.join(dash, "_live")
473
627
  if a.live:
474
- write_live(live, title, state, outline, manuscript, board, setup, voice, stories)
628
+ write_live(live, title, state, outline, manuscript, board, setup, voice, stories, cover_live, cover_parts)
475
629
 
476
630
  # 3. Say what was built, in numbers the ghostwriter can check against the folder.
477
631
  total = 0
@@ -496,11 +650,20 @@ def main():
496
650
  ("%d lines, %s" % (len(setup["items"]), setup["status"])) if setup else "not written yet",
497
651
  ("%d lines, %s" % (len(voice["items"]) + len((voice.get("style") or {}).get("items", [])), voice["status"])) if voice else "not written yet",
498
652
  ("%d, %s" % (len(stories["items"]), stories["status"])) if stories else "not written yet"))
653
+ if cover is None:
654
+ print(" cover: not started")
655
+ else:
656
+ own = (cover_live or {}).get("own")
657
+ names = {c["id"]: c["name"] for c in cover["concepts"]}
658
+ print(" cover: %d direction%s (%d with a drawing); the author's own picture: %s; chosen: %s" % (
659
+ len(cover["concepts"]), "" if len(cover["concepts"]) == 1 else "s", sum(1 for c in cover["concepts"] if "motif" in c),
660
+ ("%d KB, carried as coverimg/p-00%s" % (round(own["bytes"] / 1000), " to p-%02d" % (own["parts"] - 1) if own["parts"] > 1 else "")) if own else "none",
661
+ "the author's own picture" if cover["chosen"] == "own" else names.get(cover["chosen"], "nothing yet")))
499
662
  for n in notes:
500
663
  print("NOTE " + n)
501
664
 
502
665
 
503
- def write_live(live, title, state, outline, manuscript, board, setup, voice, stories):
666
+ def write_live(live, title, state, outline, manuscript, board, setup, voice, stories, cover=None, cover_parts=()):
504
667
  os.makedirs(os.path.join(live, "data", "manuscript"), exist_ok=True)
505
668
  open(os.path.join(live, "book-dashboard.html"), "w", encoding="utf-8").write(shell(title + " Dashboard"))
506
669
  json.dump(state, open(os.path.join(live, "data", "state.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
@@ -514,6 +677,17 @@ def write_live(live, title, state, outline, manuscript, board, setup, voice, sto
514
677
  json.dump(body, open(os.path.join(live, "data", "notes", note["id"] + ".json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
515
678
  for key, ch in manuscript.items():
516
679
  json.dump(ch, open(os.path.join(live, "data", "manuscript", key + ".json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
680
+ if cover is not None:
681
+ json.dump(cover, open(os.path.join(live, "data", "cover.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
682
+ cdir = os.path.join(live, "data", "coverimg")
683
+ if cover_parts or os.path.isdir(cdir):
684
+ os.makedirs(cdir, exist_ok=True)
685
+ for part in cover_parts:
686
+ json.dump(part, open(os.path.join(cdir, "p-%02d.json" % part["i"]), "w", encoding="utf-8"))
687
+ for name in sorted(os.listdir(cdir)): # parts left from an earlier, larger picture are emptied: nothing in the folder is ever deleted
688
+ m = re.match(r"^p-(\d\d)\.json$", name)
689
+ if m and int(m.group(1)) >= len(cover_parts):
690
+ json.dump({"sig": "", "i": int(m.group(1)), "of": 0, "data": ""}, open(os.path.join(cdir, name), "w", encoding="utf-8"))
517
691
 
518
692
 
519
693
  if __name__ == "__main__":