Files
propresenter-auto-notes/src/spelling.py
T

193 lines
8.2 KiB
Python
Raw Normal View History

2026-10-14 12:37:18 -04:00
"""Typo check for the slide text, using macOS's own spelling checker (the one TextEdit uses).
A word is fixed only when macOS has a confident correction for it (the same one it would
auto-correct while typing). Names it doesn't know (Rahab, Chniouli) have none, so they are
left alone. ALL-CAPS emphasis words are checked too (macOS normally skips those), and
doubled small words ("in in") are fixed. Every fix is reported. To undo one, the word goes
into allowed-words.txt (never touched again) and the check is simply run again on the
text from before the fixes.
"""
import json
import os
import re
import subprocess
import tempfile
ALLOWED = os.path.expanduser("~/Library/Application Support/MessageNotes/allowed-words.txt")
# Greek/Hebrew words preachers use, which a dictionary would "correct" (dunamis -> dynamos)
SERMON_WORDS = {"dunamis", "agape", "phileo", "storge", "eros", "logos", "rhema", "koinonia", "ekklesia",
"metanoia", "charis", "pneuma", "sozo", "soteria", "kairos", "chronos", "shalom", "hesed",
"chesed", "ruach", "nephesh", "torah", "elohim", "adonai", "yahweh", "jehovah", "selah",
"hallelujah", "hosanna", "maranatha", "abba", "parakletos", "paraclete", "ekballo",
"anankazo", "anankaz\u014d", "dokimos", "teleios", "makarios", "pistis", "zoe", "bios",
"psuche", "sarx", "kosmos", "basileia", "euangelion", "apostolos", "diakonos", "doulos",
"proskuneo", "latreia", "hagios", "kardia", "nous", "dikaiosune", "hamartia", "aletheia",
"emunah", "tikvah", "shekinah", "kavod", "qadosh", "berakah", "mishpat", "tzedakah",
"nachash", "satan", "gehenna", "hades", "sheol", "abaddon", "nephilim", "seraphim", "cherubim"}
DOUBLE_OK_TO_FIX = {"the", "a", "an", "and", "to", "of", "in", "is", "it", "on", "for", "with", "be",
"we", "you", "i", "my", "his", "he", "god", "that"}
JXA = r"""
ObjC.import('AppKit'); ObjC.import('Foundation');
function run(argv) {
var data = $.NSData.dataWithContentsOfFile(argv[0]);
var items = JSON.parse($.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js);
var sc = $.NSSpellChecker.sharedSpellChecker;
return JSON.stringify(items.map(function (s) {
// macOS skips ALL-CAPS words; check emphasis words like OBEDIENCE in lower case instead
var t = s.replace(/[A-Z][A-Z'\u2019]{3,}/g, function (w) { return w.toLowerCase(); });
var str = $(t), flagged = {}, start = 0, guard = 0;
while (guard++ < 200) { // words macOS doesn't know
var r = sc.checkSpellingOfStringStartingAt(str, start);
var loc = Number(r.location), len = Number(r.length);
if (!len || loc < start || loc >= t.length) break; // nothing more (or wrapped around)
flagged[loc] = len;
start = loc + len;
}
// every word: macOS's own autocorrection (catches "teh", "dont" that the dictionary accepts)
var out = [], re = /[A-Za-z\u00c0-\u024f]+(?:['\u2019][A-Za-z]+)?/g, m;
while ((m = re.exec(t)) !== null) {
var l = m.index, n = m[0].length;
var c = sc.correctionForWordRangeInStringLanguageInSpellDocumentWithTag($.NSMakeRange(l, n), str, "en", 0);
var corr = (!c || c.isNil()) ? null : c.js;
if (corr || flagged[l] === n) out.push({loc: l, len: n, corr: corr, flagged: flagged[l] === n});
delete flagged[l];
}
for (var k in flagged) out.push({loc: Number(k), len: flagged[k], corr: null, flagged: true});
return out;
}));
}
"""
class Unavailable(Exception):
pass
def _run_jxa(texts):
tmp = tempfile.mkdtemp(prefix="msgnotes-")
try:
src, inp = os.path.join(tmp, "s.js"), os.path.join(tmp, "in.json")
with open(src, "w") as f:
f.write(JXA)
with open(inp, "w") as f:
json.dump(texts, f)
p = subprocess.run(["/usr/bin/osascript", "-l", "JavaScript", src, inp],
capture_output=True, text=True, timeout=120)
if p.returncode != 0:
raise Unavailable(p.stderr.strip()[-300:])
out = json.loads(p.stdout)
if len(out) != len(texts):
raise Unavailable("unexpected answer from the spelling checker")
return out
except (OSError, ValueError, subprocess.TimeoutExpired) as e:
raise Unavailable(str(e))
finally:
for fn in ("s.js", "in.json"):
try:
os.remove(os.path.join(tmp, fn))
except OSError:
pass
try:
os.rmdir(tmp)
except OSError:
pass
def allowed_words():
try:
with open(ALLOWED, encoding="utf-8") as f:
return {w.strip().lower() for w in f if w.strip()}
except OSError:
return set()
def allow(words):
os.makedirs(os.path.dirname(ALLOWED), exist_ok=True)
have = allowed_words()
with open(ALLOWED, "a", encoding="utf-8") as f:
for w in words:
if w.lower() not in have:
f.write(w + "\n")
have.add(w.lower())
class Fix:
"""One change in one text segment: segment text before -> after."""
__slots__ = ("slide", "line", "seg", "word", "new", "kind")
def __init__(self, slide, line, seg, word, new, kind):
self.slide, self.line, self.seg, self.word, self.new, self.kind = slide, line, seg, word, new, kind
class Report:
def __init__(self):
self.fixes = [] # applied
self.unknown = [] # (slide, word) - not in the dictionary, no confident correction
def match_case(word, corr):
if word.isupper() and len(word) > 1:
return corr.upper()
if word[:1].isupper():
return corr[:1].upper() + corr[1:]
return corr
def _segments(plan):
"""Every checkable text segment: (slide, line index, seg index, text)."""
for s in plan.slides:
if s.kind in ("picture", "clearpicture"):
continue
for li, ln in enumerate(s.lines):
for si, seg in enumerate(ln.segs):
if seg[0].strip():
yield s, li, si, seg[0]
def _starts_sentence(text, loc):
before = text[:loc].rstrip()
return not before or before[-1] in ".!?:\u201c\"(" or before.endswith("\u2014")
def check_and_fix(plan, skip=None):
"""Fix confident typos in the plan's slide text. Returns a Report.
skip: words (lowercase) never to change, on top of allowed-words.txt."""
rep = Report()
segs = list(_segments(plan))
if not segs:
return rep
results = _run_jxa([t for _, _, _, t in segs])
ok = allowed_words() | set(skip or ()) | SERMON_WORDS
for (s, li, si, text), found in zip(segs, results):
edits = [] # (start, end, replacement, word)
for f in found:
loc, ln = f["loc"], f["len"]
word = text[loc:loc + ln]
if not word or word.lower() in ok or any(ch.isdigit() for ch in word):
continue
corr = f["corr"]
name_like = word[:1].isupper() and not word.isupper() and not _starts_sentence(text, loc)
# "lampstand" -> "lamp stand", "firstfruits" -> "first fruits": Bible words, not typos
splits = corr and (" " in corr or "-" in corr) and not (" " in word or "-" in word)
if corr and not name_like and not splits and corr.lower() != word.lower():
edits.append((loc, loc + ln, match_case(word, corr), word))
elif not corr and word[:1].islower():
rep.unknown.append((s, word))
# doubled words: "in in" -> "in"
for m in re.finditer(r"\b(\w+)(\s+)(\1)\b", text, re.I):
w = m.group(1)
if w.lower() in DOUBLE_OK_TO_FIX and m.group(1) == m.group(3) and (w + " " + w).lower() not in ok:
edits.append((m.start(2), m.end(3), "", w + " " + w))
if not edits:
continue
new = text
for a, b, repl, word in sorted(edits, reverse=True):
new = new[:a] + repl + new[b:]
for a, b, repl, word in sorted(edits):
kind = "double" if repl == "" else "spelling"
rep.fixes.append(Fix(s, li, si, word, repl if repl else word.split(" ")[0], kind))
seg = s.lines[li].segs[si]
s.lines[li].segs[si] = (new,) + tuple(seg[1:])
return rep