morphconv 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- morph/__init__.py +0 -0
- morph/batch.py +492 -0
- morph/cli.py +703 -0
- morph/converters/__init__.py +19 -0
- morph/converters/archives.py +85 -0
- morph/converters/audio.py +58 -0
- morph/converters/csv_json.py +28 -0
- morph/converters/csv_to_xlsx.py +290 -0
- morph/converters/documents.py +252 -0
- morph/converters/ebooks.py +36 -0
- morph/converters/fonts.py +72 -0
- morph/converters/images.py +119 -0
- morph/converters/json_yaml.py +26 -0
- morph/converters/models_3d.py +149 -0
- morph/converters/ocr.py +49 -0
- morph/converters/pandas_extra.py +127 -0
- morph/converters/svg.py +63 -0
- morph/converters/video.py +153 -0
- morph/converters/vtracer_converter.py +84 -0
- morph/converters/web.py +92 -0
- morph/converters/xlsx_to_csv.py +272 -0
- morph/deps.py +228 -0
- morph/ffmpeg_utils.py +79 -0
- morph/history.py +136 -0
- morph/progress.py +78 -0
- morph/registry.py +236 -0
- morph/tui.py +792 -0
- morphconv-1.0.1.dist-info/METADATA +396 -0
- morphconv-1.0.1.dist-info/RECORD +33 -0
- morphconv-1.0.1.dist-info/WHEEL +5 -0
- morphconv-1.0.1.dist-info/entry_points.txt +2 -0
- morphconv-1.0.1.dist-info/licenses/LICENSE +21 -0
- morphconv-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-discovers and imports every converter module in this package.
|
|
3
|
+
|
|
4
|
+
Dropping a new file in morph/converters/ (that calls `register(...)` at
|
|
5
|
+
import time) is enough to add a conversion — nothing else needs editing.
|
|
6
|
+
Files prefixed with `_` are treated as private engines (e.g. _csv_to_excel.py)
|
|
7
|
+
and are not auto-imported themselves; they're imported by whichever module
|
|
8
|
+
wraps them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib
|
|
14
|
+
import pkgutil
|
|
15
|
+
|
|
16
|
+
for _finder, _name, _ispkg in pkgutil.iter_modules(__path__):
|
|
17
|
+
if _name.startswith("_"):
|
|
18
|
+
continue
|
|
19
|
+
importlib.import_module(f"{__name__}.{_name}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
converters/archives.py — archive family (zip <-> tar variants), stdlib only.
|
|
3
|
+
|
|
4
|
+
Conversion here means: extract every member from the source archive, then
|
|
5
|
+
repack them into the destination format, preserving relative paths.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tarfile
|
|
11
|
+
import tempfile
|
|
12
|
+
import zipfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..registry import ConversionResult, OptionSpec, register
|
|
16
|
+
|
|
17
|
+
# format id -> tarfile mode suffix ("" for plain tar, or compression codec)
|
|
18
|
+
_TAR_MODES = {"tar": "", "tar.gz": "gz", "tar.bz2": "bz2", "tar.xz": "xz"}
|
|
19
|
+
FORMATS = ["zip", *_TAR_MODES.keys()]
|
|
20
|
+
|
|
21
|
+
OPTIONS = [
|
|
22
|
+
OptionSpec("strip_top_level", ("--strip-top-level",),
|
|
23
|
+
"Drop a single shared top-level folder when repacking, if every member has one.",
|
|
24
|
+
default=False, action="store_true"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extract(input_path: Path, dst_dir: Path, fmt: str) -> None:
|
|
29
|
+
if fmt == "zip":
|
|
30
|
+
with zipfile.ZipFile(input_path) as zf:
|
|
31
|
+
zf.extractall(dst_dir)
|
|
32
|
+
else:
|
|
33
|
+
with tarfile.open(input_path, f"r:{_TAR_MODES[fmt]}" if _TAR_MODES[fmt] else "r:") as tf:
|
|
34
|
+
tf.extractall(dst_dir, filter="data")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _maybe_strip_top_level(root: Path) -> Path:
|
|
38
|
+
entries = list(root.iterdir())
|
|
39
|
+
if len(entries) == 1 and entries[0].is_dir():
|
|
40
|
+
return entries[0]
|
|
41
|
+
return root
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _pack(src_dir: Path, output_path: Path, fmt: str) -> int:
|
|
45
|
+
count = 0
|
|
46
|
+
if fmt == "zip":
|
|
47
|
+
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
48
|
+
for f in src_dir.rglob("*"):
|
|
49
|
+
if f.is_file():
|
|
50
|
+
zf.write(f, f.relative_to(src_dir))
|
|
51
|
+
count += 1
|
|
52
|
+
else:
|
|
53
|
+
mode = f"w:{_TAR_MODES[fmt]}" if _TAR_MODES[fmt] else "w"
|
|
54
|
+
with tarfile.open(output_path, mode) as tf:
|
|
55
|
+
for f in src_dir.rglob("*"):
|
|
56
|
+
if f.is_file():
|
|
57
|
+
tf.add(f, f.relative_to(src_dir))
|
|
58
|
+
count += 1
|
|
59
|
+
return count
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _make_converter(src_fmt: str, dst_fmt: str):
|
|
63
|
+
def _convert(input_path: Path, output_path: Path, *, strip_top_level: bool = False,
|
|
64
|
+
**_options) -> ConversionResult:
|
|
65
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
66
|
+
tmp_path = Path(tmp)
|
|
67
|
+
_extract(input_path, tmp_path, src_fmt)
|
|
68
|
+
pack_root = _maybe_strip_top_level(tmp_path) if strip_top_level else tmp_path
|
|
69
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
n_files = _pack(pack_root, output_path, dst_fmt)
|
|
71
|
+
return ConversionResult(output=output_path, extra={"files": n_files})
|
|
72
|
+
return _convert
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
for _src in FORMATS:
|
|
76
|
+
for _dst in FORMATS:
|
|
77
|
+
if _dst == _src:
|
|
78
|
+
continue
|
|
79
|
+
register(
|
|
80
|
+
_src, _dst,
|
|
81
|
+
backend="stdlib",
|
|
82
|
+
family="archive",
|
|
83
|
+
description=f"{_src} → {_dst}",
|
|
84
|
+
options=OPTIONS,
|
|
85
|
+
)(_make_converter(_src, _dst))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
converters/audio.py — audio family, powered by ffmpeg.
|
|
3
|
+
|
|
4
|
+
ffmpeg picks a sensible default codec per container from the output
|
|
5
|
+
extension alone in almost all cases, so the base conversion needs no
|
|
6
|
+
codec-specific logic — options here just override the defaults.
|
|
7
|
+
|
|
8
|
+
Every hop reports real fractional progress (not just a spinner) via
|
|
9
|
+
ffmpeg's -progress stream, parsed in ffmpeg_utils.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..ffmpeg_utils import ProgressCallback, run_ffmpeg
|
|
18
|
+
from ..registry import ConversionResult, OptionSpec, register
|
|
19
|
+
|
|
20
|
+
FORMATS = ["mp3", "wav", "flac", "ogg", "aac", "m4a", "opus", "wma"]
|
|
21
|
+
|
|
22
|
+
OPTIONS = [
|
|
23
|
+
OptionSpec("bitrate", ("-b", "--bitrate"), "Audio bitrate, e.g. '192k'. Default: ffmpeg's per-codec default."),
|
|
24
|
+
OptionSpec("sample_rate", ("--sample-rate",), "Output sample rate in Hz, e.g. 44100.", type=int),
|
|
25
|
+
OptionSpec("channels", ("--channels",), "Output channel count: 1 (mono) or 2 (stereo).", type=int),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _audio_convert(input_path: Path, output_path: Path, *, bitrate: Optional[str] = None,
|
|
30
|
+
sample_rate: Optional[int] = None, channels: Optional[int] = None,
|
|
31
|
+
_progress: Optional[ProgressCallback] = None, **_options) -> ConversionResult:
|
|
32
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-i", str(input_path)]
|
|
34
|
+
if bitrate:
|
|
35
|
+
cmd += ["-b:a", bitrate]
|
|
36
|
+
if sample_rate:
|
|
37
|
+
cmd += ["-ar", str(sample_rate)]
|
|
38
|
+
if channels:
|
|
39
|
+
cmd += ["-ac", str(channels)]
|
|
40
|
+
cmd.append(str(output_path))
|
|
41
|
+
run_ffmpeg(cmd, input_path=input_path, progress=_progress)
|
|
42
|
+
return ConversionResult(output=output_path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
for _src in FORMATS:
|
|
46
|
+
for _dst in FORMATS:
|
|
47
|
+
if _dst == _src:
|
|
48
|
+
continue
|
|
49
|
+
register(
|
|
50
|
+
_src, _dst,
|
|
51
|
+
backend="ffmpeg",
|
|
52
|
+
requires_binary="ffmpeg",
|
|
53
|
+
family="audio",
|
|
54
|
+
description=f"{_src} → {_dst} (ffmpeg)",
|
|
55
|
+
lossy=(_dst in ("mp3", "aac", "ogg", "m4a")),
|
|
56
|
+
options=OPTIONS,
|
|
57
|
+
supports_progress=True,
|
|
58
|
+
)(_audio_convert)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""converters/csv_json.py — csv <-> json, native pandas implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from ..registry import ConversionResult, register
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@register("csv", "json", backend="native", family="data", description="csv → json (records)")
|
|
14
|
+
def csv_to_json(input_path: Path, output_path: Path, **options) -> ConversionResult:
|
|
15
|
+
df = pd.read_csv(input_path, dtype=str, keep_default_na=True)
|
|
16
|
+
records = json.loads(df.to_json(orient="records", date_format="iso"))
|
|
17
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
output_path.write_text(json.dumps(records, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
19
|
+
return ConversionResult(output=output_path, rows=len(df))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@register("json", "csv", backend="native", family="data", description="json (list of records) → csv", lossy=True)
|
|
23
|
+
def json_to_csv(input_path: Path, output_path: Path, **options) -> ConversionResult:
|
|
24
|
+
data = json.loads(input_path.read_text(encoding="utf-8"))
|
|
25
|
+
df = pd.json_normalize(data)
|
|
26
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
df.to_csv(output_path, index=False)
|
|
28
|
+
return ConversionResult(output=output_path, rows=len(df))
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
converters/csv_to_xlsx.py — csv -> xlsx, a fully self-contained converter.
|
|
3
|
+
|
|
4
|
+
Everything needed to turn a CSV into a polished, styled workbook lives in
|
|
5
|
+
this one file: encoding/delimiter sniffing, type inference, styling engine,
|
|
6
|
+
and the register() call with its full OptionSpec flag surface. No hidden
|
|
7
|
+
private engine module — what you see here is what runs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, cast
|
|
16
|
+
|
|
17
|
+
import chardet
|
|
18
|
+
import openpyxl
|
|
19
|
+
import openpyxl.cell
|
|
20
|
+
import pandas as pd
|
|
21
|
+
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
22
|
+
from openpyxl.utils import get_column_letter
|
|
23
|
+
from openpyxl.worksheet.table import Table, TableStyleInfo
|
|
24
|
+
|
|
25
|
+
from ..registry import ConversionResult, OptionSpec, register
|
|
26
|
+
|
|
27
|
+
# ─────────────────────────── helpers ────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def _detect_encoding(path: Path) -> str:
|
|
30
|
+
"""Return best-guess encoding using chardet."""
|
|
31
|
+
raw = path.read_bytes()
|
|
32
|
+
result = chardet.detect(raw)
|
|
33
|
+
enc = result.get("encoding") or "utf-8"
|
|
34
|
+
if enc.lower() == "ascii":
|
|
35
|
+
enc = "utf-8"
|
|
36
|
+
return enc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _detect_delimiter(path: Path, encoding: str) -> str:
|
|
40
|
+
"""Sniff the CSV delimiter; fall back to comma."""
|
|
41
|
+
try:
|
|
42
|
+
sample = path.read_text(encoding=encoding, errors="replace")[:8192]
|
|
43
|
+
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
|
|
44
|
+
return dialect.delimiter
|
|
45
|
+
except csv.Error:
|
|
46
|
+
return ","
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _infer_types(df: pd.DataFrame, date_input_fmt: Optional[str] = None) -> pd.DataFrame:
|
|
50
|
+
"""Coerce every column to the tightest appropriate type:
|
|
51
|
+
bool -> bool, numeric -> float/int, datetime -> datetime, else str."""
|
|
52
|
+
bool_map: dict[str, bool] = {
|
|
53
|
+
"true": True, "false": False,
|
|
54
|
+
"yes": True, "no": False,
|
|
55
|
+
"1": True, "0": False,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for col in df.columns:
|
|
59
|
+
series: pd.Series = cast(pd.Series, df[col])
|
|
60
|
+
|
|
61
|
+
lower: pd.Series = cast(pd.Series, series.dropna()).astype(str).str.strip().str.lower()
|
|
62
|
+
if len(lower) > 0 and lower.isin(bool_map).all():
|
|
63
|
+
_bm = bool_map
|
|
64
|
+
df[col] = series.map(
|
|
65
|
+
lambda v, bm=_bm: bm.get(str(v).strip().lower(), v) # type: ignore[return-value]
|
|
66
|
+
if pd.notna(v) else v # type: ignore[arg-type]
|
|
67
|
+
)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
num: pd.Series = pd.to_numeric(series, errors="coerce") # type: ignore[assignment]
|
|
71
|
+
if num.notna().sum() / max(len(series), 1) >= 0.9:
|
|
72
|
+
df[col] = num
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
if date_input_fmt:
|
|
77
|
+
dt: pd.Series = pd.to_datetime( # type: ignore[assignment]
|
|
78
|
+
series, format=date_input_fmt, errors="coerce"
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
dt = pd.to_datetime(series, format="mixed", errors="coerce") # type: ignore[assignment]
|
|
82
|
+
if dt.notna().sum() / max(len(series), 1) >= 0.8:
|
|
83
|
+
df[col] = dt
|
|
84
|
+
continue
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
return df
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _px_width(text: str) -> float:
|
|
92
|
+
return max(len(str(text)) + 2, 10)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _apply_table_style(ws, n_rows: int, n_cols: int, table_style: str = "TableStyleMedium9") -> None:
|
|
96
|
+
if n_rows < 1:
|
|
97
|
+
return
|
|
98
|
+
ref = f"A1:{get_column_letter(n_cols)}{n_rows + 1}"
|
|
99
|
+
tbl = Table(displayName=f"Table_{ws.title.replace(' ', '_')}", ref=ref)
|
|
100
|
+
style = TableStyleInfo(name=table_style, showFirstColumn=False, showLastColumn=False,
|
|
101
|
+
showRowStripes=True, showColumnStripes=False)
|
|
102
|
+
tbl.tableStyleInfo = style
|
|
103
|
+
ws.add_table(tbl)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _style_header(ws, n_cols: int, header_bg: str = "1F3864", header_fg: str = "FFFFFF") -> None:
|
|
107
|
+
fill = PatternFill("solid", fgColor=header_bg)
|
|
108
|
+
font = Font(bold=True, color=header_fg, size=11)
|
|
109
|
+
alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
110
|
+
border = Border(bottom=Side(style="medium", color="FFFFFF"))
|
|
111
|
+
for col_idx in range(1, n_cols + 1):
|
|
112
|
+
cell = ws.cell(row=1, column=col_idx)
|
|
113
|
+
cell.fill = fill
|
|
114
|
+
cell.font = font
|
|
115
|
+
cell.alignment = alignment
|
|
116
|
+
cell.border = border
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _apply_row_banding(ws, n_rows: int, n_cols: int, band_color: str = "EEF2FF") -> None:
|
|
120
|
+
fill = PatternFill("solid", fgColor=band_color)
|
|
121
|
+
for row_idx in range(3, n_rows + 2, 2):
|
|
122
|
+
for col_idx in range(1, n_cols + 1):
|
|
123
|
+
ws.cell(row=row_idx, column=col_idx).fill = fill
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _autofit_columns(ws, df: pd.DataFrame) -> None:
|
|
127
|
+
for col_idx, col_name in enumerate(df.columns, start=1):
|
|
128
|
+
col_letter = get_column_letter(col_idx)
|
|
129
|
+
max_w = _px_width(col_name)
|
|
130
|
+
for val in df[col_name]:
|
|
131
|
+
max_w = max(max_w, _px_width(val))
|
|
132
|
+
ws.column_dimensions[col_letter].width = min(max_w, 60)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _apply_number_formats(ws, df: pd.DataFrame) -> None:
|
|
136
|
+
for col_idx, col_name in enumerate(df.columns, start=1):
|
|
137
|
+
dtype = df[col_name].dtype
|
|
138
|
+
fmt = None
|
|
139
|
+
if pd.api.types.is_datetime64_any_dtype(dtype):
|
|
140
|
+
fmt = "YYYY-MM-DD HH:MM:SS"
|
|
141
|
+
elif pd.api.types.is_float_dtype(dtype):
|
|
142
|
+
fmt = "#,##0.00"
|
|
143
|
+
elif pd.api.types.is_integer_dtype(dtype):
|
|
144
|
+
fmt = "#,##0"
|
|
145
|
+
if fmt:
|
|
146
|
+
for row_idx in range(2, ws.max_row + 1):
|
|
147
|
+
ws.cell(row=row_idx, column=col_idx).number_format = fmt
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _write_workbook(
|
|
151
|
+
input_path: Path, output_path: Path, *,
|
|
152
|
+
sheet_name: str = "Sheet1", delimiter: Optional[str] = None, encoding: Optional[str] = None,
|
|
153
|
+
header: bool = True, infer_types: bool = True, date_input_fmt: Optional[str] = None,
|
|
154
|
+
freeze_header: bool = True, freeze_cols: int = 0, add_table: bool = True,
|
|
155
|
+
table_style: str = "TableStyleMedium9", row_banding: bool = True, band_color: str = "EEF2FF",
|
|
156
|
+
header_bg: str = "1F3864", header_fg: str = "FFFFFF", autofit: bool = True,
|
|
157
|
+
number_formats: bool = True, zoom: int = 100, row_height: Optional[float] = None,
|
|
158
|
+
password: Optional[str] = None, workbook_password: Optional[str] = None,
|
|
159
|
+
append_to_existing: bool = False, skiprows: int = 0, nrows: Optional[int] = None,
|
|
160
|
+
usecols: Optional[str] = None,
|
|
161
|
+
) -> dict:
|
|
162
|
+
"""Convert a single CSV file to a styled Excel sheet. Returns {rows, cols, sheet, output}."""
|
|
163
|
+
enc = encoding or _detect_encoding(input_path)
|
|
164
|
+
delim = delimiter or _detect_delimiter(input_path, enc)
|
|
165
|
+
|
|
166
|
+
read_kwargs: dict = dict(
|
|
167
|
+
filepath_or_buffer=input_path, sep=delim, encoding=enc,
|
|
168
|
+
header=0 if header else None, skiprows=skiprows if skiprows else None,
|
|
169
|
+
nrows=nrows, dtype=str, keep_default_na=True, on_bad_lines="warn",
|
|
170
|
+
)
|
|
171
|
+
if usecols:
|
|
172
|
+
cols = [c.strip() for c in usecols.split(",")]
|
|
173
|
+
try:
|
|
174
|
+
cols = [int(c) for c in cols]
|
|
175
|
+
except ValueError:
|
|
176
|
+
pass
|
|
177
|
+
read_kwargs["usecols"] = cols
|
|
178
|
+
|
|
179
|
+
df = pd.read_csv(**read_kwargs)
|
|
180
|
+
|
|
181
|
+
if infer_types:
|
|
182
|
+
df = _infer_types(df, date_input_fmt=date_input_fmt)
|
|
183
|
+
|
|
184
|
+
n_rows, n_cols = df.shape
|
|
185
|
+
|
|
186
|
+
if append_to_existing and output_path.exists():
|
|
187
|
+
wb = openpyxl.load_workbook(output_path)
|
|
188
|
+
existing = wb.sheetnames
|
|
189
|
+
base = sheet_name
|
|
190
|
+
suffix = 1
|
|
191
|
+
while sheet_name in existing:
|
|
192
|
+
sheet_name = f"{base}_{suffix}"
|
|
193
|
+
suffix += 1
|
|
194
|
+
ws = wb.create_sheet(title=sheet_name)
|
|
195
|
+
else:
|
|
196
|
+
wb = openpyxl.Workbook()
|
|
197
|
+
ws = wb.active
|
|
198
|
+
assert ws is not None
|
|
199
|
+
ws.title = sheet_name
|
|
200
|
+
|
|
201
|
+
if header:
|
|
202
|
+
for col_idx, col_name in enumerate(df.columns, start=1):
|
|
203
|
+
ws.cell(row=1, column=col_idx, value=str(col_name))
|
|
204
|
+
|
|
205
|
+
for row_idx, row in enumerate(df.itertuples(index=False), start=2):
|
|
206
|
+
for col_idx, value in enumerate(row, start=1):
|
|
207
|
+
cell = cast(openpyxl.cell.Cell, ws.cell(row=row_idx, column=col_idx))
|
|
208
|
+
if pd.isna(value) if not isinstance(value, str) else False: # type: ignore[arg-type]
|
|
209
|
+
cell.value = None
|
|
210
|
+
elif isinstance(value, (int, float, bool)):
|
|
211
|
+
cell.value = value
|
|
212
|
+
elif isinstance(value, datetime):
|
|
213
|
+
cell.value = value
|
|
214
|
+
else:
|
|
215
|
+
cell.value = str(value) if value is not None else None
|
|
216
|
+
|
|
217
|
+
if header and n_rows > 0:
|
|
218
|
+
_style_header(ws, n_cols, header_bg=header_bg, header_fg=header_fg)
|
|
219
|
+
if row_banding and n_rows > 0:
|
|
220
|
+
_apply_row_banding(ws, n_rows, n_cols, band_color=band_color)
|
|
221
|
+
if autofit:
|
|
222
|
+
_autofit_columns(ws, df)
|
|
223
|
+
if number_formats:
|
|
224
|
+
_apply_number_formats(ws, df)
|
|
225
|
+
|
|
226
|
+
if freeze_header or freeze_cols > 0:
|
|
227
|
+
freeze_row = 2 if freeze_header and header else 1
|
|
228
|
+
freeze_col = freeze_cols + 1
|
|
229
|
+
ws.freeze_panes = f"{get_column_letter(freeze_col)}{freeze_row}"
|
|
230
|
+
|
|
231
|
+
if add_table and header and n_rows > 0:
|
|
232
|
+
_apply_table_style(ws, n_rows, n_cols, table_style=table_style)
|
|
233
|
+
|
|
234
|
+
ws.row_dimensions[1].height = 20
|
|
235
|
+
if row_height is not None:
|
|
236
|
+
for row_idx in range(2, n_rows + 2):
|
|
237
|
+
ws.row_dimensions[row_idx].height = row_height
|
|
238
|
+
|
|
239
|
+
ws.sheet_view.zoomScale = max(10, min(zoom, 400))
|
|
240
|
+
|
|
241
|
+
if password:
|
|
242
|
+
ws.protection.sheet = True
|
|
243
|
+
ws.protection.password = password
|
|
244
|
+
ws.protection.enable()
|
|
245
|
+
|
|
246
|
+
if workbook_password:
|
|
247
|
+
wb.security.workbookPassword = workbook_password
|
|
248
|
+
wb.security.lockStructure = True
|
|
249
|
+
|
|
250
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
wb.save(output_path)
|
|
252
|
+
|
|
253
|
+
return {"rows": n_rows, "cols": n_cols, "sheet": ws.title, "output": output_path}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ─────────────────────────── registry wiring ────────────────────────────────
|
|
257
|
+
|
|
258
|
+
OPTIONS = [
|
|
259
|
+
OptionSpec("delimiter", ("-d", "--delimiter"), "Field delimiter. Auto-detected if omitted."),
|
|
260
|
+
OptionSpec("encoding", ("-e", "--encoding"), "Input encoding. Auto-detected if omitted."),
|
|
261
|
+
OptionSpec("header", ("--no-header",), "Treat first row as data, not a header.", default=True, action="store_false"),
|
|
262
|
+
OptionSpec("skiprows", ("--skip-rows",), "Rows to skip at the top of the CSV.", default=0, type=int),
|
|
263
|
+
OptionSpec("nrows", ("--nrows",), "Maximum data rows to read.", type=int),
|
|
264
|
+
OptionSpec("usecols", ("--cols",), "Comma-separated column names or indices to include."),
|
|
265
|
+
OptionSpec("date_input_fmt", ("--date-fmt-in",), "strftime format of dates in the CSV."),
|
|
266
|
+
OptionSpec("sheet_name", ("-s", "--sheet"), "Sheet name in the output workbook.", default="Sheet1"),
|
|
267
|
+
OptionSpec("infer_types", ("--no-infer",), "Disable smart type inference.", default=True, action="store_false"),
|
|
268
|
+
OptionSpec("freeze_header", ("--no-freeze",), "Disable frozen header row.", default=True, action="store_false"),
|
|
269
|
+
OptionSpec("freeze_cols", ("--freeze-cols",), "Left-most columns to freeze.", default=0, type=int),
|
|
270
|
+
OptionSpec("add_table", ("--no-table",), "Disable Excel Table / auto-filter.", default=True, action="store_false"),
|
|
271
|
+
OptionSpec("table_style", ("--table-style",), "Excel table style name.", default="TableStyleMedium9"),
|
|
272
|
+
OptionSpec("row_banding", ("--no-banding",), "Disable alternating row banding.", default=True, action="store_false"),
|
|
273
|
+
OptionSpec("band_color", ("--band-color",), "Hex colour for banded rows (no #).", default="EEF2FF"),
|
|
274
|
+
OptionSpec("autofit", ("--no-autofit",), "Disable auto column width.", default=True, action="store_false"),
|
|
275
|
+
OptionSpec("number_formats", ("--no-formats",), "Disable number/date format strings.", default=True, action="store_false"),
|
|
276
|
+
OptionSpec("header_bg", ("--header-bg",), "Header background hex colour (no #).", default="1F3864"),
|
|
277
|
+
OptionSpec("header_fg", ("--header-fg",), "Header text hex colour (no #).", default="FFFFFF"),
|
|
278
|
+
OptionSpec("zoom", ("--zoom",), "Sheet zoom level (10-400).", default=100, type=int),
|
|
279
|
+
OptionSpec("row_height", ("--row-height",), "Data row height in points.", type=float),
|
|
280
|
+
OptionSpec("password", ("--password",), "Password-protect the output sheet."),
|
|
281
|
+
OptionSpec("workbook_password", ("--wb-password",), "Password-protect the workbook structure."),
|
|
282
|
+
OptionSpec("append_to_existing", ("-a", "--append"), "Append as a new sheet into an existing workbook.",
|
|
283
|
+
default=False, action="store_true"),
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
@register("csv", "xlsx", backend="native", family="data",
|
|
287
|
+
description="csv → xlsx (styled workbook)", options=OPTIONS)
|
|
288
|
+
def csv_to_xlsx(input_path: Path, output_path: Path, **options) -> ConversionResult:
|
|
289
|
+
stats = _write_workbook(input_path, output_path, **options)
|
|
290
|
+
return ConversionResult(output=stats["output"], rows=stats["rows"])
|