#!/usr/bin/env python3
"""
Excel to Markdown Converter
Supported formats:
.xlsx Excel workbook
.xlsm Excel macro-enabled workbook
Unsupported by default:
.xls Legacy binary Excel format; resave as .xlsx first
All paths produce the same output convention:
.md Markdown file
"""
import argparse
import re
import sys
from datetime import date, datetime, time
from pathlib import Path
from typing import Any
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from _batch import run_path_batch # noqa: E402
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
configure_utf8_stdio()
# ─────────────────────────────────────────────────────────────
# Format registry
# ─────────────────────────────────────────────────────────────
EXCEL_FORMATS = {".xlsx", ".xlsm"}
LEGACY_EXCEL_FORMATS = {".xls"}
# ─────────────────────────────────────────────────────────────
# Shared helpers
# ─────────────────────────────────────────────────────────────
def _format_size(size: int) -> str:
for unit in ("B", "KB", "MB"):
if size < 1024:
return f"{size:.0f} {unit}"
size /= 1024
return f"{size:.1f} GB"
def _report_result(out_file: Path) -> None:
size = out_file.stat().st_size
print(f"[OK] Saved Markdown to: {out_file} ({_format_size(size)})")
def _is_empty(value: Any) -> bool:
return value is None or (isinstance(value, str) and value.strip() == "")
def _markdown_escape(value: str) -> str:
value = value.replace("\\", "\\\\")
value = value.replace("|", "\\|")
value = value.replace("\r\n", "\n").replace("\r", "\n")
return re.sub(r"\s*\n\s*", "
", value).strip()
def _format_cell_value(value: Any) -> str:
if _is_empty(value):
return ""
if isinstance(value, bool):
return "TRUE" if value else "FALSE"
if isinstance(value, datetime):
return value.isoformat(sep=" ", timespec="seconds")
if isinstance(value, date):
return value.isoformat()
if isinstance(value, time):
return value.isoformat(timespec="seconds")
if isinstance(value, float):
return _markdown_escape(f"{value:g}")
return _markdown_escape(str(value))
def _is_numeric_value(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _sheet_state_label(sheet_state: str) -> str:
if sheet_state == "visible":
return "visible"
return sheet_state or "unknown"
# ─────────────────────────────────────────────────────────────
# Worksheet extraction
# ─────────────────────────────────────────────────────────────
def _merged_value_map(worksheet) -> dict[tuple[int, int], Any]:
"""Return propagated values for merged cells, keyed by (row, column).
Merged regions whose top-left cell is empty are intentionally skipped.
These are typically formatting-only ranges that carry no textual content.
"""
merged_values: dict[tuple[int, int], Any] = {}
for merged_range in worksheet.merged_cells.ranges:
value = worksheet.cell(merged_range.min_row, merged_range.min_col).value
if _is_empty(value):
continue
for row in range(merged_range.min_row, merged_range.max_row + 1):
for col in range(merged_range.min_col, merged_range.max_col + 1):
merged_values[(row, col)] = value
return merged_values
def _cell_value(worksheet, row: int, col: int, merged_values: dict[tuple[int, int], Any]) -> Any:
value = worksheet.cell(row, col).value
if _is_empty(value):
return merged_values.get((row, col), value)
return value
def _content_bounds(worksheet, merged_values: dict[tuple[int, int], Any]) -> tuple[int, int, int, int] | None:
min_row = min_col = None
max_row = max_col = None
for row in worksheet.iter_rows():
for cell in row:
if _is_empty(cell.value):
continue
min_row = cell.row if min_row is None else min(min_row, cell.row)
max_row = cell.row if max_row is None else max(max_row, cell.row)
min_col = cell.column if min_col is None else min(min_col, cell.column)
max_col = cell.column if max_col is None else max(max_col, cell.column)
for row, col in merged_values:
min_row = row if min_row is None else min(min_row, row)
max_row = row if max_row is None else max(max_row, row)
min_col = col if min_col is None else min(min_col, col)
max_col = col if max_col is None else max(max_col, col)
if min_row is None or min_col is None or max_row is None or max_col is None:
return None
return min_row, min_col, max_row, max_col
def _trim_trailing_empty_cells(row: list[Any]) -> list[Any]:
trimmed = list(row)
while trimmed and _is_empty(trimmed[-1]):
trimmed.pop()
return trimmed
def _extract_rows(
worksheet,
bounds: tuple[int, int, int, int],
merged_values: dict[tuple[int, int], Any],
max_rows: int,
max_cols: int,
) -> tuple[list[list[Any]], bool, bool]:
min_row, min_col, max_row, max_col = bounds
row_limit = max_row
col_limit = max_col
rows_truncated = False
cols_truncated = False
if max_rows > 0 and (max_row - min_row + 1) > max_rows:
row_limit = min_row + max_rows - 1
rows_truncated = True
if max_cols > 0 and (max_col - min_col + 1) > max_cols:
col_limit = min_col + max_cols - 1
cols_truncated = True
rows: list[list[Any]] = []
width = 0
for row_index in range(min_row, row_limit + 1):
row = [
_cell_value(worksheet, row_index, col_index, merged_values)
for col_index in range(min_col, col_limit + 1)
]
row = _trim_trailing_empty_cells(row)
width = max(width, len(row))
rows.append(row)
if width == 0:
return [], rows_truncated, cols_truncated
normalized_rows = [row + [""] * (width - len(row)) for row in rows]
return normalized_rows, rows_truncated, cols_truncated
def _column_alignments(rows: list[list[Any]]) -> list[str]:
if not rows:
return []
width = len(rows[0])
alignments: list[str] = []
data_rows = rows[1:] if len(rows) > 1 else rows
for col_index in range(width):
values = [row[col_index] for row in data_rows if not _is_empty(row[col_index])]
if values and all(_is_numeric_value(value) for value in values):
alignments.append("---:")
else:
alignments.append("---")
return alignments
def _rows_to_markdown_table(rows: list[list[Any]]) -> str:
if not rows:
return "_No tabular content found._"
formatted_rows = [[_format_cell_value(value) for value in row] for row in rows]
width = len(formatted_rows[0])
separator = _column_alignments(rows)
lines = [
"| " + " | ".join(formatted_rows[0]) + " |",
"| " + " | ".join(separator or ["---"] * width) + " |",
]
for row in formatted_rows[1:]:
lines.append("| " + " | ".join(row) + " |")
return "\n".join(lines)
# ─────────────────────────────────────────────────────────────
# Excel → Markdown
# ─────────────────────────────────────────────────────────────
def _convert_excel(input_file: Path, out_file: Path, max_rows: int, max_cols: int) -> str:
try:
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
except ImportError:
print("[ERROR] openpyxl not installed. Run: pip install openpyxl")
return ""
workbook = load_workbook(input_file, data_only=True, read_only=False)
visible_sheets = [sheet for sheet in workbook.worksheets if sheet.sheet_state == "visible"]
lines: list[str] = [
f"# Spreadsheet Source: {input_file.name}",
"",
"## Workbook Summary",
"",
f"- Sheets: {len(workbook.worksheets)}",
f"- Visible sheets: {', '.join(sheet.title for sheet in visible_sheets) or 'None'}",
"",
"> Note: Formula cells are exported as cached values. This converter does not recalculate formulas.",
"",
]
if not visible_sheets:
lines.extend(["_No visible sheets found._", ""])
for worksheet in visible_sheets:
merged_values = _merged_value_map(worksheet)
bounds = _content_bounds(worksheet, merged_values)
lines.extend([
f"## Sheet: {worksheet.title}",
"",
f"- State: {_sheet_state_label(worksheet.sheet_state)}",
])
if bounds is None:
lines.extend(["", "_No content found._", ""])
continue
min_row, min_col, max_row, max_col = bounds
used_range = (
f"{get_column_letter(min_col)}{min_row}:"
f"{get_column_letter(max_col)}{max_row}"
)
rows, rows_truncated, cols_truncated = _extract_rows(
worksheet,
bounds,
merged_values,
max_rows=max_rows,
max_cols=max_cols,
)
lines.extend([
f"- Used range: {used_range}",
f"- Rows: {max_row - min_row + 1}",
f"- Columns: {max_col - min_col + 1}",
"",
])
if rows_truncated or cols_truncated:
limit_notes = []
if rows_truncated:
limit_notes.append(f"rows limited to {max_rows}")
if cols_truncated:
limit_notes.append(f"columns limited to {max_cols}")
lines.extend([f"> Truncated: {', '.join(limit_notes)}.", ""])
lines.extend([_rows_to_markdown_table(rows), ""])
markdown = "\n".join(lines).rstrip() + "\n"
out_file.write_text(markdown, encoding="utf-8")
_report_result(out_file)
return markdown
# ─────────────────────────────────────────────────────────────
# Dispatcher
# ─────────────────────────────────────────────────────────────
def convert_to_markdown(
input_path: str,
output_path: str | None = None,
max_rows: int = 0,
max_cols: int = 0,
) -> str:
input_file = Path(input_path)
if not input_file.exists():
print(f"[ERROR] File not found: {input_path}")
return ""
suffix = input_file.suffix.lower()
if suffix in LEGACY_EXCEL_FORMATS:
print("[ERROR] Unsupported legacy Excel format: .xls")
print(" Please resave the workbook as .xlsx and run this converter again.")
return ""
if suffix not in EXCEL_FORMATS:
supported = ", ".join(sorted(EXCEL_FORMATS))
print(f"[ERROR] Unsupported format: {suffix}")
print(f" Supported: {supported}")
return ""
if max_rows < 0 or max_cols < 0:
print("[ERROR] --max-rows and --max-cols must be zero or positive integers")
return ""
out_file = Path(output_path) if output_path else input_file.with_suffix(".md")
out_file.parent.mkdir(parents=True, exist_ok=True)
print(f"[INFO] Converting Excel workbook: {input_file.name}")
markdown = _convert_excel(input_file, out_file, max_rows=max_rows, max_cols=max_cols)
if markdown:
profile_path = write_conversion_profile_best_effort(
input_path=str(input_file),
markdown_path=out_file,
converter="excel_to_md.py",
conversion_type=suffix.lstrip("."),
)
if profile_path:
print(f" Wrote conversion profile -> {profile_path}")
return markdown
def main() -> int:
parser = argparse.ArgumentParser(
description="Convert Excel workbooks to Markdown",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python excel_to_md.py report.xlsx
python excel_to_md.py report.xlsx budget.xlsm
python excel_to_md.py ./workbooks -o ./markdown
python excel_to_md.py report.xlsx -o output.md
python excel_to_md.py report.xlsm --max-rows 200 --max-cols 40
Supported formats:
.xlsx .xlsm
Unsupported by default:
.xls Resave as .xlsx first
""",
)
parser.add_argument("inputs", nargs="+", help="Input Excel workbook(s) or directories")
parser.add_argument(
"-o",
"--output",
help="Output Markdown file for one input, or output directory for multiple inputs/directories",
)
parser.add_argument(
"--max-rows",
type=int,
default=0,
help="Maximum rows per sheet to export (0 = no limit)",
)
parser.add_argument(
"--max-cols",
type=int,
default=0,
help="Maximum columns per sheet to export (0 = no limit)",
)
args = parser.parse_args()
return run_path_batch(
args.inputs,
EXCEL_FORMATS | LEGACY_EXCEL_FORMATS,
args.output,
lambda source, output: bool(
convert_to_markdown(
str(source),
str(output),
max_rows=args.max_rows,
max_cols=args.max_cols,
)
),
)
if __name__ == "__main__":
raise SystemExit(main())