
Document Converter Suite
- 458 installs
- 84 repo stars
- Updated April 8, 2026
- dkyazzentwatwa/chatgpt-skills
Document Converter Suite is an agent skill that converts PDFs, Office docs, markdown, HTML, and tables between editable formats with best-effort extraction and rebuild workflows.
About
Document Converter Suite is an agent skill that performs best-effort extraction and rebuild workflows across common document formats. It converts PDFs, Office files, markdown, HTML, and tables while preserving clean structure rather than pixel-perfect layout. Builders use the included Python scripts for single-file conversion, batch processing, PDF toolkit operations, table extraction, and simple form filling. The skill follows a short workflow that first confirms source and target formats plus whether editability or fidelity matters more, then selects the right script and explicitly flags limitations of the output. It is especially useful when you need to move content between tools in an automated pipeline without promising visual perfection.
- Converts between pdf, docx, pptx, xlsx, txt, csv, md, and html
- Extracts tables and spreadsheet-style grids into clean editable outputs
- Bundled PDF utilities: merge, split, rotate, watermark, page extraction
- Includes dedicated scripts for form filling and table extraction
- Always states when output is best-effort and may lose layout or images
Document Converter Suite by the numbers
- 458 all-time installs (skills.sh)
- Ranked #424 of 2,719 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dkyazzentwatwa/chatgpt-skills --skill document-converter-suiteAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 458 |
|---|---|
| repo stars | ★ 84 |
| Last updated | April 8, 2026 |
| Repository | dkyazzentwatwa/chatgpt-skills ↗ |
What it does
Reliably convert PDFs, Office documents, markdown, HTML, and tables into editable formats inside agent-driven workflows.
Who is it for?
Best when you regularly move content between PDFs, spreadsheets, markdown, and HTML inside automated agent workflows or data pipelines.
Skip if: Projects that require pixel-perfect visual fidelity, advanced OCR on scanned documents, or complex styled layouts that cannot tolerate any loss.
When should I use this skill?
When you need to convert documents between common formats, extract tables, run PDF utilities, or fill simple forms inside an agent workflow.
What you get
You receive clean, editable output in the target format along with an explicit note about any lost layout, images, or formatting so you can decide next steps or invoke further processing.
- Converted document or batch of documents in the target format
- Explicit note describing any fidelity or layout limitations
By the numbers
- 8 supported formats: pdf, docx, pptx, xlsx, txt, csv, md, html
- 4 bundled utility scripts including pdf_toolkit, table_extractor, and form_filler
Files
Document Converter Suite
Run best-effort extraction and rebuild workflows across common document formats. Preserve clean structure, not pixel-perfect layout.
Use This For
- Converting between
pdf,docx,pptx,xlsx,txt,csv,md, andhtml - Pulling tables or spreadsheet-style grids into editable outputs
- Running utility PDF operations such as merge, split, rotate, watermark, or page extraction
- Filling simple document or form-style templates
Workflow
1. Confirm the source format, target format, and whether editability or fidelity matters more. 2. Use scripts/convert.py for single documents and scripts/batch_convert.py for folders. 3. Use the bundled utility scripts when the user needs a focused PDF or table task:
scripts/pdf_toolkit.pyscripts/table_extractor.pyscripts/form_filler.py
4. Say explicitly when the output is best-effort and likely to lose layout, images, OCR text, or advanced formatting.
Guardrails
- Do not promise visual fidelity.
- Treat scanned PDFs as OCR problems, not conversion problems.
- Raise safety caps gradually on large sheets or documents instead of processing everything blindly.
References
references/conversion_matrix.mdfor supported paths.references/limitations.mdfor failure modes and tradeoffs.
display_name: 'Document Converter Suite'
short_description: 'Convert documents and run focused PDF or table utilities.'
default_prompt: 'Help me convert or extract content from a document.'
Conversion Matrix (Best-Effort)
This suite supports conversions between 8 document formats:
Office Formats: PDF (.pdf), Word (.docx), PowerPoint (.pptx), Excel (.xlsx) Text Formats: Plain Text (.txt), CSV (.csv), Markdown (.md), HTML (.html)
Total conversion paths: 64 (8×8 matrix)
What "Best-Effort" Means
- Text-first: prioritize text and basic structure (titles, bullets, simple tables).
- No OCR: scanned PDFs are treated as images; extracted text may be empty.
- No layout engine: complex pagination, fonts, images, charts, and precise positioning are not preserved.
- Quality varies: some paths are high-fidelity (MD ↔ HTML, CSV ↔ XLSX), others are lossy (PDF → anything).
Conversion Tiers
Tier 1: High-Fidelity (Minimal Data Loss)
These conversions preserve most semantic structure:
- TXT ↔ MD ↔ HTML: Natural text format conversions
- CSV ↔ XLSX: Direct tabular data mapping
- MD ↔ DOCX: Good structure preservation (headings, lists, tables)
- HTML ↔ DOCX: Good semantic mapping (tags → styles)
Tier 2: Good Quality (Some Structure Loss)
These conversions work well but lose some formatting:
- DOCX ↔ PDF: Text and tables preserved, no complex layouts
- MD/HTML → PPTX: Headings become slide titles
- PPTX → DOCX: Slides flatten to sections with bullets
- XLSX → CSV: First sheet exported (warns if multiple sheets)
- TXT → (DOCX/PDF/HTML): Lines become paragraphs
Tier 3: Lossy (Significant Structure Loss)
These conversions extract content but lose significant structure:
- PDF → (any format): Text extraction only, no layout
- PPTX → XLSX: Only tables extracted
- XLSX → PPTX: Large tables become bullet summaries
- DOCX → CSV: Only tables extracted, text content lost
Format-Specific Outputs
PDF → *
- → DOCX/PPTX/TXT/MD/HTML: One section/slide/block per PDF page with heading "Page N"
- → XLSX: One sheet with one line per row, grouped by page
- → CSV: Text lines from all pages (very lossy)
Limitation: No OCR for scanned PDFs
DOCX → *
- → PDF: Text and tables in simple PDF layout
- → PPTX: Headings start new slides; paragraphs become bullets; tables on separate slides
- → XLSX: Text in "Text" sheet; each table becomes its own sheet
- → TXT: Paragraphs with heading markers (e.g., "### Heading")
- → CSV: First table only (warns if multiple tables)
- → MD/HTML: Headings, paragraphs, lists, and tables preserved
Quality Improvement: Smart heading detection (font size + bold + ALL CAPS)
PPTX → *
- → DOCX: One section per slide with bullets; tables preserved
- → PDF: One page per slide with title/bullets
- → XLSX: "Slides" sheet + one sheet per table
- → TXT/MD/HTML: Slide titles as headings, bullets as lists
- → CSV: First table only (warns if no tables)
Quality Improvement: Multi-table support (one slide per table)
XLSX → *
- → DOCX: One section per sheet; cell grid as table
- → PPTX: Small sheets (≤20 rows, ≤10 cols) as tables; large sheets as bullet summaries
- → PDF: One page per sheet with table
- → TXT/MD/HTML: Sheet names as headings, cells as tables
- → CSV: First sheet only (warns if multiple sheets)
Quality Improvement: Truncation warnings when data exceeds max_rows/max_cols
TXT → *
- → DOCX/PDF: Lines become paragraphs
- → PPTX: Lines chunked to slides (10-12 lines per slide)
- → XLSX: One line per row
- → CSV: Each line becomes a CSV row
- → MD: Lines with markdown syntax preserved
- → HTML: Lines as
<p>paragraphs
CSV → *
- → XLSX: Direct mapping (CSV subset of XLSX)
- → DOCX: First row as heading, rest as table
- → PPTX: Headers + rows as table on slides
- → PDF/HTML: Table rendering
- → TXT/MD: Pipe-separated values
Quality: Auto-delimiter detection with csv.Sniffer
MD → *
- → DOCX: Headings → styles, lists → bullets, tables → tables
- → HTML: Near-lossless (both use similar semantic model)
- → PPTX: H2+ starts new slides
- → XLSX: Headings as sheet names, tables as sheets
- → PDF: Rendered via sections
- → TXT: Markdown syntax preserved
Parsing: Uses mistune with custom renderer
HTML → *
- → MD: Near-lossless (tags → markdown syntax)
- → DOCX: Semantic tags → Word styles
- → PPTX: H2+ starts new slides
- → XLSX: Tables extracted to sheets
- → PDF: Rendered via sections
- → TXT: Clean text extraction
- → CSV: First table only
Parsing: Uses beautifulsoup4 with lxml
Full Conversion Matrix
| DOCX | PPTX | XLSX | TXT | CSV | MD | HTML | ||
|---|---|---|---|---|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ | |
| DOCX | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| PPTX | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| XLSX | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| TXT | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| CSV | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ |
| MD | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ |
| HTML | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
✓ = Supported (59 paths) ✗ = Not recommended (too lossy) (5 paths)
Note: All formats can convert to themselves (8 paths = copy operation)
Edge Cases
- PDF → CSV: Not implemented (too lossy)
- CSV → PDF/MD: Not implemented (better to use CSV → XLSX → PDF/MD)
- Multiple tables in PPTX: Now creates one slide per table (improved in this version)
- Large XLSX files: Truncation warnings printed to stderr; use
--max-rowsand--max-colsto adjust - DOCX headings without styles: Now detected via font size + bold + ALL CAPS heuristics
Limitations and Gotchas
PDF Realities
- Scanned PDFs:
pypdfcannot OCR. If the PDF is basically photos of pages, extracted text will be empty. - Weird PDFs: Some PDFs have text split into individual positioned glyphs; extracted text can look scrambled.
- No images: Images are not extracted from PDFs (image extraction infrastructure exists but not yet integrated).
- No complex layouts: Multi-column layouts, text boxes, and complex positioning are not preserved.
Office Format Realities
DOCX
- Heading detection improvements: Now uses font size + bold + ALL CAPS heuristics, but may still miss headings with unconventional formatting.
- Images: Not yet extracted (infrastructure exists via
image_handler.py). - Complex formatting: Font colors, highlighting, borders, and advanced styles are lost.
- Track changes: Revision history and comments are not preserved.
PPTX
- Multi-table support: ✅ Improved! Now creates one slide per table when multiple tables exist.
- Slide visuals: Images, charts, shapes, animations, and transitions are not rendered.
- Speaker notes: Not extracted.
- Slide layouts: All slides use basic Title + Content layout in output.
XLSX
- Data caps: Only a bounded grid of values is exported (defaults: 200 rows × 50 cols).
- Truncation warnings: ✅ Improved! Warnings now printed to stderr when data is truncated.
- Formulas: Exported as computed values (using
data_only=True). - Charts: Not extracted or rendered.
- Conditional formatting: Lost in conversion.
- Multiple sheets: When converting to CSV, only first sheet is exported (with warning).
Text Format Realities
Plain Text (TXT)
- No structure: Very lossy for structured documents; best for simple note-taking or logging.
- Line-based: Everything is treated as lines; paragraphs, headings, and formatting are inferred heuristically.
- No tables: Tables become pipe-separated text.
CSV
- Tables only: Non-tabular content is lost or forced into tabular format.
- Delimiter detection: Uses
csv.Snifferfor auto-detection, but may fail on unusual delimiters. - Single sheet: Only one table per file; multiple tables require multiple CSV files.
- No formatting: All cells are plain text strings.
Markdown (MD)
- Parsing variations: Different Markdown flavors exist; this uses CommonMark via
mistune. - Images: Rendered as
but images are not embedded or extracted. - Advanced features: No support for footnotes, definition lists, or extended syntax.
- Code blocks: Language detection from class attributes (e.g.,
class="language-python").
HTML
- CSS/JavaScript: External stylesheets and scripts are not processed or preserved.
- Complex layouts:
<div>-based layouts, flexbox, and grid are flattened to semantic content. - Images: Referenced via
<img>tags but not embedded or extracted. - Forms: Form elements are extracted as text but not functional in output.
- Parsing: Uses
beautifulsoup4withlxmlparser; malformed HTML may produce unexpected results.
Image Extraction
- Foundation only:
image_handler.pyprovides hash-based deduplication and PIL format detection. - Not yet integrated: Images are not automatically extracted from any format.
- Placeholders: Text formats show
[Image: filename.png]placeholders. - Future work: Full integration requires per-format extraction logic.
Data Safety
- Max chars: Default 300,000 characters for text-based formats to prevent memory issues.
- Max pages: Default 200 pages for PDF to prevent long processing times.
- Max rows/cols: Default 200 rows × 50 columns for XLSX to prevent memory issues.
- Truncation behavior: Data beyond limits is silently dropped (but warnings are now printed for XLSX).
Conversion Quality by Tier
Tier 1: High-Fidelity
- TXT ↔ MD ↔ HTML: Minimal loss (text structure preserved)
- CSV ↔ XLSX: Direct mapping (CSV is subset of XLSX)
- MD ↔ DOCX: Good structure preservation
- HTML ↔ DOCX: Semantic tags map well to Word styles
Tier 2: Good Quality
- DOCX ↔ PDF: Text and tables preserved
- PPTX → DOCX: Slides flatten to sections
- XLSX → CSV: First sheet only
Tier 3: Lossy
- PDF → anything: Text extraction only, no layout
- PPTX → XLSX: Only tables extracted
- DOCX → CSV: Only tables extracted
Things to Be Explicit About
- Ask for the user's priority: "make it editable" vs "make it visually identical".
- If the user needs visually identical output, suggest using a renderer (e.g., LibreOffice/PowerPoint) outside the sandbox.
- For large spreadsheets, recommend increasing
--max-rowsand--max-colsgradually. - For documents with many images, explain that images will show as placeholders.
- For scanned PDFs, recommend using OCR tools before conversion.
Format Recommendations
When to use each format:
- PDF: Final, read-only distribution
- DOCX: Editable text documents with formatting
- PPTX: Presentations with slides
- XLSX: Tabular data, spreadsheets, data analysis
- TXT: Simple notes, logs, plain text
- CSV: Data exchange, database imports, simple tables
- MD: Documentation, README files, technical writing
- HTML: Web publishing, rich text with links
Best conversion paths:
- Documentation: MD → HTML (web) or MD → DOCX (printable)
- Data: CSV → XLSX (analysis) or XLSX → CSV (portability)
- Reports: DOCX → PDF (distribution)
- Presentations: PPTX → PDF (handouts)
- Web to print: HTML → DOCX → PDF
Known Issues
- DOCX tables with merged cells: May render incorrectly
- PPTX tables with complex formatting: Formatting lost, structure preserved
- XLSX with very wide rows: May exceed column limit (50 cols default)
- PDF with vertical text: May extract in wrong order
- HTML with nested tables: May flatten incorrectly
- Markdown code fences without language: Rendered as plain code block
#!/usr/bin/env python3
"""Document Converter Suite (batch)
Batch-convert files in a directory (optionally recursive).
Notes:
- Uses the same best-effort, structure-first conversions as scripts/convert.py
- Skips files already in the target extension
Examples:
python scripts/batch_convert.py ./inbox --to pdf --recursive
python scripts/batch_convert.py ./inbox --to docx --outdir ./out --overwrite
python scripts/batch_convert.py ./inbox --to xlsx --pattern "*.pptx"
"""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Iterable, Optional
from lib.conversion import convert_document
from lib.types import SUPPORTED_INPUT_EXTS, SUPPORTED_OUTPUT_EXTS
def _positive_int(value: str) -> int:
try:
i = int(value)
except ValueError as e:
raise argparse.ArgumentTypeError(str(e))
if i <= 0:
raise argparse.ArgumentTypeError("Must be a positive integer")
return i
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Batch convert documents in a folder")
p.add_argument("indir", type=str, help="Input directory")
p.add_argument(
"--to",
required=True,
choices=sorted({e.lstrip(".") for e in SUPPORTED_OUTPUT_EXTS}),
help="Output format: pdf/docx/pptx/xlsx",
)
p.add_argument(
"--outdir",
default=None,
help="Optional output directory. Defaults to writing next to each input file.",
)
p.add_argument("--recursive", action="store_true", help="Search subdirectories")
p.add_argument(
"--pattern",
default="*",
help="Glob pattern to filter input files (default: '*'). Example: '*.pdf'",
)
p.add_argument(
"--flatten",
action="store_true",
help="If --outdir is set, write all outputs directly into that directory (no subfolders).",
)
p.add_argument("--max-pages", type=_positive_int, default=200)
p.add_argument("--max-chars", type=_positive_int, default=300000)
p.add_argument("--max-rows", type=_positive_int, default=200)
p.add_argument("--max-cols", type=_positive_int, default=50)
p.add_argument("--overwrite", action="store_true")
p.add_argument("--verbose", action="store_true")
return p
def iter_files(indir: Path, recursive: bool, pattern: str) -> Iterable[Path]:
if recursive:
for p in indir.rglob(pattern):
if p.is_file():
yield p
else:
for p in indir.glob(pattern):
if p.is_file():
yield p
def main(argv: Optional[Iterable[str]] = None) -> int:
args = build_parser().parse_args(argv)
indir = Path(args.indir).expanduser().resolve()
if not indir.exists() or not indir.is_dir():
raise SystemExit(f"Input directory not found: {indir}")
out_ext = f".{args.to.lower().lstrip('.')}"
if out_ext not in SUPPORTED_OUTPUT_EXTS:
raise SystemExit(f"Unsupported output extension: {out_ext}")
outdir = Path(args.outdir).expanduser().resolve() if args.outdir else None
if outdir:
outdir.mkdir(parents=True, exist_ok=True)
converted = 0
skipped = 0
failed = 0
for in_path in iter_files(indir, args.recursive, args.pattern):
if in_path.suffix.lower() not in SUPPORTED_INPUT_EXTS:
continue
if in_path.suffix.lower() == out_ext:
skipped += 1
continue
if outdir:
if args.flatten:
out_path = outdir / (in_path.stem + out_ext)
else:
rel = in_path.relative_to(indir)
out_path = (outdir / rel).with_suffix(out_ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
else:
out_path = in_path.with_suffix(out_ext)
if out_path.exists() and not args.overwrite:
skipped += 1
if args.verbose:
print(f"↷ skip (exists): {out_path}")
continue
try:
convert_document(
input_path=in_path,
output_path=out_path,
max_pages=args.max_pages,
max_chars=args.max_chars,
max_rows=args.max_rows,
max_cols=args.max_cols,
verbose=args.verbose,
)
converted += 1
if args.verbose:
print(f"✓ {in_path.name} -> {out_path.name}")
except Exception as e:
failed += 1
if args.verbose:
print(f"✗ failed: {in_path} ({e})")
print(f"Done. converted={converted} skipped={skipped} failed={failed}")
return 0 if failed == 0 else 2
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Document Converter Suite (single-file)
Convert between PDF, DOCX, PPTX, and XLSX with lightweight, dependency-minimal rules.
This intentionally does *not* attempt pixel-perfect layout conversion. It focuses on:
- Extracting text and basic structure (headings, bullets, tables)
- Rebuilding that content in the target format
Supported formats: .pdf, .docx, .pptx, .xlsx
Examples:
python scripts/convert.py input.pdf --to docx
python scripts/convert.py deck.pptx --to pdf --out deck.pdf
python scripts/convert.py sheet.xlsx --to pptx --max-rows 40 --max-cols 10
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from typing import Iterable, Optional
from lib.conversion import convert_document
from lib.types import SUPPORTED_INPUT_EXTS, SUPPORTED_OUTPUT_EXTS
def _positive_int(value: str) -> int:
try:
i = int(value)
except ValueError as e:
raise argparse.ArgumentTypeError(str(e))
if i <= 0:
raise argparse.ArgumentTypeError("Must be a positive integer")
return i
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Convert a document between PDF/DOCX/PPTX/XLSX")
p.add_argument("input", type=str, help="Path to input file")
p.add_argument(
"--to",
required=True,
choices=sorted(SUPPORTED_OUTPUT_EXTS),
help="Output format (extension without dot), e.g. pdf, docx, pptx, xlsx",
)
p.add_argument(
"--out",
default=None,
help="Optional output path. If omitted, write next to input with new extension.",
)
p.add_argument(
"--max-pages",
type=_positive_int,
default=200,
help="Safety cap for PDF page processing (default: 200)",
)
p.add_argument(
"--max-chars",
type=_positive_int,
default=300000,
help="Safety cap for extracted text size per document (default: 300000)",
)
p.add_argument(
"--max-rows",
type=_positive_int,
default=200,
help="Safety cap for spreadsheet rows per sheet (default: 200)",
)
p.add_argument(
"--max-cols",
type=_positive_int,
default=50,
help="Safety cap for spreadsheet columns per sheet (default: 50)",
)
p.add_argument(
"--overwrite",
action="store_true",
help="Overwrite output file if it exists",
)
p.add_argument(
"--verbose",
action="store_true",
help="Print extra progress output",
)
return p
def main(argv: Optional[Iterable[str]] = None) -> int:
args = build_parser().parse_args(argv)
in_path = Path(args.input).expanduser().resolve()
if not in_path.exists():
raise SystemExit(f"Input not found: {in_path}")
if in_path.suffix.lower() not in SUPPORTED_INPUT_EXTS:
raise SystemExit(
f"Unsupported input extension '{in_path.suffix}'. Supported: {sorted(SUPPORTED_INPUT_EXTS)}"
)
out_ext = f".{args.to.lower().lstrip('.')}"
if out_ext not in SUPPORTED_OUTPUT_EXTS:
raise SystemExit(f"Unsupported output extension '{out_ext}'.")
if args.out:
out_path = Path(args.out).expanduser().resolve()
else:
out_path = in_path.with_suffix(out_ext)
if out_path.exists() and not args.overwrite:
raise SystemExit(
f"Output exists: {out_path}\nUse --overwrite or provide a different --out path."
)
out_path.parent.mkdir(parents=True, exist_ok=True)
convert_document(
input_path=in_path,
output_path=out_path,
max_pages=args.max_pages,
max_chars=args.max_chars,
max_rows=args.max_rows,
max_cols=args.max_cols,
verbose=args.verbose,
)
if args.verbose:
print(f"✅ Wrote: {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
Form Filler - Fill PDF forms programmatically.
"""
import argparse
import json
import os
from pathlib import Path
from typing import Dict, List, Any, Optional
import pandas as pd
try:
import fitz # PyMuPDF
PYMUPDF_AVAILABLE = True
except ImportError:
PYMUPDF_AVAILABLE = False
class FormFiller:
"""Fill PDF forms with data."""
def __init__(self):
"""Initialize the form filler."""
if not PYMUPDF_AVAILABLE:
raise ImportError("PyMuPDF (fitz) is required for form filling")
self.doc = None
self.filepath = None
self.mapping = {}
def load(self, filepath: str) -> 'FormFiller':
"""Load a PDF form."""
self.filepath = filepath
self.doc = fitz.open(filepath)
return self
def list_fields(self) -> List[Dict]:
"""List all form fields."""
if not self.doc:
raise ValueError("No document loaded")
fields = []
for page_num, page in enumerate(self.doc):
widgets = page.widgets()
if widgets:
for widget in widgets:
field_info = {
"name": widget.field_name,
"type": self._get_field_type(widget.field_type),
"page": page_num,
"value": widget.field_value,
"rect": list(widget.rect)
}
# Get options for choice fields
if widget.field_type in [fitz.PDF_WIDGET_TYPE_LISTBOX,
fitz.PDF_WIDGET_TYPE_COMBOBOX]:
field_info["options"] = widget.choice_values
fields.append(field_info)
return fields
def _get_field_type(self, field_type: int) -> str:
"""Convert field type constant to string."""
type_map = {
fitz.PDF_WIDGET_TYPE_TEXT: "text",
fitz.PDF_WIDGET_TYPE_CHECKBOX: "checkbox",
fitz.PDF_WIDGET_TYPE_RADIOBUTTON: "radio",
fitz.PDF_WIDGET_TYPE_LISTBOX: "listbox",
fitz.PDF_WIDGET_TYPE_COMBOBOX: "dropdown",
fitz.PDF_WIDGET_TYPE_BUTTON: "button",
fitz.PDF_WIDGET_TYPE_SIGNATURE: "signature"
}
return type_map.get(field_type, "unknown")
def get_field_info(self, field_name: str) -> Optional[Dict]:
"""Get information about a specific field."""
fields = self.list_fields()
for field in fields:
if field["name"] == field_name:
return field
return None
def get_field_value(self, field_name: str) -> Any:
"""Get current value of a field."""
field_info = self.get_field_info(field_name)
if field_info:
return field_info.get("value")
return None
def set_mapping(self, mapping: Dict[str, str]) -> 'FormFiller':
"""Set data key to field name mapping."""
self.mapping = mapping
return self
def fill(self, data: Dict) -> 'FormFiller':
"""Fill form with data dictionary."""
if not self.doc:
raise ValueError("No document loaded")
for key, value in data.items():
# Apply mapping if exists
field_name = self.mapping.get(key, key)
self.fill_field(field_name, value)
return self
def fill_field(self, name: str, value: Any) -> 'FormFiller':
"""Fill a specific form field."""
if not self.doc:
raise ValueError("No document loaded")
for page in self.doc:
widgets = page.widgets()
if widgets:
for widget in widgets:
if widget.field_name == name:
self._set_widget_value(widget, value)
return self
def _set_widget_value(self, widget, value: Any):
"""Set value on a widget based on its type."""
field_type = widget.field_type
if field_type == fitz.PDF_WIDGET_TYPE_TEXT:
widget.field_value = str(value) if value is not None else ""
widget.update()
elif field_type == fitz.PDF_WIDGET_TYPE_CHECKBOX:
# Checkboxes: True/False or "Yes"/"Off"
if isinstance(value, bool):
widget.field_value = "Yes" if value else "Off"
else:
widget.field_value = str(value)
widget.update()
elif field_type == fitz.PDF_WIDGET_TYPE_RADIOBUTTON:
widget.field_value = str(value)
widget.update()
elif field_type in [fitz.PDF_WIDGET_TYPE_LISTBOX, fitz.PDF_WIDGET_TYPE_COMBOBOX]:
widget.field_value = str(value)
widget.update()
def fill_from_json(self, filepath: str) -> 'FormFiller':
"""Fill form from JSON file."""
with open(filepath) as f:
data = json.load(f)
return self.fill(data)
def fill_from_csv_row(self, row: Dict) -> 'FormFiller':
"""Fill form from a CSV row dictionary."""
return self.fill(row)
def flatten(self) -> 'FormFiller':
"""Flatten form (make fields non-editable)."""
if not self.doc:
raise ValueError("No document loaded")
for page in self.doc:
# Get all annotations (widgets are annotations)
annots = page.annots()
if annots:
for annot in annots:
# Set the annotation to be read-only
annot.set_flags(fitz.ANNOT_BF_ReadOnly)
return self
def save(self, filepath: str, flatten: bool = False) -> str:
"""Save filled form to file."""
if not self.doc:
raise ValueError("No document loaded")
if flatten:
self.flatten()
self.doc.save(filepath, garbage=4, deflate=True)
return filepath
def close(self):
"""Close the document."""
if self.doc:
self.doc.close()
def batch_fill(self, input_form: str, data_file: str,
output_dir: str, name_field: str = None) -> List[str]:
"""
Fill multiple copies of form from data file.
Args:
input_form: Path to PDF form template
data_file: Path to JSON or CSV data file
output_dir: Output directory for filled forms
name_field: Field to use for naming output files
Returns:
List of generated file paths
"""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Load data
if data_file.endswith('.json'):
with open(data_file) as f:
data = json.load(f)
if not isinstance(data, list):
data = [data]
else:
df = pd.read_csv(data_file)
data = df.to_dict(orient='records')
generated = []
base_name = Path(input_form).stem
for i, row in enumerate(data):
# Load fresh copy of form
self.load(input_form)
# Fill with data
self.fill(row)
# Determine output filename
if name_field and name_field in row:
filename = f"{base_name}_{row[name_field]}.pdf"
else:
filename = f"{base_name}_{i}.pdf"
# Clean filename
filename = "".join(c if c.isalnum() or c in '._-' else '_' for c in filename)
output_file = output_path / filename
self.save(str(output_file))
generated.append(str(output_file))
self.close()
print(f"Generated: {filename}")
return generated
def main():
parser = argparse.ArgumentParser(description="Form Filler")
parser.add_argument("--input", "-i", required=True, help="Input PDF form")
parser.add_argument("--output", "-o", help="Output PDF file")
parser.add_argument("--data", "-d", help="Data file (JSON)")
parser.add_argument("--list-fields", action="store_true", help="List form fields")
parser.add_argument("--batch", help="Batch fill from CSV/JSON data file")
parser.add_argument("--output-dir", help="Output directory for batch")
parser.add_argument("--name-field", help="Field to use for output filenames")
parser.add_argument("--mapping", help="Field mapping JSON file")
parser.add_argument("--flatten", action="store_true", help="Flatten output PDF")
args = parser.parse_args()
filler = FormFiller()
# List fields mode
if args.list_fields:
filler.load(args.input)
fields = filler.list_fields()
print(f"Form: {args.input}")
print(f"Total fields: {len(fields)}\n")
for field in fields:
print(f"Name: {field['name']}")
print(f" Type: {field['type']}")
print(f" Page: {field['page'] + 1}")
if field.get('value'):
print(f" Current value: {field['value']}")
if field.get('options'):
print(f" Options: {field['options']}")
print()
filler.close()
return
# Batch mode
if args.batch:
output_dir = args.output_dir or "filled_forms"
generated = filler.batch_fill(
args.input,
args.batch,
output_dir,
name_field=args.name_field
)
print(f"\nGenerated {len(generated)} filled forms in: {output_dir}")
return
# Single form filling
if args.data and args.output:
filler.load(args.input)
# Load mapping if provided
if args.mapping:
with open(args.mapping) as f:
filler.set_mapping(json.load(f))
# Fill from data
filler.fill_from_json(args.data)
# Save
filler.save(args.output, flatten=args.flatten)
print(f"Filled form saved: {args.output}")
# Show summary
with open(args.data) as f:
data = json.load(f)
print(f"Filled {len(data)} fields")
filler.close()
return
parser.print_help()
if __name__ == "__main__":
main()
"""Internal helper modules for Document Converter Suite."""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
from typing import List, Optional
from docx import Document as DocxDocument
from .csv_reader import read_csv_content
from .csv_writer import write_csv_from_rows
from .docx_reader import read_docx_content
from .docx_writer import write_docx_from_text
from .html_reader import read_html_content
from .html_writer import write_html_from_sections
from .md_reader import read_md_content
from .md_writer import write_md_from_sections
from .pdf_reader import read_pdf_text
from .pdf_writer import write_pdf_from_sections
from .pptx_reader import read_pptx_content
from .pptx_writer import write_pptx_from_slides
from .txt_reader import read_txt_content
from .txt_writer import write_txt_from_lines
from .types import SUPPORTED_INPUT_EXTS, SUPPORTED_OUTPUT_EXTS
from .utils import chunk_list, guess_bullets, safe_cell_value
from .xlsx_reader import read_xlsx_content
from .xlsx_writer import write_xlsx_from_sheets
def _print_warnings(warnings: List[str]) -> None:
"""Print warnings to stderr with visual indicator."""
for warning in warnings:
print(f"⚠️ {warning}", file=sys.stderr)
def convert_document(
input_path: Path,
output_path: Path,
max_pages: int = 200,
max_chars: int = 300000,
max_rows: int = 200,
max_cols: int = 50,
verbose: bool = False,
) -> None:
"""Convert a single document between PDF, DOCX, PPTX, XLSX.
This is best-effort conversion focused on text and basic tables.
"""
in_ext = input_path.suffix.lower()
out_ext = output_path.suffix.lower()
if in_ext not in SUPPORTED_INPUT_EXTS:
raise ValueError(f"Unsupported input extension: {in_ext}")
if out_ext not in SUPPORTED_OUTPUT_EXTS:
raise ValueError(f"Unsupported output extension: {out_ext}")
# Shortcut: same extension -> copy
if in_ext == out_ext:
if verbose:
print(f"Copying (same format): {input_path} -> {output_path}")
shutil.copy2(str(input_path), str(output_path))
return
# === Convert to DOCX ===
if out_ext == ".docx":
if in_ext == ".pdf":
_pdf_to_docx(input_path, output_path, max_pages=max_pages, max_chars=max_chars)
return
if in_ext == ".pptx":
_pptx_to_docx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".xlsx":
_xlsx_to_docx(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".md":
_md_to_docx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".txt":
_txt_to_docx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".csv":
_csv_to_docx(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".html":
_html_to_docx(input_path, output_path, max_chars=max_chars)
return
# === Convert to PPTX ===
if out_ext == ".pptx":
if in_ext == ".pdf":
_pdf_to_pptx(input_path, output_path, max_pages=max_pages, max_chars=max_chars)
return
if in_ext == ".docx":
_docx_to_pptx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".xlsx":
_xlsx_to_pptx(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".md":
_md_to_pptx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".txt":
_txt_to_pptx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".csv":
_csv_to_pptx(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".html":
_html_to_pptx(input_path, output_path, max_chars=max_chars)
return
# === Convert to XLSX ===
if out_ext == ".xlsx":
if in_ext == ".pdf":
_pdf_to_xlsx(input_path, output_path, max_pages=max_pages, max_chars=max_chars)
return
if in_ext == ".docx":
_docx_to_xlsx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".pptx":
_pptx_to_xlsx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".md":
_md_to_xlsx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".txt":
_txt_to_xlsx(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".csv":
_csv_to_xlsx(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".html":
_html_to_xlsx(input_path, output_path, max_chars=max_chars)
return
# === Convert to PDF ===
if out_ext == ".pdf":
if in_ext == ".docx":
_docx_to_pdf(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".pptx":
_pptx_to_pdf(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".xlsx":
_xlsx_to_pdf(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".md":
_md_to_pdf(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".txt":
_txt_to_pdf(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".html":
_html_to_pdf(input_path, output_path, max_chars=max_chars)
return
# === Convert to TXT ===
if out_ext == ".txt":
if in_ext == ".pdf":
_pdf_to_txt(input_path, output_path, max_pages=max_pages, max_chars=max_chars)
return
if in_ext == ".docx":
_docx_to_txt(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".pptx":
_pptx_to_txt(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".xlsx":
_xlsx_to_txt(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".md":
_md_to_txt(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".csv":
_csv_to_txt(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".html":
_html_to_txt(input_path, output_path, max_chars=max_chars)
return
# === Convert to CSV ===
if out_ext == ".csv":
if in_ext == ".xlsx":
_xlsx_to_csv(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".docx":
_docx_to_csv(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".pptx":
_pptx_to_csv(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".txt":
_txt_to_csv(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".html":
_html_to_csv(input_path, output_path, max_chars=max_chars)
return
# === Convert to MD ===
if out_ext == ".md":
if in_ext == ".docx":
_docx_to_md(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".txt":
_txt_to_md(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".pdf":
_pdf_to_md(input_path, output_path, max_pages=max_pages, max_chars=max_chars)
return
if in_ext == ".pptx":
_pptx_to_md(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".xlsx":
_xlsx_to_md(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".html":
_html_to_md(input_path, output_path, max_chars=max_chars)
return
# === Convert to HTML ===
if out_ext == ".html":
if in_ext == ".md":
_md_to_html(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".docx":
_docx_to_html(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".txt":
_txt_to_html(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".pdf":
_pdf_to_html(input_path, output_path, max_pages=max_pages, max_chars=max_chars)
return
if in_ext == ".pptx":
_pptx_to_html(input_path, output_path, max_chars=max_chars)
return
if in_ext == ".xlsx":
_xlsx_to_html(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
if in_ext == ".csv":
_csv_to_html(input_path, output_path, max_rows=max_rows, max_cols=max_cols)
return
raise ValueError(f"No conversion path implemented: {in_ext} -> {out_ext}")
# --------------------------
# PDF -> *
# --------------------------
def _pdf_to_docx(input_path: Path, output_path: Path, max_pages: int, max_chars: int) -> None:
pdf = read_pdf_text(input_path, max_pages=max_pages, max_chars=max_chars)
doc = DocxDocument()
doc.add_heading(input_path.stem, level=1)
for i, page_text in enumerate(pdf.pages, start=1):
doc.add_heading(f"Page {i}", level=2)
for ln in page_text.splitlines():
ln = (ln or "").strip()
if not ln:
continue
doc.add_paragraph(ln)
if i < len(pdf.pages):
doc.add_page_break()
doc.save(str(output_path))
def _pdf_to_pptx(input_path: Path, output_path: Path, max_pages: int, max_chars: int) -> None:
pdf = read_pdf_text(input_path, max_pages=max_pages, max_chars=max_chars)
slides: List[dict] = []
for i, page_text in enumerate(pdf.pages, start=1):
slides.append({"title": f"Page {i}", "bullets": guess_bullets(page_text)})
write_pptx_from_slides(output_path, slides, title=input_path.stem)
def _pdf_to_xlsx(input_path: Path, output_path: Path, max_pages: int, max_chars: int) -> None:
pdf = read_pdf_text(input_path, max_pages=max_pages, max_chars=max_chars)
rows: List[List[str]] = []
for i, page_text in enumerate(pdf.pages, start=1):
rows.append([f"=== Page {i} ==="])
for ln in page_text.splitlines():
ln = (ln or "").strip()
if ln:
rows.append([ln])
rows.append([""])
write_xlsx_from_sheets(output_path, [{"name": "PDF Text", "rows": rows}])
# --------------------------
# DOCX -> *
# --------------------------
def _docx_to_pptx(input_path: Path, output_path: Path, max_chars: int) -> None:
content = read_docx_content(input_path, max_chars=max_chars)
# Heuristic: if there are headings, start slides at headings.
slides: List[dict] = []
current_title: Optional[str] = None
current_bullets: List[str] = []
def flush():
nonlocal current_title, current_bullets
if current_title or current_bullets:
slides.append({"title": current_title or "", "bullets": current_bullets})
current_title = None
current_bullets = []
for p in content.paragraphs:
# Treat lines that match known headings as slide boundaries
if p in set(content.headings):
flush()
current_title = p
continue
# Chunk big docs into manageable slides
current_bullets.append(p)
if len(current_bullets) >= 10:
flush()
flush()
if not slides:
slides = [{"title": input_path.stem, "bullets": content.paragraphs[:10]}]
# Normalize bullets a bit
for s in slides:
s["bullets"] = [b for b in guess_bullets("\n".join(s.get("bullets") or []), max_lines=12)]
write_pptx_from_slides(output_path, slides, title=input_path.stem)
def _docx_to_xlsx(input_path: Path, output_path: Path, max_chars: int) -> None:
content = read_docx_content(input_path, max_chars=max_chars)
sheets: List[dict] = []
# Text sheet
text_rows = [[p] for p in content.paragraphs]
sheets.append({"name": "Text", "rows": text_rows})
# Tables -> their own sheets
for idx, t in enumerate(content.tables, start=1):
sheets.append({"name": f"Table{idx}", "rows": t.rows})
write_xlsx_from_sheets(output_path, sheets)
def _docx_to_pdf(input_path: Path, output_path: Path, max_chars: int) -> None:
content = read_docx_content(input_path, max_chars=max_chars)
tables = [t.rows for t in content.tables]
sections = [{"heading": input_path.stem, "paragraphs": content.paragraphs, "tables": tables}]
write_pdf_from_sections(output_path, title=input_path.stem, sections=sections)
# --------------------------
# PPTX -> *
# --------------------------
def _pptx_to_docx(input_path: Path, output_path: Path, max_chars: int) -> None:
content = read_pptx_content(input_path, max_chars=max_chars)
doc = DocxDocument()
doc.add_heading(input_path.stem, level=1)
for s in content.slides:
heading = s.title or f"Slide {s.index}"
doc.add_heading(heading, level=2)
# Text blocks -> bullets-ish
for block in s.texts:
for bullet in guess_bullets(block, max_lines=20):
doc.add_paragraph(bullet, style="List Bullet")
# Tables
for t_i, t in enumerate(s.tables, start=1):
if not t:
continue
doc.add_paragraph("")
doc.add_heading(f"Table {t_i}", level=3)
n_rows = len(t)
n_cols = max((len(r) for r in t), default=0)
if n_cols <= 0:
continue
table = doc.add_table(rows=n_rows, cols=n_cols)
for r_i, row in enumerate(t):
for c_i in range(n_cols):
table.cell(r_i, c_i).text = safe_cell_value(row[c_i] if c_i < len(row) else "")
doc.save(str(output_path))
def _pptx_to_xlsx(input_path: Path, output_path: Path, max_chars: int) -> None:
content = read_pptx_content(input_path, max_chars=max_chars)
# Slides sheet: one row per slide with combined text
rows: List[List[str]] = [["Slide", "Title", "Text"]]
for s in content.slides:
combined = "\n\n".join(s.texts)
rows.append([str(s.index), s.title, combined])
sheets: List[dict] = [{"name": "Slides", "rows": rows}]
# Any tables: each table becomes its own sheet (bounded name)
t_counter = 1
for s in content.slides:
for t in s.tables:
name = f"Table{t_counter}"[:31]
sheets.append({"name": name, "rows": t})
t_counter += 1
write_xlsx_from_sheets(output_path, sheets)
def _pptx_to_pdf(input_path: Path, output_path: Path, max_chars: int) -> None:
content = read_pptx_content(input_path, max_chars=max_chars)
sections: List[dict] = []
for s in content.slides:
heading = s.title or f"Slide {s.index}"
paras: List[str] = []
for block in s.texts:
paras.extend(guess_bullets(block, max_lines=25))
tables = s.tables
sections.append({"heading": heading, "paragraphs": paras, "tables": tables})
write_pdf_from_sections(output_path, title=input_path.stem, sections=sections)
# --------------------------
# XLSX -> *
# --------------------------
def _xlsx_to_docx(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
content = read_xlsx_content(input_path, max_rows=max_rows, max_cols=max_cols)
_print_warnings(content.warnings)
doc = DocxDocument()
doc.add_heading(input_path.stem, level=1)
for sh in content.sheets:
doc.add_heading(sh.name, level=2)
if not sh.cells:
doc.add_paragraph("(empty sheet)")
continue
rows = sh.cells
n_rows = len(rows)
n_cols = max((len(r) for r in rows), default=0)
table = doc.add_table(rows=n_rows, cols=n_cols)
for r_i, row in enumerate(rows):
for c_i in range(n_cols):
table.cell(r_i, c_i).text = safe_cell_value(row[c_i] if c_i < len(row) else "")
doc.add_paragraph("")
doc.save(str(output_path))
def _xlsx_to_pptx(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
content = read_xlsx_content(input_path, max_rows=max_rows, max_cols=max_cols)
_print_warnings(content.warnings)
slides: List[dict] = []
for sh in content.sheets:
rows = sh.cells
# If small enough, push it as a table; otherwise summarize first column as bullets.
if len(rows) <= 20 and (max((len(r) for r in rows), default=0) <= 10):
slides.append({"title": sh.name, "bullets": [], "tables": [rows]})
else:
first_col = [r[0] for r in rows if r and r[0]]
bullets = first_col[:12] if first_col else ["(sheet too large to render as table)"]
slides.append({"title": sh.name, "bullets": bullets})
write_pptx_from_slides(output_path, slides, title=input_path.stem)
def _xlsx_to_pdf(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
content = read_xlsx_content(input_path, max_rows=max_rows, max_cols=max_cols)
_print_warnings(content.warnings)
sections: List[dict] = []
for sh in content.sheets:
sections.append({"heading": sh.name, "paragraphs": [], "tables": [sh.cells]})
write_pdf_from_sections(output_path, title=input_path.stem, sections=sections)
# --------------------------
# TXT -> *
# --------------------------
def _txt_to_docx(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert plain text to DOCX: lines become paragraphs."""
content = read_txt_content(input_path, max_chars=max_chars)
doc = DocxDocument()
doc.add_heading(input_path.stem, level=1)
for line in content.lines:
if line.strip():
doc.add_paragraph(line)
doc.save(str(output_path))
def _txt_to_pptx(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert plain text to PPTX: chunk lines into slides."""
content = read_txt_content(input_path, max_chars=max_chars)
slides: List[dict] = []
# Chunk lines into slides (10-12 lines per slide)
lines = [l for l in content.lines if l.strip()]
for chunk in chunk_list(lines, size=12):
slides.append({"title": "", "bullets": chunk})
if not slides:
slides = [{"title": input_path.stem, "bullets": ["(empty file)"]}]
write_pptx_from_slides(output_path, slides, title=input_path.stem)
def _txt_to_xlsx(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert plain text to XLSX: one line per row."""
content = read_txt_content(input_path, max_chars=max_chars)
rows: List[List[str]] = [[line] for line in content.lines]
write_xlsx_from_sheets(output_path, [{"name": "Text", "rows": rows}])
def _txt_to_pdf(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert plain text to PDF: lines become paragraphs."""
content = read_txt_content(input_path, max_chars=max_chars)
paragraphs = [line for line in content.lines if line.strip()]
sections: List[dict] = [{"heading": input_path.stem, "paragraphs": paragraphs, "tables": []}]
write_pdf_from_sections(output_path, title=input_path.stem, sections=sections)
def _txt_to_csv(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert plain text to CSV: one line per row, single column."""
content = read_txt_content(input_path, max_chars=max_chars)
rows: List[List[str]] = [[line] for line in content.lines]
write_csv_from_rows(output_path, rows=rows)
# --------------------------
# * -> TXT
# --------------------------
def _pdf_to_txt(input_path: Path, output_path: Path, max_pages: int, max_chars: int) -> None:
"""Convert PDF to plain text with page separators."""
pdf = read_pdf_text(input_path, max_pages=max_pages, max_chars=max_chars)
lines: List[str] = []
for i, page_text in enumerate(pdf.pages, start=1):
lines.append(f"=== Page {i} ===")
lines.extend(page_text.splitlines())
lines.append("")
write_txt_from_lines(output_path, lines)
def _docx_to_txt(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert DOCX to plain text: flatten paragraphs with heading markers."""
content = read_docx_content(input_path, max_chars=max_chars)
lines: List[str] = []
heading_set = set(content.headings)
for para in content.paragraphs:
if para in heading_set:
lines.append("")
lines.append(f"## {para}")
lines.append("")
else:
lines.append(para)
write_txt_from_lines(output_path, lines)
def _pptx_to_txt(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert PPTX to plain text: slide title + bullets."""
content = read_pptx_content(input_path, max_chars=max_chars)
lines: List[str] = []
for slide in content.slides:
if slide.title:
lines.append(f"## {slide.title}")
lines.append("")
for text in slide.texts:
lines.append(f" - {text}")
lines.append("")
write_txt_from_lines(output_path, lines)
def _xlsx_to_txt(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
"""Convert XLSX to plain text: sheet headers + cell values."""
content = read_xlsx_content(input_path, max_rows=max_rows, max_cols=max_cols)
_print_warnings(content.warnings)
lines: List[str] = []
for sh in content.sheets:
lines.append(f"### {sh.name}")
lines.append("")
for row in sh.cells:
line = " | ".join(safe_cell_value(cell) for cell in row)
lines.append(line)
lines.append("")
write_txt_from_lines(output_path, lines)
def _csv_to_txt(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
"""Convert CSV to plain text: pipe-separated values."""
content = read_csv_content(input_path, max_rows=max_rows, max_cols=max_cols)
lines: List[str] = []
for row in content.rows:
lines.append(" | ".join(row))
write_txt_from_lines(output_path, lines)
# --------------------------
# CSV -> *
# --------------------------
def _csv_to_docx(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
"""Convert CSV to DOCX: first row as heading, rest as table."""
content = read_csv_content(input_path, max_rows=max_rows, max_cols=max_cols)
doc = DocxDocument()
doc.add_heading(input_path.stem, level=1)
if not content.rows:
doc.add_paragraph("(empty file)")
else:
# Create table from CSV rows
n_rows = len(content.rows)
n_cols = max((len(r) for r in content.rows), default=0)
table = doc.add_table(rows=n_rows, cols=n_cols)
for r_i, row in enumerate(content.rows):
for c_i in range(n_cols):
cell_value = row[c_i] if c_i < len(row) else ""
table.cell(r_i, c_i).text = cell_value
doc.save(str(output_path))
def _csv_to_pptx(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
"""Convert CSV to PPTX: headers + rows as table slide."""
content = read_csv_content(input_path, max_rows=max_rows, max_cols=max_cols)
if not content.rows:
slides = [{"title": input_path.stem, "bullets": ["(empty file)"]}]
else:
slides = [{"title": input_path.stem, "bullets": [], "tables": [content.rows]}]
write_pptx_from_slides(output_path, slides, title=input_path.stem)
def _csv_to_xlsx(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
"""Convert CSV to XLSX: direct mapping."""
content = read_csv_content(input_path, max_rows=max_rows, max_cols=max_cols)
write_xlsx_from_sheets(output_path, [{"name": "Sheet1", "rows": content.rows}])
# --------------------------
# * -> CSV
# --------------------------
def _xlsx_to_csv(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
"""Convert XLSX to CSV: export first sheet only, warn if multiple sheets."""
content = read_xlsx_content(input_path, max_rows=max_rows, max_cols=max_cols)
_print_warnings(content.warnings)
if len(content.sheets) > 1:
print(f"⚠️ Warning: XLSX has {len(content.sheets)} sheets, exporting only first sheet '{content.sheets[0].name}'", file=sys.stderr)
if content.sheets:
write_csv_from_rows(output_path, rows=content.sheets[0].cells)
else:
write_csv_from_rows(output_path, rows=[])
def _docx_to_csv(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert DOCX to CSV: extract tables only (text content lost)."""
import sys
content = read_docx_content(input_path, max_chars=max_chars)
if not content.tables:
print(f"⚠️ Warning: No tables found in DOCX, CSV will be empty", file=sys.stderr)
write_csv_from_rows(output_path, rows=[])
else:
# Export first table only
if len(content.tables) > 1:
print(f"⚠️ Warning: DOCX has {len(content.tables)} tables, exporting only first table", file=sys.stderr)
write_csv_from_rows(output_path, rows=content.tables[0].rows)
def _pptx_to_csv(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert PPTX to CSV: extract tables only (text content lost)."""
import sys
content = read_pptx_content(input_path, max_chars=max_chars)
# Collect all tables from all slides
all_tables: List[List[List[str]]] = []
for slide in content.slides:
all_tables.extend(slide.tables)
if not all_tables:
print(f"⚠️ Warning: No tables found in PPTX, CSV will be empty", file=sys.stderr)
write_csv_from_rows(output_path, rows=[])
else:
# Export first table only
if len(all_tables) > 1:
print(f"⚠️ Warning: PPTX has {len(all_tables)} tables, exporting only first table", file=sys.stderr)
write_csv_from_rows(output_path, rows=all_tables[0])
# --------------------------
# MD -> *
# --------------------------
def _md_to_docx(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert Markdown to DOCX with semantic mapping."""
content = read_md_content(input_path, max_chars=max_chars)
doc = DocxDocument()
# Add title if present
if content.title:
doc.add_heading(content.title, level=1)
for section in content.sections:
section_type = section.get('type')
if section_type == 'heading':
level = min(section.get('level', 1), 9) # DOCX supports up to level 9
doc.add_heading(section.get('text', ''), level=level)
elif section_type == 'paragraph':
doc.add_paragraph(section.get('text', ''))
elif section_type == 'list':
items = section.get('items', [])
ordered = section.get('ordered', False)
style = 'List Number' if ordered else 'List Bullet'
for item in items:
doc.add_paragraph(item, style=style)
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
if headers or rows:
all_rows = [headers] + rows if headers else rows
if all_rows:
n_rows = len(all_rows)
n_cols = max((len(r) for r in all_rows), default=0)
table = doc.add_table(rows=n_rows, cols=n_cols)
for r_i, row in enumerate(all_rows):
for c_i in range(n_cols):
cell_value = row[c_i] if c_i < len(row) else ""
table.cell(r_i, c_i).text = str(cell_value)
elif section_type == 'code':
code = section.get('code', '')
doc.add_paragraph(code, style='No Spacing')
doc.save(str(output_path))
def _md_to_pptx(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert Markdown to PPTX: each H2 starts new slide."""
content = read_md_content(input_path, max_chars=max_chars)
slides: List[dict] = []
current_title = content.title or input_path.stem
current_bullets: List[str] = []
current_tables: List[List[List[str]]] = []
def flush_slide():
nonlocal current_title, current_bullets, current_tables
if current_title or current_bullets or current_tables:
slides.append({
"title": current_title or "",
"bullets": current_bullets[:],
"tables": current_tables[:]
})
current_title = ""
current_bullets = []
current_tables = []
for section in content.sections:
section_type = section.get('type')
if section_type == 'heading':
level = section.get('level', 1)
text = section.get('text', '')
# H2 and above start new slides
if level >= 2:
flush_slide()
current_title = text
else:
# H1 becomes a bullet
current_bullets.append(f"# {text}")
elif section_type == 'paragraph':
text = section.get('text', '')
if text:
current_bullets.append(text)
elif section_type == 'list':
items = section.get('items', [])
current_bullets.extend(items)
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
table_rows = [headers] + rows if headers else rows
if table_rows:
current_tables.append(table_rows)
elif section_type == 'code':
code = section.get('code', '')
current_bullets.append(f"Code: {code[:100]}...")
flush_slide()
if not slides:
slides = [{"title": content.title or input_path.stem, "bullets": ["(empty document)"]}]
write_pptx_from_slides(output_path, slides, title=content.title or input_path.stem)
def _md_to_xlsx(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert Markdown to XLSX: sections as sheets."""
content = read_md_content(input_path, max_chars=max_chars)
sheets: List[dict] = []
current_sheet_name = "Content"
current_rows: List[List[str]] = []
for section in content.sections:
section_type = section.get('type')
if section_type == 'heading':
level = section.get('level', 1)
text = section.get('text', '')
# H1 and H2 become sheet names
if level <= 2 and current_rows:
sheets.append({"name": current_sheet_name[:31], "rows": current_rows[:]})
current_sheet_name = text[:31]
current_rows = []
else:
current_rows.append([f"{'#' * level} {text}"])
elif section_type == 'paragraph':
text = section.get('text', '')
if text:
current_rows.append([text])
elif section_type == 'list':
items = section.get('items', [])
for item in items:
current_rows.append([f"- {item}"])
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
if headers:
current_rows.append(headers)
current_rows.extend(rows)
if current_rows:
sheets.append({"name": current_sheet_name[:31], "rows": current_rows})
if not sheets:
sheets = [{"name": "Sheet1", "rows": [["(empty document)"]]}]
write_xlsx_from_sheets(output_path, sheets)
def _md_to_pdf(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert Markdown to PDF via sections."""
content = read_md_content(input_path, max_chars=max_chars)
sections: List[dict] = []
current_heading = ""
current_paragraphs: List[str] = []
current_tables: List[List[List[str]]] = []
def flush_section():
nonlocal current_heading, current_paragraphs, current_tables
if current_heading or current_paragraphs or current_tables:
sections.append({
"heading": current_heading,
"paragraphs": current_paragraphs[:],
"tables": current_tables[:]
})
current_heading = ""
current_paragraphs = []
current_tables = []
for section in content.sections:
section_type = section.get('type')
if section_type == 'heading':
flush_section()
current_heading = section.get('text', '')
elif section_type == 'paragraph':
text = section.get('text', '')
if text:
current_paragraphs.append(text)
elif section_type == 'list':
items = section.get('items', [])
for item in items:
current_paragraphs.append(f"• {item}")
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
table_rows = [headers] + rows if headers else rows
if table_rows:
current_tables.append(table_rows)
elif section_type == 'code':
code = section.get('code', '')
current_paragraphs.append(f"[Code Block]\n{code}")
flush_section()
if not sections:
sections = [{"heading": content.title or input_path.stem, "paragraphs": ["(empty document)"], "tables": []}]
write_pdf_from_sections(output_path, title=content.title or input_path.stem, sections=sections)
def _md_to_txt(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert Markdown to plain text: preserve markdown syntax."""
content = read_md_content(input_path, max_chars=max_chars)
lines: List[str] = []
if content.title:
lines.append(f"# {content.title}")
lines.append("")
for section in content.sections:
section_type = section.get('type')
if section_type == 'heading':
level = section.get('level', 1)
text = section.get('text', '')
lines.append(f"{'#' * level} {text}")
lines.append("")
elif section_type == 'paragraph':
text = section.get('text', '')
if text:
lines.append(text)
lines.append("")
elif section_type == 'list':
items = section.get('items', [])
ordered = section.get('ordered', False)
for idx, item in enumerate(items, start=1):
prefix = f"{idx}." if ordered else "-"
lines.append(f"{prefix} {item}")
lines.append("")
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
if headers:
lines.append('| ' + ' | '.join(headers) + ' |')
lines.append('| ' + ' | '.join(['---'] * len(headers)) + ' |')
for row in rows:
padded = row + [''] * (len(headers) - len(row)) if headers else row
lines.append('| ' + ' | '.join(str(c) for c in padded) + ' |')
lines.append("")
elif section_type == 'code':
lang = section.get('language', '')
code = section.get('code', '')
lines.append(f"```{lang}")
lines.append(code)
lines.append("```")
lines.append("")
write_txt_from_lines(output_path, lines)
# --------------------------
# * -> MD
# --------------------------
def _docx_to_md(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert DOCX to Markdown with structure preservation."""
content = read_docx_content(input_path, max_chars=max_chars)
sections: List[dict] = []
heading_set = set(content.headings)
for para in content.paragraphs:
if para in heading_set:
sections.append({'type': 'heading', 'level': 2, 'text': para})
else:
sections.append({'type': 'paragraph', 'text': para})
# Add tables
for table in content.tables:
if table.rows:
sections.append({
'type': 'table',
'headers': table.rows[0] if table.rows else [],
'rows': table.rows[1:] if len(table.rows) > 1 else []
})
write_md_from_sections(output_path, sections, title=input_path.stem)
def _txt_to_md(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert plain text to Markdown: lines become paragraphs."""
content = read_txt_content(input_path, max_chars=max_chars)
sections: List[dict] = []
for line in content.lines:
if line.strip():
sections.append({'type': 'paragraph', 'text': line})
write_md_from_sections(output_path, sections, title=input_path.stem)
def _pdf_to_md(input_path: Path, output_path: Path, max_pages: int, max_chars: int) -> None:
"""Convert PDF to Markdown: pages as sections."""
pdf = read_pdf_text(input_path, max_pages=max_pages, max_chars=max_chars)
sections: List[dict] = []
for i, page_text in enumerate(pdf.pages, start=1):
sections.append({'type': 'heading', 'level': 2, 'text': f"Page {i}"})
for line in page_text.splitlines():
if line.strip():
sections.append({'type': 'paragraph', 'text': line})
write_md_from_sections(output_path, sections, title=input_path.stem)
def _pptx_to_md(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert PPTX to Markdown: slides as sections."""
content = read_pptx_content(input_path, max_chars=max_chars)
sections: List[dict] = []
for slide in content.slides:
if slide.title:
sections.append({'type': 'heading', 'level': 2, 'text': slide.title})
# Add text as list items
if slide.texts:
sections.append({'type': 'list', 'ordered': False, 'items': slide.texts})
# Add tables
for table in slide.tables:
if table:
sections.append({
'type': 'table',
'headers': table[0] if table else [],
'rows': table[1:] if len(table) > 1 else []
})
write_md_from_sections(output_path, sections, title=input_path.stem)
def _xlsx_to_md(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
"""Convert XLSX to Markdown: sheets as sections with tables."""
content = read_xlsx_content(input_path, max_rows=max_rows, max_cols=max_cols)
_print_warnings(content.warnings)
sections: List[dict] = []
for sheet in content.sheets:
sections.append({'type': 'heading', 'level': 2, 'text': sheet.name})
if sheet.cells:
sections.append({
'type': 'table',
'headers': sheet.cells[0] if sheet.cells else [],
'rows': sheet.cells[1:] if len(sheet.cells) > 1 else []
})
write_md_from_sections(output_path, sections, title=input_path.stem)
# --------------------------
# HTML -> *
# --------------------------
def _html_to_docx(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert HTML to DOCX: semantic structure preserved."""
content = read_html_content(input_path, max_chars=max_chars)
doc = DocxDocument()
for section in content.sections:
section_type = section.get('type')
if section_type == 'heading':
level = min(section.get('level', 1), 6)
text = section.get('text', '')
para = doc.add_paragraph(text, style=f'Heading {level}')
elif section_type == 'paragraph':
text = section.get('text', '')
if text:
doc.add_paragraph(text)
elif section_type == 'list':
items = section.get('items', [])
for item in items:
doc.add_paragraph(item, style='List Bullet')
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
if headers or rows:
num_cols = len(headers) if headers else (len(rows[0]) if rows else 1)
num_rows = (1 if headers else 0) + len(rows)
table = doc.add_table(rows=num_rows, cols=num_cols)
table.style = 'Light Grid Accent 1'
row_idx = 0
if headers:
for col_idx, header in enumerate(headers):
table.cell(row_idx, col_idx).text = str(header)
row_idx += 1
for row_data in rows:
for col_idx, cell_value in enumerate(row_data):
if col_idx < num_cols:
table.cell(row_idx, col_idx).text = str(cell_value)
row_idx += 1
elif section_type == 'code':
code = section.get('code', '')
if code:
para = doc.add_paragraph(code)
para.style = 'No Spacing'
doc.save(str(output_path))
def _html_to_pptx(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert HTML to PPTX: headings as slide titles."""
content = read_html_content(input_path, max_chars=max_chars)
slides = []
current_slide = {'title': content.title or input_path.stem, 'bullets': [], 'tables': []}
for section in content.sections:
section_type = section.get('type')
if section_type == 'heading':
# Start new slide on H2 or higher
if section.get('level', 1) <= 2:
if current_slide['bullets'] or current_slide['tables']:
slides.append(current_slide)
current_slide = {'title': section.get('text', ''), 'bullets': [], 'tables': []}
elif section_type == 'paragraph':
text = section.get('text', '')
if text:
current_slide['bullets'].append(text)
elif section_type == 'list':
items = section.get('items', [])
current_slide['bullets'].extend(items)
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
if headers or rows:
table_data = [headers] + rows if headers else rows
current_slide['tables'].append(table_data)
if current_slide['bullets'] or current_slide['tables']:
slides.append(current_slide)
if not slides:
slides = [{'title': content.title or input_path.stem, 'bullets': ['(No content)'], 'tables': []}]
write_pptx_from_slides(output_path, slides)
def _html_to_xlsx(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert HTML to XLSX: tables to sheets."""
content = read_html_content(input_path, max_chars=max_chars)
sheets = []
table_count = 0
for section in content.sections:
if section.get('type') == 'table':
table_count += 1
headers = section.get('headers', [])
rows = section.get('rows', [])
if headers or rows:
cells = [headers] + rows if headers else rows
sheets.append({
'name': f'Table{table_count}',
'cells': cells
})
if not sheets:
# Create a sheet with document title
sheets = [{
'name': 'Content',
'cells': [[content.title or input_path.stem]]
}]
write_xlsx_from_sheets(output_path, sheets)
def _html_to_pdf(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert HTML to PDF via sections."""
content = read_html_content(input_path, max_chars=max_chars)
write_pdf_from_sections(output_path, content.sections, title=content.title)
def _html_to_txt(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert HTML to plain text."""
content = read_html_content(input_path, max_chars=max_chars)
lines = []
if content.title:
lines.append(content.title)
lines.append('=' * len(content.title))
lines.append('')
for section in content.sections:
section_type = section.get('type')
if section_type == 'heading':
level = section.get('level', 1)
text = section.get('text', '')
prefix = '#' * level
lines.append(f"{prefix} {text}")
lines.append('')
elif section_type == 'paragraph':
text = section.get('text', '')
if text:
lines.append(text)
lines.append('')
elif section_type == 'list':
items = section.get('items', [])
for item in items:
lines.append(f"• {item}")
lines.append('')
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
if headers:
lines.append(' | '.join(headers))
lines.append('-' * 40)
for row in rows:
lines.append(' | '.join(str(cell) for cell in row))
lines.append('')
elif section_type == 'code':
code = section.get('code', '')
lines.append(code)
lines.append('')
write_txt_from_lines(output_path, lines)
def _html_to_csv(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert HTML to CSV: first table only."""
content = read_html_content(input_path, max_chars=max_chars)
for section in content.sections:
if section.get('type') == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
write_csv_from_rows(output_path, rows, headers=headers if headers else None)
return
# No tables found, create a simple CSV
write_csv_from_rows(output_path, [[content.title or input_path.stem]])
def _html_to_md(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert HTML to Markdown: direct section mapping."""
content = read_html_content(input_path, max_chars=max_chars)
write_md_from_sections(output_path, content.sections, title=content.title)
# --------------------------
# * -> HTML
# --------------------------
def _md_to_html(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert Markdown to HTML: direct section mapping."""
content = read_md_content(input_path, max_chars=max_chars)
write_html_from_sections(output_path, content.sections, title=content.title)
def _docx_to_html(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert DOCX to HTML: paragraphs and tables."""
content = read_docx_content(input_path, max_chars=max_chars)
sections: List[dict] = []
# Add headings
for heading in content.headings:
sections.append({'type': 'heading', 'level': 2, 'text': heading})
# Add paragraphs
for para in content.paragraphs:
if para.strip():
sections.append({'type': 'paragraph', 'text': para})
# Add tables
for table in content.tables:
if table.cells:
sections.append({
'type': 'table',
'headers': table.cells[0] if table.cells else [],
'rows': table.cells[1:] if len(table.cells) > 1 else []
})
write_html_from_sections(output_path, sections, title=input_path.stem)
def _txt_to_html(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert plain text to HTML: lines as paragraphs."""
content = read_txt_content(input_path, max_chars=max_chars)
sections: List[dict] = []
for line in content.lines:
if line.strip():
sections.append({'type': 'paragraph', 'text': line})
write_html_from_sections(output_path, sections, title=input_path.stem)
def _pdf_to_html(input_path: Path, output_path: Path, max_pages: int, max_chars: int) -> None:
"""Convert PDF to HTML: pages as sections."""
pdf = read_pdf_text(input_path, max_pages=max_pages, max_chars=max_chars)
sections: List[dict] = []
for i, page_text in enumerate(pdf.pages, start=1):
sections.append({'type': 'heading', 'level': 2, 'text': f"Page {i}"})
for line in page_text.splitlines():
if line.strip():
sections.append({'type': 'paragraph', 'text': line})
write_html_from_sections(output_path, sections, title=input_path.stem)
def _pptx_to_html(input_path: Path, output_path: Path, max_chars: int) -> None:
"""Convert PPTX to HTML: slides as sections."""
content = read_pptx_content(input_path, max_chars=max_chars)
sections: List[dict] = []
for slide in content.slides:
if slide.title:
sections.append({'type': 'heading', 'level': 2, 'text': slide.title})
# Add text as list items
if slide.texts:
sections.append({'type': 'list', 'ordered': False, 'items': slide.texts})
# Add tables
for table in slide.tables:
if table:
sections.append({
'type': 'table',
'headers': table[0] if table else [],
'rows': table[1:] if len(table) > 1 else []
})
write_html_from_sections(output_path, sections, title=input_path.stem)
def _xlsx_to_html(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
"""Convert XLSX to HTML: sheets as sections with tables."""
content = read_xlsx_content(input_path, max_rows=max_rows, max_cols=max_cols)
_print_warnings(content.warnings)
sections: List[dict] = []
for sheet in content.sheets:
sections.append({'type': 'heading', 'level': 2, 'text': sheet.name})
if sheet.cells:
sections.append({
'type': 'table',
'headers': sheet.cells[0] if sheet.cells else [],
'rows': sheet.cells[1:] if len(sheet.cells) > 1 else []
})
write_html_from_sections(output_path, sections, title=input_path.stem)
def _csv_to_html(input_path: Path, output_path: Path, max_rows: int, max_cols: int) -> None:
"""Convert CSV to HTML: data as table."""
content = read_csv_content(input_path, max_rows=max_rows, max_cols=max_cols)
sections: List[dict] = []
headers = content.headers if content.has_header else []
rows = content.rows
# If has_header, the first row is already in headers, so use remaining rows
if content.has_header and rows:
rows = rows
sections.append({
'type': 'table',
'headers': headers,
'rows': rows
})
write_html_from_sections(output_path, sections, title=input_path.stem)
#!/usr/bin/env python3
"""CSV reader for document converter suite."""
import csv
from dataclasses import dataclass
from pathlib import Path
from typing import List
@dataclass
class CsvContent:
"""CSV document content."""
rows: List[List[str]]
headers: List[str]
has_header: bool
def read_csv_content(
path: Path,
max_rows: int = 200,
max_cols: int = 50,
detect_header: bool = True
) -> CsvContent:
"""
Read CSV file with optional header detection.
Args:
path: Path to CSV file
max_rows: Maximum rows to read (default 200)
max_cols: Maximum columns per row (default 50)
detect_header: Whether to auto-detect headers (default True)
Returns:
CsvContent with rows, headers, and header detection flag
"""
with open(path, 'r', encoding='utf-8', errors='replace') as f:
# Try to detect dialect and headers
sniffer = csv.Sniffer()
sample = f.read(2048)
f.seek(0)
try:
dialect = sniffer.sniff(sample)
has_header = detect_header and sniffer.has_header(sample)
except:
# Fallback to excel dialect if sniffing fails
dialect = csv.excel
has_header = False
reader = csv.reader(f, dialect=dialect)
rows = []
headers = []
for i, row in enumerate(reader):
if i >= max_rows:
break
# Trim to max columns
trimmed = row[:max_cols] if len(row) > max_cols else row
if i == 0 and has_header:
headers = trimmed
rows.append(trimmed)
return CsvContent(rows=rows, headers=headers, has_header=has_header)
#!/usr/bin/env python3
"""CSV writer for document converter suite."""
import csv
from pathlib import Path
from typing import List, Optional
def write_csv_from_rows(
output_path: Path,
rows: List[List[str]],
headers: Optional[List[str]] = None
) -> None:
"""
Write CSV file from rows with optional headers.
Args:
output_path: Path to output CSV file
rows: List of rows (each row is a list of strings)
headers: Optional header row to prepend
"""
with open(output_path, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
if headers:
writer.writerow(headers)
writer.writerows(rows)
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from docx import Document
@dataclass
class DocxTable:
rows: List[List[str]]
@dataclass
class DocxContent:
paragraphs: List[str]
headings: List[str]
heading_levels: Dict[str, int] = field(default_factory=dict)
tables: List[DocxTable] = field(default_factory=list)
def _is_heading(paragraph) -> Optional[int]:
"""
Detect if paragraph is a heading and return level (1-6) or None.
Uses multi-heuristic approach:
1. Primary: Style name contains "Heading"
2. Secondary: Font size >= 14pt + bold
3. Tertiary: Short text (< 100 chars) + ALL CAPS + bold
"""
txt = (paragraph.text or "").strip()
if not txt:
return None
# Primary: Style name
try:
style_name = paragraph.style.name if paragraph.style else ""
if style_name.lower().startswith("heading"):
# Extract level from style name (e.g., "Heading 1" -> 1)
try:
parts = style_name.split()
if len(parts) >= 2 and parts[1].isdigit():
level = int(parts[1])
return min(max(level, 1), 6) # Clamp to 1-6
except Exception:
pass
return 2 # Default heading level if can't extract
except Exception:
pass
# Secondary: Font size + bold
try:
if paragraph.runs:
# Check first run for formatting
run = paragraph.runs[0]
font = run.font
if font.size and font.bold:
size_pt = font.size.pt
if size_pt >= 18:
return 1
elif size_pt >= 16:
return 2
elif size_pt >= 14:
return 3
except Exception:
pass
# Tertiary: Short + ALL CAPS + bold
try:
if len(txt) < 100 and txt.isupper() and paragraph.runs:
run = paragraph.runs[0]
if run.font.bold:
return 2
except Exception:
pass
return None
def read_docx_content(path: Path, max_chars: int = 300000) -> DocxContent:
"""Read text and basic tables from a .docx.
Heuristics:
- Heading detection: multi-heuristic (style, font size+bold, ALL CAPS+bold)
- Tables: raw cell text
"""
doc = Document(str(path))
paras: List[str] = []
headings: List[str] = []
heading_levels: Dict[str, int] = {}
budget = max_chars
for p in doc.paragraphs:
txt = (p.text or "").strip()
if not txt:
continue
# Check if it's a heading
heading_level = _is_heading(p)
if heading_level is not None:
headings.append(txt)
heading_levels[txt] = heading_level
if budget <= 0:
break
if len(txt) > budget:
txt = txt[:budget]
budget -= len(txt)
paras.append(txt)
tables: List[DocxTable] = []
for t in doc.tables:
rows: List[List[str]] = []
for row in t.rows:
rows.append([(cell.text or "").strip() for cell in row.cells])
if rows:
tables.append(DocxTable(rows=rows))
return DocxContent(
paragraphs=paras,
headings=headings,
heading_levels=heading_levels,
tables=tables
)
from __future__ import annotations
from pathlib import Path
from typing import Iterable, List, Optional
from docx import Document
def write_docx_from_text(
output_path: Path,
title: Optional[str],
paragraphs: Iterable[str],
tables: Optional[List[List[List[str]]]] = None,
) -> None:
"""Write a simple DOCX with optional tables."""
doc = Document()
if title:
doc.add_heading(title, level=1)
for p in paragraphs:
if p is None:
continue
txt = str(p).strip()
if not txt:
continue
doc.add_paragraph(txt)
if tables:
for idx, t in enumerate(tables, start=1):
# Add a little separation between the narrative text and tables.
doc.add_paragraph("")
doc.add_heading(f"Table {idx}", level=2)
if not t:
continue
n_rows = len(t)
n_cols = max((len(r) for r in t), default=0)
if n_cols == 0:
continue
table = doc.add_table(rows=n_rows, cols=n_cols)
for r_i, row in enumerate(t):
for c_i in range(n_cols):
val = row[c_i] if c_i < len(row) else ""
table.cell(r_i, c_i).text = str(val or "")
doc.save(str(output_path))
#!/usr/bin/env python3
"""HTML reader for document converter suite."""
from bs4 import BeautifulSoup
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
@dataclass
class HtmlContent:
"""HTML document content."""
title: Optional[str]
sections: List[dict] = field(default_factory=list)
def read_html_content(path: Path, max_chars: int = 300000) -> HtmlContent:
"""
Read HTML file and parse into structured sections.
Args:
path: Path to HTML file
max_chars: Maximum characters to read (default 300,000)
Returns:
HtmlContent with title and sections
"""
with open(path, 'r', encoding='utf-8', errors='replace') as f:
html = f.read(max_chars)
soup = BeautifulSoup(html, 'lxml')
# Extract title from <title> tag or first <h1>
title = None
title_tag = soup.find('title')
if title_tag and title_tag.string:
title = title_tag.string.strip()
# Get body content, or fallback to entire document
body = soup.find('body')
if not body:
body = soup
sections = []
# Process elements in order
for element in body.descendants:
if not hasattr(element, 'name') or element.name is None:
continue
# Headings
if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
level = int(element.name[1])
text = element.get_text(strip=True)
if text:
sections.append({
'type': 'heading',
'level': level,
'text': text
})
# Set title from first h1 if not already set
if not title and level == 1:
title = text
# Paragraphs
elif element.name == 'p':
text = element.get_text(strip=True)
if text:
sections.append({
'type': 'paragraph',
'text': text
})
# Lists
elif element.name in ['ul', 'ol']:
ordered = element.name == 'ol'
items = []
for li in element.find_all('li', recursive=False):
item_text = li.get_text(strip=True)
if item_text:
items.append(item_text)
if items:
sections.append({
'type': 'list',
'ordered': ordered,
'items': items
})
# Tables
elif element.name == 'table':
headers = []
rows = []
# Extract headers from <thead> or first <tr>
thead = element.find('thead')
if thead:
header_row = thead.find('tr')
if header_row:
for th in header_row.find_all(['th', 'td']):
headers.append(th.get_text(strip=True))
else:
# Check if first row looks like headers
first_row = element.find('tr')
if first_row:
first_cells = first_row.find_all('th')
if first_cells:
for th in first_cells:
headers.append(th.get_text(strip=True))
# Extract body rows
tbody = element.find('tbody')
row_container = tbody if tbody else element
for tr in row_container.find_all('tr'):
# Skip header row if it was already processed
if headers and tr == element.find('tr'):
first_cells = tr.find_all('th')
if first_cells:
continue
row = []
for td in tr.find_all(['td', 'th']):
row.append(td.get_text(strip=True))
if row:
rows.append(row)
if headers or rows:
sections.append({
'type': 'table',
'headers': headers,
'rows': rows
})
# Code blocks
elif element.name == 'pre':
code_element = element.find('code')
code_text = code_element.get_text() if code_element else element.get_text()
# Try to detect language from class (common pattern: class="language-python")
language = ''
if code_element and code_element.get('class'):
classes = code_element.get('class')
for cls in classes:
if cls.startswith('language-'):
language = cls.replace('language-', '')
break
if code_text.strip():
sections.append({
'type': 'code',
'language': language,
'code': code_text.rstrip()
})
return HtmlContent(title=title, sections=sections)
#!/usr/bin/env python3
"""HTML writer for document converter suite."""
from pathlib import Path
from typing import List, Optional
DEFAULT_CSS = """
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
color: #333;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.3;
}
h1 { font-size: 2em; border-bottom: 2px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
p {
margin-bottom: 1em;
}
ul, ol {
margin-bottom: 1em;
padding-left: 2em;
}
li {
margin-bottom: 0.25em;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1em;
overflow-x: auto;
display: block;
}
th, td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #f5f5f5;
font-weight: 600;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
pre {
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px;
overflow-x: auto;
margin-bottom: 1em;
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
}
"""
def write_html_from_sections(
output_path: Path,
sections: List[dict],
title: Optional[str] = None,
css: Optional[str] = None
) -> None:
"""
Write HTML file from structured sections.
Args:
output_path: Path to output HTML file
sections: List of section dictionaries with type and content
title: Optional title for the document
css: Optional custom CSS (if None, uses default styles)
"""
if css is None:
css = DEFAULT_CSS
# Build HTML document
html_parts = []
# DOCTYPE and head
html_parts.append('<!DOCTYPE html>')
html_parts.append('<html lang="en">')
html_parts.append('<head>')
html_parts.append(' <meta charset="UTF-8">')
html_parts.append(' <meta name="viewport" content="width=device-width, initial-scale=1.0">')
if title:
html_parts.append(f' <title>{_escape_html(title)}</title>')
else:
html_parts.append(' <title>Document</title>')
html_parts.append(' <style>')
html_parts.append(css)
html_parts.append(' </style>')
html_parts.append('</head>')
html_parts.append('<body>')
# Add title as h1 if provided
if title:
html_parts.append(f' <h1>{_escape_html(title)}</h1>')
# Process sections
for section in sections:
section_type = section.get('type')
if section_type == 'heading':
level = section.get('level', 1)
text = section.get('text', '')
html_parts.append(f' <h{level}>{_escape_html(text)}</h{level}>')
elif section_type == 'paragraph':
text = section.get('text', '')
if text:
html_parts.append(f' <p>{_escape_html(text)}</p>')
elif section_type == 'list':
items = section.get('items', [])
ordered = section.get('ordered', False)
tag = 'ol' if ordered else 'ul'
html_parts.append(f' <{tag}>')
for item in items:
html_parts.append(f' <li>{_escape_html(item)}</li>')
html_parts.append(f' </{tag}>')
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
html_parts.append(' <table>')
# Write headers
if headers:
html_parts.append(' <thead>')
html_parts.append(' <tr>')
for header in headers:
html_parts.append(f' <th>{_escape_html(header)}</th>')
html_parts.append(' </tr>')
html_parts.append(' </thead>')
# Write body
if rows:
html_parts.append(' <tbody>')
for row in rows:
html_parts.append(' <tr>')
for cell in row:
html_parts.append(f' <td>{_escape_html(str(cell))}</td>')
html_parts.append(' </tr>')
html_parts.append(' </tbody>')
html_parts.append(' </table>')
elif section_type == 'code':
language = section.get('language', '')
code = section.get('code', '')
lang_class = f' class="language-{language}"' if language else ''
html_parts.append(' <pre>')
html_parts.append(f'<code{lang_class}>{_escape_html(code)}</code>')
html_parts.append(' </pre>')
# Close HTML
html_parts.append('</body>')
html_parts.append('</html>')
# Write to file
with open(output_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(html_parts))
def _escape_html(text: str) -> str:
"""Escape HTML special characters."""
return (text
.replace('&', '&')
.replace('<', '<')
.replace('>', '>')
.replace('"', '"')
.replace("'", '''))
#!/usr/bin/env python3
"""Image extraction and handling for document converter suite."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
try:
from PIL import Image
PILLOW_AVAILABLE = True
except ImportError:
PILLOW_AVAILABLE = False
@dataclass
class ImageRef:
"""Reference to an extracted image."""
id: str # Hash-based unique ID
path: Path # Path to saved image file
format: str # Image format (png, jpg, etc.)
width: Optional[int] = None
height: Optional[int] = None
alt_text: Optional[str] = None
def save_image(
image_data: bytes,
output_dir: Path,
prefix: str = "img",
alt_text: Optional[str] = None
) -> ImageRef:
"""
Save image data to file with hash-based deduplication.
Args:
image_data: Raw image bytes
output_dir: Directory to save image
prefix: Filename prefix (default: "img")
alt_text: Optional alt text for the image
Returns:
ImageRef with image metadata
Raises:
ValueError: If Pillow is not available
RuntimeError: If image format cannot be determined
"""
if not PILLOW_AVAILABLE:
raise ValueError("Pillow library is required for image extraction. Install with: pip install Pillow")
# Create output directory if needed
output_dir.mkdir(parents=True, exist_ok=True)
# Generate hash ID to avoid duplicates
image_hash = hashlib.sha256(image_data).hexdigest()[:12]
image_id = f"{prefix}_{image_hash}"
# Detect format using PIL
try:
from io import BytesIO
img = Image.open(BytesIO(image_data))
format_lower = img.format.lower() if img.format else "png"
width, height = img.size
# Save image
image_path = output_dir / f"{image_id}.{format_lower}"
# Check if already exists (deduplication)
if not image_path.exists():
with open(image_path, 'wb') as f:
f.write(image_data)
return ImageRef(
id=image_id,
path=image_path,
format=format_lower,
width=width,
height=height,
alt_text=alt_text
)
except Exception as e:
raise RuntimeError(f"Failed to process image: {e}")
def get_image_placeholder(image_ref: ImageRef) -> str:
"""
Generate a text placeholder for an image.
Args:
image_ref: Image reference
Returns:
Formatted placeholder string
"""
if image_ref.alt_text:
return f"[Image: {image_ref.alt_text} ({image_ref.path.name})]"
else:
return f"[Image: {image_ref.path.name}]"
#!/usr/bin/env python3
"""Markdown reader for document converter suite."""
import mistune
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
@dataclass
class MdContent:
"""Markdown document content."""
title: Optional[str]
sections: List[dict] = field(default_factory=list)
class StructuredMarkdownRenderer(mistune.HTMLRenderer):
"""Custom mistune renderer that builds structured sections instead of HTML."""
def __init__(self):
super().__init__()
self.sections = []
self._list_items = []
self._list_ordered = False
self._current_table_headers = []
self._current_table_rows = []
def heading(self, text, level, **attrs):
"""Handle heading elements."""
self.sections.append({
'type': 'heading',
'level': level,
'text': text
})
return ''
def paragraph(self, text):
"""Handle paragraph elements."""
self.sections.append({
'type': 'paragraph',
'text': text
})
return ''
def list(self, text, ordered, **attrs):
"""Handle list elements."""
if self._list_items:
self.sections.append({
'type': 'list',
'ordered': ordered,
'items': self._list_items[:]
})
self._list_items = []
return ''
def list_item(self, text):
"""Handle list item elements."""
# Strip any HTML tags that might be in the text
clean_text = text.strip()
self._list_items.append(clean_text)
return ''
def block_code(self, code, info=None):
"""Handle code block elements."""
self.sections.append({
'type': 'code',
'language': info or '',
'code': code
})
return ''
def table(self, text):
"""Handle table elements."""
if self._current_table_rows:
self.sections.append({
'type': 'table',
'headers': self._current_table_headers[:],
'rows': self._current_table_rows[:]
})
self._current_table_headers = []
self._current_table_rows = []
return ''
def table_head(self, text):
"""Handle table header."""
return text
def table_body(self, text):
"""Handle table body."""
return text
def table_row(self, text):
"""Handle table row."""
return text
def table_cell(self, text, align=None, is_head=False):
"""Handle table cell."""
# This is called for each cell - we need to accumulate them
# mistune calls this in order, so we track state
clean_text = text.strip()
if is_head:
self._current_table_headers.append(clean_text)
else:
# For body cells, we need to group them into rows
# This is a simplified approach - we'll append to the last row
# or create a new row if needed
if not self._current_table_rows or len(self._current_table_rows[-1]) >= len(self._current_table_headers):
self._current_table_rows.append([])
self._current_table_rows[-1].append(clean_text)
return ''
def block_quote(self, text):
"""Handle block quote elements."""
self.sections.append({
'type': 'paragraph',
'text': f"> {text}"
})
return ''
def thematic_break(self):
"""Handle horizontal rule."""
return ''
# Inline elements - just return the text
def emphasis(self, text):
return text
def strong(self, text):
return text
def link(self, text, url, title=None):
return f"{text} ({url})" if url else text
def image(self, alt, url, title=None):
return f"[Image: {alt or url}]"
def codespan(self, text):
return f"`{text}`"
def read_md_content(path: Path, max_chars: int = 300000) -> MdContent:
"""
Read Markdown file and parse into structured sections.
Args:
path: Path to markdown file
max_chars: Maximum characters to read (default 300,000)
Returns:
MdContent with title and sections
"""
with open(path, 'r', encoding='utf-8', errors='replace') as f:
text = f.read(max_chars)
# Create custom renderer
renderer = StructuredMarkdownRenderer()
markdown = mistune.create_markdown(renderer=renderer)
# Parse markdown
markdown(text)
# Extract title (first H1 if present)
title = None
for section in renderer.sections:
if section.get('type') == 'heading' and section.get('level') == 1:
title = section['text']
break
return MdContent(title=title, sections=renderer.sections)
#!/usr/bin/env python3
"""Markdown writer for document converter suite."""
from pathlib import Path
from typing import List, Optional
def write_md_from_sections(
output_path: Path,
sections: List[dict],
title: Optional[str] = None
) -> None:
"""
Write Markdown file from structured sections.
Args:
output_path: Path to output markdown file
sections: List of section dictionaries with type and content
title: Optional title to prepend as H1
"""
lines = []
# Add title if provided
if title:
lines.append(f"# {title}")
lines.append("")
for section in sections:
section_type = section.get('type')
if section_type == 'heading':
level = section.get('level', 1)
text = section.get('text', '')
lines.append(f"{'#' * level} {text}")
lines.append("")
elif section_type == 'paragraph':
text = section.get('text', '')
if text:
lines.append(text)
lines.append("")
elif section_type == 'list':
items = section.get('items', [])
ordered = section.get('ordered', False)
for idx, item in enumerate(items, start=1):
if ordered:
lines.append(f"{idx}. {item}")
else:
lines.append(f"- {item}")
lines.append("")
elif section_type == 'table':
headers = section.get('headers', [])
rows = section.get('rows', [])
if headers:
# Write header row
lines.append('| ' + ' | '.join(headers) + ' |')
# Write separator row
lines.append('| ' + ' | '.join(['---'] * len(headers)) + ' |')
# Write data rows
for row in rows:
# Pad row to match header length if needed
padded_row = row + [''] * (len(headers) - len(row)) if headers else row
lines.append('| ' + ' | '.join(str(cell) for cell in padded_row) + ' |')
lines.append("")
elif section_type == 'code':
language = section.get('language', '')
code = section.get('code', '')
lines.append(f"```{language}")
lines.append(code.rstrip())
lines.append("```")
lines.append("")
# Write to file
with open(output_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import List
from pypdf import PdfReader
from .utils import clamp_text
@dataclass
class PdfText:
pages: List[str]
def read_pdf_text(path: Path, max_pages: int = 200, max_chars: int = 300000) -> PdfText:
"""Extract text from a PDF using pypdf.
Limitations:
- No OCR
- Text extraction quality depends on the PDF (embedded text vs scanned)
"""
reader = PdfReader(str(path))
pages: List[str] = []
page_count = min(len(reader.pages), max_pages)
remaining = max_chars
for i in range(page_count):
page = reader.pages[i]
try:
text = page.extract_text() or ""
except Exception:
text = ""
# clamp global budget across all pages
if remaining <= 0:
pages.append("…(truncated)…")
break
if len(text) > remaining:
text = text[:remaining]
remaining -= len(text)
pages.append(text)
# If we hit the page cap and there are more pages, signal it
if len(reader.pages) > page_count:
pages.append(f"…(stopped at {page_count} pages of {len(reader.pages)})…")
return PdfText(pages=[clamp_text(p, 100000) for p in pages])
from __future__ import annotations
from pathlib import Path
from typing import List, Optional
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
def _escape(s: str) -> str:
"""Minimal HTML escaping for ReportLab Paragraph."""
return (
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
)
def write_pdf_from_sections(
output_path: Path,
title: Optional[str],
sections: List[dict],
) -> None:
"""Write a simple PDF from text and optional tables.
Each section dict can have:
- heading: str
- paragraphs: list[str]
- tables: list[list[list[str]]]
"""
styles = getSampleStyleSheet()
doc = SimpleDocTemplate(
str(output_path),
pagesize=letter,
leftMargin=0.75 * inch,
rightMargin=0.75 * inch,
topMargin=0.75 * inch,
bottomMargin=0.75 * inch,
)
story = []
if title:
story.append(Paragraph(_escape(title), styles["Title"]))
story.append(Spacer(1, 0.2 * inch))
for s_i, sec in enumerate(sections):
heading = (sec.get("heading") or "").strip()
paras = sec.get("paragraphs") or []
tables = sec.get("tables") or []
if heading:
story.append(Paragraph(_escape(heading), styles["Heading2" if title else "Heading1"]))
story.append(Spacer(1, 0.12 * inch))
for p in paras:
txt = str(p).strip()
if not txt:
continue
story.append(Paragraph(_escape(txt), styles["BodyText"]))
story.append(Spacer(1, 0.08 * inch))
for t in tables:
if not t:
continue
data = [[_escape(str(c or "")) for c in row] for row in t]
if not data:
continue
tbl = Table(data, hAlign="LEFT")
tbl.setStyle(
TableStyle(
[
("GRID", (0, 0), (-1, -1), 0.5, colors.black),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]
)
)
story.append(tbl)
story.append(Spacer(1, 0.15 * inch))
if s_i < len(sections) - 1:
story.append(PageBreak())
doc.build(story)
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
from pptx import Presentation
@dataclass
class SlideContent:
index: int
title: str
texts: List[str]
tables: List[List[List[str]]]
@dataclass
class PptxContent:
slides: List[SlideContent]
def _shape_text(shape) -> Optional[str]:
try:
if hasattr(shape, "has_text_frame") and shape.has_text_frame:
txt = shape.text_frame.text
return (txt or "").strip()
except Exception:
return None
return None
def read_pptx_content(path: Path, max_chars: int = 300000) -> PptxContent:
"""Extract slide titles, text shapes, and tables from a .pptx."""
prs = Presentation(str(path))
slides: List[SlideContent] = []
budget = max_chars
for i, slide in enumerate(prs.slides):
title = ""
try:
if slide.shapes.title is not None:
title = (slide.shapes.title.text or "").strip()
except Exception:
title = ""
texts: List[str] = []
tables: List[List[List[str]]] = []
for shape in slide.shapes:
# tables
try:
if hasattr(shape, "has_table") and shape.has_table:
t = shape.table
rows: List[List[str]] = []
for r in t.rows:
rows.append([(c.text or "").strip() for c in r.cells])
if rows:
tables.append(rows)
continue
except Exception:
pass
txt = _shape_text(shape)
if not txt:
continue
if txt == title:
continue
if budget <= 0:
break
if len(txt) > budget:
txt = txt[:budget]
budget -= len(txt)
texts.append(txt)
slides.append(SlideContent(index=i + 1, title=title, texts=texts, tables=tables))
return PptxContent(slides=slides)
from __future__ import annotations
from pathlib import Path
from typing import List, Optional
from pptx import Presentation
def write_pptx_from_slides(
output_path: Path,
slides: List[dict],
title: Optional[str] = None,
) -> None:
"""Write a basic PPTX.
Each slide dict can have:
- title: str
- bullets: list[str]
- tables: list[list[list[str]]]
"""
prs = Presentation()
# Optional title slide
if title:
try:
layout = prs.slide_layouts[0]
except Exception:
layout = prs.slide_layouts[1]
slide = prs.slides.add_slide(layout)
if slide.shapes.title:
slide.shapes.title.text = title
for s in slides:
try:
layout = prs.slide_layouts[1] # Title + Content
except Exception:
layout = prs.slide_layouts[0]
slide = prs.slides.add_slide(layout)
if slide.shapes.title:
slide.shapes.title.text = (s.get("title") or "").strip()
# Text / bullets into the body placeholder if present
body = None
for shape in slide.placeholders:
try:
if shape.placeholder_format.type == 2: # BODY
body = shape
break
except Exception:
continue
if body is None and len(slide.shapes) > 1:
# best-effort: use second shape if it's a placeholder
try:
body = slide.shapes[1]
except Exception:
body = None
bullets = s.get("bullets") or []
if body is not None and hasattr(body, "text_frame"):
tf = body.text_frame
tf.clear()
if bullets:
first = tf.paragraphs[0]
first.text = bullets[0]
first.level = 0
for b in bullets[1:]:
p = tf.add_paragraph()
p.text = b
p.level = 0
else:
tf.text = ""
# Tables: create one slide per table when multiple tables exist
tables = s.get("tables") or []
if tables:
# Render first table on the current slide
for table_idx, t in enumerate(tables):
# For tables after the first, create new slides
if table_idx > 0:
try:
layout = prs.slide_layouts[1] # Title + Content
except Exception:
layout = prs.slide_layouts[0]
slide = prs.slides.add_slide(layout)
slide_title = s.get("title") or ""
if slide.shapes.title:
slide.shapes.title.text = f"{slide_title} - Table {table_idx + 1}"
if not t:
continue
rows = len(t)
cols = max((len(r) for r in t), default=0)
if rows <= 0 or cols <= 0:
continue
# Simple positioning: below title, centered-ish
try:
from pptx.util import Inches
left = Inches(0.5)
top = Inches(1.5)
width = Inches(9)
height = Inches(5)
shape = slide.shapes.add_table(rows, cols, left, top, width, height)
tbl = shape.table
except Exception:
continue
for r_i in range(rows):
for c_i in range(cols):
val = t[r_i][c_i] if c_i < len(t[r_i]) else ""
tbl.cell(r_i, c_i).text = str(val or "")
prs.save(str(output_path))
#!/usr/bin/env python3
"""Plain text reader for document converter suite."""
from dataclasses import dataclass
from pathlib import Path
from typing import List
@dataclass
class TxtContent:
"""Plain text document content."""
lines: List[str]
def read_txt_content(path: Path, max_chars: int = 300000) -> TxtContent:
"""
Read plain text file, preserving line structure.
Args:
path: Path to text file
max_chars: Maximum characters to read (default 300,000)
Returns:
TxtContent with lines
"""
with open(path, 'r', encoding='utf-8', errors='replace') as f:
text = f.read(max_chars)
return TxtContent(lines=text.splitlines())
#!/usr/bin/env python3
"""Plain text writer for document converter suite."""
from pathlib import Path
from typing import List
def write_txt_from_lines(output_path: Path, lines: List[str]) -> None:
"""
Write plain text file from lines.
Args:
output_path: Path to output text file
lines: List of text lines to write
"""
with open(output_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
"""Shared constants and small types."""
from __future__ import annotations
SUPPORTED_INPUT_EXTS = {".pdf", ".docx", ".pptx", ".xlsx", ".txt", ".csv", ".md", ".html"}
SUPPORTED_OUTPUT_EXTS = {".pdf", ".docx", ".pptx", ".xlsx", ".txt", ".csv", ".md", ".html"}
from __future__ import annotations
import re
from typing import Iterable, List
def clamp_text(text: str, max_chars: int) -> str:
"""Clamp text length to a max char count, preserving a readable ending."""
if max_chars <= 0:
return ""
if len(text) <= max_chars:
return text
return text[: max_chars - 20] + "\n…(truncated)…\n"
def nonempty_lines(text: str) -> List[str]:
return [ln.strip() for ln in text.splitlines() if ln.strip()]
def guess_bullets(text: str, max_lines: int = 12, max_line_chars: int = 180) -> List[str]:
"""Turn a text blob into bullet-ish lines.
- Prefer existing line breaks
- Strip common bullet markers
- Clamp length to avoid runaway text boxes
"""
lines = nonempty_lines(text)
cleaned: List[str] = []
for ln in lines:
ln = re.sub(r"^[-•\u2022\*\s]+", "", ln).strip()
if not ln:
continue
if len(ln) > max_line_chars:
ln = ln[: max_line_chars - 1] + "…"
cleaned.append(ln)
if len(cleaned) >= max_lines:
break
if not cleaned and text.strip():
cleaned = [text.strip()[: max_line_chars - 1] + "…" if len(text.strip()) > max_line_chars else text.strip()]
return cleaned
def chunk_list(items: List[str], chunk_size: int) -> List[List[str]]:
if chunk_size <= 0:
return [items]
return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)]
def safe_cell_value(v) -> str:
if v is None:
return ""
try:
return str(v)
except Exception:
return ""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Tuple
from openpyxl import load_workbook
@dataclass
class SheetContent:
name: str
cells: List[List[str]] # 2D grid (rows x cols)
@dataclass
class XlsxContent:
sheets: List[SheetContent]
warnings: List[str] = field(default_factory=list)
def read_xlsx_content(path: Path, max_rows: int = 200, max_cols: int = 50) -> XlsxContent:
"""Read a bounded grid of values from each sheet with truncation warnings."""
wb = load_workbook(str(path), data_only=True, read_only=True)
sheets: List[SheetContent] = []
warnings: List[str] = []
for ws in wb.worksheets:
# Get actual sheet dimensions
actual_rows = ws.max_row
actual_cols = ws.max_column
# Check for truncation
if actual_rows > max_rows:
warnings.append(
f"Sheet '{ws.title}': Truncated {actual_rows} rows → {max_rows} rows "
f"({actual_rows - max_rows} rows omitted)"
)
if actual_cols > max_cols:
warnings.append(
f"Sheet '{ws.title}': Truncated {actual_cols} columns → {max_cols} columns "
f"({actual_cols - max_cols} columns omitted)"
)
# Read grid with caps
grid: List[List[str]] = []
for r_i, row in enumerate(ws.iter_rows(min_row=1, max_row=max_rows, max_col=max_cols, values_only=True), start=1):
grid.append(["" if v is None else str(v) for v in row])
sheets.append(SheetContent(name=ws.title, cells=grid))
return XlsxContent(sheets=sheets, warnings=warnings)
from __future__ import annotations
from pathlib import Path
from typing import List
from openpyxl import Workbook
def write_xlsx_from_sheets(output_path: Path, sheets: List[dict]) -> None:
"""Write an XLSX from a list of sheet dicts.
Each sheet dict can have:
- name: str
- rows: list[list[str]]
Notes:
- This is a plain-value export (no formulas / styling).
"""
wb = Workbook()
# Remove default sheet if we are creating our own
if sheets:
default = wb.active
wb.remove(default)
for idx, sh in enumerate(sheets, start=1):
name = (sh.get("name") or f"Sheet{idx}")[:31]
ws = wb.create_sheet(title=name)
rows = sh.get("rows") or []
for r_i, row in enumerate(rows, start=1):
for c_i, val in enumerate(row, start=1):
ws.cell(row=r_i, column=c_i, value=val)
if not sheets:
wb.active.title = "Sheet1"
wb.save(str(output_path))
PyMuPDF>=1.23.0
PyPDF2>=3.0.0
beautifulsoup4>=4.11.1
opencv-python>=4.8.0
openpyxl>=3.0.10
openpyxl>=3.1.0
pandas>=2.0.0
pdfplumber>=0.10.0
pillow>=10.0.0
pypdf>=3.0.0
pytesseract>=0.3.10
python-docx>=0.8.11
python-pptx>=0.6.21
reportlab>=3.6.12
reportlab>=4.0.0
Related skills
How it compares
Use instead of ad-hoc chat prompts or one-off online converters when you need repeatable, scriptable conversion inside an agent workflow.
FAQ
Who is document-converter-suite for?
Developers who use Claude Code, Cursor, or similar agents and need reliable document format conversion inside their workflows.
When should I use document-converter-suite?
Use it when you need to turn PDFs into editable markdown, extract tables from spreadsheets, fill simple forms, merge or split PDFs, or move content between Office formats and HTML inside an automated pipeline.
Is document-converter-suite safe to install?
Users should review the Security Audits panel on this page before installing. The skill follows explicit guardrails that avoid blind processing of large documents and clearly states output limitations.