ansi-pixel 0.2.0__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.
ansi_pixel/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """ansi-pixel: Convert images into true-color ANSI terminal art."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ansi_pixel.converter import (
6
+ FilterInput,
7
+ ImageSource,
8
+ ResamplingFilter,
9
+ get_terminal_width,
10
+ image_to_ansi,
11
+ parse_hex_color,
12
+ parse_resampling_filter,
13
+ )
14
+ from ansi_pixel.exporters import (
15
+ OutputFormat,
16
+ export_ansi,
17
+ export_format,
18
+ export_html,
19
+ export_javascript,
20
+ export_markdown,
21
+ export_python,
22
+ infer_format_from_path,
23
+ )
24
+ from ansi_pixel.optimizer import AnsiOptimizer, RGBColor, strip_ansi
25
+ from ansi_pixel.render import render_image
26
+
27
+ __version__ = "0.2.0"
28
+
29
+ __all__ = [
30
+ "AnsiOptimizer",
31
+ "FilterInput",
32
+ "ImageSource",
33
+ "OutputFormat",
34
+ "RGBColor",
35
+ "ResamplingFilter",
36
+ "export_ansi",
37
+ "export_format",
38
+ "export_html",
39
+ "export_javascript",
40
+ "export_markdown",
41
+ "export_python",
42
+ "get_terminal_width",
43
+ "image_to_ansi",
44
+ "infer_format_from_path",
45
+ "parse_hex_color",
46
+ "parse_resampling_filter",
47
+ "render_image",
48
+ "strip_ansi",
49
+ ]
ansi_pixel/cli.py ADDED
@@ -0,0 +1,299 @@
1
+ """Command-line interface entry point for ansi-pixel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import urllib.error
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import TextIO
12
+
13
+ from PIL import UnidentifiedImageError
14
+
15
+ from ansi_pixel import __version__
16
+ from ansi_pixel.converter import ResamplingFilter, get_terminal_width, image_to_ansi
17
+ from ansi_pixel.exporters import (
18
+ OutputFormat,
19
+ export_format,
20
+ infer_format_from_path,
21
+ )
22
+
23
+
24
+ def format_lines(lines: list[str], output_format: OutputFormat | str) -> str:
25
+ """Serialize rendered ANSI lines into the target representation format.
26
+
27
+ Args:
28
+ lines: Rendered ANSI art lines.
29
+ output_format: Desired OutputFormat or format name.
30
+
31
+ Returns:
32
+ Formatted string suitable for writing to stdout or a file.
33
+ """
34
+ return export_format(lines, output_format)
35
+
36
+
37
+ def write_output(
38
+ lines: list[str],
39
+ output_format: OutputFormat | str = OutputFormat.ANSI,
40
+ file_path: str | Path | None = None,
41
+ ) -> None:
42
+ """Save rendered ANSI art lines to a file according to the requested format.
43
+
44
+ Args:
45
+ lines: Rendered ANSI art lines.
46
+ output_format: Target format (ansi, md, py, js, html, txt).
47
+ file_path: Destination path. If None, defaults to 'output.<ext>'.
48
+ """
49
+ fmt = (
50
+ output_format
51
+ if isinstance(output_format, OutputFormat)
52
+ else OutputFormat(str(output_format).lower())
53
+ )
54
+ if file_path is None:
55
+ file_path = f"output.{fmt.value}"
56
+ content = format_lines(lines, fmt)
57
+ Path(file_path).write_text(content, encoding="utf-8")
58
+
59
+
60
+ def is_color_enabled(color_mode: str = "auto", stream: TextIO | None = None) -> bool:
61
+ """Determine whether ANSI color escape codes should be emitted.
62
+
63
+ Adheres strictly to the NO_COLOR standard (https://no-color.org) and non-TTY pipe detection.
64
+
65
+ Args:
66
+ color_mode: Color choice ('auto', 'always', or 'never').
67
+ stream: Output stream to inspect for interactive TTY (defaults to sys.stdout).
68
+
69
+ Returns:
70
+ True if ANSI color codes should be included, False if suppressed.
71
+ """
72
+ if color_mode == "never":
73
+ return False
74
+ if color_mode == "always":
75
+ return True
76
+
77
+ # color_mode == "auto":
78
+ # 1. NO_COLOR standard: if NO_COLOR environment variable is present and non-empty, disable color
79
+ if os.environ.get("NO_COLOR", "") != "":
80
+ return False
81
+
82
+ target_stream = stream if stream is not None else sys.stdout
83
+ return bool(getattr(target_stream, "isatty", lambda: False)())
84
+
85
+
86
+ def build_parser() -> argparse.ArgumentParser:
87
+ """Construct and configure the command-line argument parser.
88
+
89
+ Returns:
90
+ Configured ArgumentParser instance.
91
+ """
92
+ parser = argparse.ArgumentParser(
93
+ prog="ansi-pixel",
94
+ description=(
95
+ "Convert an image into true-color ANSI art for terminal banners, "
96
+ "SSH previews, and Markdown."
97
+ ),
98
+ )
99
+ parser.add_argument(
100
+ "-V",
101
+ "--version",
102
+ action="version",
103
+ version=f"%(prog)s {__version__}",
104
+ help="Show program's version number and exit",
105
+ )
106
+ parser.add_argument(
107
+ "image",
108
+ nargs="?",
109
+ default=None,
110
+ help="Path to image file, image URL, or '-' to read from standard input",
111
+ )
112
+ parser.add_argument(
113
+ "-w",
114
+ "--width",
115
+ type=int,
116
+ default=None,
117
+ help="Output width in terminal characters (default: auto-detected terminal width)",
118
+ )
119
+ parser.add_argument(
120
+ "-o",
121
+ "--output",
122
+ type=str,
123
+ default=None,
124
+ metavar="FILE",
125
+ help="Path to save output file instead of writing solely to stdout",
126
+ )
127
+ parser.add_argument(
128
+ "-f",
129
+ "--format",
130
+ type=str,
131
+ choices=[f.value for f in OutputFormat],
132
+ default=None,
133
+ help="Output format: ansi, md, py, js, html (default: ansi or inferred from --output)",
134
+ )
135
+ parser.add_argument(
136
+ "--filter",
137
+ type=str,
138
+ choices=[f.value for f in ResamplingFilter],
139
+ default=ResamplingFilter.NEAREST.value,
140
+ help="Resampling filter: nearest, lanczos, or bilinear (default: nearest)",
141
+ )
142
+ parser.add_argument(
143
+ "--trim-bg",
144
+ action="store_true",
145
+ help="Strip white/solid background (treat near-white pixels as transparent)",
146
+ )
147
+ parser.add_argument(
148
+ "--chroma-key",
149
+ type=str,
150
+ default=None,
151
+ help="Hex color to treat as transparent (e.g. #FFFFFF or 00FF00)",
152
+ )
153
+ parser.add_argument(
154
+ "--color",
155
+ dest="color",
156
+ choices=["auto", "always", "never"],
157
+ default="auto",
158
+ nargs="?",
159
+ const="always",
160
+ help=(
161
+ "When to output ANSI colors: auto, always, never (default: auto). "
162
+ "Follows NO_COLOR standard."
163
+ ),
164
+ )
165
+ parser.add_argument(
166
+ "--no-color",
167
+ dest="color",
168
+ action="store_const",
169
+ const="never",
170
+ help="Disable ANSI color codes (alias for --color=never)",
171
+ )
172
+ parser.add_argument(
173
+ "--print",
174
+ dest="print_output",
175
+ action=argparse.BooleanOptionalAction,
176
+ default=None,
177
+ help="Control printing to stdout when -o/--output is provided; use --no-print to silence",
178
+ )
179
+ return parser
180
+
181
+
182
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
183
+ """Parse command line arguments.
184
+
185
+ Args:
186
+ argv: Optional sequence of argument strings. Defaults to sys.argv[1:].
187
+
188
+ Returns:
189
+ Parsed arguments Namespace.
190
+ """
191
+ parser = build_parser()
192
+ return parser.parse_args(argv)
193
+
194
+
195
+ def main(argv: Sequence[str] | None = None) -> int:
196
+ """Main CLI execution entry point.
197
+
198
+ Args:
199
+ argv: Optional sequence of CLI arguments.
200
+
201
+ Returns:
202
+ POSIX exit code: 0 on success, 1 on input/runtime error, 2 on usage error.
203
+ """
204
+ try:
205
+ args = parse_args(argv)
206
+ except SystemExit as exc:
207
+ return int(exc.code) if isinstance(exc.code, int) else 2
208
+
209
+ # Handle image argument: if omitted, check whether stdin is piped
210
+ image_source: str | None = args.image
211
+ if image_source is None:
212
+ if not sys.stdin.isatty():
213
+ image_source = "-"
214
+ else:
215
+ sys.stderr.write("ansi-pixel: error: the following arguments are required: image\n")
216
+ return 2
217
+
218
+ # Validate width if explicitly provided
219
+ if args.width is not None and args.width < 1:
220
+ sys.stderr.write("Error: target_width must be at least 1.\n")
221
+ return 2
222
+
223
+ target_width: int = args.width if args.width is not None else get_terminal_width()
224
+
225
+ # Determine whether color codes should be generated
226
+ if args.output is not None:
227
+ # Saving to file: honor explicit --color flag and NO_COLOR env var
228
+ color_enabled = (args.color != "never") and (
229
+ os.environ.get("NO_COLOR", "") == "" or args.color == "always"
230
+ )
231
+ else:
232
+ # Outputting to stdout: check non-TTY pipe detection and NO_COLOR standard
233
+ color_enabled = is_color_enabled(args.color, sys.stdout)
234
+
235
+ try:
236
+ lines = image_to_ansi(
237
+ image_source,
238
+ target_width=target_width,
239
+ filter=args.filter,
240
+ trim_bg=args.trim_bg,
241
+ chroma_key=args.chroma_key,
242
+ color=color_enabled,
243
+ )
244
+ except FileNotFoundError:
245
+ sys.stderr.write(f"Error: image not found: {image_source}\n")
246
+ return 1
247
+ except UnidentifiedImageError:
248
+ desc = "standard input" if image_source == "-" else repr(image_source)
249
+ sys.stderr.write(f"Error: unsupported or invalid image: {desc}\n")
250
+ return 1
251
+ except urllib.error.HTTPError as exc:
252
+ sys.stderr.write(f"Error: HTTP {exc.code} {exc.reason}: {image_source}\n")
253
+ return 1
254
+ except urllib.error.URLError as exc:
255
+ sys.stderr.write(f"Error: failed to fetch URL '{image_source}': {exc.reason}\n")
256
+ return 1
257
+ except TimeoutError:
258
+ sys.stderr.write(f"Error: request timed out fetching URL: {image_source}\n")
259
+ return 1
260
+ except ValueError as exc:
261
+ sys.stderr.write(f"Error: {exc}\n")
262
+ err_msg = str(exc).lower()
263
+ if any(keyword in err_msg for keyword in ("target_width", "filter", "hex", "choice")):
264
+ return 2
265
+ return 1
266
+ except OSError as exc:
267
+ sys.stderr.write(f"Error: could not open image: {exc}\n")
268
+ return 1
269
+
270
+ # Determine output serialization format
271
+ if args.format is not None:
272
+ out_format = OutputFormat(args.format.lower())
273
+ elif args.output is not None:
274
+ out_format = infer_format_from_path(args.output)
275
+ else:
276
+ out_format = OutputFormat.ANSI
277
+
278
+ formatted_output = format_lines(lines, out_format)
279
+
280
+ # Write to destination file if -o/--output was specified
281
+ if args.output is not None:
282
+ try:
283
+ target_path = Path(args.output)
284
+ target_path.parent.mkdir(parents=True, exist_ok=True)
285
+ target_path.write_text(formatted_output, encoding="utf-8")
286
+ except OSError as exc:
287
+ sys.stderr.write(f"Error: failed to write output file '{args.output}': {exc}\n")
288
+ return 1
289
+
290
+ # Output to stdout by default (when -o is omitted) or when --print is explicitly enabled
291
+ should_print = args.print_output if args.print_output is not None else (args.output is None)
292
+ if should_print:
293
+ sys.stdout.write(formatted_output)
294
+
295
+ return 0
296
+
297
+
298
+ if __name__ == "__main__":
299
+ sys.exit(main())