Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ repos:
additional_dependencies:
- pytest
- types-setuptools
- types-pkg_resources
- types-mock
exclude: >
(?x)^(
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@ $ sudo tail /var/log/messages | ccze -A | ansi2html > logs.html
$ task rc._forcecolor:yes limit:0 burndown | ansi2html > burndown.html
```

### Running a command with colors

You can also have `ansi2html` run a command inside a pseudo‑terminal so it emits colored output, which is then converted to HTML:

```shell
$ ansi2html git log -p > git-log.html
```

- Everything after the first non-option token is treated as the command and its arguments.
- To avoid ambiguity with `ansi2html` options, you can separate with `--`:

```shell
$ ansi2html --inline -- git log -p > inline-git-log.html
```

For embeddable snippets, use `--standalone` (or `-S`) to wrap the converted output in a
`<code>` element without the full HTML template:

```shell
$ echo $'\e[31mRED\e[0m' | ansi2html --standalone
```

See the list of full options with:

```shell
Expand Down
42 changes: 41 additions & 1 deletion man/ansi2html.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ SYNOPSIS
--------
*ansi2html* ['OPTIONS'] [*--inline*] [*--partial*]

*ansi2html* ['OPTIONS'] [*--*] *COMMAND* [*ARGS*...]


DESCRIPTION
-----------
Tool to convert text with ANSI color codes to HTML.
Tool to convert text with ANSI color codes to HTML (or LaTeX).

In addition to reading from standard input, `ansi2html` can run a command
inside a pseudo-terminal (PTY) to capture its colored output and convert it
to HTML. This enables natural usage like converting `git log` with colors
without forcing color flags. When running a command in a PTY, `ansi2html`
sets `GIT_PAGER=cat` and `PAGER=cat` by default to avoid interactive pagers
that could block execution. Set these explicitly to override.


OPTIONS
Expand All @@ -30,6 +39,10 @@ OPTIONS
*-i*, *--inline*::
Inline style without headers or template.


*-S*, *--standalone*::
Like --inline, but wrap the snippet in a "<code>" element (HTML output only).

*-H*, *--headers*::
Just produce the "<style>" tag.

Expand All @@ -39,6 +52,12 @@ OPTIONS
*-l*, *--light-background*::
Set output to "light background" mode.

*-W*, *--no-line-wrap*::
Disable line wrapping.

*-L*, *--latex*::
Export as LaTeX instead of HTML.

*-a*, *--linkify*::
Transform URLs into "<a>" links.

Expand All @@ -54,6 +73,12 @@ OPTIONS
*--output-encoding='ENCODING'*::
Specify output encoding.

*-s* 'SCHEME', *--scheme*='SCHEME'::
Specify color palette scheme. See `--help` for choices.

*-t* 'TITLE', *--title*='TITLE'::
Specify output title.

*-h*, *--help*::
Show this help message and exit.

Expand All @@ -71,6 +96,21 @@ $ sudo tail /var/log/messages | ccze -A | ansi2html > logs.html
$ task burndown | ansi2html > burndown.html
-------------------

Run a command and convert its colored output (PTY-backed):

-------------------
$ ansi2html git log -p > git-log.html

# Separate options from the command explicitly:
$ ansi2html --inline -- git log -p > inline-git-log.html

# Disable pager explicitly (optional; ansi2html defaults to no pager):
$ GIT_PAGER=cat ansi2html -- git show HEAD

# Produce an inline snippet wrapped in <code> (no full HTML):
$ echo $'\e[31mRED\e[0m' | ansi2html --standalone
-------------------


HOMEPAGE
--------
Expand Down
161 changes: 145 additions & 16 deletions src/ansi2html/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@

import argparse
import io
import os
import pty
import re
import select
import subprocess
import sys
from collections import OrderedDict
from typing import Iterator, List, Optional, Set, Tuple, Union
Expand Down Expand Up @@ -656,14 +660,38 @@ def produce_headers(self) -> str:

def main() -> None:
"""
$ ls --color=always | ansi2html > directories.html
$ sudo tail /var/log/messages | ccze -A | ansi2html > logs.html
$ task burndown | ansi2html > burndown.html
Usage:
ansi2html [OPTIONS] [--] COMMAND [ARGS...]
ansi2html [OPTIONS] # read from stdin

Examples:
$ ls --color=always | ansi2html > directories.html
$ sudo tail /var/log/messages | ccze -A | ansi2html > logs.html
$ ansi2html git log -p > git-log.html
$ ansi2html --inline -- git log -p > inline-git-log.html
"""

scheme_names = sorted(SCHEME.keys())
version_str = version("ansi2html")
parser = argparse.ArgumentParser(usage=main.__doc__)
parser = argparse.ArgumentParser(
description=(
"Convert text with ANSI color codes to HTML/LaTeX.\n\n"
"Can also run a command inside a PTY (pseudo‑terminal) and convert\n"
"its colored output. After '--', or after the first non-option,\n"
"arguments are treated as the command to run."
),
epilog=(
"Examples:\n"
" ls --color=always | ansi2html > directories.html\n"
" ansi2html git log -p > git-log.html\n"
" ansi2html --inline -- git log -p > inline-git-log.html\n\n"
"Notes:\n"
" - In PTY mode, ansi2html sets GIT_PAGER=cat and PAGER=cat by default\n"
" to avoid hanging pagers; set them explicitly to override."
),
formatter_class=argparse.RawTextHelpFormatter,
usage=main.__doc__,
)
parser.add_argument(
"-V", "--version", action="version", version=f"%(prog)s {version_str}"
)
Expand Down Expand Up @@ -691,6 +719,17 @@ def main() -> None:
action="store_true",
help="Inline style without headers or template.",
)
parser.add_argument(
"-S",
"--standalone",
dest="standalone",
default=False,
action="store_true",
help=(
"Like --inline, but wrap the snippet in a <code> element. "
"(HTML output only)"
),
)
parser.add_argument(
"-H",
"--headers",
Expand Down Expand Up @@ -777,11 +816,38 @@ def main() -> None:
"-t", "--title", dest="output_title", default="", help="Specify output title"
)

