yacta 0.0.1__tar.gz

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.
yacta-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.3
2
+ Name: yacta
3
+ Version: 0.0.1
4
+ Summary: Yet Another Converter for Text to ANSI
5
+ Author: ars6502
6
+ Author-email: ars6502 <arun.r.sharma@proton.me>
7
+ Requires-Dist: pyhyphen>=4.0.4
8
+ Requires-Dist: typer>=0.24.1
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Yet Another Converter for Text to ANSI
13
+
14
+ For documents formatted specifically for terminal/console
yacta-0.0.1/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Yet Another Converter for Text to ANSI
2
+
3
+ For documents formatted specifically for terminal/console
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "yacta"
3
+ version = "0.0.1"
4
+ description = "Yet Another Converter for Text to ANSI"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "ars6502", email = "arun.r.sharma@proton.me" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "pyhyphen>=4.0.4",
12
+ "typer>=0.24.1",
13
+ ]
14
+
15
+ [project.scripts]
16
+ yacta = "yacta:cmd.main"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.11.3,<0.12.0"]
20
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ from .common import *
2
+
3
+ from .command_block_processor import *
4
+ from .css_parser import *
5
+ from .selector_parser import *
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+ import re
3
+ import shlex
4
+ import subprocess
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, List, Optional, Tuple
8
+ from hyphen import Hyphenator
9
+ from .common import *
10
+
11
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
12
+
13
+ def get_padding(style: Dict[str, object]) -> Tuple[int, int, int, int]:
14
+ """Return block padding as ``(top, bottom, left, right)`` integers.
15
+
16
+ Missing padding declarations default to zero.
17
+ """
18
+ return (
19
+ int(style.get("padding-top", 0)),
20
+ int(style.get("padding-bottom", 0)),
21
+ int(style.get("padding-left", 0)),
22
+ int(style.get("padding-right", 0)),
23
+ )
24
+
25
+
26
+ def get_wrap_width(style: Dict[str, object]) -> Optional[int]:
27
+ """Return the available text width inside a styled block.
28
+
29
+ When ``max-width`` is present, the method subtracts left and right padding to
30
+ produce the width available to the actual text content. ``None`` means the text
31
+ should not be wrapped to a specific width.
32
+ """
33
+ max_width = style.get("max-width")
34
+ if max_width is None:
35
+ return None
36
+
37
+ _, _, padding_left, padding_right = get_padding(style)
38
+ return max(1, int(max_width) - padding_left - padding_right)
39
+
40
+ class ANSIBuilder:
41
+ """Build ANSI escape sequences and styled terminal output fragments.
42
+
43
+ This class owns all knowledge of how colors, resets, inline emphasis, and
44
+ padded block output are translated into terminal control sequences. Keeping the
45
+ logic here makes the rest of the renderer operate mainly on plain text and
46
+ layout decisions.
47
+ """
48
+ RESET = "\033[0m"
49
+ BOLD = "\033[1m"
50
+ BOLD_OFF = "\033[22m"
51
+ STYLE_CODES = {
52
+ "BOLD": "1",
53
+ "DIM": "2",
54
+ "ITALIC": "3",
55
+ "UNDERLINE": "4",
56
+ "REVERSE": "7",
57
+ "BLACK": "30",
58
+ "RED": "31",
59
+ "GREEN": "32",
60
+ "YELLOW": "33",
61
+ "BLUE": "34",
62
+ "MAGENTA": "35",
63
+ "CYAN": "36",
64
+ "WHITE": "37",
65
+ "BRIGHT_BLACK": "90",
66
+ "BRIGHT_RED": "91",
67
+ "BRIGHT_GREEN": "92",
68
+ "BRIGHT_YELLOW": "93",
69
+ "BRIGHT_BLUE": "94",
70
+ "BRIGHT_MAGENTA": "95",
71
+ "BRIGHT_CYAN": "96",
72
+ "BRIGHT_WHITE": "97",
73
+ }
74
+
75
+ def fg(self, rgb: Tuple[int, int, int]) -> str:
76
+ """Return the ANSI escape sequence for a 24-bit foreground color."""
77
+ r, g, b = rgb
78
+ return f"\033[38;2;{r};{g};{b}m"
79
+
80
+ def bg(self, rgb: Tuple[int, int, int]) -> str:
81
+ """Return the ANSI escape sequence for a 24-bit background color."""
82
+ r, g, b = rgb
83
+ return f"\033[48;2;{r};{g};{b}m"
84
+
85
+ def block_prefix(self, style: Dict[str, object]) -> str:
86
+ """Build the base ANSI prefix for a rendered block.
87
+
88
+ The prefix applies the block-level foreground and background colors defined by
89
+ a style map. It is prepended to each physical output line in the block and also
90
+ used after inline resets so block colors continue to apply.
91
+ """
92
+ parts: List[str] = []
93
+ if "color" in style:
94
+ parts.append(self.fg(style["color"])) # type: ignore[arg-type]
95
+ if "background-color" in style:
96
+ parts.append(self.bg(style["background-color"])) # type: ignore[arg-type]
97
+ return "".join(parts)
98
+
99
+ def visible_len(self, rendered: str) -> int:
100
+ """Return the printable width of a rendered string.
101
+
102
+ ANSI escape sequences do not occupy terminal columns, so they are removed
103
+ before counting characters.
104
+ """
105
+ return len(ANSI_RE.sub("", rendered))
106
+
107
+ def pad_rendered_line(self, rendered: str, target_width: int) -> str:
108
+ """Pad a rendered string with spaces up to ``target_width`` visible columns.
109
+
110
+ The method measures width after stripping ANSI escapes so padding aligns with
111
+ what the terminal actually displays rather than with the raw string length.
112
+ """
113
+ visible = self.visible_len(rendered)
114
+ return rendered + (" " * max(0, target_width - visible))
115
+
116
+ def render_token(self, token: Token, block_prefix: str = "") -> str:
117
+ """Render a ``Token`` into ANSI text, restoring the block style afterward.
118
+
119
+ If the token has inline styles, the method emits a style escape sequence, the
120
+ token text, and then resets terminal state. When ``block_prefix`` is supplied,
121
+ it is re-applied after the reset so block colors remain active for the rest of
122
+ the line.
123
+ """
124
+ if not token.styles:
125
+ return token.text
126
+
127
+ codes = [self.STYLE_CODES[style] for style in token.styles]
128
+ restore = f"{self.RESET}{block_prefix}" if block_prefix else self.RESET
129
+ return f"\033[{';'.join(codes)}m{token.text}{restore}"
130
+
131
+ def render_plain_line(self, parts: List[Token], block_prefix: str) -> str:
132
+ """Render a sequence of tokens separated by single spaces.
133
+
134
+ This preserves the token order exactly as it appears in the wrapped line model
135
+ and applies inline ANSI styling token by token.
136
+ """
137
+ return " ".join(self.render_token(part, block_prefix) for part in parts)
138
+
139
+ def justify_rendered_line(self, parts: List[Token], width: int, block_prefix: str) -> str:
140
+ """Render and justify a non-final line to exactly ``width`` columns.
141
+
142
+ Visible spacing is distributed across the gaps between tokens while each token
143
+ continues to be rendered with its inline styles. The calculation is based on
144
+ visible character widths rather than raw string lengths.
145
+ """
146
+ if not parts:
147
+ return " " * width
148
+ if len(parts) == 1:
149
+ return self.render_token(parts[0], block_prefix)
150
+
151
+ total_chars = sum(len(part.text) for part in parts)
152
+ gaps = len(parts) - 1
153
+ total_spaces = width - total_chars
154
+ base, extra = divmod(total_spaces, gaps)
155
+
156
+ output: List[str] = []
157
+ for index, part in enumerate(parts[:-1]):
158
+ output.append(self.render_token(part, block_prefix))
159
+ output.append(" " * (base + (1 if index < extra else 0)))
160
+ output.append(self.render_token(parts[-1], block_prefix))
161
+ return "".join(output)
162
+
163
+ def align_rendered_line(
164
+ self,
165
+ rendered: str,
166
+ visible: str,
167
+ width: Optional[int],
168
+ alignment: str,
169
+ ) -> str:
170
+ """Apply left, right, or center alignment to a rendered line.
171
+
172
+ ``visible`` supplies the plain-text version of the line so alignment can be
173
+ computed correctly even when ``rendered`` contains ANSI escape sequences.
174
+ """
175
+ if width is None:
176
+ return rendered
177
+
178
+ visible_len = len(visible)
179
+ if visible_len >= width:
180
+ return rendered
181
+
182
+ if alignment == "right":
183
+ return (" " * (width - visible_len)) + rendered
184
+ if alignment == "center":
185
+ left = (width - visible_len) // 2
186
+ right = width - visible_len - left
187
+ return (" " * left) + rendered + (" " * right)
188
+ return rendered + (" " * (width - visible_len))
189
+
190
+ def name_prefix(self, style: Dict[str, object]) -> str:
191
+ """Build the ANSI prefix for description-list labels.
192
+
193
+ Description names may use ``name-color`` when present, otherwise they inherit
194
+ the normal text color. The background color, if any, is always preserved so the
195
+ label blends into the containing block.
196
+ """
197
+ parts: List[str] = []
198
+ if "name-color" in style:
199
+ parts.append(self.fg(style["name-color"])) # type: ignore[arg-type]
200
+ elif "color" in style:
201
+ parts.append(self.fg(style["color"])) # type: ignore[arg-type]
202
+ if "background-color" in style:
203
+ parts.append(self.bg(style["background-color"])) # type: ignore[arg-type]
204
+ return "".join(parts)
205
+
206
+ def render_description_name(self, name: str, style: Dict[str, object], block_prefix: str) -> str:
207
+ """Render the leading name of a description item in bold, with optional color.
208
+
209
+ The generated string resets styling at the end of the label and then restores
210
+ the surrounding block prefix so subsequent description text continues with the
211
+ block's normal styling.
212
+ """
213
+ name_prefix = self.name_prefix(style)
214
+ if not name_prefix:
215
+ return f"{self.BOLD}{name}{self.BOLD_OFF}"
216
+ return f"{name_prefix}{self.BOLD}{name}{self.RESET}{block_prefix}"
217
+
218
+ def render_lines_block(
219
+ self,
220
+ rendered_lines: List[str],
221
+ style: Dict[str, object],
222
+ visible_lines: Optional[List[str]] = None,
223
+ ) -> str:
224
+ """Wrap already-rendered lines in block-level padding and color styling.
225
+
226
+ This method is responsible for adding top and bottom blank padding lines, left
227
+ and right space padding, and the block-level ANSI prefix/reset around every
228
+ physical output line.
229
+ """
230
+ padding_top, padding_bottom, padding_left, padding_right = get_padding(style)
231
+ prefix = self.block_prefix(style)
232
+
233
+ if not rendered_lines:
234
+ rendered_lines = [""]
235
+
236
+ if visible_lines is None:
237
+ visible_lines = [ANSI_RE.sub("", line) for line in rendered_lines]
238
+
239
+ content_width = max(len(line) for line in visible_lines) + padding_left + padding_right
240
+ blank_line = " " * content_width
241
+ visible_width = max(len(line) for line in visible_lines)
242
+
243
+ lines: List[str] = []
244
+
245
+ for _ in range(padding_top):
246
+ lines.append(f"{prefix}{blank_line}{self.RESET}")
247
+
248
+ for rendered_line in rendered_lines:
249
+ padded = self.pad_rendered_line(rendered_line, visible_width)
250
+ content_line = f"{' ' * padding_left}{padded}{' ' * padding_right}"
251
+ lines.append(f"{prefix}{content_line}{self.RESET}")
252
+
253
+ for _ in range(padding_bottom):
254
+ lines.append(f"{prefix}{blank_line}{self.RESET}")
255
+
256
+ return "\n".join(lines)
257
+
258
+ def render_single_block(self, text: str, style: Dict[str, object]) -> str:
259
+ """Render a one-line block using the same padding and color rules as multi-line blocks."""
260
+ return self.render_lines_block([text], style, visible_lines=[ANSI_RE.sub("", text)])
261
+
@@ -0,0 +1,33 @@
1
+ import typer
2
+ from typing import Optional
3
+ from .common import *
4
+
5
+
6
+ def main():
7
+ app = typer.Typer()
8
+
9
+ @app.command()
10
+ def run(
11
+ stylesheet: str = typer.Argument(help="Path to CSS-like stylesheet"),
12
+ textfile: str = typer.Argument(help="Path to marked-up text file"),
13
+ language: str = typer.Option(
14
+ "en_US",
15
+ "--language",
16
+ help="Hyphenation language for all non-header selectors (default: en_US)"
17
+ )
18
+ ) -> int:
19
+ """Parse command-line arguments, render the requested files, and return a process exit code.
20
+ Exit code ``0`` indicates success. ``1`` reports a missing file, and ``2``
21
+ reports invalid input such as malformed stylesheet values.
22
+ """
23
+ try:
24
+ render_file(stylesheet, textfile, language)
25
+ return 0
26
+ except FileNotFoundError as e:
27
+ print(f"File not found: {e.filename}", file=sys.stderr)
28
+ return 1
29
+ except ValueError as e:
30
+ print(f"Error: {e}", file=sys.stderr)
31
+ return 2
32
+
33
+ app()
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+ import re
3
+ import shlex
4
+ import subprocess
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, List, Optional, Tuple
8
+ from hyphen import Hyphenator
9
+
10
+ COMMAND_BLOCK_OPEN = "{{{"
11
+ COMMAND_BLOCK_CLOSE = "}}}"
12
+
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ParsedCommandBlock:
17
+ """Represent one parsed command-expansion block from the content file.
18
+
19
+ ``command`` stores the command line extracted from the opening ``{{{ ...`` line.
20
+ ``stdin_text`` stores the literal multiline payload found between the opening and
21
+ closing markers. That payload is later piped to the command's standard input and
22
+ the resulting standard output is fed back into the normal content parser.
23
+ """
24
+ command: str
25
+ stdin_text: str
26
+
27
+
28
+ class CommandBlockProcessor:
29
+ """Expand ``{{{ command ... }}}`` blocks before normal content parsing begins.
30
+
31
+ The processor scans raw input lines for opening markers that include a command on
32
+ the same line, collects all following lines up to the closing ``}}}`` marker,
33
+ pipes that collected text to the command's standard input, captures the command's
34
+ standard output, and reinserts the output lines into the parse stream in place of
35
+ the original block.
36
+ """
37
+ OPEN_RE = re.compile(r"^\s*\{\{\{\s*(?P<command>.*?)\s*$")
38
+
39
+ @classmethod
40
+ def parse_opening_line(cls, line: str) -> Optional[str]:
41
+ """Return the command embedded in a command-block opening line.
42
+
43
+ The opening marker must appear on the same line as the command text, for
44
+ example ``{{{ sort -r``. Surrounding whitespace is ignored. When the line is
45
+ not a command-block opener, ``None`` is returned.
46
+ """
47
+ match = cls.OPEN_RE.match(line.rstrip("\n"))
48
+ if not match:
49
+ return None
50
+ return match.group("command").strip()
51
+
52
+ @staticmethod
53
+ def is_closing_line(line: str) -> bool:
54
+ """Return ``True`` when ``line`` is the closing ``}}}`` marker.
55
+
56
+ Surrounding whitespace is ignored so the marker can be indented in the input
57
+ file without affecting recognition.
58
+ """
59
+ return line.rstrip("\n").strip() == COMMAND_BLOCK_CLOSE
60
+
61
+ @classmethod
62
+ def parse_block(cls, raw_lines: List[str], start_index: int) -> Tuple[ParsedCommandBlock, int]:
63
+ """Parse one command block starting at ``start_index``.
64
+
65
+ The opening line contributes the command, every following raw line up to the
66
+ closing marker becomes part of the command's standard input, and the returned
67
+ integer points to the first source line after the block. A ``ValueError`` is
68
+ raised for malformed blocks such as an empty command or a missing closing
69
+ marker.
70
+ """
71
+ command = cls.parse_opening_line(raw_lines[start_index])
72
+ if command is None:
73
+ raise ValueError("Invalid command block: missing opening '{{{' marker")
74
+ if not command:
75
+ raise ValueError("Invalid command block: missing command after '{{{' marker")
76
+
77
+ stdin_lines: List[str] = []
78
+ index = start_index + 1
79
+
80
+ while index < len(raw_lines):
81
+ raw_line = raw_lines[index]
82
+ if cls.is_closing_line(raw_line):
83
+ return ParsedCommandBlock(command, "".join(stdin_lines)), index + 1
84
+
85
+ stdin_lines.append(raw_line)
86
+ index += 1
87
+
88
+ raise ValueError("Invalid command block: missing closing '}}}'")
89
+
90
+ @staticmethod
91
+ def run_command(command: str, stdin_text: str) -> List[str]:
92
+ """Execute ``command`` with ``stdin_text`` piped to standard input.
93
+
94
+ The command line is split with ``shlex.split`` and executed with
95
+ ``shell=False`` so arguments are parsed predictably and shell expansion is not
96
+ applied implicitly. Standard output is returned as a list of raw lines that
97
+ can be reinserted into the parser input stream. Non-zero exit codes and start
98
+ failures are reported as ``ValueError`` with the captured error details.
99
+ """
100
+ argv = shlex.split(command)
101
+ if not argv:
102
+ raise ValueError("Invalid command block: empty command")
103
+
104
+ try:
105
+ completed = subprocess.run(
106
+ argv,
107
+ input=stdin_text,
108
+ text=True,
109
+ capture_output=True,
110
+ check=False,
111
+ )
112
+ except OSError as e:
113
+ raise ValueError(f"Command block failed to start: {command}: {e}") from e
114
+
115
+ if completed.returncode != 0:
116
+ stderr = completed.stderr.rstrip("\n")
117
+ if stderr:
118
+ raise ValueError(
119
+ f"Command block failed with exit code {completed.returncode}: {command}: {stderr}"
120
+ )
121
+ raise ValueError(
122
+ f"Command block failed with exit code {completed.returncode}: {command}"
123
+ )
124
+
125
+ return completed.stdout.splitlines(keepends=True)
126
+
127
+ @classmethod
128
+ def expand_blocks(cls, raw_lines: List[str]) -> List[str]:
129
+ """Replace every command block in ``raw_lines`` with the command's output.
130
+
131
+ The returned list can be fed directly into the rest of the parsing pipeline.
132
+ Lines that are not part of a command block are preserved unchanged. Command
133
+ output is inserted verbatim so it can contain normal selector lines, blank
134
+ lines, or even structural markers such as the two-column delimiters.
135
+ """
136
+ expanded: List[str] = []
137
+ index = 0
138
+
139
+ while index < len(raw_lines):
140
+ command = cls.parse_opening_line(raw_lines[index])
141
+ if command is None:
142
+ expanded.append(raw_lines[index])
143
+ index += 1
144
+ continue
145
+
146
+ block, index = cls.parse_block(raw_lines, index)
147
+ expanded.extend(cls.run_command(block.command, block.stdin_text))
148
+
149
+ return expanded
150
+