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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/package.json +49 -0
- package/skills/defluffisizer/README.md +182 -0
- package/skills/defluffisizer/SKILL.md +128 -0
- package/skills/defluffisizer/bin/defluffisizer.js +170 -0
- package/skills/defluffisizer/guardrails/anti_humanizer_theater.md +20 -0
- package/skills/defluffisizer/guardrails/fact_preservation.md +25 -0
- package/skills/defluffisizer/guardrails/format_safety.md +30 -0
- package/skills/defluffisizer/knowledge/format_capabilities.md +62 -0
- package/skills/defluffisizer/pipeline/01_intake_and_scope.md +34 -0
- package/skills/defluffisizer/pipeline/02_extract_and_map.md +36 -0
- package/skills/defluffisizer/pipeline/03_rewrite_and_defluff.md +49 -0
- package/skills/defluffisizer/pipeline/04_apply_and_validate.md +43 -0
- package/skills/defluffisizer/prompts/append_system_prompt.txt +17 -0
- package/skills/defluffisizer/references/ai_slop_signals.md +128 -0
- package/skills/defluffisizer/references/evaluation_playbook.md +52 -0
- package/skills/defluffisizer/references/format_playbook.md +45 -0
- package/skills/defluffisizer/references/human_signal_playbook.md +47 -0
- package/skills/defluffisizer/scripts/defluff_lint.py +319 -0
- package/skills/defluffisizer/scripts/document_text_io.py +272 -0
- package/skills/defluffisizer/scripts/pdf_write.py +231 -0
- package/skills/defluffisizer/scripts/pptx_rewrite.py +303 -0
|
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
|
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()
|