opts = parser.parse_args(sys.argv[1:])
# Split args to support running a command in a PTY for colored output.
# Examples:
# - ansi2html --inline -- git log -p
# - ansi2html git log -p
argv = sys.argv[1:]
ansi_args: List[str]
cmd_args: List[str]
if "--" in argv:
sep = argv.index("--")
ansi_args = argv[:sep]
cmd_args = argv[sep + 1 :]
else:
# Treat the first non-option token as the start of the command
split_at = None
for i, tok in enumerate(argv):
if not tok.startswith("-"):
split_at = i
break
if split_at is not None:
ansi_args = argv[:split_at]
cmd_args = argv[split_at:]
else:
ansi_args = argv
cmd_args = []

opts = parser.parse_args(ansi_args)

inline_mode = bool(opts.inline or opts.standalone)

conv = Ansi2HTMLConverter(
latex=opts.latex,
inline=opts.inline,
inline=inline_mode,
dark_bg=not opts.light_background,
line_wrap=not opts.no_line_wrap,
font_size=opts.font_size,
Expand All @@ -793,11 +859,12 @@ def main() -> None:
title=opts.output_title,
)

if hasattr(sys.stdin, "detach") and not isinstance(
sys.stdin, io.StringIO
): # e.g. during tests
input_buffer = sys.stdin.detach()
sys.stdin = io.TextIOWrapper(input_buffer, opts.input_encoding, "replace")
if not cmd_args:
if hasattr(sys.stdin, "detach") and not isinstance(
sys.stdin, io.StringIO
): # e.g. during tests
input_buffer = sys.stdin.detach()
sys.stdin = io.TextIOWrapper(input_buffer, opts.input_encoding, "replace")

def _print(output_unicode: str, end: str = "\n") -> None:
if hasattr(sys.stdout, "buffer"):
Expand All @@ -807,12 +874,74 @@ def _print(output_unicode: str, end: str = "\n") -> None:
sys.stdout.write(output_unicode + end)

# Produce only the headers and quit
if opts.headers:
if opts.headers and not cmd_args:
_print(conv.produce_headers(), end="")
return

full = not bool(opts.partial or opts.inline)
output = conv.convert(
"".join(sys.stdin.readlines()), full=full, ensure_trailing_newline=True
)
full = not bool(opts.partial or inline_mode)

if cmd_args:
# Run the command inside a PTY so it emits color, capture output
env = os.environ.copy()
env.setdefault("TERM", "xterm-256color")
# Disable interactive pagers when running commands in a PTY, to avoid hangs
# with tools like git that default to paging when stdout is a TTY.
env.setdefault("GIT_PAGER", "cat")
env.setdefault("PAGER", "cat")
# Keep color passthrough if a pager is still used for some reason
env.setdefault("LESS", "-R")

master_fd, slave_fd = pty.openpty()
try:
try:
with subprocess.Popen(
cmd_args,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
env=env,
close_fds=True,
) as proc:
# Parent does not use the slave end
os.close(slave_fd)

chunks: List[bytes] = []
while True:
rlist, _, _ = select.select([master_fd], [], [], 0.1)
if master_fd in rlist:
try:
data = os.read(master_fd, 4096)
except OSError:
break
if not data:
break
chunks.append(data)
# Break when the process exits and the PTY drained
if proc.poll() is not None and not rlist:
# Try one last read; if nothing, break
try:
data = os.read(master_fd, 4096)
if data:
chunks.append(data)
continue
except OSError:
pass
break
except FileNotFoundError:
os.close(master_fd)
os.close(slave_fd)
raise
finally:
try:
os.close(master_fd)
except OSError:
pass

ansi_text = b"".join(chunks).decode(opts.input_encoding, "replace")
else:
ansi_text = "".join(sys.stdin.readlines())

output = conv.convert(ansi_text, full=full, ensure_trailing_newline=True)
if opts.standalone and not opts.latex:
output = f'<code style="white-space: pre;">{output}</code>'
_print(output, end="")
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Test package init to allow relative imports."""
26 changes: 26 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import importlib
import os
import sys
from typing import Any, Iterable, Tuple
from unittest.mock import patch


def import_local_converter() -> Any:
# Ensure we run the local project module, not any installed one
mod_names = [k for k in list(sys.modules.keys()) if k.startswith("ansi2html")]
for k in mod_names:
sys.modules.pop(k, None)
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "src"))
)
return importlib.import_module("ansi2html.converter")


def run_with_fake_pty(conv: Any, argv: Iterable[str], script: str) -> None:
def _fake_openpty() -> Tuple[int, int]:
r, w = os.pipe()
return r, w

with patch("sys.argv", new=list(argv)):
with patch.object(conv.pty, "openpty", side_effect=_fake_openpty):
conv.main()
Loading
Loading