#!/usr/bin/env python3
"""plank_html_edit.py — change a few slides of an HTML deck and leave the rest alone.
For a deck that ALREADY EXISTS. Building a new one is plank_deck.py's job; this
file never draws anything — it copies the person's deck and rewrites the text
of the slides you name, so every other slide, the styles, the fonts and the
images come through byte for byte.
Works on a plain HTML deck (one per slide) and on a «Bundled Page»
export, where the whole deck is ONE JSON string inside
)', re.S)
SECTION = re.compile(r"", re.S | re.I)
TEXT_NODE = re.compile(r">([^<]+)<") # linear; whitespace-only nodes dropped in _nodes
SKIP = re.compile(r"<(style|script|svg)\b.*?\1>", re.S | re.I)
class EditError(ValueError):
pass
# --- page <-> deck -----------------------------------------------------------
class Deck:
def __init__(self, path):
self.path = Path(path)
self.page = self.path.read_text(encoding="utf-8")
m = TEMPLATE.search(self.page)
self.bundled = bool(m)
if m:
try:
self.html = json.loads(m.group(2))
except json.JSONDecodeError as e:
raise EditError(f"{path}: the bundle template does not decode ({e.msg}) — "
"this file is already broken; start from the original")
else:
self.html = self.page
def sections(self):
return list(SECTION.finditer(self.html))
def _render(self) -> str:
if not self.bundled:
return self.html
raw = json.dumps(self.html, ensure_ascii=False).replace("", "<\\u002F")
m = TEMPLATE.search(self.page)
return self.page[:m.start(2)] + raw + self.page[m.end(2):]
def save(self, path=None):
out = self._render()
if self.bundled: # prove the page will load before writing it
m = TEMPLATE.search(out)
try:
json.loads(m.group(2))
except (json.JSONDecodeError, AttributeError) as e:
raise EditError(f"refusing to save: the template would not decode ({e})")
Path(path or self.path).write_text(out, encoding="utf-8")
def _nodes(section: str):
skip = [m.span() for m in SKIP.finditer(section)]
return [m for m in TEXT_NODE.finditer(section)
if m.group(1).strip() and not any(a <= m.start() < b for a, b in skip)]
def _text(m) -> str:
return re.sub(r"\s+", " ", htmllib.unescape(m.group(1))).strip()
def _esc(s: str) -> str:
return htmllib.escape(s, quote=False)
def _replace_section(deck: Deck, idx: int, new: str):
secs = deck.sections()
m = secs[idx]
deck.html = deck.html[:m.start()] + new + deck.html[m.end():]
def _slide(deck: Deck, n: int) -> int:
secs = deck.sections()
if not 1 <= n <= len(secs):
raise EditError(f"no slide {n}: this deck has {len(secs)}")
return n - 1
def set_nodes(deck: Deck, n: int, mapping: dict):
i = _slide(deck, n)
sec = deck.sections()[i].group(0)
nodes = _nodes(sec)
for k in mapping:
if not 1 <= int(k) <= len(nodes):
raise EditError(f"slide {n} has no text node {k} (it has {len(nodes)})")
want = {int(k) - 1: v for k, v in mapping.items()}
out, pos = [], 0
for j, m in enumerate(nodes):
if j in want:
out.append(sec[pos:m.start(1)] + _esc(want[j]))
pos = m.end(1)
out.append(sec[pos:])
_replace_section(deck, i, "".join(out))
def rewrite(deck: Deck, n: int, lines: list):
i = _slide(deck, n)
sec = deck.sections()[i].group(0)
nodes = _nodes(sec)
if len(lines) > len(nodes):
raise EditError(f"slide {n} has {len(nodes)} text nodes, you gave {len(lines)} lines — "
"duplicate a slide with more room, or shorten")
set_nodes(deck, n, {str(j + 1): (lines[j] if j < len(lines) else "")
for j in range(len(nodes))})
def duplicate(deck: Deck, n: int, after: int):
i = _slide(deck, n)
a = _slide(deck, after)
body = deck.sections()[i].group(0)
at = deck.sections()[a].end()
deck.html = deck.html[:at] + "\n" + body + deck.html[at:]
def delete(deck: Deck, n: int):
_replace_section(deck, _slide(deck, n), "")
# --- reading -----------------------------------------------------------------
def slide_lines(deck: Deck):
return [[_text(m) for m in _nodes(s.group(0))] for s in deck.sections()]
def inventory(path) -> str:
d = Deck(path)
out = [f"{path}: {len(d.sections())} slides"
+ (" (Bundled Page — edit only through this tool)" if d.bundled else "")]
for k, lines in enumerate(slide_lines(d), 1):
label = re.search(r'data-label="([^"]*)"', d.sections()[k - 1].group(0))
out.append(f"\nslide {k}" + (f" — {label.group(1)}" if label else ""))
for j, t in enumerate(lines, 1):
out.append(f" [{j}] {t[:90]}")
return "\n".join(out)
def _norm(s):
s = unicodedata.normalize("NFKC", s).replace(" ", " ")
return re.sub(r"\s+", " ", s).strip().casefold()
def diff(src, dst, max_changed=4, max_added=2, keep_body=()):
a, b = slide_lines(Deck(src)), slide_lines(Deck(dst))
ka = [" ".join(_norm(x) for x in s) for s in a]
kb = [" ".join(_norm(x) for x in s) for s in b]
pairs = sorted(((SequenceMatcher(None, x, y, autojunk=False).ratio() if x != y else 1.0, i, j)
for i, x in enumerate(ka) for j, y in enumerate(kb)), reverse=True)
us, ur, best = set(), set(), {}
for sim, i, j in pairs:
if sim < 0.35 or i in us or j in ur:
continue
us.add(i); ur.add(j); best[i] = (j, sim)
kept = [i for i, (_, s) in best.items() if s >= 0.999]
edited = sorted(i for i, (_, s) in best.items() if s < 0.999)
dropped = [i for i in range(len(a)) if i not in best]
added = [j for j in range(len(b)) if j not in ur]
out = [f"{len(kept)}/{len(a)} slides unchanged, {len(edited)} edited, "
f"{len(added)} added, {len(dropped)} dropped"]
problems = 0
for i in edited:
j, sim = best[i]
stale = [l for l in a[i] if len(l.split()) >= 3 and _norm(l) in {_norm(x) for x in b[j]}]
head_changed = [_norm(x) for x in a[i][:2]] != [_norm(x) for x in b[j][:2]]
warn = ""
if head_changed and len(stale) >= 3 and (i + 1) in keep_body:
warn = " (new headline over the old body — allowed by --keep-body)"
elif head_changed and len(stale) >= 3:
problems += 1
warn = (f" ⚠ new headline over {len(stale)} old lines, e.g. «{stale[0]}» — "
"rewrite the slide through (\"lines\")")
out.append(f" slide {i+1} -> {j+1}: edited ({sim:.0%} similar){warn}")
for j in added:
out.append(f" slide {j+1}: new")
for i in dropped:
out.append(f" slide {i+1} of the source: gone")
if len(edited) + len(dropped) > max_changed:
problems += 1
out.append(f"✗ {len(edited) + len(dropped)} of the person's slides changed (limit {max_changed}): "
"an adaptation keeps the deck theirs. Revert the slides the request did not "
"name (copy again and re-apply only those), or ask the person first.")
if len(added) > max_added:
problems += 1
out.append(f"✗ {len(added)} slides added (limit {max_added}): add only what the request "
"names, or ask the person first.")
if problems:
out.append("NOT DONE — fix the ✗/⚠ above and run diff again.")
return "\n".join(out), problems
def apply(path, edits):
d = Deck(path)
for e in edits:
if "duplicate" in e:
duplicate(d, int(e["duplicate"]), int(e.get("after", e["duplicate"])))
elif "delete" in e:
delete(d, int(e["delete"]))
elif "lines" in e:
rewrite(d, int(e["slide"]), list(e["lines"]))
elif "set" in e:
set_nodes(d, int(e["slide"]), e["set"])
else:
raise EditError(f"unknown edit: {e}")
d.save()
USAGE = """usage:
plank_html_edit.py inventory DECK
plank_html_edit.py copy ORIGINAL NEW
plank_html_edit.py apply DECK EDITS.json
plank_html_edit.py diff ORIGINAL NEW [--max-changed N] [--max-added N]
[--keep-body N --because ""]
Full help: run with no arguments."""
NEEDS = {"inventory": 1, "copy": 2, "apply": 2, "diff": 2}
def main(argv):
if len(argv) < 2:
print(__doc__)
return 2
cmd, args = argv[1], argv[2:]
if "--help" in args or "-h" in args or cmd in ("--help", "-h", "help"):
print(__doc__)
return 0
positional = [a for k, a in enumerate(args)
if not a.startswith("--") and (k == 0 or not args[k - 1].startswith("--"))]
if cmd not in NEEDS or len(positional) < NEEDS[cmd]:
print(USAGE, file=sys.stderr)
return 2
try:
if cmd == "inventory":
print(inventory(args[0]))
elif cmd == "copy":
if Path(args[1]).exists():
raise EditError(f"{args[1]} exists — never overwrite; pick a new name")
Deck(args[0])
shutil.copyfile(args[0], args[1])
print(f"copied; edit {args[1]}, the original stays as it is")
elif cmd == "apply":
apply(args[0], json.loads(Path(args[1]).read_text(encoding="utf-8")))
print(f"saved {args[0]}")
elif cmd == "diff":
def opt(name, default):
return int(args[args.index(name) + 1]) if name in args else default
keep = tuple(int(args[k + 1]) for k, a in enumerate(args) if a == "--keep-body")
because = args[args.index("--because") + 1].strip() if "--because" in args else ""
if keep and not because:
raise EditError("--keep-body needs --because \"\". If nobody asked, the slide is "
"half-edited: rewrite it through with \"lines\".")
text, problems = diff(positional[0], positional[1],
max_changed=opt("--max-changed", 4),
max_added=opt("--max-added", 2), keep_body=keep)
print(text)
if keep:
print(f"Kept the old body under a new headline on slide(s) "
f"{', '.join(map(str, keep))} because the person asked: «{because}». "
"Say this in your reply.")
return 1 if problems else 0
else:
print(__doc__)
return 2
except EditError as e:
print(f"EditError: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))