defluffisizer 1.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.
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Render a local HTML file to PDF, for defluffisizer's pdf-in / pdf-out path.
4
+
5
+ Chrome's CLI --headless --print-to-pdf switch always burns a date/URL/
6
+ page-number header and footer into the page with no way to turn it off in
7
+ current Chrome (the --print-to-pdf-no-header switch has been removed), so
8
+ this drives Page.printToPDF over the DevTools Protocol directly instead,
9
+ where displayHeaderFooter is a real, working parameter. Pure standard
10
+ library: no websocket-client, no puppeteer, no pip install.
11
+
12
+ Example:
13
+ python3 scripts/pdf_write.py page.html output.pdf
14
+ """
15
+
16
+ import base64
17
+ import json
18
+ import os
19
+ import shutil
20
+ import socket
21
+ import struct
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+ CHROME_CANDIDATES = [
29
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
30
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
31
+ "google-chrome",
32
+ "google-chrome-stable",
33
+ "chromium",
34
+ "chromium-browser",
35
+ ]
36
+
37
+ INSTALL_HINT = (
38
+ "No Chrome or Chromium found for PDF rendering.\n"
39
+ "Install Google Chrome (https://google.com/chrome), or "
40
+ "`brew install --cask google-chrome` (macOS) / `apt install chromium` (Debian/Ubuntu)."
41
+ )
42
+
43
+
44
+ def find_chrome():
45
+ for candidate in CHROME_CANDIDATES:
46
+ if os.path.isabs(candidate):
47
+ if os.path.exists(candidate):
48
+ return candidate
49
+ elif shutil.which(candidate):
50
+ return shutil.which(candidate)
51
+ return None
52
+
53
+
54
+ def _ws_handshake(sock, host, port, path):
55
+ key = base64.b64encode(os.urandom(16)).decode()
56
+ request = (
57
+ f"GET {path} HTTP/1.1\r\n"
58
+ f"Host: {host}:{port}\r\n"
59
+ "Upgrade: websocket\r\n"
60
+ "Connection: Upgrade\r\n"
61
+ f"Sec-WebSocket-Key: {key}\r\n"
62
+ "Sec-WebSocket-Version: 13\r\n\r\n"
63
+ )
64
+ sock.sendall(request.encode())
65
+ response = b""
66
+ while b"\r\n\r\n" not in response:
67
+ response += sock.recv(4096)
68
+ if b"101" not in response.split(b"\r\n", 1)[0]:
69
+ raise SystemExit(f"Chrome DevTools WebSocket handshake failed: {response[:200]!r}")
70
+
71
+
72
+ def _ws_send(sock, payload):
73
+ data = json.dumps(payload).encode()
74
+ length = len(data)
75
+ mask = os.urandom(4)
76
+ if length <= 125:
77
+ header = struct.pack("!BB", 0x81, 0x80 | length)
78
+ elif length <= 65535:
79
+ header = struct.pack("!BBH", 0x81, 0x80 | 126, length)
80
+ else:
81
+ header = struct.pack("!BBQ", 0x81, 0x80 | 127, length)
82
+ masked = bytes(byte ^ mask[i % 4] for i, byte in enumerate(data))
83
+ sock.sendall(header + mask + masked)
84
+
85
+
86
+ def _ws_recv_message(sock):
87
+ buffered = b""
88
+
89
+ def read_exact(n):
90
+ nonlocal buffered
91
+ while len(buffered) < n:
92
+ chunk = sock.recv(65536)
93
+ if not chunk:
94
+ raise SystemExit("Chrome DevTools WebSocket closed unexpectedly")
95
+ buffered += chunk
96
+ out, rest = buffered[:n], buffered[n:]
97
+ buffered = rest
98
+ return out
99
+
100
+ message = b""
101
+ while True:
102
+ header = read_exact(2)
103
+ fin = header[0] & 0x80
104
+ opcode = header[0] & 0x0F
105
+ length = header[1] & 0x7F
106
+ if length == 126:
107
+ length = struct.unpack("!H", read_exact(2))[0]
108
+ elif length == 127:
109
+ length = struct.unpack("!Q", read_exact(8))[0]
110
+ payload = read_exact(length)
111
+ if opcode == 0x8:
112
+ raise SystemExit("Chrome DevTools WebSocket connection closed by Chrome")
113
+ message += payload
114
+ if fin:
115
+ return message
116
+
117
+
118
+ def html_to_pdf(html_path, pdf_path, timeout=30, port=9333):
119
+ """Render html_path to a real PDF at pdf_path using headless Chrome."""
120
+ chrome = find_chrome()
121
+ if not chrome:
122
+ raise SystemExit(INSTALL_HINT)
123
+
124
+ html_path = Path(html_path).resolve()
125
+ pdf_path = Path(pdf_path).resolve()
126
+
127
+ proc = subprocess.Popen(
128
+ [
129
+ chrome,
130
+ "--headless=new",
131
+ "--disable-gpu",
132
+ f"--remote-debugging-port={port}",
133
+ "--no-first-run",
134
+ "--no-default-browser-check",
135
+ "--disable-extensions",
136
+ "about:blank",
137
+ ],
138
+ stdout=subprocess.DEVNULL,
139
+ stderr=subprocess.DEVNULL,
140
+ )
141
+
142
+ try:
143
+ deadline = time.time() + timeout
144
+ ready = False
145
+ while time.time() < deadline:
146
+ try:
147
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1)
148
+ ready = True
149
+ break
150
+ except Exception:
151
+ time.sleep(0.2)
152
+ if not ready:
153
+ raise SystemExit("Chrome did not start a debuggable session in time.")
154
+
155
+ new_tab_request = urllib.request.Request(
156
+ f"http://127.0.0.1:{port}/json/new?file://{html_path}", method="PUT"
157
+ )
158
+ with urllib.request.urlopen(new_tab_request, timeout=5) as response:
159
+ tab = json.loads(response.read())
160
+ ws_path = tab["webSocketDebuggerUrl"].split(f"127.0.0.1:{port}", 1)[1]
161
+
162
+ sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
163
+ try:
164
+ _ws_handshake(sock, "127.0.0.1", port, ws_path)
165
+ time.sleep(0.5)
166
+
167
+ _ws_send(sock, {"id": 1, "method": "Page.enable", "params": {}})
168
+ _ws_recv_message(sock)
169
+
170
+ _ws_send(
171
+ sock,
172
+ {
173
+ "id": 2,
174
+ "method": "Page.printToPDF",
175
+ "params": {
176
+ "displayHeaderFooter": False,
177
+ "printBackground": True,
178
+ "preferCSSPageSize": True,
179
+ },
180
+ },
181
+ )
182
+ while True:
183
+ message = json.loads(_ws_recv_message(sock))
184
+ if message.get("id") == 2:
185
+ if "error" in message:
186
+ raise SystemExit(f"Page.printToPDF failed: {message['error']}")
187
+ pdf_path.write_bytes(base64.b64decode(message["result"]["data"]))
188
+ return pdf_path
189
+ finally:
190
+ sock.close()
191
+ finally:
192
+ proc.terminate()
193
+ try:
194
+ proc.wait(timeout=5)
195
+ except Exception:
196
+ proc.kill()
197
+
198
+
199
+ def text_to_html(text):
200
+ """Wrap plain rewritten text in a minimal, clean, readable page.
201
+
202
+ This is the generic fallback when the caller has not supplied a
203
+ styled .html file: no attempt to clone the source PDF's original
204
+ design, just clean type on a page, single column, in reading order.
205
+ """
206
+ escaped = (
207
+ text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
208
+ )
209
+ paragraphs = "\n".join(
210
+ f"<p>{line}</p>" for line in escaped.split("\n") if line.strip()
211
+ )
212
+ return f"""<!doctype html>
213
+ <html><head><meta charset="utf-8"><style>
214
+ @page {{ size: letter; margin: 1in; }}
215
+ body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 12pt;
216
+ line-height: 1.5; color: #1a1a1a; }}
217
+ p {{ margin: 0 0 12pt 0; }}
218
+ </style></head><body>
219
+ {paragraphs}
220
+ </body></html>"""
221
+
222
+
223
+ def main():
224
+ if len(sys.argv) != 3:
225
+ raise SystemExit("Usage: pdf_write.py <input.html> <output.pdf>")
226
+ out = html_to_pdf(sys.argv[1], sys.argv[2])
227
+ print(f"Wrote {out}")
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Extract and rewrite text inside PPTX files while preserving the deck structure.
4
+
5
+ Examples:
6
+ python3 scripts/pptx_rewrite.py extract input.pptx --out editable.json
7
+ python3 scripts/pptx_rewrite.py apply input.pptx replacements.json --output output.pptx
8
+ """
9
+
10
+ import argparse
11
+ import copy
12
+ import difflib
13
+ import json
14
+ import re
15
+ import zipfile
16
+ import xml.etree.ElementTree as ET
17
+ from pathlib import Path
18
+
19
+ NS = {
20
+ "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
21
+ "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
22
+ }
23
+ A_NS = f"{{{NS['a']}}}"
24
+ P_NS = f"{{{NS['p']}}}"
25
+ SKIP_TAGS = {"nvGrpSpPr", "grpSpPr"}
26
+ SLIDE_RE = re.compile(r"ppt/slides/slide(\d+)\.xml$")
27
+
28
+ ET.register_namespace("a", NS["a"])
29
+ ET.register_namespace("p", NS["p"])
30
+
31
+
32
+ def local_name(tag):
33
+ return tag.split("}", 1)[-1]
34
+
35
+
36
+ def slide_sort_key(name):
37
+ match = SLIDE_RE.match(name)
38
+ return int(match.group(1)) if match else 0
39
+
40
+
41
+ def paragraph_text(paragraph):
42
+ parts = []
43
+ for node in paragraph:
44
+ if node.tag in {f"{A_NS}r", f"{A_NS}fld"}:
45
+ text_node = node.find("a:t", NS)
46
+ if text_node is not None and text_node.text:
47
+ parts.append(text_node.text)
48
+ return "".join(parts)
49
+
50
+
51
+ def txbody_to_text(txbody):
52
+ lines = []
53
+ for paragraph in txbody.findall("a:p", NS):
54
+ text = paragraph_text(paragraph).strip()
55
+ if text:
56
+ lines.append(text)
57
+ return "\n".join(lines)
58
+
59
+
60
+ def find_text_targets(parent, path=""):
61
+ targets = {}
62
+ child_index = 0
63
+ for child in list(parent):
64
+ tag = local_name(child.tag)
65
+ if tag in SKIP_TAGS:
66
+ continue
67
+ child_index += 1
68
+ child_path = f"{path}/{tag}[{child_index}]" if path else f"{tag}[{child_index}]"
69
+
70
+ if tag == "sp":
71
+ txbody = child.find("p:txBody", NS)
72
+ if txbody is not None:
73
+ targets[child_path] = txbody
74
+ elif tag == "graphicFrame":
75
+ table = child.find(".//a:tbl", NS)
76
+ if table is not None:
77
+ for row_index, row in enumerate(table.findall("a:tr", NS), start=1):
78
+ for col_index, cell in enumerate(row.findall("a:tc", NS), start=1):
79
+ txbody = cell.find("a:txBody", NS)
80
+ if txbody is not None:
81
+ cell_path = f"{child_path}/table[{row_index},{col_index}]"
82
+ targets[cell_path] = txbody
83
+ elif tag == "grpSp":
84
+ targets.update(find_text_targets(child, child_path))
85
+
86
+ return targets
87
+
88
+
89
+ def extract_slide(root, slide_number):
90
+ sp_tree = root.find(".//p:spTree", NS)
91
+ if sp_tree is None:
92
+ return {"slide": slide_number, "elements": []}
93
+
94
+ elements = []
95
+ for path, txbody in find_text_targets(sp_tree).items():
96
+ text = txbody_to_text(txbody)
97
+ if not text:
98
+ continue
99
+ elements.append({"path": path, "text": text})
100
+
101
+ return {"slide": slide_number, "elements": elements}
102
+
103
+
104
+ def clone_paragraph_template(txbody):
105
+ templates = []
106
+ for paragraph in txbody.findall("a:p", NS):
107
+ template = {
108
+ "pPr": None,
109
+ "rPr": None,
110
+ "endParaRPr": None,
111
+ "text": paragraph_text(paragraph).strip(),
112
+ }
113
+ ppr = paragraph.find("a:pPr", NS)
114
+ if ppr is not None:
115
+ template["pPr"] = copy.deepcopy(ppr)
116
+ for node in paragraph:
117
+ if node.tag in {f"{A_NS}r", f"{A_NS}fld"}:
118
+ rpr = node.find("a:rPr", NS)
119
+ if rpr is not None:
120
+ template["rPr"] = copy.deepcopy(rpr)
121
+ break
122
+ end_para = paragraph.find("a:endParaRPr", NS)
123
+ if end_para is not None:
124
+ template["endParaRPr"] = copy.deepcopy(end_para)
125
+ templates.append(template)
126
+
127
+ if not templates:
128
+ templates.append({"pPr": None, "rPr": None, "endParaRPr": None, "text": ""})
129
+ return templates
130
+
131
+
132
+ def assign_templates(templates, lines):
133
+ """Match each rewritten line to the paragraph it most likely descends from.
134
+
135
+ Matching by raw position breaks alignment and bullet level as soon as a
136
+ rewrite drops, merges, or reorders a bullet: the fact that used to sit
137
+ at index 2 (top level bullet) slides into index 1's template (a
138
+ sub-bullet) once the fluff above it is cut, so it silently picks up the
139
+ wrong indent and bullet character. Matching by text similarity keeps a
140
+ surviving sentence anchored to its own original formatting even when
141
+ lines around it disappear.
142
+ """
143
+ scored = []
144
+ for line_index, line in enumerate(lines):
145
+ for template_index, template in enumerate(templates):
146
+ score = difflib.SequenceMatcher(None, line.lower(), template["text"].lower()).ratio()
147
+ scored.append((score, line_index, template_index))
148
+ scored.sort(key=lambda item: item[0], reverse=True)
149
+
150
+ assignment = [None] * len(lines)
151
+ used_templates = set()
152
+ for score, line_index, template_index in scored:
153
+ if score < 0.35:
154
+ break
155
+ if assignment[line_index] is not None or template_index in used_templates:
156
+ continue
157
+ assignment[line_index] = template_index
158
+ used_templates.add(template_index)
159
+
160
+ fallback_pool = [i for i in range(len(templates)) if i not in used_templates] or list(range(len(templates)))
161
+ for line_index in range(len(lines)):
162
+ if assignment[line_index] is not None:
163
+ continue
164
+ position = line_index / max(len(lines) - 1, 1)
165
+ assignment[line_index] = fallback_pool[round(position * (len(fallback_pool) - 1))]
166
+
167
+ return [templates[index] for index in assignment]
168
+
169
+
170
+ def reset_stale_autofit(txbody):
171
+ """Clear cached shrink-to-fit numbers after a text rewrite.
172
+
173
+ PowerPoint bakes the shrink percentage for the old text into
174
+ normAutofit fontScale/lnSpcReduction. Left in place after a rewrite,
175
+ a box that used to need 3 crowded bullets keeps rendering at the old
176
+ shrunk size even though the new text is short enough for full size,
177
+ which reads as a mostly empty box with undersized text. Clearing the
178
+ cached numbers tells PowerPoint to recompute fit for the new text.
179
+ """
180
+ body_pr = txbody.find("a:bodyPr", NS)
181
+ if body_pr is None:
182
+ return
183
+ autofit = body_pr.find("a:normAutofit", NS)
184
+ if autofit is None:
185
+ return
186
+ autofit.attrib.pop("fontScale", None)
187
+ autofit.attrib.pop("lnSpcReduction", None)
188
+
189
+
190
+ def set_txbody_text(txbody, new_text):
191
+ templates = clone_paragraph_template(txbody)
192
+ for paragraph in list(txbody.findall("a:p", NS)):
193
+ txbody.remove(paragraph)
194
+
195
+ lines = [line.strip() for line in new_text.split("\n")]
196
+ lines = [line for line in lines if line] or [""]
197
+ assigned = assign_templates(templates, lines)
198
+
199
+ for line, template in zip(lines, assigned):
200
+ paragraph = ET.SubElement(txbody, f"{A_NS}p")
201
+ if template["pPr"] is not None:
202
+ paragraph.append(copy.deepcopy(template["pPr"]))
203
+ if line:
204
+ run = ET.SubElement(paragraph, f"{A_NS}r")
205
+ if template["rPr"] is not None:
206
+ run.append(copy.deepcopy(template["rPr"]))
207
+ text_node = ET.SubElement(run, f"{A_NS}t")
208
+ text_node.text = line
209
+ if template["endParaRPr"] is not None:
210
+ paragraph.append(copy.deepcopy(template["endParaRPr"]))
211
+
212
+ reset_stale_autofit(txbody)
213
+
214
+
215
+ def load_replacements(path):
216
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
217
+ slides = data.get("slides")
218
+ if not isinstance(slides, list):
219
+ raise ValueError("Replacement file must contain a top level 'slides' list")
220
+
221
+ replacements = {}
222
+ for slide in slides:
223
+ slide_number = int(slide["slide"])
224
+ replacements[slide_number] = {}
225
+ for element in slide.get("elements", []):
226
+ replacements[slide_number][element["path"]] = element["text"]
227
+ return replacements
228
+
229
+
230
+ def extract_pptx(input_path):
231
+ slides = []
232
+ with zipfile.ZipFile(input_path) as archive:
233
+ slide_names = sorted(
234
+ [name for name in archive.namelist() if SLIDE_RE.match(name)],
235
+ key=slide_sort_key,
236
+ )
237
+ for slide_name in slide_names:
238
+ slide_number = slide_sort_key(slide_name)
239
+ root = ET.fromstring(archive.read(slide_name))
240
+ slides.append(extract_slide(root, slide_number))
241
+ return {"source": str(Path(input_path).resolve()), "slides": slides}
242
+
243
+
244
+ def apply_replacements(input_path, replacement_path, output_path):
245
+ replacements = load_replacements(replacement_path)
246
+ input_path = Path(input_path)
247
+ output_path = Path(output_path)
248
+
249
+ with zipfile.ZipFile(input_path) as source, zipfile.ZipFile(
250
+ output_path,
251
+ "w",
252
+ compression=zipfile.ZIP_DEFLATED,
253
+ ) as dest:
254
+ for info in source.infolist():
255
+ data = source.read(info.filename)
256
+ match = SLIDE_RE.match(info.filename)
257
+ if match:
258
+ slide_number = int(match.group(1))
259
+ slide_replacements = replacements.get(slide_number)
260
+ if slide_replacements:
261
+ root = ET.fromstring(data)
262
+ sp_tree = root.find(".//p:spTree", NS)
263
+ if sp_tree is None:
264
+ raise ValueError(f"Slide {slide_number} has no shape tree")
265
+ targets = find_text_targets(sp_tree)
266
+ missing = sorted(set(slide_replacements) - set(targets))
267
+ if missing:
268
+ raise ValueError(
269
+ f"Missing text target(s) on slide {slide_number}: {', '.join(missing)}"
270
+ )
271
+ for path, text in slide_replacements.items():
272
+ set_txbody_text(targets[path], text)
273
+ data = ET.tostring(root, encoding="utf-8", xml_declaration=True)
274
+ dest.writestr(info, data)
275
+
276
+
277
+ def main():
278
+ parser = argparse.ArgumentParser(description="Extract and rewrite PPTX text.")
279
+ subparsers = parser.add_subparsers(dest="command", required=True)
280
+
281
+ extract_parser = subparsers.add_parser("extract", help="Extract editable text from a deck")
282
+ extract_parser.add_argument("input", help="Input PPTX file")
283
+ extract_parser.add_argument("--out", required=True, help="Output JSON path")
284
+
285
+ apply_parser = subparsers.add_parser("apply", help="Apply text replacements to a deck")
286
+ apply_parser.add_argument("input", help="Input PPTX file")
287
+ apply_parser.add_argument("replacements", help="JSON file with edited slide text")
288
+ apply_parser.add_argument("--output", required=True, help="Output PPTX file")
289
+
290
+ args = parser.parse_args()
291
+
292
+ if args.command == "extract":
293
+ payload = extract_pptx(args.input)
294
+ Path(args.out).write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
295
+ return
296
+
297
+ if args.command == "apply":
298
+ apply_replacements(args.input, args.replacements, args.output)
299
+ return
300
+
301
+
302
+ if __name__ == "__main__":
303
+ main()