# -*- coding: utf-8 -*-
#!/usr/bin/env python3
"""
ProPresenter Font Fixer v2.0
Load a native ProPresenter 7 .proBundle, inspect slides, detect Malayalam
text boxes, patch the Malayalam font, and re-export a fixed bundle.
KEY FIX v2.0: Proper protobuf-length-aware RTF patching. The .pro binary
wraps each RTF block as a protobuf length-delimited field. Simply replacing
bytes in-place without updating the varint length prefix corrupts the file
and makes ProPresenter show blank slides on import.
This version rebuilds the binary with corrected length prefixes.
Built for CSI East Parade Malayalam Pastorate -- Ajai John
"""
import re, json, zipfile, os, sys, struct, threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from tkinter import font as tkfont
from pathlib import Path
APP_TITLE = "ProPresenter Font Fixer"
APP_VERSION = "2.0"
DEFAULT_MAL_FONT = "Gayathri"
# ======================================================================
# VARINT HELPERS
# ======================================================================
def _ev(v):
"""Encode unsigned integer as protobuf varint bytes."""
p = []
while v > 0x7F:
p.append((v & 0x7F) | 0x80)
v >>= 7
p.append(v & 0x7F)
return bytes(p)
def _dv(data, pos):
"""Decode protobuf varint at data[pos]; return (value, new_pos)."""
r = 0; s = 0
while True:
b = data[pos]; pos += 1
r |= (b & 0x7F) << s; s += 7
if not (b & 0x80):
return r, pos
# ======================================================================
# MALAYALAM DETECTION
# ======================================================================
def is_malayalam(text):
return any(0x0D00 <= ord(c) <= 0x0D7F for c in text)
# ======================================================================
# RTF UTILITIES
# ======================================================================
def clean_rtf_text(rtf):
"""Extract plain text from an RTF string."""
s = rtf
if s.startswith('{') and s.endswith('}'): s = s[1:-1]
for _ in range(10):
new = re.sub(r'\{[^{}]*\}', ' ', s)
if new == s: break
s = new
s = re.sub(r'\\u(\d+)\s?\?', lambda m: chr(int(m.group(1))), s)
s = re.sub(r'\\par(?=[^a-z]|$)', '\n', s)
s = re.sub(r'\\tab(?=[^a-z]|$)', ' ', s)
s = re.sub(r'\\line(?=[^a-z]|$)', '\n', s)
s = re.sub(r'\\[a-zA-Z*]+-?\d*\s?', '', s)
s = re.sub(r"\\'[0-9a-fA-F]{2}", '', s)
s = s.replace('\\', '')
s = re.sub(r'[ \t]+', ' ', s)
return '\n'.join(l.strip() for l in s.split('\n') if l.strip())
def get_rtf_font(rtf):
"""Extract the primary font name from RTF fonttable."""
m = re.search(r'\\fnil[ \t]+([^;}\r\n\\][^;}\r\n\\]*)', rtf)
if m: return m.group(1).strip()
m = re.search(r'\{\\fonttbl[^}]*?[ \t]([A-Za-z][A-Za-z0-9 _-]+);', rtf)
if m: return m.group(1).strip()
return ''
def get_rtf_fontsize(rtf):
m = re.search(r'\\fs(\d+)', rtf)
return int(m.group(1)) if m else None
def get_rtf_align(rtf):
m = re.search(r'\\q([clr])', rtf)
return {'c': 'center', 'l': 'left', 'r': 'right'}.get(m.group(1), 'left') if m else 'left'
def patch_rtf_font(rtf, new_font):
"""Replace the font name inside the RTF fonttable declaration only."""
return re.sub(
r'(\\fnil[ \t]+)([^;}\r\n\\]+)(;)',
lambda m: m.group(1) + new_font + m.group(3),
rtf
)
# ======================================================================
# RTF SPAN EXTRACTION
# ======================================================================
RTF_MARKER = b'{\x5crtf0' # {\rtf0 as literal bytes
def extract_rtf_spans(data):
"""
Locate all RTF blocks in raw .pro binary.
For each span also locate the protobuf length-prefix varint that
precedes it so we can rewrite it correctly when patching changes the size.
"""
spans = []; pos = 0
while True:
idx = data.find(RTF_MARKER, pos)
if idx == -1: break
depth = 0; end = idx
for j in range(idx, min(idx + 60000, len(data))):
b = data[j]
if b == 0x7B: depth += 1
elif b == 0x7D:
depth -= 1
if depth == 0: end = j + 1; break
raw = data[idx:end]
try:
rtf_str = raw.decode('utf-8', errors='replace')
except Exception:
rtf_str = ''
text = clean_rtf_text(rtf_str)
font = get_rtf_font(rtf_str)
fs = get_rtf_fontsize(rtf_str)
aln = get_rtf_align(rtf_str)
mal = is_malayalam(text)
# Find protobuf length-prefix varint immediately before this RTF block
rtf_len = end - idx
varint_start = -1
for back in range(1, 6):
vs = idx - back
if vs < 0: break
try:
v, ve = _dv(data, vs)
if ve == idx and v == rtf_len:
varint_start = vs
break
except Exception:
pass
spans.append({
'start': idx, 'end': end,
'varint_start': varint_start,
'rtf_str': rtf_str, 'text': text,
'font': font, 'fontsize': fs, 'align': aln,
'has_malayalam': mal,
})
pos = idx + 1
return spans
# ======================================================================
# SLIDE GROUPING
# ======================================================================
def group_spans_into_slides(spans):
if not spans: return []
slides = []; current = [spans[0]]
for sp in spans[1:]:
gap = sp['start'] - current[-1]['end']
if gap > 300:
slides.append(current); current = [sp]
else:
current.append(sp)
if current: slides.append(current)
return slides
# ======================================================================
# BINARY FONT BLOB DETECTION
# ======================================================================
KNOWN_MAL_FONTS_BINARY = [
b'Gayathri-Regular', b'Gayathri', b'AnjaliOldLipi',
b'Rachana', b'Meera', b'Kartika', b'Noto Sans Malayalam',
b'Malayalam MN', b'Lohit Malayalam',
]
def detect_binary_fonts(data):
found = []
for fb in KNOWN_MAL_FONTS_BINARY:
if fb in data:
found.append({'font': fb.decode('utf-8'), 'count': data.count(fb), 'bytes': fb})
return found
def patch_binary_font(data, old_fb, new_fb):
if old_fb == new_fb: return data, 0
old_len = len(old_fb); new_len = len(new_fb)
result = bytearray(); pos = 0; count = 0
while pos < len(data):
idx = data.find(old_fb, pos)
if idx == -1: result.extend(data[pos:]); break
result.extend(data[pos:idx])
for back in range(1, 6):
vs = idx - back
if vs < 0: break
try:
v, ve = _dv(data, vs)
if ve == idx and v == old_len:
del result[-back:]
result.extend(_ev(new_len)); break
except Exception:
pass
result.extend(new_fb)
count += 1; pos = idx + old_len
return bytes(result), count
# ======================================================================
# BUNDLE READER
# ======================================================================
class BundleData:
def __init__(self):
self.source_path = ''
self.pro_filename = ''
self.other_files = []
self.raw_pro = b''
self.pres_name = ''
self.rtf_spans = []
self.slides = []
self.binary_fonts = []
def load(self, filepath):
self.source_path = filepath
with zipfile.ZipFile(filepath, 'r') as zf:
names = zf.namelist()
pro_files = [n for n in names if n.endswith('.pro')]
if not pro_files:
raise ValueError("No .pro file found in bundle")
self.pro_filename = pro_files[0]
self.raw_pro = zf.read(self.pro_filename)
self.other_files = []
for n in names:
if n != self.pro_filename:
try: self.other_files.append((n, zf.read(n)))
except Exception: pass
data = self.raw_pro; pos = 0
pres_name = Path(self.pro_filename).stem
while pos < len(data):
try:
tag, pos = _dv(data, pos); fn = tag >> 3; wt = tag & 7
if wt == 0: _, pos = _dv(data, pos)
elif wt == 2:
ln, pos2 = _dv(data, pos)
if fn == 3:
try: pres_name = data[pos2:pos2+ln].decode('utf-8')
except Exception: pass
break
pos = pos2 + ln
elif wt == 5: pos += 4
elif wt == 1: pos += 8
else: break
except Exception: break
self.pres_name = pres_name
self.rtf_spans = extract_rtf_spans(self.raw_pro)
self.slides = group_spans_into_slides(self.rtf_spans)
self.binary_fonts = detect_binary_fonts(self.raw_pro)
# ======================================================================
# PATCH ENGINE -- length-aware rebuild
# ======================================================================
def build_patched_pro(bundle, new_font, patch_binary_blobs):
"""
Rebuild .pro binary with corrected protobuf length prefixes after RTF patching.
The .pro file is a flat protobuf binary. Each RTF block is stored as:
[tag varint] [length varint] [RTF bytes...]
If we simply splice new RTF bytes in-place the length varint becomes wrong
and ProPresenter cannot parse the slide -- it shows blank slides.
Correct approach: iterate the raw bytes. When we reach a span to patch,
emit the new length varint followed by new RTF bytes, skipping the old ones.
Everything outside a patched span is copied verbatim.
"""
data = bundle.raw_pro
# Pre-compute new RTF for all Malayalam spans that need a font change
patch_map = {} # key = varint_start (or span start) -> span dict with _new_rtf_bytes
for sp in bundle.rtf_spans:
if not sp['has_malayalam']: continue
new_rtf = patch_rtf_font(sp['rtf_str'], new_font)
if new_rtf == sp['rtf_str']: continue
sp = dict(sp) # shallow copy so we don't mutate the original
sp['_new_rtf_bytes'] = new_rtf.encode('utf-8')
key = sp['varint_start'] if sp['varint_start'] != -1 else sp['start']
patch_map[key] = sp
rtf_count = 0
bin_count = 0
if not patch_map and not patch_binary_blobs:
return data, 0, 0
# Rebuild with corrected varints
result = bytearray()
pos = 0
patch_points = sorted(patch_map.keys())
pp_idx = 0
while pos < len(data):
next_pp = patch_points[pp_idx] if pp_idx < len(patch_points) else len(data)
if pos < next_pp:
result.extend(data[pos:next_pp])
pos = next_pp
continue
sp = patch_map[next_pp]
new_rtf_bytes = sp['_new_rtf_bytes']
if sp['varint_start'] != -1:
# We have already copied up to varint_start.
# Emit new varint (new RTF length) + new RTF, skip old varint + old RTF.
result.extend(_ev(len(new_rtf_bytes)))
result.extend(new_rtf_bytes)
pos = sp['end'] # skip old varint+RTF (varint_start was our copy cursor)
else:
# No varint found -- best effort: just replace RTF bytes
result.extend(new_rtf_bytes)
pos = sp['end']
rtf_count += 1
pp_idx += 1
patched = bytes(result)
if patch_binary_blobs:
for bf in bundle.binary_fonts:
old_fb = bf['bytes']
new_fb = new_font.encode('utf-8')
if old_fb == new_fb: continue
patched, n = patch_binary_font(patched, old_fb, new_fb)
bin_count += n
return patched, rtf_count, bin_count
# ======================================================================
# BUNDLE WRITER
# ======================================================================
def export_bundle(bundle, patched_pro, output_path, new_pro_name):
if not new_pro_name.endswith('.pro'):
new_pro_name += '.pro'
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr(new_pro_name, patched_pro)
for (arcname, content) in bundle.other_files:
if arcname.endswith('/'):
try: zf.mkdir(arcname.rstrip('/'))
except Exception: pass
else:
zf.writestr(arcname, content)
# ======================================================================
# SYSTEM FONT ENUMERATION
# ======================================================================
def get_system_fonts():
try:
fams = sorted(set(tkfont.families()), key=lambda s: s.lower())
return [f for f in fams if f and not f.startswith('@')]
except Exception:
return []
# ======================================================================
# FONT PICKER DIALOG
# ======================================================================
class FontPickerDialog(tk.Toplevel):
"""
Modal dialog listing all installed system fonts with live search filter.
Double-click or OK to select. .result holds the chosen font name.
"""
def __init__(self, parent, current_font=''):
super().__init__(parent)
self.title("Choose Font")
self.geometry("420x520")
self.resizable(True, True)
self.transient(parent)
self.grab_set()
self.result = None
self._all_fonts = get_system_fonts()
self._build(current_font)
self.wait_window()
def _build(self, current_font):
top = ttk.Frame(self, padding=(8,8,8,4)); top.pack(fill='x')
ttk.Label(top, text="Search:").pack(side='left')
self._q = tk.StringVar()
self._q.trace_add('write', self._filter)
ttk.Entry(top, textvariable=self._q, width=32).pack(
side='left', padx=(4,0), fill='x', expand=True)
mid = ttk.Frame(self, padding=(8,0,8,4)); mid.pack(fill='both', expand=True)
self._lb = tk.Listbox(mid, font=('Segoe UI', 10), selectmode='single',
activestyle='dotbox', exportselection=False)
vsb = ttk.Scrollbar(mid, orient='vertical', command=self._lb.yview)
self._lb.configure(yscrollcommand=vsb.set)
self._lb.pack(side='left', fill='both', expand=True)
vsb.pack(side='right', fill='y')
self._lb.bind('<Double-Button-1>', lambda e: self._ok())
self._lb.bind('<<ListboxSelect>>', self._on_sel)
self._populate(self._all_fonts, current_font)
self._prev = tk.StringVar()
ttk.Label(self, textvariable=self._prev, foreground='#555',
padding=(8,2)).pack(fill='x')
bot = ttk.Frame(self, padding=(8,4,8,8)); bot.pack(fill='x')
ttk.Button(bot, text="OK", command=self._ok, width=10).pack(side='right', padx=2)
ttk.Button(bot, text="Cancel", command=self.destroy, width=10).pack(side='right', padx=2)
self.bind('<Return>', lambda e: self._ok())
self.bind('<Escape>', lambda e: self.destroy())
def _populate(self, fonts, select=''):
self._lb.delete(0, 'end')
sel_idx = None
for i, f in enumerate(fonts):
self._lb.insert('end', f)
if f.lower() == select.lower(): sel_idx = i
if sel_idx is not None:
self._lb.selection_set(sel_idx); self._lb.see(sel_idx)
def _filter(self, *_):
q = self._q.get().lower()
self._populate([f for f in self._all_fonts if q in f.lower()] if q else self._all_fonts)
def _on_sel(self, *_):
sel = self._lb.curselection()
if sel: self._prev.set(f"Selected: {self._lb.get(sel[0])}")
def _ok(self):
sel = self._lb.curselection()
if sel: self.result = self._lb.get(sel[0])
self.destroy()
# ======================================================================
# SIMPLE STRING DIALOG
# ======================================================================
def _ask_string(parent, title, prompt, default=''):
result = [None]
win = tk.Toplevel(parent)
win.title(title); win.geometry("440x150")
win.transient(parent); win.grab_set(); win.resizable(False, False)
ttk.Label(win, text=prompt, wraplength=400).pack(padx=14, pady=(12,4))
var = tk.StringVar(value=default)
e = ttk.Entry(win, textvariable=var, width=48)
e.pack(padx=14); e.select_range(0, 'end'); e.focus_set()
def _ok(): result[0] = var.get(); win.destroy()
bf = ttk.Frame(win); bf.pack(pady=10)
ttk.Button(bf, text="OK", command=_ok, width=10).pack(side='left', padx=4)
ttk.Button(bf, text="Cancel", command=win.destroy, width=10).pack(side='left', padx=4)
win.bind('<Return>', lambda e: _ok())
win.bind('<Escape>', lambda e: win.destroy())
win.wait_window(); return result[0]
# ======================================================================
# MAIN APPLICATION
# ======================================================================
class FontFixerApp(tk.Tk):
def __init__(self):
super().__init__()
self.title(f"{APP_TITLE} v{APP_VERSION}")
self.geometry("1060x800")
self.minsize(800, 560)
self.bundle = None
self._patch_binary_var = tk.BooleanVar(value=False)
self._new_font_var = tk.StringVar(value=DEFAULT_MAL_FONT)
self._status_var = tk.StringVar(value="No bundle loaded. Use File > Open or Ctrl+O.")
self._slide_frames = []
self._build_ui()
# ------------------------------------------------------------------ UI build
def _build_ui(self):
self._build_menu()
self._build_toolbar()
self._build_info_bar()
self._build_main_area()
self._build_statusbar()
def _build_menu(self):
m = tk.Menu(self); self.config(menu=m)
fm = tk.Menu(m, tearoff=0)
fm.add_command(label="Open .proBundle...", command=self._open, accelerator="Ctrl+O")
fm.add_separator()
fm.add_command(label="Export Fixed Bundle...", command=self._export, accelerator="Ctrl+S")
fm.add_separator()
fm.add_command(label="Exit", command=self.quit)
m.add_cascade(label="File", menu=fm)
hm = tk.Menu(m, tearoff=0)
hm.add_command(label="About", command=self._about)
m.add_cascade(label="Help", menu=hm)
self.bind_all('<Control-o>', lambda e: self._open())
self.bind_all('<Control-s>', lambda e: self._export())
def _build_toolbar(self):
tb = ttk.Frame(self, padding=(6,5)); tb.pack(fill='x', side='top')
ttk.Button(tb, text="Open Bundle", command=self._open ).pack(side='left', padx=2)
ttk.Separator(tb, orient='vertical').pack(side='left', fill='y', padx=8)
ttk.Label(tb, text="Malayalam Font:").pack(side='left')
ttk.Entry(tb, textvariable=self._new_font_var, width=24).pack(side='left', padx=(4,2))
ttk.Button(tb, text="Browse Fonts...", command=self._pick_font).pack(side='left', padx=2)
ttk.Separator(tb, orient='vertical').pack(side='left', fill='y', padx=8)
ttk.Button(tb, text="Apply Font & Export...", command=self._export).pack(side='left', padx=2)
def _build_info_bar(self):
ib = ttk.LabelFrame(self, text="Bundle Info", padding=(8,4))
ib.pack(fill='x', padx=8, pady=(4,0))
r1 = ttk.Frame(ib); r1.pack(fill='x')
ttk.Label(r1, text="File:").grid(row=0, column=0, sticky='w', padx=(0,4))
self._lbl_file = ttk.Label(r1, text="--", foreground='#333')
self._lbl_file.grid(row=0, column=1, sticky='w')
ttk.Label(r1, text="Presentation:").grid(row=0, column=2, sticky='w', padx=(16,4))
self._lbl_pres = ttk.Label(r1, text="--", foreground='#333')
self._lbl_pres.grid(row=0, column=3, sticky='w')
ttk.Label(r1, text="Slides:").grid(row=0, column=4, sticky='w', padx=(16,4))
self._lbl_slides = ttk.Label(r1, text="--", foreground='#333')
self._lbl_slides.grid(row=0, column=5, sticky='w')
ttk.Label(r1, text="Malayalam boxes:").grid(row=0, column=6, sticky='w', padx=(16,4))
self._lbl_mal = ttk.Label(r1, text="--", foreground='#1a7a1a',
font=('Segoe UI', 9, 'bold'))
self._lbl_mal.grid(row=0, column=7, sticky='w')
r2 = ttk.Frame(ib); r2.pack(fill='x', pady=(4,0))
ttk.Label(r2, text="Binary font hits:").grid(row=0, column=0, sticky='w', padx=(0,4))
self._lbl_bin = ttk.Label(r2, text="--", foreground='#555')
self._lbl_bin.grid(row=0, column=1, sticky='w')
ttk.Checkbutton(
r2,
text="Also patch binary blobs (enable if RTF-only fix still shows blank slides)",
variable=self._patch_binary_var
).grid(row=0, column=2, sticky='w', padx=(28,0))
def _build_main_area(self):
outer = ttk.Frame(self); outer.pack(fill='both', expand=True, padx=8, pady=6)
sf = ttk.LabelFrame(outer, text="Slides (click a card to see full detail)", padding=4)
sf.pack(side='left', fill='both', expand=True)
self._main_canvas = tk.Canvas(sf, highlightthickness=0, bg='#f5f5f5')
vsb = ttk.Scrollbar(sf, orient='vertical', command=self._main_canvas.yview)
self._scroll_inner = ttk.Frame(self._main_canvas)
self._scroll_inner.bind('<Configure>',
lambda e: self._main_canvas.configure(
scrollregion=self._main_canvas.bbox('all')))
self._main_canvas.create_window((0,0), window=self._scroll_inner, anchor='nw')
self._main_canvas.configure(yscrollcommand=vsb.set)
self._main_canvas.pack(side='left', fill='both', expand=True)
vsb.pack(side='right', fill='y')
self._main_canvas.bind_all("<MouseWheel>",
lambda e: self._main_canvas.yview_scroll(int(-1*(e.delta/120)), "units"))
df = ttk.LabelFrame(outer, text="Selected Slide Detail", padding=6, width=340)
df.pack(side='right', fill='y', padx=(6,0))
df.pack_propagate(False)
self._detail_text = scrolledtext.ScrolledText(
df, width=38, wrap='word', font=('Consolas', 9),
state='disabled', bg='#fdfdfd')
self._detail_text.pack(fill='both', expand=True)
def _build_statusbar(self):
ttk.Label(self, textvariable=self._status_var,
relief='sunken', padding=3, anchor='w').pack(fill='x', side='bottom')
# ------------------------------------------------------------------ Font picker
def _pick_font(self):
dlg = FontPickerDialog(self, current_font=self._new_font_var.get())
if dlg.result:
self._new_font_var.set(dlg.result)
self._status_var.set(f"Font set to: {dlg.result}")
# ------------------------------------------------------------------ Slide rendering
def _clear_slides(self):
for f in self._slide_frames: f.destroy()
self._slide_frames.clear()
def _render_slides(self):
self._clear_slides()
if not self.bundle or not self.bundle.slides:
ttk.Label(self._scroll_inner, text="No slides detected.",
foreground='#888').pack(pady=20)
return
for i, spans in enumerate(self.bundle.slides):
mal_spans = [s for s in spans if s['has_malayalam']]
other_spans = [s for s in spans if not s['has_malayalam'] and s['text'].strip()]
card = ttk.Frame(self._scroll_inner, relief='groove', padding=6)
card.pack(fill='x', padx=4, pady=3)
card.bind('<Button-1>', lambda e, idx=i: self._show_detail(idx))
self._slide_frames.append(card)
hdr = ttk.Frame(card); hdr.pack(fill='x')
hdr.bind('<Button-1>', lambda e, idx=i: self._show_detail(idx))
ttk.Label(hdr, text=f"Slide {i+1}",
font=('Segoe UI', 9, 'bold')).pack(side='left')
mal_col = '#1a7a1a' if mal_spans else '#aaa'
ttk.Label(hdr, text=f" {len(mal_spans)} Malayalam box{'es' if len(mal_spans)!=1 else ''}",
foreground=mal_col, font=('Segoe UI',9)).pack(side='left', padx=(6,0))
ttk.Label(hdr, text=f" {len(other_spans)} other",
foreground='#777', font=('Segoe UI',9)).pack(side='left', padx=(6,0))
for sp in mal_spans:
preview = sp['text'][:130].replace('\n', ' / ')
row = ttk.Frame(card); row.pack(fill='x', pady=1)
row.bind('<Button-1>', lambda e, idx=i: self._show_detail(idx))
ttk.Label(row, text="ML", foreground='white', background='#1a7a1a',
font=('Segoe UI',7,'bold'), width=3,
padding=(2,1)).pack(side='left', padx=(0,4))
ttk.Label(row, text=preview, foreground='#1a3a6a',
font=('Segoe UI',9), wraplength=500,
justify='left').pack(side='left', fill='x')
font_txt = sp['font'] or '(unknown)'
is_ok = sp['font'] and 'gayathri' in sp['font'].lower()
ttk.Label(row, text=f"[{font_txt}]",
foreground='#1a7a1a' if is_ok else '#cc2200',
font=('Segoe UI',8)).pack(side='right', padx=4)
for sp in other_spans[:2]:
preview = sp['text'][:80].replace('\n', ' / ')
row = ttk.Frame(card); row.pack(fill='x', pady=1)
ttk.Label(row, text="TX", foreground='#777',
font=('Segoe UI',7), width=3,
padding=(2,1)).pack(side='left', padx=(0,4))
ttk.Label(row, text=preview, foreground='#555',
font=('Segoe UI',9), wraplength=500).pack(side='left')
ttk.Label(row, text=f"[{sp['font'] or '?'}]",
foreground='#999', font=('Segoe UI',8)).pack(side='right', padx=4)
ttk.Separator(card, orient='horizontal').pack(fill='x', pady=(4,0))
def _show_detail(self, idx):
if not self.bundle or idx >= len(self.bundle.slides): return
spans = self.bundle.slides[idx]
lines = [f"=== Slide {idx+1} ===\n"]
for j, sp in enumerate(spans):
lines.append(f"--- Text box {j+1} ---")
lines.append(f" Malayalam : {'YES' if sp['has_malayalam'] else 'no'}")
lines.append(f" Font (RTF) : {sp['font'] or '(not detected)'}")
lines.append(f" Font size : {sp['fontsize'] or '?'}")
lines.append(f" Alignment : {sp['align']}")
lines.append(f" Varint found: {'YES (offset ' + str(sp['varint_start']) + ')' if sp['varint_start'] != -1 else 'NO -- length-prefix not found'}")
lines.append(f" Byte range : {sp['start']} - {sp['end']}")
lines.append(f" Text content:")
for tl in sp['text'].split('\n'):
lines.append(f" {tl}")
lines.append("")
self._detail_text.config(state='normal')
self._detail_text.delete('1.0', 'end')
self._detail_text.insert('1.0', '\n'.join(lines))
self._detail_text.config(state='disabled')
# ------------------------------------------------------------------ File ops
def _open(self):
path = filedialog.askopenfilename(
title="Open ProPresenter Bundle",
filetypes=[("ProPresenter Bundle", "*.proBundle *.probundle"),
("All files", "*.*")])
if not path: return
try:
b = BundleData(); b.load(path)
self.bundle = b
self._lbl_file.config(text=Path(path).name)
self._lbl_pres.config(text=b.pres_name)
self._lbl_slides.config(text=str(len(b.slides)))
mal_count = sum(1 for sp in b.rtf_spans if sp['has_malayalam'])
self._lbl_mal.config(text=str(mal_count))
if b.binary_fonts:
self._lbl_bin.config(
text=", ".join(f"{x['font']} x{x['count']}" for x in b.binary_fonts),
foreground='#8a3a00')
else:
self._lbl_bin.config(text="None detected", foreground='#555')
self._render_slides()
self._status_var.set(
f"Loaded: {Path(path).name} | {len(b.slides)} slides | "
f"{mal_count} Malayalam boxes detected | Click a slide for detail")
except Exception as ex:
messagebox.showerror("Load Error", f"Failed to load bundle:\n{ex}")
def _export(self):
if not self.bundle:
messagebox.showwarning("No Bundle", "Open a .proBundle file first."); return
new_font = self._new_font_var.get().strip()
if not new_font:
messagebox.showwarning("Font Name", "Enter or choose a font name first."); return
patch_bin = self._patch_binary_var.get()
if not patch_bin and self.bundle.binary_fonts:
resp = messagebox.askyesnocancel(
"Binary Font Blobs Detected",
"Binary font name strings were found in the bundle:\n\n"
+ "\n".join(f" - {x['font']} (x{x['count']})" for x in self.bundle.binary_fonts)
+ "\n\nPatch these in addition to RTF font tables?\n\n"
"YES = RTF + binary blobs (recommended)\n"
"NO = RTF only\n"
"CANCEL = abort export")
if resp is None: return
patch_bin = resp
suggested = Path(self.bundle.source_path).stem + "_fixed"
out_path = filedialog.asksaveasfilename(
title="Save Fixed Bundle",
defaultextension=".proBundle",
initialfile=f"{suggested}.proBundle",
filetypes=[("ProPresenter Bundle", "*.proBundle"), ("All files", "*.*")])
if not out_path: return
new_pro_name = _ask_string(
self, "Internal Filename",
"Name for the .pro file inside the zip\n"
"(ProPresenter displays this as the presentation title):",
default=suggested)
if new_pro_name is None: return
new_pro_name = (new_pro_name.strip() or suggested)
try:
patched, rtf_n, bin_n = build_patched_pro(self.bundle, new_font, patch_bin)
export_bundle(self.bundle, patched, out_path, new_pro_name)
messagebox.showinfo("Export Successful",
f"Bundle exported!\n\n"
f" RTF patches : {rtf_n}\n"
f" Binary patches : {bin_n}\n\n"
f"Saved to:\n{out_path}")
self._status_var.set(
f"Exported: {Path(out_path).name} | "
f"RTF: {rtf_n} patches | Binary: {bin_n} patches")
except Exception as ex:
messagebox.showerror("Export Error", f"Export failed:\n{ex}")
def _about(self):
messagebox.showinfo("About",
f"{APP_TITLE} v{APP_VERSION}\n\n"
"Fix Malayalam font resets in ProPresenter 7 bundles.\n\n"
"v2.0: Protobuf-length-aware RTF patching.\n"
"Correctly updates varint length prefixes when RTF\n"
"content changes size -- prevents blank slide imports.\n\n"
"Built for CSI East Parade Malayalam Pastorate")
# ======================================================================
# ENTRY POINT
# ======================================================================
def main():
app = FontFixerApp()
app.mainloop()
if __name__ == '__main__':
main()