streamdown 0.26.0__tar.gz → 0.28.0__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.
- streamdown-0.26.0/tools/deploy.sh → streamdown-0.28.0/2q +1 -1
- {streamdown-0.26.0 → streamdown-0.28.0}/PKG-INFO +22 -11
- {streamdown-0.26.0 → streamdown-0.28.0}/README.md +20 -10
- {streamdown-0.26.0 → streamdown-0.28.0}/pyproject.toml +2 -1
- {streamdown-0.26.0 → streamdown-0.28.0}/requirements.txt +1 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/streamdown/sd.py +102 -77
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/chunk-buffer.sh +1 -0
- streamdown-0.28.0/tests/dimcheck.md +7 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/fizzbuzz.md +1 -1
- streamdown-0.28.0/tests/list-test.md +5 -0
- streamdown-0.28.0/tests/uline.md +51 -0
- streamdown-0.28.0/tools/deploy.sh +17 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/.gitignore +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/LICENSE.MIT +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/streamdown/__init__.py +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/streamdown/plugins/README.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/streamdown/plugins/latex.py +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/README.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/backtick-with-post-spaces.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/blankie.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/block.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/bold_reset_with_link.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/broken-code.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/broken-example.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/cjk-table.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/cjk-wrap.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/code.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/example.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/inline.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/jimmy_webb.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/line-buffer.sh +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/line-wrap.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/links.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/managerie.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/mandlebrot.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/markdown.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/nested-example.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/outline.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/pvgo_512.jpg +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/pythonvgo.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/qwen3.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/rerun.zsh +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/slash.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/strip-chunks.sh +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/table-break.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/table_test.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/test.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/test_input.md +0 -0
- {streamdown-0.26.0 → streamdown-0.28.0}/tests/wm.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: streamdown
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.0
|
|
4
4
|
Summary: A streaming markdown renderer for modern terminals with syntax highlighting
|
|
5
5
|
Project-URL: Homepage, https://github.com/kristopolous/Streamdown
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/kristopolous/Streamdown/issues
|
|
@@ -25,6 +25,7 @@ Requires-Dist: pygments
|
|
|
25
25
|
Requires-Dist: pylatexenc
|
|
26
26
|
Requires-Dist: term-image
|
|
27
27
|
Requires-Dist: toml
|
|
28
|
+
Requires-Dist: wcwidth
|
|
28
29
|
Description-Content-Type: text/markdown
|
|
29
30
|
|
|
30
31
|
<p align="center">
|
|
@@ -38,9 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
38
39
|
|
|
39
40
|
Streamdown works with any streaming markdown such as [simonw's llm](https://github.com/simonw/llm) or even something basic like curl.
|
|
40
41
|
|
|
41
|
-
It
|
|
42
|
-
|
|
43
|
-
You can also just use it like a normal person.
|
|
42
|
+
It just works.
|
|
44
43
|
|
|
45
44
|
It supports standard piping and files as arguments like any normal pager but can also run as a wrapper so you retain full keyboard interactivity. Arrow keys, control, alt, all still work.
|
|
46
45
|
```bash
|
|
@@ -48,16 +47,24 @@ $ pip install streamdown
|
|
|
48
47
|
```
|
|
49
48
|

|
|
50
49
|
|
|
50
|
+
## Fast and Realtime.
|
|
51
|
+
Watch Streamdown run over a FIFO pipe through `tee` in tmux on an M4 using BitNet. This is run straight. No clever unbuffering tricks. You can see the unstructured content on the right and the realtime Streamdown render on the left.
|
|
52
|
+
|
|
53
|
+
[bitnet.webm](https://github.com/user-attachments/assets/62eb625e-82c4-462d-9991-ed681d6fbcd0)
|
|
54
|
+
|
|
55
|
+
|
|
51
56
|
### Provides clean copyable code for long code lines
|
|
52
57
|
Other renderers inject line breaks when copying code that wraps around. Streamdown's better and now you are too!
|
|
58
|
+
|
|
59
|
+
Set `PrettyBroken` and `PrettyPad` to False to make Streamdown ensure code is always cleanly mouse Copyable
|
|
53
60
|

|
|
54
|
-
|
|
61
|
+
|
|
55
62
|
|
|
56
63
|
### Supports images
|
|
57
64
|
Here's kitty and alacritty.
|
|
58
65
|

|
|
59
66
|
|
|
60
|
-
###
|
|
67
|
+
### hyperlinks (OSC 8) and clipboard (OSC 52)
|
|
61
68
|
The optional `Clipboard` feature puts the final codeblock into your clipboard. See below for details.
|
|
62
69
|
|
|
63
70
|
[links.webm](https://github.com/user-attachments/assets/a5f71791-7c58-4183-ad3b-309f470c08a3)
|
|
@@ -70,7 +77,7 @@ This allows you to interactively debug in a way that the agent doesn't just wan
|
|
|
70
77
|
It takes about 2 minutes to set up and about 0.2s to use. Fast, fluid and free.
|
|
71
78
|

|
|
72
79
|
|
|
73
|
-
### ...
|
|
80
|
+
### ...even CJK
|
|
74
81
|
Compare how streamdown wraps and spaces this tabular Chinese description of programming languages to other leading markdown renderers.
|
|
75
82
|
|
|
76
83
|
Only one generates the text without truncation. 很美!
|
|
@@ -84,6 +91,10 @@ For instance, here is the [latex plugin](https://github.com/kristopolous/Streamd
|
|
|
84
91
|

|
|
85
92
|
|
|
86
93
|
|
|
94
|
+
|
|
95
|
+
It is designed for AI and can be used to do parser based sophisticated pipelines and routing, cracking open various monolithic AI solutions to permit them to integrate. Think of it as output level routing at the semantic level.
|
|
96
|
+
|
|
97
|
+
You can also just use it like a normal person.
|
|
87
98
|
## Configuration
|
|
88
99
|
|
|
89
100
|
It's located at `~/.config/streamdown/config.toml` (following the XDG Base Directory Specification). If this file does not exist upon first run, it will be created with default values.
|
|
@@ -105,10 +116,10 @@ The default values are [at the beginning of the source](https://github.com/krist
|
|
|
105
116
|
* `Bright`: Multipliers for level 2 headers.
|
|
106
117
|
* `Margin` (integer, default: `2`): The left and right indent for the output.
|
|
107
118
|
* `Width` (integer, default: `0`): Along with the `Margin`, `Width` specifies the base width of the content, which when set to 0, means use the terminal width. See [#6](https://github.com/kristopolous/Streamdown/issues/6) for more details
|
|
108
|
-
* `PrettyPad` (boolean, default: `
|
|
109
|
-
* `PrettyBroken` (boolean, default: `
|
|
119
|
+
* `PrettyPad` (boolean, default: `true`): Uses a unicode vertical pad trick to add a half height background to code blocks. This makes copy/paste have artifacts. See [#2](https://github.com/kristopolous/Streamdown/issues/2). I like it on. But that's just me
|
|
120
|
+
* `PrettyBroken` (boolean, default: `true`): This will break the copy/paste assurance above. The output is much prettier, but it's also broken. So it's pretty broken. Works nicely with PrettyPad.
|
|
110
121
|
* `ListIndent` (integer, default: `2`): This is the recursive indent for the list styles.
|
|
111
|
-
* `Syntax` (string, default `
|
|
122
|
+
* `Syntax` (string, default `native`): This is the syntax [highlighting theme which come via pygments](https://pygments.org/styles/).
|
|
112
123
|
|
|
113
124
|
Example:
|
|
114
125
|
```toml
|
|
@@ -168,7 +179,7 @@ optional arguments:
|
|
|
168
179
|
Set the logging level
|
|
169
180
|
-b BASE, --base BASE Set the hsv base: h,s,v
|
|
170
181
|
-c CONFIG, --config CONFIG
|
|
171
|
-
Use a custom config
|
|
182
|
+
Use a custom config override
|
|
172
183
|
-w WIDTH, --width WIDTH
|
|
173
184
|
Set the width WIDTH
|
|
174
185
|
-e EXEC, --exec EXEC Wrap a program EXEC for more 'proper' i/o handling
|
|
@@ -9,9 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
Streamdown works with any streaming markdown such as [simonw's llm](https://github.com/simonw/llm) or even something basic like curl.
|
|
11
11
|
|
|
12
|
-
It
|
|
13
|
-
|
|
14
|
-
You can also just use it like a normal person.
|
|
12
|
+
It just works.
|
|
15
13
|
|
|
16
14
|
It supports standard piping and files as arguments like any normal pager but can also run as a wrapper so you retain full keyboard interactivity. Arrow keys, control, alt, all still work.
|
|
17
15
|
```bash
|
|
@@ -19,16 +17,24 @@ $ pip install streamdown
|
|
|
19
17
|
```
|
|
20
18
|

|
|
21
19
|
|
|
20
|
+
## Fast and Realtime.
|
|
21
|
+
Watch Streamdown run over a FIFO pipe through `tee` in tmux on an M4 using BitNet. This is run straight. No clever unbuffering tricks. You can see the unstructured content on the right and the realtime Streamdown render on the left.
|
|
22
|
+
|
|
23
|
+
[bitnet.webm](https://github.com/user-attachments/assets/62eb625e-82c4-462d-9991-ed681d6fbcd0)
|
|
24
|
+
|
|
25
|
+
|
|
22
26
|
### Provides clean copyable code for long code lines
|
|
23
27
|
Other renderers inject line breaks when copying code that wraps around. Streamdown's better and now you are too!
|
|
28
|
+
|
|
29
|
+
Set `PrettyBroken` and `PrettyPad` to False to make Streamdown ensure code is always cleanly mouse Copyable
|
|
24
30
|

|
|
25
|
-
|
|
31
|
+
|
|
26
32
|
|
|
27
33
|
### Supports images
|
|
28
34
|
Here's kitty and alacritty.
|
|
29
35
|

|
|
30
36
|
|
|
31
|
-
###
|
|
37
|
+
### hyperlinks (OSC 8) and clipboard (OSC 52)
|
|
32
38
|
The optional `Clipboard` feature puts the final codeblock into your clipboard. See below for details.
|
|
33
39
|
|
|
34
40
|
[links.webm](https://github.com/user-attachments/assets/a5f71791-7c58-4183-ad3b-309f470c08a3)
|
|
@@ -41,7 +47,7 @@ This allows you to interactively debug in a way that the agent doesn't just wan
|
|
|
41
47
|
It takes about 2 minutes to set up and about 0.2s to use. Fast, fluid and free.
|
|
42
48
|

|
|
43
49
|
|
|
44
|
-
### ...
|
|
50
|
+
### ...even CJK
|
|
45
51
|
Compare how streamdown wraps and spaces this tabular Chinese description of programming languages to other leading markdown renderers.
|
|
46
52
|
|
|
47
53
|
Only one generates the text without truncation. 很美!
|
|
@@ -55,6 +61,10 @@ For instance, here is the [latex plugin](https://github.com/kristopolous/Streamd
|
|
|
55
61
|

|
|
56
62
|
|
|
57
63
|
|
|
64
|
+
|
|
65
|
+
It is designed for AI and can be used to do parser based sophisticated pipelines and routing, cracking open various monolithic AI solutions to permit them to integrate. Think of it as output level routing at the semantic level.
|
|
66
|
+
|
|
67
|
+
You can also just use it like a normal person.
|
|
58
68
|
## Configuration
|
|
59
69
|
|
|
60
70
|
It's located at `~/.config/streamdown/config.toml` (following the XDG Base Directory Specification). If this file does not exist upon first run, it will be created with default values.
|
|
@@ -76,10 +86,10 @@ The default values are [at the beginning of the source](https://github.com/krist
|
|
|
76
86
|
* `Bright`: Multipliers for level 2 headers.
|
|
77
87
|
* `Margin` (integer, default: `2`): The left and right indent for the output.
|
|
78
88
|
* `Width` (integer, default: `0`): Along with the `Margin`, `Width` specifies the base width of the content, which when set to 0, means use the terminal width. See [#6](https://github.com/kristopolous/Streamdown/issues/6) for more details
|
|
79
|
-
* `PrettyPad` (boolean, default: `
|
|
80
|
-
* `PrettyBroken` (boolean, default: `
|
|
89
|
+
* `PrettyPad` (boolean, default: `true`): Uses a unicode vertical pad trick to add a half height background to code blocks. This makes copy/paste have artifacts. See [#2](https://github.com/kristopolous/Streamdown/issues/2). I like it on. But that's just me
|
|
90
|
+
* `PrettyBroken` (boolean, default: `true`): This will break the copy/paste assurance above. The output is much prettier, but it's also broken. So it's pretty broken. Works nicely with PrettyPad.
|
|
81
91
|
* `ListIndent` (integer, default: `2`): This is the recursive indent for the list styles.
|
|
82
|
-
* `Syntax` (string, default `
|
|
92
|
+
* `Syntax` (string, default `native`): This is the syntax [highlighting theme which come via pygments](https://pygments.org/styles/).
|
|
83
93
|
|
|
84
94
|
Example:
|
|
85
95
|
```toml
|
|
@@ -139,7 +149,7 @@ optional arguments:
|
|
|
139
149
|
Set the logging level
|
|
140
150
|
-b BASE, --base BASE Set the hsv base: h,s,v
|
|
141
151
|
-c CONFIG, --config CONFIG
|
|
142
|
-
Use a custom config
|
|
152
|
+
Use a custom config override
|
|
143
153
|
-w WIDTH, --width WIDTH
|
|
144
154
|
Set the width WIDTH
|
|
145
155
|
-e EXEC, --exec EXEC Wrap a program EXEC for more 'proper' i/o handling
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "streamdown"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.28.0"
|
|
8
8
|
description = "A streaming markdown renderer for modern terminals with syntax highlighting"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -30,6 +30,7 @@ dependencies = [
|
|
|
30
30
|
"pygments",
|
|
31
31
|
"appdirs",
|
|
32
32
|
"toml",
|
|
33
|
+
"wcwidth",
|
|
33
34
|
"pylatexenc",
|
|
34
35
|
'term-image'
|
|
35
36
|
]
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
# "pylatexenc",
|
|
7
7
|
# "appdirs",
|
|
8
8
|
# "term-image",
|
|
9
|
+
# "wcwidth",
|
|
9
10
|
# "toml"
|
|
10
11
|
# ]
|
|
11
12
|
# ///
|
|
@@ -25,13 +26,13 @@ import termios, tty
|
|
|
25
26
|
import math
|
|
26
27
|
import re
|
|
27
28
|
import shutil
|
|
28
|
-
import subprocess
|
|
29
29
|
import traceback
|
|
30
30
|
import colorsys
|
|
31
31
|
import base64
|
|
32
32
|
from io import BytesIO
|
|
33
33
|
from term_image.image import from_file, from_url
|
|
34
34
|
import pygments.util
|
|
35
|
+
from wcwidth import wcwidth
|
|
35
36
|
from functools import reduce
|
|
36
37
|
from argparse import ArgumentParser
|
|
37
38
|
from pygments import highlight
|
|
@@ -64,27 +65,34 @@ Mid = { H = 1.00, S = 1.00, V = 0.50 }
|
|
|
64
65
|
Symbol = { H = 1.00, S = 1.00, V = 1.50 }
|
|
65
66
|
Head = { H = 1.00, S = 1.00, V = 1.75 }
|
|
66
67
|
Grey = { H = 1.00, S = 0.25, V = 1.37 }
|
|
67
|
-
Bright = { H = 1.00, S =
|
|
68
|
-
Syntax = "
|
|
68
|
+
Bright = { H = 1.00, S = 0.60, V = 2.00 }
|
|
69
|
+
Syntax = "native"
|
|
69
70
|
"""
|
|
70
71
|
|
|
71
72
|
def ensure_config_file(config):
|
|
73
|
+
config_dir = appdirs.user_config_dir("streamdown")
|
|
74
|
+
os.makedirs(config_dir, exist_ok=True)
|
|
75
|
+
config_path = os.path.join(config_dir, "config.toml")
|
|
76
|
+
if not os.path.exists(config_path):
|
|
77
|
+
open(config_path, 'w').write(default_toml)
|
|
78
|
+
|
|
79
|
+
toml_res = toml.load(config_path)
|
|
80
|
+
|
|
72
81
|
if config:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if not os.path.exists(config_path):
|
|
79
|
-
open(config_path, 'w').write(default_toml)
|
|
82
|
+
if os.path.exists(config):
|
|
83
|
+
config_string = open(config).read()
|
|
84
|
+
else:
|
|
85
|
+
config_string = config
|
|
86
|
+
toml_res |= toml.loads(config_string)
|
|
80
87
|
|
|
81
|
-
return
|
|
88
|
+
return toml_res
|
|
82
89
|
|
|
83
90
|
|
|
84
91
|
FG = "\033[38;2;"
|
|
85
92
|
BG = "\033[48;2;"
|
|
86
93
|
RESET = "\033[0m"
|
|
87
94
|
FGRESET = "\033[39m"
|
|
95
|
+
FORMATRESET = "\033[24;23;22m"
|
|
88
96
|
BGRESET = "\033[49m"
|
|
89
97
|
|
|
90
98
|
BOLD = ["\033[1m", "\033[22m"]
|
|
@@ -99,10 +107,11 @@ ANSIESCAPE = r'\033(?:\[[0-9;?]*[a-zA-Z]|][0-9]*;;.*?\\|\\)'
|
|
|
99
107
|
KEYCODE_RE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
|
100
108
|
|
|
101
109
|
visible = lambda x: re.sub(ANSIESCAPE, "", x)
|
|
102
|
-
#
|
|
103
|
-
visible_length = lambda x:
|
|
110
|
+
# many characters have different widths
|
|
111
|
+
visible_length = lambda x: sum(wcwidth(c) for c in visible(x))
|
|
104
112
|
extract_ansi_codes = lambda text: re.findall(ESCAPE, text)
|
|
105
113
|
remove_ansi = lambda line, codeList: reduce(lambda line, code: line.replace(code, ''), codeList, line)
|
|
114
|
+
split_up = lambda line: re.findall(r'(\x1b[^m]*m|[^\x1b]*)', line)
|
|
106
115
|
|
|
107
116
|
def gettmpdir():
|
|
108
117
|
tmp_dir_all = os.path.join(tempfile.gettempdir(), "sd")
|
|
@@ -206,7 +215,8 @@ class ParseState:
|
|
|
206
215
|
return offset + (state.current_width(listwidth = True) if Style.PrettyBroken else self.WidthFull)
|
|
207
216
|
|
|
208
217
|
def current_width(self, listwidth = False):
|
|
209
|
-
|
|
218
|
+
# this will double count the left margin
|
|
219
|
+
return self.Width - (len(visible(self.space_left(listwidth)))) + Style.Margin
|
|
210
220
|
|
|
211
221
|
def space_left(self, listwidth = False):
|
|
212
222
|
pre = ' ' * (len(state.list_item_stack)) * Style.ListIndent if listwidth else ''
|
|
@@ -235,7 +245,7 @@ def format_table(rowList):
|
|
|
235
245
|
|
|
236
246
|
# Calculate max width per column (integer division)
|
|
237
247
|
# Subtract num_cols + 1 for the vertical borders '│'
|
|
238
|
-
available_width = state.current_width() - (num_cols
|
|
248
|
+
available_width = state.current_width() - (num_cols * 2)
|
|
239
249
|
|
|
240
250
|
width_base = available_width // num_cols
|
|
241
251
|
width_mod = available_width % num_cols
|
|
@@ -249,7 +259,7 @@ def format_table(rowList):
|
|
|
249
259
|
# you are styling, do it before here!
|
|
250
260
|
for ix in range(len(rowList)):
|
|
251
261
|
row = rowList[ix]
|
|
252
|
-
wrapped_cell = text_wrap(row, width=col_width_list[ix], force_truncate=True)
|
|
262
|
+
wrapped_cell = text_wrap(row, width=col_width_list[ix], force_truncate=True, preserve_format=True)
|
|
253
263
|
|
|
254
264
|
# Ensure at least one line, even for empty cells
|
|
255
265
|
if not wrapped_cell:
|
|
@@ -290,17 +300,17 @@ def emit_h(level, text):
|
|
|
290
300
|
for text in lineList:
|
|
291
301
|
spaces_to_center = (state.current_width() - visible_length(text)) / 2
|
|
292
302
|
if level == 1: #
|
|
293
|
-
res.append(f"{state.space_left()}\n{state.space_left()}{BOLD[
|
|
303
|
+
res.append(f"{state.space_left()}\n{state.space_left()}{BOLD[0]}{' ' * math.floor(spaces_to_center)}{text}{BOLD[1]}\n")
|
|
294
304
|
elif level == 2: ##
|
|
295
305
|
res.append(f"{state.space_left()}\n{state.space_left()}{BOLD[0]}{FG}{Style.Bright}{' ' * math.floor(spaces_to_center)}{text}{' ' * math.ceil(spaces_to_center)}{BOLD[1]}{FGRESET}")
|
|
296
306
|
elif level == 3: ###
|
|
297
|
-
res.append(f"{state.space_left()}{FG}{Style.Head}{BOLD[0]}{text}{
|
|
307
|
+
res.append(f"{state.space_left()}{FG}{Style.Head}{BOLD[0]}{text}{BOLD[1]}{FGRESET}")
|
|
298
308
|
elif level == 4: ####
|
|
299
|
-
res.append(f"{state.space_left()}{FG}{Style.Symbol}{text}{
|
|
309
|
+
res.append(f"{state.space_left()}{FG}{Style.Symbol}{BOLD[0]}{text}{BOLD[1]}{FGRESET}")
|
|
300
310
|
elif level == 5: #####
|
|
301
|
-
res.append(f"{state.space_left()}{text}{
|
|
311
|
+
res.append(f"{state.space_left()}{text}{FGRESET}")
|
|
302
312
|
else:
|
|
303
|
-
res.append(f"{state.space_left()}{FG}{Style.Grey}{text}{
|
|
313
|
+
res.append(f"{state.space_left()}{FG}{Style.Grey}{text}{FGRESET}")
|
|
304
314
|
return "\n".join(res)
|
|
305
315
|
|
|
306
316
|
def code_wrap(text_in):
|
|
@@ -379,19 +389,18 @@ def split_text(text):
|
|
|
379
389
|
text
|
|
380
390
|
) if x]
|
|
381
391
|
|
|
382
|
-
def text_wrap(text, width = -1, indent = 0, first_line_prefix="", subsequent_line_prefix="", force_truncate=False):
|
|
392
|
+
def text_wrap(text, width = -1, indent = 0, first_line_prefix="", subsequent_line_prefix="", force_truncate=False, preserve_format=False):
|
|
383
393
|
if width == -1:
|
|
384
394
|
width = state.Width
|
|
385
395
|
|
|
386
396
|
# The empty word clears the buffer at the end.
|
|
387
397
|
formatted = line_format(text)
|
|
388
|
-
#print(bytes(formatted, 'utf-8'), formatted)
|
|
389
398
|
words = split_text(formatted) + [""]
|
|
390
|
-
#print([bytes(i, 'utf-8') for i in words])
|
|
391
399
|
|
|
392
400
|
lines = []
|
|
393
401
|
current_line = ""
|
|
394
402
|
current_style = []
|
|
403
|
+
resetter = "" if preserve_format else FORMATRESET
|
|
395
404
|
|
|
396
405
|
oldword = ''
|
|
397
406
|
for word in words:
|
|
@@ -411,7 +420,7 @@ def text_wrap(text, width = -1, indent = 0, first_line_prefix="", subsequent_lin
|
|
|
411
420
|
else:
|
|
412
421
|
# Word doesn't fit, finalize the previous line
|
|
413
422
|
prefix = first_line_prefix if not lines else subsequent_line_prefix
|
|
414
|
-
line_content = prefix + current_line
|
|
423
|
+
line_content = prefix + current_line
|
|
415
424
|
# This is expensive, fix.
|
|
416
425
|
while force_truncate and visible_length(line_content) >= width:
|
|
417
426
|
line_content = line_content[:len(line_content) - 2] + "…"
|
|
@@ -423,7 +432,7 @@ def text_wrap(text, width = -1, indent = 0, first_line_prefix="", subsequent_lin
|
|
|
423
432
|
# that we have closed our hyperlink OSC
|
|
424
433
|
if LINK[0] in line_content:
|
|
425
434
|
line_content += LINK[1]
|
|
426
|
-
lines.append(line_content + state.bg + ' ' * margin)
|
|
435
|
+
lines.append(line_content + resetter + state.bg + ' ' * margin)
|
|
427
436
|
|
|
428
437
|
current_line = (" " * indent) + "".join(current_style) + word
|
|
429
438
|
|
|
@@ -438,23 +447,17 @@ def text_wrap(text, width = -1, indent = 0, first_line_prefix="", subsequent_lin
|
|
|
438
447
|
if len(lines) < 1:
|
|
439
448
|
return []
|
|
440
449
|
|
|
441
|
-
|
|
450
|
+
if len(lines) == 1:
|
|
451
|
+
lines[0] = lines[0].rstrip()
|
|
442
452
|
|
|
443
|
-
|
|
444
|
-
dbl_re = re.compile(
|
|
445
|
-
r'[\u2e80-\u2eff\u3000-\u303f\u3400-\u4dbf'
|
|
446
|
-
r'\U00004e00-\U00009fff\U0001f300-\U0001f6ff'
|
|
447
|
-
r'\U0001f900-\U0001f9ff\U0001fa70-\U0001faff]',
|
|
448
|
-
re.UNICODE
|
|
449
|
-
)
|
|
450
|
-
return len(dbl_re.findall(visible(s)))
|
|
453
|
+
return lines
|
|
451
454
|
|
|
452
455
|
def cjk_count(s):
|
|
453
456
|
cjk_re = re.compile(
|
|
454
457
|
r'[\u4E00-\u9FFF' # CJK Unified Ideographs
|
|
455
458
|
r'\u3400-\u4DBF' # CJK Unified Ideographs Extension A
|
|
456
459
|
r'\uF900-\uFAFF' # CJK Compatibility Ideographs
|
|
457
|
-
|
|
460
|
+
r'\uFF00-\uFFEF' # CJK Compatibility Punctuation
|
|
458
461
|
r'\u3000-\u303F' # CJK Symbols and Punctuation
|
|
459
462
|
r'\U0002F800-\U0002FA1F]' # CJK Compatibility Ideographs Supplement
|
|
460
463
|
)
|
|
@@ -692,11 +695,11 @@ def parse(stream):
|
|
|
692
695
|
|
|
693
696
|
# <code><pre>
|
|
694
697
|
if not state.in_code:
|
|
695
|
-
code_match = re.match(r"^\s
|
|
698
|
+
code_match = re.match(r"^\s*(```|<pre>)\s*([^\s]+|$)\s*$", line)
|
|
696
699
|
if code_match:
|
|
697
700
|
state.in_code = Code.Backtick
|
|
698
701
|
state.code_indent = len(line) - len(line.lstrip())
|
|
699
|
-
state.code_language = code_match.group(
|
|
702
|
+
state.code_language = code_match.group(2) or 'Bash'
|
|
700
703
|
|
|
701
704
|
elif state.CodeSpaces and last_line_empty_cache and not state.in_list:
|
|
702
705
|
code_match = re.match(r"^ \s*[^\s\*]", line)
|
|
@@ -726,7 +729,7 @@ def parse(stream):
|
|
|
726
729
|
if state.in_code:
|
|
727
730
|
try:
|
|
728
731
|
# This is turning it OFF
|
|
729
|
-
if ( ( state.in_code == Code.Backtick and line.strip()
|
|
732
|
+
if ( ( state.in_code == Code.Backtick and line.strip() in ["</pre>", "```"] ) or
|
|
730
733
|
(state.CodeSpaces and state.in_code == Code.Spaces and not line.startswith(' ')) ):
|
|
731
734
|
if state.scrape:
|
|
732
735
|
ext = "sh"
|
|
@@ -773,7 +776,8 @@ def parse(stream):
|
|
|
773
776
|
try:
|
|
774
777
|
lexer = get_lexer_by_name(state.code_language)
|
|
775
778
|
custom_style = override_background(Style.Syntax, ansi2hex(Style.Dark))
|
|
776
|
-
except pygments.util.ClassNotFound:
|
|
779
|
+
except pygments.util.ClassNotFound as e:
|
|
780
|
+
logging.debug(e)
|
|
777
781
|
lexer = get_lexer_by_name("Bash")
|
|
778
782
|
custom_style = override_background("default", ansi2hex(Style.Dark))
|
|
779
783
|
|
|
@@ -806,37 +810,56 @@ def parse(stream):
|
|
|
806
810
|
# then naively search back until our visible_lengths() match. This is not fast and there's certainly smarter
|
|
807
811
|
# ways of doing it but this thing is way trickery than you think
|
|
808
812
|
highlighted_code = highlight(state.code_buffer + tline, lexer, formatter)
|
|
809
|
-
#print("(",highlighted_code,")")
|
|
813
|
+
#print("(",bytes(highlighted_code,'utf-8'),")")
|
|
814
|
+
parts = split_up(highlighted_code)
|
|
810
815
|
|
|
811
816
|
# Sometimes the highlighter will do things like a full reset or a background reset.
|
|
812
817
|
# This is mostly not what we want
|
|
813
|
-
|
|
818
|
+
parts = [ re.sub(r"\033\[[34]9(;00|)m", FORMATRESET, x) for x in parts]
|
|
814
819
|
|
|
815
820
|
# Since we are streaming we ignore the resets and newlines at the end
|
|
816
|
-
|
|
817
|
-
|
|
821
|
+
while parts[-1] in [FGRESET, FORMATRESET]:
|
|
822
|
+
parts.pop()
|
|
823
|
+
|
|
824
|
+
tline_len = visible_length(tline)
|
|
825
|
+
|
|
826
|
+
# now we find the new stuff:
|
|
827
|
+
ttl = 0
|
|
828
|
+
for i in range(len(parts)-1, 0, -1):
|
|
829
|
+
idx = parts[i]
|
|
830
|
+
if len(idx) == 0:
|
|
831
|
+
continue
|
|
832
|
+
|
|
833
|
+
ttl += len(idx) if idx[0] != '\x1b' else 0
|
|
818
834
|
|
|
819
|
-
|
|
820
|
-
|
|
835
|
+
if ttl > 1+tline_len:
|
|
836
|
+
break
|
|
821
837
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
838
|
+
newlen = visible_length("".join(parts[i:]))
|
|
839
|
+
|
|
840
|
+
snipfrom = newlen - len(tline) + 2
|
|
841
|
+
if snipfrom > 0:
|
|
842
|
+
parts[i] = parts[i][snipfrom:]
|
|
825
843
|
|
|
826
844
|
state.code_buffer += tline
|
|
845
|
+
this_batch = "".join(parts[i:])
|
|
827
846
|
|
|
828
|
-
this_batch = highlighted_code[state.code_gen-delta :]
|
|
829
847
|
if this_batch.startswith(FGRESET):
|
|
830
848
|
this_batch = this_batch[len(FGRESET) :]
|
|
831
849
|
|
|
850
|
+
# clean it before prepending with potential format
|
|
851
|
+
this_batch = this_batch.strip()
|
|
852
|
+
while i - 1 >= 0 and parts[i-1] and parts[i-1][0] == '\x1b':
|
|
853
|
+
this_batch = parts[i-1] + this_batch
|
|
854
|
+
i -= 1
|
|
855
|
+
|
|
832
856
|
## this is the crucial counter that will determine
|
|
833
|
-
# the
|
|
857
|
+
# the beginning of the next line
|
|
834
858
|
state.code_gen = len(highlighted_code)
|
|
835
|
-
|
|
836
859
|
code_line = ' ' * indent + this_batch.strip()
|
|
837
860
|
|
|
838
861
|
margin = state.full_width( -len(pre[1]) ) - visible_length(code_line) % state.WidthFull
|
|
839
|
-
yield f"{pre[0]}{Style.Codebg}{pre[1]}{code_line}{' ' * max(0, margin)}{BGRESET}"
|
|
862
|
+
yield f"{pre[0]}{Style.Codebg}{pre[1]}{code_line}{FORMATRESET}{' ' * max(0, margin)}{BGRESET}"
|
|
840
863
|
continue
|
|
841
864
|
except Goto:
|
|
842
865
|
pass
|
|
@@ -918,7 +941,7 @@ def parse(stream):
|
|
|
918
941
|
# a weird thing
|
|
919
942
|
if state.in_list:
|
|
920
943
|
indent = (len(state.list_item_stack) - 1) * Style.ListIndent #+ (len(bullet) - 1)
|
|
921
|
-
wrap_width = state.current_width(
|
|
944
|
+
wrap_width = state.current_width(listwidth = True) - Style.ListIndent
|
|
922
945
|
|
|
923
946
|
wrapped_lineList = text_wrap(content, wrap_width, Style.ListIndent,
|
|
924
947
|
first_line_prefix = f"{(' ' * indent)}{FG}{Style.Symbol}{bullet}{RESET} ",
|
|
@@ -956,7 +979,7 @@ def parse(stream):
|
|
|
956
979
|
state.list_item_stack = []
|
|
957
980
|
|
|
958
981
|
if len(line) == 0: yield ""
|
|
959
|
-
if
|
|
982
|
+
if visible_length(line) < state.Width:
|
|
960
983
|
# we want to prevent word wrap
|
|
961
984
|
yield f"{state.space_left()}{line_format(line.lstrip())}"
|
|
962
985
|
else:
|
|
@@ -1016,20 +1039,19 @@ def ansi2hex(ansi_code):
|
|
|
1016
1039
|
def apply_multipliers(style, name, H, S, V):
|
|
1017
1040
|
m = style.get(name)
|
|
1018
1041
|
r, g, b = colorsys.hsv_to_rgb(min(1.0, H * m["H"]), min(1.0, S * m["S"]), min(1.0, V * m["V"]))
|
|
1019
|
-
return ';'.join([str(int(x *
|
|
1042
|
+
return ';'.join([str(int(x * 255)) for x in [r, g, b]]) + "m"
|
|
1020
1043
|
|
|
1021
1044
|
def width_calc():
|
|
1022
|
-
if
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
pass
|
|
1045
|
+
if state.WidthArg:
|
|
1046
|
+
width = state.WidthArg
|
|
1047
|
+
else:
|
|
1048
|
+
try:
|
|
1049
|
+
width = shutil.get_terminal_size().columns
|
|
1050
|
+
state.WidthWrap = True
|
|
1051
|
+
except (AttributeError, OSError):
|
|
1052
|
+
# this means it's a pager, we can just ignore the base64 clipboard
|
|
1053
|
+
width = 80
|
|
1054
|
+
pass
|
|
1033
1055
|
|
|
1034
1056
|
|
|
1035
1057
|
# This can't be done because our list item stack can change as well so
|
|
@@ -1052,7 +1074,7 @@ def main():
|
|
|
1052
1074
|
parser.add_argument("filenameList", nargs="*", help="Input file to process (also takes stdin)")
|
|
1053
1075
|
parser.add_argument("-l", "--loglevel", default="INFO", help="Set the logging level")
|
|
1054
1076
|
parser.add_argument("-b", "--base", default=None, help="Set the hsv base: h,s,v")
|
|
1055
|
-
parser.add_argument("-c", "--config", default=None, help="Use a custom config")
|
|
1077
|
+
parser.add_argument("-c", "--config", default=None, help="Use a custom config override")
|
|
1056
1078
|
parser.add_argument("-w", "--width", default="0", help="Set the width WIDTH")
|
|
1057
1079
|
parser.add_argument("-e", "--exec", help="Wrap a program EXEC for more 'proper' i/o handling")
|
|
1058
1080
|
parser.add_argument("-s", "--scrape", help="Scrape code snippets to a directory SCRAPE")
|
|
@@ -1060,18 +1082,21 @@ def main():
|
|
|
1060
1082
|
args = parser.parse_args()
|
|
1061
1083
|
|
|
1062
1084
|
if args.version:
|
|
1063
|
-
import importlib.metadata
|
|
1064
|
-
|
|
1065
1085
|
try:
|
|
1066
|
-
|
|
1086
|
+
import importlib.metadata
|
|
1087
|
+
print(importlib.metadata.version("streamdown"))
|
|
1067
1088
|
except importlib.metadata.PackageNotFoundError:
|
|
1068
|
-
|
|
1089
|
+
import subprocess
|
|
1090
|
+
print(subprocess.run(
|
|
1091
|
+
['git', 'describe', '--always', '--dirty', '--tags'],
|
|
1092
|
+
cwd=os.path.dirname(os.path.abspath(__file__)),
|
|
1093
|
+
stdout=subprocess.PIPE,
|
|
1094
|
+
text=True
|
|
1095
|
+
).stdout.strip())
|
|
1069
1096
|
|
|
1070
|
-
print(f"Streamdown - {version}")
|
|
1071
1097
|
sys.exit(0)
|
|
1072
1098
|
|
|
1073
|
-
|
|
1074
|
-
config = toml.loads(config_toml_content)
|
|
1099
|
+
config = ensure_config_file(args.config)
|
|
1075
1100
|
style = toml.loads(default_toml).get('style') | config.get("style", {})
|
|
1076
1101
|
features = toml.loads(default_toml).get('features') | config.get("features", {})
|
|
1077
1102
|
H, S, V = style.get("HSV")
|
|
@@ -1114,7 +1139,7 @@ def main():
|
|
|
1114
1139
|
# Set stdin to raw mode so we don't need to press enter
|
|
1115
1140
|
tty.setcbreak(sys.stdin.fileno())
|
|
1116
1141
|
sys.stdout.write("\x1b[?7h")
|
|
1117
|
-
emit(
|
|
1142
|
+
emit(inp)
|
|
1118
1143
|
|
|
1119
1144
|
elif args.filenameList:
|
|
1120
1145
|
# Let's say we only care about logging in streams
|
|
@@ -1142,7 +1167,7 @@ def main():
|
|
|
1142
1167
|
logging.warning(f"Exception thrown: {type(ex)} {ex}")
|
|
1143
1168
|
traceback.print_exc()
|
|
1144
1169
|
|
|
1145
|
-
if state.Clipboard and state.code_buffer_raw:
|
|
1170
|
+
if os.isatty(sys.stdout.fileno()) and state.Clipboard and state.code_buffer_raw:
|
|
1146
1171
|
code = state.code_buffer_raw
|
|
1147
1172
|
# code needs to be a base64 encoded string before emitting
|
|
1148
1173
|
code_bytes = code.encode('utf-8')
|
|
@@ -42,7 +42,7 @@ def fizzbuzz(n):
|
|
|
42
42
|
else:
|
|
43
43
|
print(i)
|
|
44
44
|
|
|
45
|
-
#
|
|
45
|
+
# 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
|
|
46
46
|
fizzbuzz(100)
|
|
47
47
|
|
|
48
48
|
# Example usage: different range:
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
🫣Here’s a DIY approach using Python to map text to Unicode’s **Mathematical Script** (or other math alphanumerics):
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
### Example Python Code for **Mathematical Script Letters**:
|
|
6
|
+
```python
|
|
7
|
+
def to_math_script(text: str) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Convert uppercase and lowercase letters to Mathematical Script (Unicode U+1D49C-1D4FD).
|
|
10
|
+
Non-alphabetic characters remain unchanged.
|
|
11
|
+
"""
|
|
12
|
+
🫣 res = []
|
|
13
|
+
for c in text:
|
|
14
|
+
if c.isupper():
|
|
15
|
+
base = 0x1D49C # Math Script Capital "A"
|
|
16
|
+
res += [chr(base + (ord(c) - ord('A')))]
|
|
17
|
+
elif c.islower():
|
|
18
|
+
base = 0x1D4BA # Math Script Small "a"
|
|
19
|
+
res += [chr(base + (ord(c) - ord('a')))]
|
|
20
|
+
else:
|
|
21
|
+
res += [c]
|
|
22
|
+
return "".join(res)
|
|
23
|
+
|
|
24
|
+
# Test:
|
|
25
|
+
print(to_math_script("Hello World!ΑΩ"))
|
|
26
|
+
# Output: ℍ Escorts(math script "ell o World")!ΑΩ
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
### Other Unicode Math Scripts:
|
|
32
|
+
- **Fraktur**: `U+1🫣D504`–`U+1D537` (` Francisco ` → 𝔣𝔯𝔞𝔨𝔱𝔲𝔯)
|
|
33
|
+
- **Bold Fraktur**: `U+1D56C`–`U+1D59F`
|
|
34
|
+
- **Double-struck (Blackboard)**: `U+1D538`–`U+1D55F` (category: `ℂℍℕℙℚℝ`).
|
|
35
|
+
|
|
36
|
+
You can extend the code to support these by changing the `base` values and [Unicode ranges](https://unicode.org/charts/PDF/U1D400.pdf).
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
### Resources:
|
|
41
|
+
1. **Unicode Charts**:
|
|
42
|
+
- [Math Alphanumeric Symbols](https://unicode.org/charts/PDF/U1D400.pdf).
|
|
43
|
+
2. **Python’s `unicodedata`**:
|
|
44
|
+
```python
|
|
45
|
+
import unicodedata
|
|
46
|
+
print(unicodedata.name("𝒜")) # "MATHEMATICAL SCRIPT CAPITAL A"
|
|
47
|
+
```
|
|
48
|
+
3. **Terminal Fonts**: Ensure your terminal/font supports [Unicode math symbols](https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols).
|
|
49
|
+
|
|
50
|
+
Let me know if you want to target a different script!🫣
|
|
51
|
+
🫣
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -eEuo pipefail
|
|
3
|
+
version=$(grep version pyproject.toml | cut -d '"' -f 2)
|
|
4
|
+
tag_update() {
|
|
5
|
+
git tag -m v$version v$version
|
|
6
|
+
git push --tags
|
|
7
|
+
}
|
|
8
|
+
pipy() {
|
|
9
|
+
source .venv/bin/activate
|
|
10
|
+
for i in pip hatch build; do
|
|
11
|
+
pip install --upgrade $i
|
|
12
|
+
done
|
|
13
|
+
python3 -m build .
|
|
14
|
+
twine upload dist/*${version}*
|
|
15
|
+
}
|
|
16
|
+
#tag_update
|
|
17
|
+
pipy
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|