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.
@@ -0,0 +1,49 @@
1
+ """
2
+ converters/ocr.py — OCR extraction from images via pytesseract (Tesseract).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import pytesseract
10
+ from PIL import Image
11
+
12
+ from ..registry import ConversionResult, OptionSpec, register
13
+ from .images import FORMATS as IMAGE_FORMATS
14
+
15
+ _OCR_OPTIONS = [
16
+ OptionSpec("lang", ("-l", "--lang"), "Language code(s) for OCR, e.g. 'eng', 'eng+fra'. Default: 'eng'."),
17
+ OptionSpec("psm", ("--psm",), "Page Segmentation Mode (0-13). Default: 3 (Fully automatic).", type=int),
18
+ ]
19
+
20
+ def _extract_text(input_path: Path, output_path: Path, *, lang: Optional[str] = None, psm: Optional[int] = None, **_options) -> ConversionResult:
21
+ try:
22
+ img = Image.open(input_path)
23
+ except Exception as e:
24
+ raise RuntimeError(f"Failed to open image for OCR: {e}")
25
+
26
+ # Build config string
27
+ config = ""
28
+ if psm is not None:
29
+ config += f"--psm {psm} "
30
+
31
+ # Run OCR
32
+ try:
33
+ text = pytesseract.image_to_string(img, lang=lang, config=config.strip())
34
+ except pytesseract.TesseractNotFoundError:
35
+ raise RuntimeError("tesseract executable not found. Please install Tesseract OCR and ensure it is on your PATH.")
36
+ except Exception as e:
37
+ raise RuntimeError(f"OCR failed: {e}")
38
+
39
+ output_path.parent.mkdir(parents=True, exist_ok=True)
40
+ output_path.write_text(text, encoding="utf-8")
41
+
42
+ return ConversionResult(output=output_path)
43
+
44
+ # Register from all valid image inputs to txt
45
+ for _src in IMAGE_FORMATS:
46
+ register(
47
+ _src, "txt", backend="tesseract", requires_binary="tesseract", family="ocr",
48
+ description=f"{_src} → txt (OCR)", lossy=True, options=_OCR_OPTIONS
49
+ )(_extract_text)
@@ -0,0 +1,127 @@
1
+ """
2
+ converters/pandas_extra.py — additional data formats supported natively by pandas.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+ import pandas as pd
8
+ from sqlalchemy import create_engine, inspect
9
+
10
+ from ..registry import ConversionResult, register, OptionSpec
11
+
12
+ def _read_csv(path: Path) -> pd.DataFrame:
13
+ return pd.read_csv(path, keep_default_na=True)
14
+
15
+ # ── Parquet ───────────────────────────────────────────────────────────────────
16
+ @register("csv", "parquet", backend="native", family="data", description="csv → parquet")
17
+ def csv_to_parquet(input_path: Path, output_path: Path, **options) -> ConversionResult:
18
+ df = _read_csv(input_path)
19
+ output_path.parent.mkdir(parents=True, exist_ok=True)
20
+ df.to_parquet(output_path, index=False)
21
+ return ConversionResult(output=output_path, rows=len(df))
22
+
23
+ @register("parquet", "csv", backend="native", family="data", description="parquet → csv")
24
+ def parquet_to_csv(input_path: Path, output_path: Path, **options) -> ConversionResult:
25
+ df = pd.read_parquet(input_path)
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))
29
+
30
+ # ── Feather ───────────────────────────────────────────────────────────────────
31
+ @register("csv", "feather", backend="native", family="data", description="csv → feather")
32
+ def csv_to_feather(input_path: Path, output_path: Path, **options) -> ConversionResult:
33
+ df = _read_csv(input_path)
34
+ output_path.parent.mkdir(parents=True, exist_ok=True)
35
+ df.to_feather(output_path)
36
+ return ConversionResult(output=output_path, rows=len(df))
37
+
38
+ @register("feather", "csv", backend="native", family="data", description="feather → csv")
39
+ def feather_to_csv(input_path: Path, output_path: Path, **options) -> ConversionResult:
40
+ df = pd.read_feather(input_path)
41
+ output_path.parent.mkdir(parents=True, exist_ok=True)
42
+ df.to_csv(output_path, index=False)
43
+ return ConversionResult(output=output_path, rows=len(df))
44
+
45
+ # ── ODS ───────────────────────────────────────────────────────────────────────
46
+ @register("csv", "ods", backend="native", family="data", description="csv → ods")
47
+ def csv_to_ods(input_path: Path, output_path: Path, **options) -> ConversionResult:
48
+ df = _read_csv(input_path)
49
+ output_path.parent.mkdir(parents=True, exist_ok=True)
50
+ df.to_excel(output_path, index=False, engine="odf")
51
+ return ConversionResult(output=output_path, rows=len(df))
52
+
53
+ @register("ods", "csv", backend="native", family="data", description="ods → csv")
54
+ def ods_to_csv(input_path: Path, output_path: Path, **options) -> ConversionResult:
55
+ df = pd.read_excel(input_path, engine="odf")
56
+ output_path.parent.mkdir(parents=True, exist_ok=True)
57
+ df.to_csv(output_path, index=False)
58
+ return ConversionResult(output=output_path, rows=len(df))
59
+
60
+ # ── XML ───────────────────────────────────────────────────────────────────────
61
+ @register("csv", "xml", backend="native", family="data", description="csv → xml")
62
+ def csv_to_xml(input_path: Path, output_path: Path, **options) -> ConversionResult:
63
+ df = _read_csv(input_path)
64
+ output_path.parent.mkdir(parents=True, exist_ok=True)
65
+ df.to_xml(output_path, index=False)
66
+ return ConversionResult(output=output_path, rows=len(df))
67
+
68
+ @register("xml", "csv", backend="native", family="data", description="xml → csv")
69
+ def xml_to_csv(input_path: Path, output_path: Path, **options) -> ConversionResult:
70
+ df = pd.read_xml(input_path)
71
+ output_path.parent.mkdir(parents=True, exist_ok=True)
72
+ df.to_csv(output_path, index=False)
73
+ return ConversionResult(output=output_path, rows=len(df))
74
+
75
+ # ── HTML ──────────────────────────────────────────────────────────────────────
76
+ @register("csv", "html", backend="native", family="data", description="csv → html (table)")
77
+ def csv_to_html(input_path: Path, output_path: Path, **options) -> ConversionResult:
78
+ df = _read_csv(input_path)
79
+ output_path.parent.mkdir(parents=True, exist_ok=True)
80
+ df.to_html(output_path, index=False)
81
+ return ConversionResult(output=output_path, rows=len(df))
82
+
83
+ @register("html", "csv", backend="native", family="data", description="html (first table) → csv")
84
+ def html_to_csv(input_path: Path, output_path: Path, **options) -> ConversionResult:
85
+ dfs = pd.read_html(input_path)
86
+ if not dfs:
87
+ raise ValueError("No tables found in HTML")
88
+ df = dfs[0]
89
+ output_path.parent.mkdir(parents=True, exist_ok=True)
90
+ df.to_csv(output_path, index=False)
91
+ return ConversionResult(output=output_path, rows=len(df))
92
+
93
+ # ── SQLite ────────────────────────────────────────────────────────────────────
94
+ _SQLITE_IN_OPTIONS = [
95
+ OptionSpec("table", ("--table",), "Table name to read from SQLite. Defaults to the first table found."),
96
+ ]
97
+
98
+ @register("sqlite", "csv", backend="native", family="data", description="sqlite → csv", options=_SQLITE_IN_OPTIONS)
99
+ def sqlite_to_csv(input_path: Path, output_path: Path, *, table: str | None = None, **options) -> ConversionResult:
100
+ engine = create_engine(f"sqlite:///{input_path.absolute()}")
101
+
102
+ if not table:
103
+ inspector = inspect(engine)
104
+ tables = inspector.get_table_names()
105
+ if not tables:
106
+ raise ValueError(f"No tables found in {input_path}")
107
+ table = tables[0]
108
+
109
+ df = pd.read_sql_table(table, engine)
110
+ output_path.parent.mkdir(parents=True, exist_ok=True)
111
+ df.to_csv(output_path, index=False)
112
+ return ConversionResult(output=output_path, rows=len(df))
113
+
114
+ _SQLITE_OUT_OPTIONS = [
115
+ OptionSpec("table", ("--table",), "Table name to write to SQLite. Defaults to the file stem."),
116
+ ]
117
+
118
+ @register("csv", "sqlite", backend="native", family="data", description="csv → sqlite", options=_SQLITE_OUT_OPTIONS)
119
+ def csv_to_sqlite(input_path: Path, output_path: Path, *, table: str | None = None, **options) -> ConversionResult:
120
+ df = _read_csv(input_path)
121
+ if not table:
122
+ table = input_path.stem
123
+
124
+ output_path.parent.mkdir(parents=True, exist_ok=True)
125
+ engine = create_engine(f"sqlite:///{output_path.absolute()}")
126
+ df.to_sql(table, engine, index=False, if_exists="replace")
127
+ return ConversionResult(output=output_path, rows=len(df))
@@ -0,0 +1,63 @@
1
+ """
2
+ converters/svg.py — SVG family, powered by cairosvg.
3
+
4
+ SVG is output-only in reverse (raster -> vector requires actual tracing,
5
+ which is a fundamentally different, much lossier operation morph doesn't
6
+ attempt) — so this file only registers svg -> {png, jpg, pdf}.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from ..registry import ConversionResult, OptionSpec, register
15
+
16
+ _RASTER_OPTIONS = [
17
+ OptionSpec("width", ("--width",), "Output width in pixels. Default: SVG's intrinsic size.", type=int),
18
+ OptionSpec("height", ("--height",), "Output height in pixels. Default: SVG's intrinsic size / aspect-correct.", type=int),
19
+ OptionSpec("background", ("--background",), "Background color for transparent areas, e.g. 'white' or '#fff'. "
20
+ "Default: transparent (png) / white (jpg)."),
21
+ ]
22
+
23
+
24
+ def _svg_to_png(input_path: Path, output_path: Path, *, width: Optional[int] = None,
25
+ height: Optional[int] = None, background: Optional[str] = None,
26
+ **_options) -> ConversionResult:
27
+ import cairosvg
28
+ output_path.parent.mkdir(parents=True, exist_ok=True)
29
+ cairosvg.svg2png(url=str(input_path), write_to=str(output_path),
30
+ output_width=width, output_height=height, background_color=background)
31
+ return ConversionResult(output=output_path)
32
+
33
+
34
+ def _svg_to_jpg(input_path: Path, output_path: Path, *, width: Optional[int] = None,
35
+ height: Optional[int] = None, background: Optional[str] = None,
36
+ **_options) -> ConversionResult:
37
+ # cairosvg has no native JPEG writer — render to PNG in memory, then let
38
+ # Pillow flatten transparency and encode as JPEG.
39
+ import io
40
+ import cairosvg
41
+ from PIL import Image
42
+
43
+ png_bytes = cairosvg.svg2png(url=str(input_path), output_width=width, output_height=height,
44
+ background_color=background or "white")
45
+ img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
46
+ output_path.parent.mkdir(parents=True, exist_ok=True)
47
+ img.save(output_path, format="JPEG")
48
+ return ConversionResult(output=output_path)
49
+
50
+
51
+ def _svg_to_pdf(input_path: Path, output_path: Path, **_options) -> ConversionResult:
52
+ import cairosvg
53
+ output_path.parent.mkdir(parents=True, exist_ok=True)
54
+ cairosvg.svg2pdf(url=str(input_path), write_to=str(output_path))
55
+ return ConversionResult(output=output_path)
56
+
57
+
58
+ register("svg", "png", backend="cairosvg", family="image",
59
+ description="svg → png (rasterize)", options=_RASTER_OPTIONS)(_svg_to_png)
60
+ register("svg", "jpg", backend="cairosvg", family="image",
61
+ description="svg → jpg (rasterize, flattened)", lossy=True, options=_RASTER_OPTIONS)(_svg_to_jpg)
62
+ register("svg", "pdf", backend="cairosvg", family="document",
63
+ description="svg → pdf (vector-preserving)")(_svg_to_pdf)
@@ -0,0 +1,153 @@
1
+ """
2
+ converters/video.py — video family, powered by ffmpeg.
3
+
4
+ Includes two genuinely useful cross-domain edges beyond video<->video:
5
+ • video -> audio (extract the audio track, e.g. mp4 -> mp3)
6
+ • video -> gif (short animated clips; palette-generated for quality)
7
+
8
+ Every hop reports real fractional progress via ffmpeg's -progress stream.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from ..ffmpeg_utils import ProgressCallback, run_ffmpeg
17
+ from ..registry import ConversionResult, OptionSpec, register
18
+ from .audio import FORMATS as AUDIO_FORMATS
19
+
20
+ FORMATS = ["mp4", "mkv", "mov", "webm", "avi", "flv", "wmv", "mpeg"]
21
+
22
+ _COMMON_OPTIONS = [
23
+ OptionSpec("resolution", ("--resolution",), "Output resolution, e.g. '1280x720'. Default: keep source size."),
24
+ OptionSpec("fps", ("--fps",), "Output frame rate.", type=int),
25
+ OptionSpec("crf", ("--crf",), "Quality (lower = better/larger, 0-51). Default: encoder default (~23).", type=int),
26
+ OptionSpec("video_codec", ("--vcodec",), "Force a video codec, e.g. 'libx264', 'libvpx-vp9'."),
27
+ OptionSpec("audio_codec", ("--acodec",), "Force an audio codec, e.g. 'aac', 'libopus'."),
28
+ OptionSpec("no_audio", ("--no-audio",), "Strip the audio track entirely.", default=False, action="store_true"),
29
+ OptionSpec("start", ("--start",), "Trim start, e.g. '00:00:10' or seconds."),
30
+ OptionSpec("duration", ("--duration",), "Trim duration, e.g. '00:00:05' or seconds."),
31
+ ]
32
+
33
+
34
+ def _input_seek_args(start: Optional[str], duration: Optional[str]) -> list[str]:
35
+ args = []
36
+ if start:
37
+ args += ["-ss", str(start)]
38
+ if duration:
39
+ args += ["-t", str(duration)]
40
+ return args
41
+
42
+
43
+ def _video_convert(input_path: Path, output_path: Path, *, resolution: Optional[str] = None,
44
+ fps: Optional[int] = None, crf: Optional[int] = None,
45
+ video_codec: Optional[str] = None, audio_codec: Optional[str] = None,
46
+ no_audio: bool = False, start: Optional[str] = None,
47
+ duration: Optional[str] = None, _progress: Optional[ProgressCallback] = None,
48
+ **_options) -> ConversionResult:
49
+ output_path.parent.mkdir(parents=True, exist_ok=True)
50
+ cmd = ["ffmpeg", "-y", "-loglevel", "error", *_input_seek_args(start, duration), "-i", str(input_path)]
51
+
52
+ vf = []
53
+ if resolution:
54
+ w, h = resolution.lower().split("x")
55
+ vf.append(f"scale={w}:{h}")
56
+ if fps:
57
+ cmd += ["-r", str(fps)]
58
+ if vf:
59
+ cmd += ["-vf", ",".join(vf)]
60
+ if crf is not None:
61
+ cmd += ["-crf", str(crf)]
62
+ if video_codec:
63
+ cmd += ["-c:v", video_codec]
64
+ if no_audio:
65
+ cmd.append("-an")
66
+ elif audio_codec:
67
+ cmd += ["-c:a", audio_codec]
68
+
69
+ cmd.append(str(output_path))
70
+ run_ffmpeg(cmd, input_path=input_path, progress=_progress)
71
+ return ConversionResult(output=output_path)
72
+
73
+
74
+ def _extract_audio(input_path: Path, output_path: Path, *, bitrate: Optional[str] = None,
75
+ sample_rate: Optional[int] = None, channels: Optional[int] = None,
76
+ _progress: Optional[ProgressCallback] = None, **_options) -> ConversionResult:
77
+ output_path.parent.mkdir(parents=True, exist_ok=True)
78
+ cmd = ["ffmpeg", "-y", "-loglevel", "error", "-i", str(input_path), "-vn"]
79
+ if bitrate:
80
+ cmd += ["-b:a", bitrate]
81
+ if sample_rate:
82
+ cmd += ["-ar", str(sample_rate)]
83
+ if channels:
84
+ cmd += ["-ac", str(channels)]
85
+ cmd.append(str(output_path))
86
+ run_ffmpeg(cmd, input_path=input_path, progress=_progress)
87
+ return ConversionResult(output=output_path)
88
+
89
+
90
+ _GIF_OPTIONS = [
91
+ OptionSpec("fps", ("--fps",), "Gif frame rate.", type=int, default=10),
92
+ OptionSpec("width", ("--width",), "Gif width in pixels (height auto-scales). Default: 480.", type=int, default=480),
93
+ OptionSpec("start", ("--start",), "Trim start, e.g. '00:00:10' or seconds."),
94
+ OptionSpec("duration", ("--duration",), "Trim duration, e.g. '00:00:05' or seconds. Recommended for long videos."),
95
+ ]
96
+
97
+
98
+ def _to_animated(input_path: Path, output_path: Path, *, fps: int = 10, width: int = 480,
99
+ start: Optional[str] = None, duration: Optional[str] = None,
100
+ _progress: Optional[ProgressCallback] = None, **_options) -> ConversionResult:
101
+ output_path.parent.mkdir(parents=True, exist_ok=True)
102
+ vf = f"fps={fps},scale={width}:-1:flags=lanczos"
103
+ cmd = ["ffmpeg", "-y", "-loglevel", "error", *_input_seek_args(start, duration),
104
+ "-i", str(input_path), "-vf", vf, "-loop", "0", str(output_path)]
105
+ run_ffmpeg(cmd, input_path=input_path, progress=_progress)
106
+ return ConversionResult(output=output_path, extra={"note": "single-pass palette; use --fps/--width to tune size"})
107
+
108
+
109
+ # video <-> video
110
+ for _src in FORMATS:
111
+ for _dst in FORMATS:
112
+ if _dst == _src:
113
+ continue
114
+ register(
115
+ _src, _dst, backend="ffmpeg", requires_binary="ffmpeg", family="video",
116
+ description=f"{_src} → {_dst} (ffmpeg)", options=_COMMON_OPTIONS, supports_progress=True,
117
+ )(_video_convert)
118
+
119
+ # video -> audio (extract track)
120
+ _EXTRACT_AUDIO_OPTIONS = [
121
+ OptionSpec("bitrate", ("-b", "--bitrate"), "Audio bitrate, e.g. '192k'."),
122
+ OptionSpec("sample_rate", ("--sample-rate",), "Output sample rate in Hz.", type=int),
123
+ OptionSpec("channels", ("--channels",), "Output channel count: 1 or 2.", type=int),
124
+ ]
125
+ for _src in FORMATS:
126
+ for _dst in AUDIO_FORMATS:
127
+ register(
128
+ _src, _dst, backend="ffmpeg", requires_binary="ffmpeg", family="video",
129
+ description=f"{_src} → {_dst} (extract audio track)", lossy=True,
130
+ options=_EXTRACT_AUDIO_OPTIONS, supports_progress=True,
131
+ )(_extract_audio)
132
+
133
+ # video -> gif / webp
134
+ for _src in FORMATS:
135
+ for _dst in ("gif", "webp"):
136
+ register(
137
+ _src, _dst, backend="ffmpeg", requires_binary="ffmpeg", family="video",
138
+ description=f"{_src} → {_dst}", lossy=True, options=_GIF_OPTIONS, supports_progress=True,
139
+ )(_to_animated)
140
+
141
+ # video -> subtitles
142
+ def _extract_subs(input_path: Path, output_path: Path, **_options) -> ConversionResult:
143
+ output_path.parent.mkdir(parents=True, exist_ok=True)
144
+ cmd = ["ffmpeg", "-y", "-loglevel", "error", "-i", str(input_path), "-map", "0:s:0", str(output_path)]
145
+ run_ffmpeg(cmd, input_path=input_path)
146
+ return ConversionResult(output=output_path)
147
+
148
+ for _src in FORMATS:
149
+ for _dst in ("srt", "vtt"):
150
+ register(
151
+ _src, _dst, backend="ffmpeg", requires_binary="ffmpeg", family="video",
152
+ description=f"{_src} → {_dst} (extract subtitles)", lossy=True,
153
+ )(_extract_subs)
@@ -0,0 +1,84 @@
1
+ """
2
+ converters/vtracer_converter.py — raster-to-SVG vectorization wrapper, powered by vtracer.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from ..registry import ConversionResult, OptionSpec, register
11
+
12
+ OPTIONS = [
13
+ OptionSpec("mode", ("--mode",), "Curve fitting mode ('spline' for smooth curves, 'polygon' for sharp shapes).", default="spline"),
14
+ OptionSpec("colormode", ("--colormode",), "Color mode ('color' or 'binary').", default="color"),
15
+ OptionSpec("hierarchical", ("--hierarchical",), "Shape layout style ('stacked' for layered shapes, 'cutout' for no overlaps).", default="stacked"),
16
+ OptionSpec("filter_speckle", ("--filter-speckle",), "Filter speckle noise (pixels, e.g. 4).", default=4, type=int),
17
+ OptionSpec("color_precision", ("--color-precision",), "Color precision (number of significant bits, 1-8).", default=6, type=int),
18
+ OptionSpec("layer_difference", ("--layer-difference",), "Color difference threshold for layering (1-255).", default=16, type=int),
19
+ OptionSpec("corner_threshold", ("--corner-threshold",), "Corner threshold (degrees, 0-180).", default=60, type=int),
20
+ OptionSpec("length_threshold", ("--length-threshold",), "Length threshold for splines.", default=4.0, type=float),
21
+ OptionSpec("max_iterations", ("--max-iterations",), "Max iterations for spline fitting.", default=10, type=int),
22
+ OptionSpec("splice_threshold", ("--splice-threshold",), "Splice threshold (degrees, 0-180).", default=45, type=int),
23
+ OptionSpec("path_precision", ("--path-precision",), "Path decimal precision (number of decimal places).", default=2, type=int),
24
+ ]
25
+
26
+
27
+ def _image_to_svg(
28
+ input_path: Path,
29
+ output_path: Path,
30
+ *,
31
+ mode: str = "spline",
32
+ colormode: str = "color",
33
+ hierarchical: str = "stacked",
34
+ filter_speckle: int = 4,
35
+ color_precision: int = 6,
36
+ layer_difference: int = 16,
37
+ corner_threshold: int = 60,
38
+ length_threshold: float = 4.0,
39
+ max_iterations: int = 10,
40
+ splice_threshold: int = 45,
41
+ path_precision: int = 2,
42
+ **_options,
43
+ ) -> ConversionResult:
44
+ import subprocess
45
+ import sys
46
+
47
+ output_path.parent.mkdir(parents=True, exist_ok=True)
48
+
49
+ code = f"""
50
+ import vtracer
51
+ vtracer.convert_image_to_svg_py(
52
+ {repr(str(input_path))},
53
+ {repr(str(output_path))},
54
+ {repr(colormode)},
55
+ {repr(hierarchical)},
56
+ {repr(mode)},
57
+ {filter_speckle},
58
+ {color_precision},
59
+ {layer_difference},
60
+ {corner_threshold},
61
+ {length_threshold},
62
+ {max_iterations},
63
+ {splice_threshold},
64
+ {path_precision}
65
+ )
66
+ """
67
+ result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
68
+ if result.returncode != 0:
69
+ raise RuntimeError(f"vtracer tracing failed:\n{result.stderr.strip()}")
70
+
71
+ return ConversionResult(output=output_path)
72
+
73
+
74
+ # Register all common raster formats to SVG
75
+ for src in ("png", "jpg", "jpeg", "webp", "bmp", "tiff", "gif"):
76
+ register(
77
+ src,
78
+ "svg",
79
+ backend="vtracer",
80
+ family="image",
81
+ description=f"{src} → svg (vectorize via vtracer)",
82
+ lossy=True,
83
+ options=OPTIONS,
84
+ )(_image_to_svg)
@@ -0,0 +1,92 @@
1
+ """
2
+ converters/web.py — extract clean documents from URLs.
3
+
4
+ Powered by `trafilatura`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import trafilatura
12
+
13
+ from ..registry import ConversionResult, register
14
+
15
+
16
+ def _fetch_static(url: str, output_format: str) -> str:
17
+ downloaded = trafilatura.fetch_url(url)
18
+ if not downloaded:
19
+ raise RuntimeError(f"Failed to fetch {url}")
20
+
21
+ if output_format == "html_raw":
22
+ return downloaded
23
+
24
+ result = trafilatura.extract(downloaded, output_format=output_format)
25
+ if not result:
26
+ raise RuntimeError(f"Failed to extract content from {url} (it might be empty or a captcha)")
27
+ return result
28
+
29
+
30
+ def _fetch_dynamic(url: str, output_format: str) -> str:
31
+ import asyncio
32
+
33
+ try:
34
+ from crawl4ai import AsyncWebCrawler
35
+ except ImportError:
36
+ raise RuntimeError("crawl4ai is required for --js, but it's not installed.")
37
+
38
+ async def run():
39
+ async with AsyncWebCrawler() as crawler:
40
+ return await crawler.arun(url)
41
+
42
+ result = asyncio.run(run())
43
+ if not result.success:
44
+ raise RuntimeError(f"Failed to crawl {url} with JS: {result.error_message}")
45
+
46
+ if output_format == "markdown":
47
+ return result.markdown
48
+ if output_format == "html_raw":
49
+ return result.html
50
+
51
+ # for txt and xml, fallback to trafilatura but feed it the JS-rendered HTML
52
+ content = trafilatura.extract(result.html, output_format=output_format)
53
+ if not content:
54
+ raise RuntimeError(f"Failed to extract {output_format} from JS-rendered content.")
55
+ return content
56
+
57
+
58
+ @register("url", "md", backend="trafilatura", family="web", description="url → md (clean article)")
59
+ def url_to_md(input_path: str | Path, output_path: Path, **options) -> ConversionResult:
60
+ url = str(input_path)
61
+ content = _fetch_dynamic(url, "markdown") if options.get("js") else _fetch_static(url, "markdown")
62
+ output_path.parent.mkdir(parents=True, exist_ok=True)
63
+ output_path.write_text(content, encoding="utf-8")
64
+ return ConversionResult(output=output_path)
65
+
66
+
67
+ @register("url", "txt", backend="trafilatura", family="web", description="url → txt (clean article)")
68
+ def url_to_txt(input_path: str | Path, output_path: Path, **options) -> ConversionResult:
69
+ url = str(input_path)
70
+ content = _fetch_dynamic(url, "txt") if options.get("js") else _fetch_static(url, "txt")
71
+ output_path.parent.mkdir(parents=True, exist_ok=True)
72
+ output_path.write_text(content, encoding="utf-8")
73
+ return ConversionResult(output=output_path)
74
+
75
+
76
+ @register("url", "xml", backend="trafilatura", family="web", description="url → xml (clean article tree)")
77
+ def url_to_xml(input_path: str | Path, output_path: Path, **options) -> ConversionResult:
78
+ url = str(input_path)
79
+ content = _fetch_dynamic(url, "xml") if options.get("js") else _fetch_static(url, "xml")
80
+ output_path.parent.mkdir(parents=True, exist_ok=True)
81
+ output_path.write_text(content, encoding="utf-8")
82
+ return ConversionResult(output=output_path)
83
+
84
+
85
+ @register("url", "html", backend="trafilatura", family="web", description="url → html (raw dump)")
86
+ def url_to_html(input_path: str | Path, output_path: Path, **options) -> ConversionResult:
87
+ url = str(input_path)
88
+ content = _fetch_dynamic(url, "html_raw") if options.get("js") else _fetch_static(url, "html_raw")
89
+ output_path.parent.mkdir(parents=True, exist_ok=True)
90
+ output_path.write_text(content, encoding="utf-8")
91
+ return ConversionResult(output=output_path)
92
+