|
1
|
--- |
|
2
|
name: file-converter |
|
3
|
description: Convert, merge, split, and compress files across formats — documents, images, audio, and more. |
|
4
|
--- |
|
5
|
|
|
6
|
# File Converter |
|
7
|
|
|
8
|
Convert between data, document, image, audio formats, and ZIP archives. One-liners for each conversion pair. |
|
9
|
|
|
10
|
## Tool Map |
|
11
|
|
|
12
|
| Domain | Tool | Install | |
|
13
|
|
|
14
|
|---|---|---| |
|
15
|
|
|
16
|
| CSV/JSON/Excel/Parquet | `pandas` | `pip install pandas openpyxl pyarrow` | |
|
17
|
|
|
18
|
| YAML | `pyyaml` | `pip install pyyaml` | |
|
19
|
|
|
20
|
| XML ↔ dict | `xmltodict` | `pip install xmltodict` | |
|
21
|
|
|
22
|
| Any doc format ↔ any | **pandoc** (CLI) | `apt install pandoc` or `pip install pypandoc_binary` | |
|
23
|
|
|
24
|
| Markdown → HTML | `markdown` | `pip install markdown` | |
|
25
|
|
|
26
|
| HTML → Markdown | `markdownify` | `pip install markdownify` | |
|
27
|
|
|
28
|
| .docx read/write | `python-docx` | `pip install python-docx` | |
|
29
|
|
|
30
|
| PDF → text/tables | `pdfplumber` | `pip install pdfplumber` | |
|
31
|
|
|
32
|
| PDF → images | `pdf2image` | `pip install pdf2image` + `apt install poppler-utils` | |
|
33
|
|
|
34
|
| PDF manipulation | `pypdf` | `pip install pypdf` | |
|
35
|
|
|
36
|
| Images | `Pillow` | `pip install Pillow` | |
|
37
|
|
|
38
|
| SVG → PNG | `cairosvg` | `pip install cairosvg` | |
|
39
|
|
|
40
|
| HEIC → JPG | `pillow-heif` | `pip install pillow-heif` | |
|
41
|
|
|
42
|
| Audio formats | `pydub` | `pip install pydub` + `apt install ffmpeg` | |
|
43
|
|
|
44
|
| EPUB ↔ other | **pandoc** or `ebooklib` | `pip install ebooklib` | |
|
45
|
|
|
46
|
| HTML → PDF | `weasyprint` | `pip install weasyprint` | |
|
47
|
|
|
48
|
| GIF creation | `Pillow` or `imageio` | `pip install imageio[ffmpeg]` | |
|
49
|
|
|
50
|
| PDF → SVG | `pdf2image` + `potrace` or `pymupdf` | `pip install pymupdf` | |
|
51
|
|
|
52
|
| ZIP archives | `zipfile` (stdlib) | built-in, no install needed | |
|
53
|
|
|
54
|
## File Input Handling |
|
55
|
|
|
56
|
When a user wants to convert a file that can't be attached directly in the chat (e.g., `.heic`, `.flac`, `.epub`, `.psd`, `.m4a`, `.wma`, `.parquet`), ask them to upload it to the project's file system. Uploaded files typically appear in `attached_assets/` or the project root. Always check both locations. If the file isn't found, ask the user where they saved it. |
|
57
|
|
|
58
|
Common unsupported-in-chat but convertible formats: `.heic`, `.avif`, `.webp`, `.flac`, `.ogg`, `.m4a`, `.wma`, `.aiff`, `.epub`, `.parquet`, `.psd`, `.svg`, `.zip` |
|
59
|
|
|
60
|
## Data Formats |
|
61
|
|
|
62
|
```python |
|
63
|
|
|
64
|
import pandas as pd, json, yaml, xmltodict |
|
65
|
|
|
66
|
# --- CSV ↔ JSON --- |
|
67
|
|
|
68
|
pd.read_csv("in.csv").to_json("out.json", orient="records", indent=2) |
|
69
|
|
|
70
|
pd.read_json("in.json").to_csv("out.csv", index=False) |
|
71
|
|
|
72
|
# --- CSV → Excel / Excel → CSV --- |
|
73
|
|
|
74
|
pd.read_csv("in.csv").to_excel("out.xlsx", index=False, engine="openpyxl") |
|
75
|
|
|
76
|
pd.read_excel("in.xlsx", sheet_name="Sheet1").to_csv("out.csv", index=False) |
|
77
|
|
|
78
|
# All sheets: pd.read_excel("in.xlsx", sheet_name=None) → dict of DataFrames |
|
79
|
|
|
80
|
# --- CSV → Parquet (columnar, compressed) --- |
|
81
|
|
|
82
|
pd.read_csv("in.csv").to_parquet("out.parquet", engine="pyarrow", compression="snappy") |
|
83
|
|
|
84
|
# --- YAML ↔ JSON --- |
|
85
|
|
|
86
|
data = yaml.safe_load(open("in.yaml")) \# ALWAYS safe_load, never load() |
|
87
|
|
|
88
|
json.dump(data, open("out.json", "w"), indent=2) |
|
89
|
|
|
90
|
yaml.safe_dump(json.load(open("in.json")), open("out.yaml", "w"), sort_keys=False) |
|
91
|
|
|
92
|
# --- XML ↔ JSON --- |
|
93
|
|
|
94
|
data = xmltodict.parse(open("in.xml").read()) |
|
95
|
|
|
96
|
json.dump(data, open("out.json", "w"), indent=2) |
|
97
|
|
|
98
|
open("out.xml", "w").write(xmltodict.unparse(data, pretty=True)) |
|
99
|
|
|
100
|
# --- JSONL (one JSON object per line) --- |
|
101
|
|
|
102
|
pd.read_json("in.jsonl", lines=True).to_csv("out.csv", index=False) |
|
103
|
|
|
104
|
``` |
|
105
|
|
|
106
|
**Encoding gotchas:** |
|
107
|
|
|
108
|
- `pd.read_csv("f.csv", encoding="utf-8-sig")` strips the BOM that Excel inserts |
|
109
|
- Auto-detect: `import chardet; enc = chardet.detect(open("f.csv","rb").read())["encoding"]` |
|
110
|
|
|
111
|
- CSV delimiter sniffing: `pd.read_csv("f.csv", sep=None, engine="python")` |
|
112
|
|
|
113
|
**Nested JSON → flat CSV:** |
|
114
|
|
|
115
|
```python |
|
116
|
|
|
117
|
pd.json_normalize(data, sep=".").to_csv("out.csv", index=False) \# {"a":{"b":1}} → column "a.b" |
|
118
|
|
|
119
|
``` |
|
120
|
|
|
121
|
## Document Formats — pandoc is the Swiss Army knife |
|
122
|
|
|
123
|
```bash |
|
124
|
|
|
125
|
# Markdown → PDF (requires LaTeX: apt install texlive-xetex) |
|
126
|
|
|
127
|
pandoc input.md -o output.pdf --pdf-engine=xelatex |
|
128
|
|
|
129
|
# Markdown → DOCX |
|
130
|
|
|
131
|
pandoc input.md -o output.docx |
|
132
|
|
|
133
|
# DOCX → Markdown (extracts images to ./media/) |
|
134
|
|
|
135
|
pandoc input.docx -o output.md --extract-media=. |
|
136
|
|
|
137
|
# HTML → Markdown |
|
138
|
|
|
139
|
pandoc input.html -o output.md -t gfm |
|
140
|
|
|
141
|
# Any → Any (pandoc supports ~40 formats) |
|
142
|
|
|
143
|
pandoc -f docx -t rst input.docx -o output.rst |
|
144
|
|
|
145
|
``` |
|
146
|
|
|
147
|
```python |
|
148
|
|
|
149
|
# From Python |
|
150
|
|
|
151
|
import pypandoc |
|
152
|
|
|
153
|
pypandoc.convert_file("in.md", "docx", outputfile="out.docx") |
|
154
|
|
|
155
|
``` |
|
156
|
|
|
157
|
**Without pandoc (pure Python):** |
|
158
|
|
|
159
|
```python |
|
160
|
|
|
161
|
# Markdown → HTML |
|
162
|
|
|
163
|
import markdown |
|
164
|
|
|
165
|
html = markdown.markdown(open("in.md").read(), extensions=["tables", "fenced_code", "toc"]) |
|
166
|
|
|
167
|
# HTML → Markdown |
|
168
|
|
|
169
|
from markdownify import markdownify |
|
170
|
|
|
171
|
md = markdownify(html, heading_style="ATX") \# ATX = \# headers, not underlines |
|
172
|
|
|
173
|
``` |
|
174
|
|
|
175
|
## PDF Operations |
|
176
|
|
|
177
|
```python |
|
178
|
|
|
179
|
# --- Extract text + tables --- |
|
180
|
|
|
181
|
import pdfplumber |
|
182
|
|
|
183
|
with pdfplumber.open("in.pdf") as pdf: |
|
184
|
|
|
185
|
text = "\n".join(p.extract_text() or "" for p in pdf.pages) |
|
186
|
|
|
187
|
tables = pdf.pages[0].extract_tables() \# list of list-of-rows |
|
188
|
|
|
189
|
# --- PDF → images (one PNG per page) --- |
|
190
|
|
|
191
|
from pdf2image import convert_from_path |
|
192
|
|
|
193
|
for i, img in enumerate(convert_from_path("in.pdf", dpi=200)): |
|
194
|
|
|
195
|
img.save(f"page_{i+1}.png") |
|
196
|
|
|
197
|
# --- Merge / split / rotate --- |
|
198
|
|
|
199
|
from pypdf import PdfReader, PdfWriter |
|
200
|
|
|
201
|
writer = PdfWriter() |
|
202
|
|
|
203
|
for path in ["a.pdf", "b.pdf"]: |
|
204
|
|
|
205
|
for page in PdfReader(path).pages: |
|
206
|
|
|
207
|
writer.add_page(page) |
|
208
|
|
|
209
|
writer.write("merged.pdf") |
|
210
|
|
|
211
|
# Extract pages 2–5 |
|
212
|
|
|
213
|
reader = PdfReader("in.pdf") |
|
214
|
|
|
215
|
writer = PdfWriter() |
|
216
|
|
|
217
|
for p in reader.pages[1:5]: |
|
218
|
|
|
219
|
writer.add_page(p) |
|
220
|
|
|
221
|
writer.write("pages_2-5.pdf") |
|
222
|
|
|
223
|
``` |
|
224
|
|
|
225
|
**PDF gotchas:** |
|
226
|
|
|
227
|
- `pdf2image` needs `poppler-utils` installed system-wide (not a pip package) |
|
228
|
- Scanned PDFs have no text layer — pdfplumber returns `None`. Use `pytesseract` OCR on pdf2image output. |
|
229
|
|
|
230
|
- `PyPDF2` is deprecated → use `pypdf` (same API, maintained fork) |
|
231
|
|
|
232
|
## Image Formats |
|
233
|
|
|
234
|
```python |
|
235
|
|
|
236
|
from PIL import Image |
|
237
|
|
|
238
|
# --- Basic conversion --- |
|
239
|
|
|
240
|
Image.open("in.png").convert("RGB").save("out.jpg", quality=90) |
|
241
|
|
|
242
|
# convert("RGB") is REQUIRED: JPEG can't store alpha channel, will raise OSError |
|
243
|
|
|
244
|
# --- WebP (best web format) --- |
|
245
|
|
|
246
|
Image.open("in.jpg").save("out.webp", quality=85, method=6) \# method 0-6, 6=best compression |
|
247
|
|
|
248
|
# --- AVIF (smallest, Pillow 11+) --- |
|
249
|
|
|
250
|
Image.open("in.jpg").save("out.avif", quality=75) |
|
251
|
|
|
252
|
# --- HEIC (iPhone photos) → JPG --- |
|
253
|
|
|
254
|
from pillow_heif import register_heif_opener |
|
255
|
|
|
256
|
register_heif_opener() |
|
257
|
|
|
258
|
Image.open("in.heic").convert("RGB").save("out.jpg", quality=90) |
|
259
|
|
|
260
|
# --- SVG → PNG --- |
|
261
|
|
|
262
|
import cairosvg |
|
263
|
|
|
264
|
cairosvg.svg2png(url="in.svg", write_to="out.png", output_width=1024) |
|
265
|
|
|
266
|
# --- Batch convert directory --- |
|
267
|
|
|
268
|
from pathlib import Path |
|
269
|
|
|
270
|
for p in Path("imgs").glob("*.png"): |
|
271
|
|
|
272
|
Image.open(p).convert("RGB").save(p.with_suffix(".jpg"), quality=85) |
|
273
|
|
|
274
|
``` |
|
275
|
|
|
276
|
**Image gotchas:** |
|
277
|
|
|
278
|
- PNG → JPG: **must** `convert("RGB")` first or transparency crashes the save |
|
279
|
- `quality` for PNG is meaningless (lossless) — use `optimize=True, compress_level=9` |
|
280
|
|
|
281
|
- Pillow can't open `.svg` natively — use `cairosvg` or `svglib` |
|
282
|
- GIF → MP4 is a video operation: `ffmpeg -i in.gif -pix_fmt yuv420p out.mp4` |
|
283
|
|
|
284
|
## Audio Formats |
|
285
|
|
|
286
|
```python |
|
287
|
|
|
288
|
from pydub import AudioSegment |
|
289
|
|
|
290
|
# --- MP3 ↔ WAV --- |
|
291
|
|
|
292
|
AudioSegment.from_mp3("in.mp3").export("out.wav", format="wav") |
|
293
|
|
|
294
|
AudioSegment.from_wav("in.wav").export("out.mp3", format="mp3", bitrate="192k") |
|
295
|
|
|
296
|
# --- FLAC → MP3 --- |
|
297
|
|
|
298
|
AudioSegment.from_file("in.flac", format="flac").export("out.mp3", format="mp3", bitrate="320k") |
|
299
|
|
|
300
|
# --- OGG → MP3 --- |
|
301
|
|
|
302
|
AudioSegment.from_ogg("in.ogg").export("out.mp3", format="mp3", bitrate="192k") |
|
303
|
|
|
304
|
# --- M4A / AAC → MP3 --- |
|
305
|
|
|
306
|
AudioSegment.from_file("in.m4a", format="m4a").export("out.mp3", format="mp3", bitrate="256k") |
|
307
|
|
|
308
|
# --- Any → Any (pydub supports mp3, wav, ogg, flac, m4a, aac, wma, aiff) --- |
|
309
|
|
|
310
|
AudioSegment.from_file("in.wma", format="wma").export("out.flac", format="flac") |
|
311
|
|
|
312
|
# --- Trim audio (first 30 seconds) --- |
|
313
|
|
|
314
|
audio = AudioSegment.from_file("in.mp3") |
|
315
|
|
|
316
|
audio[:30000].export("first_30s.mp3", format="mp3") \# milliseconds |
|
317
|
|
|
318
|
# --- Merge / concatenate --- |
|
319
|
|
|
320
|
combined = AudioSegment.from_file("a.mp3") + AudioSegment.from_file("b.mp3") |
|
321
|
|
|
322
|
combined.export("merged.mp3", format="mp3") |
|
323
|
|
|
324
|
# --- Adjust volume --- |
|
325
|
|
|
326
|
audio = AudioSegment.from_file("in.mp3") |
|
327
|
|
|
328
|
louder = audio + 6 \# +6 dB |
|
329
|
|
|
330
|
quieter = audio - 6 \# -6 dB |
|
331
|
|
|
332
|
louder.export("louder.mp3", format="mp3") |
|
333
|
|
|
334
|
# --- Get audio info --- |
|
335
|
|
|
336
|
audio = AudioSegment.from_file("in.mp3") |
|
337
|
|
|
338
|
print(f"Duration: {len(audio)/1000:.1f}s, Channels: {audio.channels}, " |
|
339
|
|
|
340
|
f"Sample rate: {audio.frame_rate}Hz, Sample width: {audio.sample_width*8}bit") |
|
341
|
|
|
342
|
# --- Batch convert directory --- |
|
343
|
|
|
344
|
from pathlib import Path |
|
345
|
|
|
346
|
for p in Path("audio").glob("*.wav"): |
|
347
|
|
|
348
|
AudioSegment.from_wav(str(p)).export(p.with_suffix(".mp3"), format="mp3", bitrate="192k") |
|
349
|
|
|
350
|
``` |
|
351
|
|
|
352
|
**Audio gotchas:** |
|
353
|
|
|
354
|
- `pydub` requires `ffmpeg` installed system-wide for non-WAV formats |
|
355
|
- Bitrate options: "128k" (small/low quality), "192k" (balanced), "256k" (high), "320k" (max for MP3) |
|
356
|
|
|
357
|
- WAV files are uncompressed — expect 10x larger file sizes than MP3 |
|
358
|
- For sample rate conversion: `audio.set_frame_rate(44100).export("out.wav", format="wav")` |
|
359
|
|
|
360
|
- Mono to stereo: `audio.set_channels(2)` / Stereo to mono: `audio.set_channels(1)` |
|
361
|
|
|
362
|
## ZIP Archives |
|
363
|
|
|
364
|
```python |
|
365
|
|
|
366
|
import zipfile |
|
367
|
|
|
368
|
from pathlib import Path |
|
369
|
|
|
370
|
# --- Create ZIP from files --- |
|
371
|
|
|
372
|
with zipfile.ZipFile("archive.zip", "w", zipfile.ZIP_DEFLATED) as zf: |
|
373
|
|
|
374
|
zf.write("file1.txt") |
|
375
|
|
|
376
|
zf.write("file2.csv") |
|
377
|
|
|
378
|
zf.write("images/photo.jpg") |
|
379
|
|
|
380
|
# --- Create ZIP from entire directory --- |
|
381
|
|
|
382
|
import shutil |
|
383
|
|
|
384
|
shutil.make_archive("archive", "zip", root_dir="my_folder") \# creates archive.zip |
|
385
|
|
|
386
|
# --- Extract all --- |
|
387
|
|
|
388
|
with zipfile.ZipFile("archive.zip", "r") as zf: |
|
389
|
|
|
390
|
zf.extractall("output_dir") |
|
391
|
|
|
392
|
# --- Extract single file --- |
|
393
|
|
|
394
|
with zipfile.ZipFile("archive.zip", "r") as zf: |
|
395
|
|
|
396
|
zf.extract("file1.txt", "output_dir") |
|
397
|
|
|
398
|
# --- List contents without extracting --- |
|
399
|
|
|
400
|
with zipfile.ZipFile("archive.zip", "r") as zf: |
|
401
|
|
|
402
|
for info in zf.infolist(): |
|
403
|
|
|
404
|
print(f"{info.filename} {info.file_size:,} bytes {info.compress_size:,} compressed") |
|
405
|
|
|
406
|
# --- Read file from ZIP without extracting --- |
|
407
|
|
|
408
|
with zipfile.ZipFile("archive.zip", "r") as zf: |
|
409
|
|
|
410
|
content = zf.read("file1.txt").decode("utf-8") |
|
411
|
|
|
412
|
# --- Add files to existing ZIP --- |
|
413
|
|
|
414
|
with zipfile.ZipFile("archive.zip", "a") as zf: |
|
415
|
|
|
416
|
zf.write("new_file.txt") |
|
417
|
|
|
418
|
# --- Create ZIP with password (read-only, use pyzipper for write) --- |
|
419
|
|
|
420
|
# pip install pyzipper |
|
421
|
|
|
422
|
import pyzipper |
|
423
|
|
|
424
|
with pyzipper.AESZipFile("secure.zip", "w", compression=pyzipper.ZIP_DEFLATED, |
|
425
|
|
|
426
|
encryption=pyzipper.WZ_AES) as zf: |
|
427
|
|
|
428
|
zf.setpassword(b"my_password") |
|
429
|
|
|
430
|
zf.write("secret.txt") |
|
431
|
|
|
432
|
# --- Batch: ZIP all PDFs in a directory --- |
|
433
|
|
|
434
|
with zipfile.ZipFile("all_pdfs.zip", "w", zipfile.ZIP_DEFLATED) as zf: |
|
435
|
|
|
436
|
for p in Path(".").glob("**/*.pdf"): |
|
437
|
|
|
438
|
zf.write(p) |
|
439
|
|
|
440
|
``` |
|
441
|
|
|
442
|
**ZIP gotchas:** |
|
443
|
|
|
444
|
- `zipfile` is in Python's standard library — no install needed |
|
445
|
- Always use `ZIP_DEFLATED` compression (default is `ZIP_STORED` = no compression) |
|
446
|
|
|
447
|
- For password-protected ZIPs, stdlib `zipfile` can only read (not write) — use `pyzipper` for encrypted writes |
|
448
|
- Max file size in standard ZIP is 4 GB; use `allowZip64=True` (default in Python 3) for larger files |
|
449
|
|
|
450
|
- `shutil.make_archive` is the simplest way to ZIP an entire directory tree |
|
451
|
|
|
452
|
## EPUB Formats |
|
453
|
|
|
454
|
```python |
|
455
|
|
|
456
|
# --- EPUB → other formats (via pandoc) --- |
|
457
|
|
|
458
|
# pandoc is the easiest way to convert EPUB |
|
459
|
|
|
460
|
import pypandoc |
|
461
|
|
|
462
|
# EPUB → Markdown |
|
463
|
|
|
464
|
pypandoc.convert_file("in.epub", "markdown", outputfile="out.md") |
|
465
|
|
|
466
|
# EPUB → HTML |
|
467
|
|
|
468
|
pypandoc.convert_file("in.epub", "html", outputfile="out.html") |
|
469
|
|
|
470
|
# EPUB → DOCX |
|
471
|
|
|
472
|
pypandoc.convert_file("in.epub", "docx", outputfile="out.docx") |
|
473
|
|
|
474
|
# EPUB → plain text |
|
475
|
|
|
476
|
pypandoc.convert_file("in.epub", "plain", outputfile="out.txt") |
|
477
|
|
|
478
|
# --- Other formats → EPUB --- |
|
479
|
|
|
480
|
# Markdown → EPUB |
|
481
|
|
|
482
|
pypandoc.convert_file("in.md", "epub", outputfile="out.epub", |
|
483
|
|
|
484
|
extra_args=["--metadata", "title=My Book"]) |
|
485
|
|
|
486
|
# HTML → EPUB |
|
487
|
|
|
488
|
pypandoc.convert_file("in.html", "epub", outputfile="out.epub", |
|
489
|
|
|
490
|
extra_args=["--metadata", "title=My Book"]) |
|
491
|
|
|
492
|
# DOCX → EPUB |
|
493
|
|
|
494
|
pypandoc.convert_file("in.docx", "epub", outputfile="out.epub") |
|
495
|
|
|
496
|
``` |
|
497
|
|
|
498
|
```bash |
|
499
|
|
|
500
|
# --- CLI equivalents --- |
|
501
|
|
|
502
|
pandoc in.epub -o out.md |
|
503
|
|
|
504
|
pandoc in.epub -o out.pdf --pdf-engine=xelatex |
|
505
|
|
|
506
|
pandoc in.md -o out.epub --metadata title="My Book" |
|
507
|
|
|
508
|
pandoc in.html -o out.epub --metadata title="My Book" --epub-cover-image=cover.jpg |
|
509
|
|
|
510
|
``` |
|
511
|
|
|
512
|
```python |
|
513
|
|
|
514
|
# --- Pure Python: read/write EPUB with ebooklib --- |
|
515
|
|
|
516
|
from ebooklib import epub |
|
517
|
|
|
518
|
# Read EPUB |
|
519
|
|
|
520
|
book = epub.read_epub("in.epub") |
|
521
|
|
|
522
|
for item in book.get_items_of_type(9): \# 9 = ITEM_DOCUMENT (HTML chapters) |
|
523
|
|
|
524
|
print(item.get_name()) |
|
525
|
|
|
526
|
html_content = item.get_content().decode("utf-8") |
|
527
|
|
|
528
|
# Create EPUB from scratch |
|
529
|
|
|
530
|
book = epub.EpubBook() |
|
531
|
|
|
532
|
book.set_identifier("id123") |
|
533
|
|
|
534
|
book.set_title("My Book") |
|
535
|
|
|
536
|
book.set_language("en") |
|
537
|
|
|
538
|
book.add_author("Author Name") |
|
539
|
|
|
540
|
ch1 = epub.EpubHtml(title="Chapter 1", file_name="ch1.xhtml", lang="en") |
|
541
|
|
|
542
|
ch1.content = "<h1>Chapter 1</h1><p>Hello world.</p>" |
|
543
|
|
|
544
|
book.add_item(ch1) |
|
545
|
|
|
546
|
book.toc = [epub.Link("ch1.xhtml", "Chapter 1", "ch1")] |
|
547
|
|
|
548
|
book.add_item(epub.EpubNcx()) |
|
549
|
|
|
550
|
book.add_item(epub.EpubNav()) |
|
551
|
|
|
552
|
book.spine = ["nav", ch1] |
|
553
|
|
|
554
|
epub.write_epub("out.epub", book) |
|
555
|
|
|
556
|
``` |
|
557
|
|
|
558
|
**EPUB gotchas:** |
|
559
|
|
|
560
|
- `pandoc` is the simplest for format-to-format EPUB conversion |
|
561
|
- Always add `--metadata title="..."` when creating EPUB — readers require a title |
|
562
|
|
|
563
|
- EPUB is essentially a ZIP of HTML files — `ebooklib` gives you fine-grained control |
|
564
|
- For EPUB → PDF, pandoc needs a LaTeX engine (`texlive-xetex`) |
|
565
|
|
|
566
|
- Cover images: use `--epub-cover-image=cover.jpg` with pandoc |
|
567
|
|
|
568
|
## HTML to PDF |
|
569
|
|
|
570
|
```python |
|
571
|
|
|
572
|
# --- weasyprint (best CSS support, no browser needed) --- |
|
573
|
|
|
574
|
from weasyprint import HTML |
|
575
|
|
|
576
|
# Simple file conversion |
|
577
|
|
|
578
|
HTML("in.html").write_pdf("out.pdf") |
|
579
|
|
|
580
|
# From URL |
|
581
|
|
|
582
|
HTML("https://example.com").write_pdf("page.pdf") |
|
583
|
|
|
584
|
# From HTML string |
|
585
|
|
|
586
|
HTML(string="<h1>Hello</h1><p>World</p>").write_pdf("out.pdf") |
|
587
|
|
|
588
|
# With custom CSS |
|
589
|
|
|
590
|
HTML("in.html").write_pdf("out.pdf", stylesheets=["custom.css"]) |
|
591
|
|
|
592
|
# With page size and margins |
|
593
|
|
|
594
|
from weasyprint import CSS |
|
595
|
|
|
596
|
HTML("in.html").write_pdf("out.pdf", stylesheets=[ |
|
597
|
|
|
598
|
CSS(string="@page { size: A4; margin: 2cm; }") |
|
599
|
|
|
600
|
]) |
|
601
|
|
|
602
|
# Landscape orientation |
|
603
|
|
|
604
|
HTML("in.html").write_pdf("out.pdf", stylesheets=[ |
|
605
|
|
|
606
|
CSS(string="@page { size: A4 landscape; margin: 1.5cm; }") |
|
607
|
|
|
608
|
]) |
|
609
|
|
|
610
|
``` |
|
611
|
|
|
612
|
```bash |
|
613
|
|
|
614
|
# --- CLI alternatives --- |
|
615
|
|
|
616
|
# pandoc (simpler, less CSS fidelity) |
|
617
|
|
|
618
|
pandoc in.html -o out.pdf --pdf-engine=xelatex |
|
619
|
|
|
620
|
# weasyprint CLI |
|
621
|
|
|
622
|
weasyprint in.html out.pdf |
|
623
|
|
|
624
|
weasyprint https://example.com page.pdf |
|
625
|
|
|
626
|
``` |
|
627
|
|
|
628
|
**HTML to PDF gotchas:** |
|
629
|
|
|
630
|
- `weasyprint` supports CSS3 including flexbox, grid, and `@page` rules — best for styled documents |
|
631
|
- `weasyprint` does NOT run JavaScript — for JS-heavy pages, use `playwright` or `pyppeteer` instead |
|
632
|
|
|
633
|
- For JS-rendered pages: `playwright` → `page.pdf()` is the most reliable option |
|
634
|
- pandoc HTML → PDF goes through LaTeX, so complex CSS layouts may not render correctly |
|
635
|
|
|
636
|
- Large HTML files with many images: use `HTML(filename="in.html", base_url=".")` so relative image paths resolve |
|
637
|
|
|
638
|
## GIF Creation |
|
639
|
|
|
640
|
```python |
|
641
|
|
|
642
|
from PIL import Image |
|
643
|
|
|
644
|
import imageio.v3 as iio |
|
645
|
|
|
646
|
from pathlib import Path |
|
647
|
|
|
648
|
# --- Images → animated GIF (Pillow) --- |
|
649
|
|
|
650
|
frames = [Image.open(f"frame_{i}.png") for i in range(10)] |
|
651
|
|
|
652
|
frames[0].save("out.gif", save_all=True, append_images=frames[1:], |
|
653
|
|
|
654
|
duration=100, loop=0) \# duration in ms, loop=0 means infinite |
|
655
|
|
|
656
|
# --- Images → GIF with optimization --- |
|
657
|
|
|
658
|
frames = [Image.open(f"frame_{i}.png").convert("RGBA") for i in range(10)] |
|
659
|
|
|
660
|
frames[0].save("out.gif", save_all=True, append_images=frames[1:], |
|
661
|
|
|
662
|
duration=100, loop=0, optimize=True) |
|
663
|
|
|
664
|
# --- Directory of images → GIF --- |
|
665
|
|
|
666
|
frame_paths = sorted(Path("frames").glob("*.png")) |
|
667
|
|
|
668
|
frames = [Image.open(p) for p in frame_paths] |
|
669
|
|
|
670
|
frames[0].save("out.gif", save_all=True, append_images=frames[1:], |
|
671
|
|
|
672
|
duration=100, loop=0) |
|
673
|
|
|
674
|
# --- GIF → individual frames --- |
|
675
|
|
|
676
|
gif = Image.open("in.gif") |
|
677
|
|
|
678
|
for i in range(gif.n_frames): |
|
679
|
|
|
680
|
gif.seek(i) |
|
681
|
|
|
682
|
gif.save(f"frame_{i}.png") |
|
683
|
|
|
684
|
# --- Resize GIF --- |
|
685
|
|
|
686
|
gif = Image.open("in.gif") |
|
687
|
|
|
688
|
resized_frames = [] |
|
689
|
|
|
690
|
for i in range(gif.n_frames): |
|
691
|
|
|
692
|
gif.seek(i) |
|
693
|
|
|
694
|
resized_frames.append(gif.copy().resize((320, 240), Image.LANCZOS)) |
|
695
|
|
|
696
|
resized_frames[0].save("small.gif", save_all=True, append_images=resized_frames[1:], |
|
697
|
|
|
698
|
duration=gif.info.get("duration", 100), loop=0) |
|
699
|
|
|
700
|
# --- Video → GIF (imageio + ffmpeg) --- |
|
701
|
|
|
702
|
import imageio.v3 as iio |
|
703
|
|
|
704
|
frames = iio.imread("in.mp4", plugin="pyav") |
|
705
|
|
|
706
|
iio.imwrite("out.gif", frames, plugin="pillow", duration=40, loop=0) |
|
707
|
|
|
708
|
# --- GIF → MP4 (ffmpeg CLI, much smaller file) --- |
|
709
|
|
|
710
|
# ffmpeg -i in.gif -pix_fmt yuv420p -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" out.mp4 |
|
711
|
|
|
712
|
``` |
|
713
|
|
|
714
|
**GIF gotchas:** |
|
715
|
|
|
716
|
- GIF is limited to 256 colors per frame — complex images lose quality |
|
717
|
- Use `optimize=True` to reduce file size, but large GIFs are still huge compared to MP4 |
|
718
|
|
|
719
|
- `duration` is per-frame in milliseconds (100ms = 10 FPS, 40ms = 25 FPS) |
|
720
|
- `loop=0` means infinite loop; `loop=1` plays once then stops |
|
721
|
|
|
722
|
- For video → GIF, consider downscaling first — full-resolution GIFs are enormous |
|
723
|
- Pillow GIF output doesn't support transparency well — use `imageio` for better results |
|
724
|
|
|
725
|
- For best quality: create GIF from video with ffmpeg: `ffmpeg -i in.mp4 -vf "fps=15,scale=480:-1" out.gif` |
|
726
|
|
|
727
|
## PDF to SVG |
|
728
|
|
|
729
|
```python |
|
730
|
|
|
731
|
# --- PyMuPDF (fitz) — best quality, vector-preserving --- |
|
732
|
|
|
733
|
import fitz \# pip install pymupdf |
|
734
|
|
|
735
|
# Single page |
|
736
|
|
|
737
|
doc = fitz.open("in.pdf") |
|
738
|
|
|
739
|
page = doc[0] |
|
740
|
|
|
741
|
svg_text = page.get_svg_image() |
|
742
|
|
|
743
|
with open("page_1.svg", "w") as f: |
|
744
|
|
|
745
|
f.write(svg_text) |
|
746
|
|
|
747
|
# All pages |
|
748
|
|
|
749
|
doc = fitz.open("in.pdf") |
|
750
|
|
|
751
|
for i, page in enumerate(doc): |
|
752
|
|
|
753
|
svg_text = page.get_svg_image() |
|
754
|
|
|
755
|
with open(f"page_{i+1}.svg", "w") as f: |
|
756
|
|
|
757
|
f.write(svg_text) |
|
758
|
|
|
759
|
doc.close() |
|
760
|
|
|
761
|
# With custom resolution (matrix scales the output) |
|
762
|
|
|
763
|
doc = fitz.open("in.pdf") |
|
764
|
|
|
765
|
page = doc[0] |
|
766
|
|
|
767
|
mat = fitz.Matrix(2, 2) \# 2x scale for higher detail |
|
768
|
|
|
769
|
svg_text = page.get_svg_image(matrix=mat) |
|
770
|
|
|
771
|
with open("page_hires.svg", "w") as f: |
|
772
|
|
|
773
|
f.write(svg_text) |
|
774
|
|
|
775
|
``` |
|
776
|
|
|
777
|
```bash |
|
778
|
|
|
779
|
# --- CLI alternatives --- |
|
780
|
|
|
781
|
# pdf2svg (if installed) |
|
782
|
|
|
783
|
pdf2svg in.pdf out.svg 1 \# page number |
|
784
|
|
|
785
|
# Inkscape CLI |
|
786
|
|
|
787
|
inkscape in.pdf --export-type=svg --export-filename=out.svg |
|
788
|
|
|
789
|
# pdftocairo (from poppler-utils) |
|
790
|
|
|
791
|
pdftocairo -svg in.pdf out.svg |
|
792
|
|
|
793
|
``` |
|
794
|
|
|
795
|
**PDF to SVG gotchas:** |
|
796
|
|
|
797
|
- `pymupdf` (imported as `fitz`) produces true vector SVGs — text stays as text, paths stay as paths |
|
798
|
- Scanned PDFs produce SVGs with embedded raster images (no vector data to extract) |
|
799
|
|
|
800
|
- Large PDFs with complex graphics produce very large SVG files |
|
801
|
- `pdf2svg` CLI tool is simple but must be installed separately (`apt install pdf2svg`) |
|
802
|
|
|
803
|
- For rasterized SVG (simpler but not truly vector): render PDF to PNG first, then embed in SVG |
|
804
|
|
|
805
|
## Validation |
|
806
|
|
|
807
|
Always verify output: |
|
808
|
|
|
809
|
```python |
|
810
|
|
|
811
|
# Row count parity |
|
812
|
|
|
813
|
assert len(pd.read_csv("out.csv")) == len(pd.read_json("in.json")) |
|
814
|
|
|
815
|
# JSON well-formed |
|
816
|
|
|
817
|
json.load(open("out.json")) |
|
818
|
|
|
819
|
# Image opens |
|
820
|
|
|
821
|
Image.open("out.jpg").verify() |
|
822
|
|
|
823
|
``` |