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,319 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Lightweight lint and comparison tool for defluffed writing.
|
|
4
|
+
|
|
5
|
+
Examples:
|
|
6
|
+
python3 scripts/defluff_lint.py draft.txt
|
|
7
|
+
python3 scripts/defluff_lint.py --before before.txt --after after.txt
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
import statistics
|
|
15
|
+
import sys
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
EM_DASH = "\u2014"
|
|
20
|
+
EN_DASH = "\u2013"
|
|
21
|
+
|
|
22
|
+
FORMULAIC_PHRASES = [
|
|
23
|
+
"in today's",
|
|
24
|
+
"it is important to note",
|
|
25
|
+
"delve into",
|
|
26
|
+
"tap into",
|
|
27
|
+
"navigate the",
|
|
28
|
+
"meaningful outcomes",
|
|
29
|
+
"robust framework",
|
|
30
|
+
"seamless solution",
|
|
31
|
+
"transformative approach",
|
|
32
|
+
"unlock value",
|
|
33
|
+
"unlock actionable",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Defensive contrast: a claim justified by knocking down a criticism nobody
|
|
37
|
+
# raised, instead of just stating the claim. A close cousin of not-X-but-Y,
|
|
38
|
+
# common enough to flag on its own.
|
|
39
|
+
DEFENSIVE_CONTRAST_PHRASES = [
|
|
40
|
+
"no compromises",
|
|
41
|
+
"not just a",
|
|
42
|
+
"not a shortcut",
|
|
43
|
+
"not a band-aid",
|
|
44
|
+
"not a band aid",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
EMPTY_AMPLIFIERS = [
|
|
48
|
+
"truly",
|
|
49
|
+
"genuinely",
|
|
50
|
+
"really",
|
|
51
|
+
"very",
|
|
52
|
+
"simply put",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
VAGUE_WORDS = [
|
|
56
|
+
"landscape",
|
|
57
|
+
"realm",
|
|
58
|
+
"journey",
|
|
59
|
+
"ecosystem",
|
|
60
|
+
"leverage",
|
|
61
|
+
"unlock",
|
|
62
|
+
"synergy",
|
|
63
|
+
"holistic",
|
|
64
|
+
"impactful",
|
|
65
|
+
"transformative",
|
|
66
|
+
"seamless",
|
|
67
|
+
"robust",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
WORD_RE = re.compile(r"[A-Za-z0-9']+")
|
|
71
|
+
SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
|
|
72
|
+
HYPHENATED_WORD_RE = re.compile(r"\b[A-Za-z0-9]+-[A-Za-z0-9]+\b")
|
|
73
|
+
NOT_X_BUT_Y_RE = re.compile(r"\bnot\b[^.?!\n]{0,120}\bbut\b", re.IGNORECASE)
|
|
74
|
+
# False contrast split across a sentence boundary, or joined with a dash,
|
|
75
|
+
# instead of "but": "This is not a shortcut. It is a real pipeline." /
|
|
76
|
+
# "It's not a rewrite — it's a rebuild." Same move as NOT_X_BUT_Y_RE, just
|
|
77
|
+
# with the seam hidden behind punctuation other than "but".
|
|
78
|
+
SPLIT_CONTRAST_RE = re.compile(
|
|
79
|
+
r"\b(?:this|it|that)(?:'s|\s+is)\s+not\b[^.?!\n—]{0,140}(?:[.!?]\s+|\s*—\s*)"
|
|
80
|
+
r"(?:it|this|that)(?:'s|\s+is)\b",
|
|
81
|
+
re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
TIGHT_EM_DASH_RE = re.compile(r"\S—\S")
|
|
84
|
+
SPACED_EM_DASH_RE = re.compile(r"\s—\s")
|
|
85
|
+
NUMBER_RE = re.compile(r"\b\d+(?:[.,:/-]\d+)*\b")
|
|
86
|
+
PROPER_TOKEN_RE = re.compile(r"\b[A-Z][a-zA-Z0-9]+\b")
|
|
87
|
+
PROPER_TOKEN_IGNORE = {
|
|
88
|
+
"A",
|
|
89
|
+
"An",
|
|
90
|
+
"And",
|
|
91
|
+
"As",
|
|
92
|
+
"At",
|
|
93
|
+
"But",
|
|
94
|
+
"By",
|
|
95
|
+
"For",
|
|
96
|
+
"From",
|
|
97
|
+
"If",
|
|
98
|
+
"In",
|
|
99
|
+
"Into",
|
|
100
|
+
"It",
|
|
101
|
+
"Its",
|
|
102
|
+
"Of",
|
|
103
|
+
"On",
|
|
104
|
+
"Or",
|
|
105
|
+
"Our",
|
|
106
|
+
"That",
|
|
107
|
+
"The",
|
|
108
|
+
"Their",
|
|
109
|
+
"There",
|
|
110
|
+
"These",
|
|
111
|
+
"This",
|
|
112
|
+
"Those",
|
|
113
|
+
"To",
|
|
114
|
+
"We",
|
|
115
|
+
"With",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_text(path_str):
|
|
120
|
+
return Path(path_str).read_text(encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def words(text):
|
|
124
|
+
return WORD_RE.findall(text)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def split_sentences(text):
|
|
128
|
+
parts = [part.strip() for part in SENTENCE_SPLIT_RE.split(text.strip()) if part.strip()]
|
|
129
|
+
if not parts and text.strip():
|
|
130
|
+
return [text.strip()]
|
|
131
|
+
return parts
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def count_phrase(text, phrase):
|
|
135
|
+
# Left boundary only: catches inflected forms of the same word
|
|
136
|
+
# ("seamlessly" for "seamless", "leverages" for "leverage") while still
|
|
137
|
+
# avoiding a mid-word false hit like "very" inside "delivery", where
|
|
138
|
+
# there is no boundary before the match at all.
|
|
139
|
+
pattern = r"\b" + re.escape(phrase)
|
|
140
|
+
return len(re.findall(pattern, text, re.IGNORECASE))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sentence_lengths(sentences):
|
|
144
|
+
return [len(words(sentence)) for sentence in sentences if words(sentence)]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def repeated_openings(sentences, n=2):
|
|
148
|
+
openings = []
|
|
149
|
+
for sentence in sentences:
|
|
150
|
+
tokens = [token.lower() for token in WORD_RE.findall(sentence)]
|
|
151
|
+
if len(tokens) >= n:
|
|
152
|
+
openings.append(" ".join(tokens[:n]))
|
|
153
|
+
counts = Counter(openings)
|
|
154
|
+
return {opening: count for opening, count in counts.items() if count > 1}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def analyze(text):
|
|
158
|
+
token_list = words(text)
|
|
159
|
+
sentences = split_sentences(text)
|
|
160
|
+
lengths = sentence_lengths(sentences)
|
|
161
|
+
proper_tokens = sorted(
|
|
162
|
+
token for token in set(PROPER_TOKEN_RE.findall(text)) if token not in PROPER_TOKEN_IGNORE
|
|
163
|
+
)
|
|
164
|
+
numbers = sorted(set(NUMBER_RE.findall(text)))
|
|
165
|
+
formulaic_counts = {phrase: count_phrase(text, phrase) for phrase in FORMULAIC_PHRASES}
|
|
166
|
+
vague_counts = {word: count_phrase(text, word) for word in VAGUE_WORDS}
|
|
167
|
+
defensive_counts = {phrase: count_phrase(text, phrase) for phrase in DEFENSIVE_CONTRAST_PHRASES}
|
|
168
|
+
amplifier_counts = {word: count_phrase(text, word) for word in EMPTY_AMPLIFIERS}
|
|
169
|
+
hyphenated_words = HYPHENATED_WORD_RE.findall(text)
|
|
170
|
+
repeated = repeated_openings(sentences)
|
|
171
|
+
|
|
172
|
+
# not_x_but_y and split_contrast both catch the same rhetorical move
|
|
173
|
+
# (false contrast for emphasis); split_contrast catches it when the
|
|
174
|
+
# writer hid the seam behind a period instead of joining with "but".
|
|
175
|
+
not_x_but_y_count = len(NOT_X_BUT_Y_RE.findall(text))
|
|
176
|
+
split_contrast_count = len(SPLIT_CONTRAST_RE.findall(text))
|
|
177
|
+
|
|
178
|
+
result = {
|
|
179
|
+
"word_count": len(token_list),
|
|
180
|
+
"sentence_count": len(sentences),
|
|
181
|
+
"avg_sentence_length": round(statistics.mean(lengths), 2) if lengths else 0,
|
|
182
|
+
"sentence_length_stddev": round(statistics.pstdev(lengths), 2) if len(lengths) > 1 else 0,
|
|
183
|
+
"em_dash_count": text.count(EM_DASH),
|
|
184
|
+
"tight_em_dash_count": len(TIGHT_EM_DASH_RE.findall(text)),
|
|
185
|
+
"spaced_em_dash_count": len(SPACED_EM_DASH_RE.findall(text)),
|
|
186
|
+
"en_dash_count": text.count(EN_DASH),
|
|
187
|
+
"hyphenated_word_count": len(hyphenated_words),
|
|
188
|
+
"hyphenated_words": hyphenated_words,
|
|
189
|
+
"not_x_but_y_count": not_x_but_y_count,
|
|
190
|
+
"split_contrast_count": split_contrast_count,
|
|
191
|
+
"false_contrast_count": not_x_but_y_count + split_contrast_count,
|
|
192
|
+
"formulaic_phrases": {key: value for key, value in formulaic_counts.items() if value},
|
|
193
|
+
"vague_words": {key: value for key, value in vague_counts.items() if value},
|
|
194
|
+
"defensive_contrast_phrases": {key: value for key, value in defensive_counts.items() if value},
|
|
195
|
+
"empty_amplifiers": {key: value for key, value in amplifier_counts.items() if value},
|
|
196
|
+
"repeated_openings": repeated,
|
|
197
|
+
"numbers": numbers,
|
|
198
|
+
"proper_tokens": proper_tokens,
|
|
199
|
+
}
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def pattern_total(result):
|
|
204
|
+
return (
|
|
205
|
+
result["em_dash_count"]
|
|
206
|
+
+ result["en_dash_count"]
|
|
207
|
+
+ result["hyphenated_word_count"]
|
|
208
|
+
+ result["false_contrast_count"]
|
|
209
|
+
+ sum(result["formulaic_phrases"].values())
|
|
210
|
+
+ sum(result["vague_words"].values())
|
|
211
|
+
+ sum(result["defensive_contrast_phrases"].values())
|
|
212
|
+
+ sum(result["empty_amplifiers"].values())
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def compare(before_text, after_text):
|
|
217
|
+
before = analyze(before_text)
|
|
218
|
+
after = analyze(after_text)
|
|
219
|
+
|
|
220
|
+
before_words = before["word_count"] or 1
|
|
221
|
+
compression_ratio = round(after["word_count"] / before_words, 3)
|
|
222
|
+
|
|
223
|
+
missing_numbers = sorted(set(before["numbers"]) - set(after["numbers"]))
|
|
224
|
+
missing_proper_tokens = sorted(set(before["proper_tokens"]) - set(after["proper_tokens"]))
|
|
225
|
+
repeated_delta = len(after["repeated_openings"]) - len(before["repeated_openings"])
|
|
226
|
+
banned_before = pattern_total(before)
|
|
227
|
+
banned_after = pattern_total(after)
|
|
228
|
+
|
|
229
|
+
score = 100
|
|
230
|
+
score -= min(30, banned_after * 5)
|
|
231
|
+
score -= min(10, sum(after["formulaic_phrases"].values()) * 2)
|
|
232
|
+
score -= min(10, sum(after["vague_words"].values()))
|
|
233
|
+
score -= 8 if compression_ratio > 1.05 else 0
|
|
234
|
+
score -= 6 if compression_ratio < 0.45 else 0
|
|
235
|
+
score -= min(10, len(missing_numbers) * 2)
|
|
236
|
+
score -= 4 if repeated_delta > 0 else 0
|
|
237
|
+
score = max(0, score)
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
"before": before,
|
|
241
|
+
"after": after,
|
|
242
|
+
"compression_ratio": compression_ratio,
|
|
243
|
+
"banned_pattern_drop": banned_before - banned_after,
|
|
244
|
+
"missing_numbers": missing_numbers,
|
|
245
|
+
"missing_proper_tokens": missing_proper_tokens,
|
|
246
|
+
"score": score,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def render_analysis(result, label):
|
|
251
|
+
lines = [
|
|
252
|
+
f"{label}:",
|
|
253
|
+
f" words: {result['word_count']}",
|
|
254
|
+
f" sentences: {result['sentence_count']}",
|
|
255
|
+
f" avg sentence length: {result['avg_sentence_length']}",
|
|
256
|
+
f" sentence length stddev: {result['sentence_length_stddev']}",
|
|
257
|
+
f" em dashes: {result['em_dash_count']} (tight, no space: {result['tight_em_dash_count']}, spaced: {result['spaced_em_dash_count']})",
|
|
258
|
+
f" en dashes: {result['en_dash_count']}",
|
|
259
|
+
f" hyphenated words: {result['hyphenated_word_count']}",
|
|
260
|
+
f" false contrast patterns: {result['false_contrast_count']} (not X but Y: {result['not_x_but_y_count']}, split across sentences: {result['split_contrast_count']})",
|
|
261
|
+
]
|
|
262
|
+
if result["formulaic_phrases"]:
|
|
263
|
+
lines.append(f" formulaic phrases: {json.dumps(result['formulaic_phrases'], ensure_ascii=False)}")
|
|
264
|
+
if result["defensive_contrast_phrases"]:
|
|
265
|
+
lines.append(f" defensive contrast phrases: {json.dumps(result['defensive_contrast_phrases'], ensure_ascii=False)}")
|
|
266
|
+
if result["empty_amplifiers"]:
|
|
267
|
+
lines.append(f" empty amplifiers: {json.dumps(result['empty_amplifiers'], ensure_ascii=False)}")
|
|
268
|
+
if result["vague_words"]:
|
|
269
|
+
lines.append(f" vague words: {json.dumps(result['vague_words'], ensure_ascii=False)}")
|
|
270
|
+
if result["repeated_openings"]:
|
|
271
|
+
lines.append(f" repeated openings: {json.dumps(result['repeated_openings'], ensure_ascii=False)}")
|
|
272
|
+
return "\n".join(lines)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main():
|
|
276
|
+
parser = argparse.ArgumentParser(description="Lint text for AI fluff patterns.")
|
|
277
|
+
parser.add_argument("path", nargs="?", help="Single text file to lint")
|
|
278
|
+
parser.add_argument("--before", help="Original text file")
|
|
279
|
+
parser.add_argument("--after", help="Rewritten text file")
|
|
280
|
+
parser.add_argument("--json", action="store_true", help="Print machine readable JSON")
|
|
281
|
+
args = parser.parse_args()
|
|
282
|
+
|
|
283
|
+
if args.before and args.after:
|
|
284
|
+
result = compare(read_text(args.before), read_text(args.after))
|
|
285
|
+
if args.json:
|
|
286
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
287
|
+
return
|
|
288
|
+
print(render_analysis(result["before"], "Before"))
|
|
289
|
+
print()
|
|
290
|
+
print(render_analysis(result["after"], "After"))
|
|
291
|
+
print()
|
|
292
|
+
print("Comparison:")
|
|
293
|
+
print(f" compression ratio: {result['compression_ratio']}")
|
|
294
|
+
print(f" banned pattern drop: {result['banned_pattern_drop']}")
|
|
295
|
+
print(f" missing numbers: {result['missing_numbers'] or 'none'}")
|
|
296
|
+
print(f" missing proper tokens: {result['missing_proper_tokens'] or 'none'}")
|
|
297
|
+
print(f" score: {result['score']}/100")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
if args.path:
|
|
301
|
+
result = analyze(read_text(args.path))
|
|
302
|
+
if args.json:
|
|
303
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
304
|
+
return
|
|
305
|
+
print(render_analysis(result, "Text"))
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
text = sys.stdin.read()
|
|
309
|
+
if not text.strip():
|
|
310
|
+
parser.error("provide a file path, --before and --after, or stdin")
|
|
311
|
+
result = analyze(text)
|
|
312
|
+
if args.json:
|
|
313
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
314
|
+
return
|
|
315
|
+
print(render_analysis(result, "Text"))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
main()
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Extract and apply text for multiple document formats used by defluffisizer.
|
|
4
|
+
|
|
5
|
+
Examples:
|
|
6
|
+
python3 scripts/document_text_io.py probe report.docx
|
|
7
|
+
python3 scripts/document_text_io.py extract pitch.pptx --out editable.json
|
|
8
|
+
python3 scripts/document_text_io.py extract report.pdf --out report.txt
|
|
9
|
+
|
|
10
|
+
# apply without --output writes {stem}_defluffed{ext} next to the source:
|
|
11
|
+
python3 scripts/document_text_io.py apply pitch.pptx editable.json
|
|
12
|
+
# -> pitch_defluffed.pptx, beside pitch.pptx
|
|
13
|
+
|
|
14
|
+
python3 scripts/document_text_io.py apply report.pdf after.txt
|
|
15
|
+
# -> report_defluffed.docx, beside report.pdf (PDF has no native writer)
|
|
16
|
+
|
|
17
|
+
# --output-dir keeps the default name but changes the directory:
|
|
18
|
+
python3 scripts/document_text_io.py apply report.pdf after.txt --output-dir ~/Desktop/out
|
|
19
|
+
|
|
20
|
+
# --output overrides both name and directory:
|
|
21
|
+
python3 scripts/document_text_io.py apply report.docx after.txt --output final/report.docx
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import pdf_write
|
|
32
|
+
import pptx_rewrite
|
|
33
|
+
|
|
34
|
+
DIRECT_TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".tsv"}
|
|
35
|
+
TEXTUTIL_EXTENSIONS = {".doc", ".docx", ".odt", ".rtf", ".html", ".htm", ".wordml"}
|
|
36
|
+
PPTX_EXTENSIONS = {".pptx"}
|
|
37
|
+
PDF_EXTENSIONS = {".pdf"}
|
|
38
|
+
HTML_EXTENSIONS = {".html", ".htm"}
|
|
39
|
+
|
|
40
|
+
TOOL_INSTALL_HINTS = {
|
|
41
|
+
"textutil": "textutil ships with macOS only. .doc/.docx/.odt/.rtf/.html support needs macOS.",
|
|
42
|
+
"pdftotext": "Install poppler: `brew install poppler` (macOS) or `apt install poppler-utils` (Debian/Ubuntu).",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_tool(tool_name):
|
|
47
|
+
if shutil.which(tool_name):
|
|
48
|
+
return
|
|
49
|
+
hint = TOOL_INSTALL_HINTS.get(tool_name, "")
|
|
50
|
+
message = f"Required tool not found on PATH: {tool_name}"
|
|
51
|
+
if hint:
|
|
52
|
+
message += f"\n{hint}"
|
|
53
|
+
raise SystemExit(message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_command(args):
|
|
57
|
+
subprocess.run(args, check=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def suffix(path_str):
|
|
61
|
+
return Path(path_str).suffix.lower()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_output_ext(source_ext):
|
|
65
|
+
"""The extension a defluffed file gets when --output is not given.
|
|
66
|
+
|
|
67
|
+
Every format, PDF included, keeps its own extension: same format in,
|
|
68
|
+
same format out.
|
|
69
|
+
"""
|
|
70
|
+
return source_ext
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def default_output_path(source, output_dir=None):
|
|
74
|
+
"""Where a defluffed file lands when the caller does not name one.
|
|
75
|
+
|
|
76
|
+
Default is `{stem}_defluffed{ext}` next to the source file, matching
|
|
77
|
+
what the skill promises: the final output stays where the source was
|
|
78
|
+
unless the caller asks for a different directory.
|
|
79
|
+
"""
|
|
80
|
+
source = Path(source)
|
|
81
|
+
directory = Path(output_dir) if output_dir else source.parent
|
|
82
|
+
ext = default_output_ext(source.suffix.lower())
|
|
83
|
+
return directory / f"{source.stem}_defluffed{ext}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def probe_format(path_str):
|
|
87
|
+
ext = suffix(path_str)
|
|
88
|
+
if ext in PPTX_EXTENSIONS:
|
|
89
|
+
return {
|
|
90
|
+
"format": ext,
|
|
91
|
+
"extract": "json",
|
|
92
|
+
"apply_back": True,
|
|
93
|
+
"layout_preservation": "yes",
|
|
94
|
+
"tool": "pptx_rewrite.py",
|
|
95
|
+
}
|
|
96
|
+
if ext in DIRECT_TEXT_EXTENSIONS:
|
|
97
|
+
return {
|
|
98
|
+
"format": ext,
|
|
99
|
+
"extract": "text",
|
|
100
|
+
"apply_back": True,
|
|
101
|
+
"layout_preservation": "n/a",
|
|
102
|
+
"tool": "direct",
|
|
103
|
+
}
|
|
104
|
+
if ext in TEXTUTIL_EXTENSIONS:
|
|
105
|
+
return {
|
|
106
|
+
"format": ext,
|
|
107
|
+
"extract": "text",
|
|
108
|
+
"apply_back": True,
|
|
109
|
+
"layout_preservation": "best_effort",
|
|
110
|
+
"tool": "textutil",
|
|
111
|
+
}
|
|
112
|
+
if ext in PDF_EXTENSIONS:
|
|
113
|
+
return {
|
|
114
|
+
"format": ext,
|
|
115
|
+
"extract": "text",
|
|
116
|
+
"apply_back": False,
|
|
117
|
+
"layout_preservation": "no",
|
|
118
|
+
"tool": "pdftotext",
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
"format": ext or "unknown",
|
|
122
|
+
"extract": "unsupported",
|
|
123
|
+
"apply_back": False,
|
|
124
|
+
"layout_preservation": "unknown",
|
|
125
|
+
"tool": None,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def extract_text(source, out_path):
|
|
130
|
+
source = Path(source)
|
|
131
|
+
out_path = Path(out_path)
|
|
132
|
+
ext = source.suffix.lower()
|
|
133
|
+
|
|
134
|
+
if ext in PPTX_EXTENSIONS:
|
|
135
|
+
payload = pptx_rewrite.extract_pptx(source)
|
|
136
|
+
out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if ext in DIRECT_TEXT_EXTENSIONS:
|
|
140
|
+
out_path.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if ext in TEXTUTIL_EXTENSIONS:
|
|
144
|
+
require_tool("textutil")
|
|
145
|
+
run_command(["textutil", "-convert", "txt", str(source), "-output", str(out_path)])
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if ext in PDF_EXTENSIONS:
|
|
149
|
+
require_tool("pdftotext")
|
|
150
|
+
run_command(["pdftotext", str(source), str(out_path)])
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
raise SystemExit(f"Unsupported source format for extract: {ext}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def write_pdf_output(rewritten, output):
|
|
157
|
+
"""Render REWRITTEN to a real PDF at OUTPUT via headless Chrome.
|
|
158
|
+
|
|
159
|
+
If REWRITTEN is already .html, it is rendered as-is: this is how a
|
|
160
|
+
caller hands over a fully styled recreation (colors, tables, layout)
|
|
161
|
+
of the source instead of the generic plain-text fallback below. Any
|
|
162
|
+
other REWRITTEN is treated as plain text and wrapped in a minimal,
|
|
163
|
+
clean, single-column page, no attempt to clone the source's design.
|
|
164
|
+
"""
|
|
165
|
+
rewritten = Path(rewritten)
|
|
166
|
+
if rewritten.suffix.lower() in HTML_EXTENSIONS:
|
|
167
|
+
pdf_write.html_to_pdf(rewritten, output)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
html = pdf_write.text_to_html(rewritten.read_text(encoding="utf-8"))
|
|
171
|
+
tmp_html = output.with_suffix(".defluffisizer_tmp.html")
|
|
172
|
+
tmp_html.write_text(html, encoding="utf-8")
|
|
173
|
+
try:
|
|
174
|
+
pdf_write.html_to_pdf(tmp_html, output)
|
|
175
|
+
finally:
|
|
176
|
+
tmp_html.unlink(missing_ok=True)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def apply_text(source, rewritten, output):
|
|
180
|
+
source = Path(source)
|
|
181
|
+
rewritten = Path(rewritten)
|
|
182
|
+
output = Path(output)
|
|
183
|
+
ext = source.suffix.lower()
|
|
184
|
+
output_ext = output.suffix.lower()
|
|
185
|
+
|
|
186
|
+
if ext in PPTX_EXTENSIONS:
|
|
187
|
+
if rewritten.suffix.lower() != ".json":
|
|
188
|
+
raise SystemExit("PPTX apply expects a replacement JSON file")
|
|
189
|
+
pptx_rewrite.apply_replacements(source, rewritten, output)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
if output_ext == ".pdf":
|
|
193
|
+
write_pdf_output(rewritten, output)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if ext in DIRECT_TEXT_EXTENSIONS:
|
|
197
|
+
output.write_text(rewritten.read_text(encoding="utf-8"), encoding="utf-8")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if ext in TEXTUTIL_EXTENSIONS:
|
|
201
|
+
if output_ext in DIRECT_TEXT_EXTENSIONS:
|
|
202
|
+
output.write_text(rewritten.read_text(encoding="utf-8"), encoding="utf-8")
|
|
203
|
+
return
|
|
204
|
+
require_tool("textutil")
|
|
205
|
+
run_command(["textutil", "-convert", output_ext.lstrip("."), str(rewritten), "-output", str(output)])
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if ext in PDF_EXTENSIONS:
|
|
209
|
+
if output_ext in DIRECT_TEXT_EXTENSIONS:
|
|
210
|
+
output.write_text(rewritten.read_text(encoding="utf-8"), encoding="utf-8")
|
|
211
|
+
return
|
|
212
|
+
if output_ext in TEXTUTIL_EXTENSIONS:
|
|
213
|
+
require_tool("textutil")
|
|
214
|
+
run_command(["textutil", "-convert", output_ext.lstrip("."), str(rewritten), "-output", str(output)])
|
|
215
|
+
return
|
|
216
|
+
raise SystemExit(f"Unsupported output format for PDF apply: {output_ext}")
|
|
217
|
+
|
|
218
|
+
raise SystemExit(f"Unsupported source format for apply: {ext}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main():
|
|
222
|
+
parser = argparse.ArgumentParser(description="Document text IO for defluffisizer.")
|
|
223
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
224
|
+
|
|
225
|
+
probe_parser = subparsers.add_parser("probe", help="Show format capabilities for a source file")
|
|
226
|
+
probe_parser.add_argument("source", help="Source file path")
|
|
227
|
+
probe_parser.add_argument("--json", action="store_true", help="Print JSON output")
|
|
228
|
+
|
|
229
|
+
extract_parser = subparsers.add_parser("extract", help="Extract editable text from a source file")
|
|
230
|
+
extract_parser.add_argument("source", help="Source file path")
|
|
231
|
+
extract_parser.add_argument("--out", required=True, help="Output text or JSON path")
|
|
232
|
+
|
|
233
|
+
apply_parser = subparsers.add_parser("apply", help="Apply rewritten content back into an artifact")
|
|
234
|
+
apply_parser.add_argument("source", help="Original source path")
|
|
235
|
+
apply_parser.add_argument("rewritten", help="Rewritten text file or PPTX replacement JSON")
|
|
236
|
+
apply_parser.add_argument(
|
|
237
|
+
"--output",
|
|
238
|
+
help="Output artifact path. Defaults to {stem}_defluffed{ext} next to the source file.",
|
|
239
|
+
)
|
|
240
|
+
apply_parser.add_argument(
|
|
241
|
+
"--output-dir",
|
|
242
|
+
help="Directory for the default output name, when --output is not given. Defaults to the source file's own directory.",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
args = parser.parse_args()
|
|
246
|
+
|
|
247
|
+
if args.command == "probe":
|
|
248
|
+
info = probe_format(args.source)
|
|
249
|
+
if args.json:
|
|
250
|
+
print(json.dumps(info, indent=2, ensure_ascii=False))
|
|
251
|
+
return
|
|
252
|
+
print(f"format: {info['format']}")
|
|
253
|
+
print(f"extract: {info['extract']}")
|
|
254
|
+
print(f"apply back: {info['apply_back']}")
|
|
255
|
+
print(f"layout preservation: {info['layout_preservation']}")
|
|
256
|
+
print(f"tool: {info['tool']}")
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
if args.command == "extract":
|
|
260
|
+
extract_text(args.source, args.out)
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
if args.command == "apply":
|
|
264
|
+
output = Path(args.output) if args.output else default_output_path(args.source, args.output_dir)
|
|
265
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
apply_text(args.source, args.rewritten, output)
|
|
267
|
+
print(f"Wrote: {output}")
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
main()
|