codex-agent-framework 0.1.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,184 @@
1
+ """Lightweight text extraction using only standard library.
2
+
3
+ This is a fallback for when the full get_text.py dependencies are not available.
4
+ Supports basic file reading and URL fetching without external dependencies
5
+ (except urllib which is stdlib).
6
+ """
7
+
8
+ import os
9
+ import re
10
+ from typing import Optional
11
+ from urllib.request import urlopen
12
+ from urllib.error import URLError, HTTPError
13
+ from html.parser import HTMLParser
14
+
15
+
16
+ def get_text(source: str) -> str:
17
+ """Extract text from a file or URL using only stdlib.
18
+
19
+ This is a simplified version that handles:
20
+ - Plain text files
21
+ - URLs (basic HTTP/HTTPS fetching)
22
+ - Directory listings
23
+
24
+ Args:
25
+ source: File path or URL to extract text from
26
+
27
+ Returns:
28
+ Extracted text content, or error message
29
+ """
30
+ # Check if it's a URL
31
+ if source.startswith(('http://', 'https://')):
32
+ return _handle_url(source)
33
+
34
+ # Check if it's a directory
35
+ if os.path.isdir(source):
36
+ return _handle_directory(source)
37
+
38
+ # Handle as file
39
+ return _handle_file(source)
40
+
41
+
42
+ def _handle_file(path: str) -> str:
43
+ """Read a text file from disk.
44
+
45
+ Args:
46
+ path: Path to file
47
+
48
+ Returns:
49
+ File contents or error message
50
+ """
51
+ try:
52
+ with open(path, 'r', encoding='utf-8') as f:
53
+ return f.read()
54
+ except FileNotFoundError:
55
+ return f"File not found: {path}"
56
+ except PermissionError:
57
+ return f"Permission denied: {path}"
58
+ except UnicodeDecodeError:
59
+ # Try binary mode for non-text files
60
+ return f"Binary file (cannot read as text): {path}"
61
+ except Exception as e:
62
+ return f"Error reading file: {e}"
63
+
64
+
65
+ def _handle_directory(path: str) -> str:
66
+ """List directory contents.
67
+
68
+ Args:
69
+ path: Path to directory
70
+
71
+ Returns:
72
+ Directory listing as text
73
+ """
74
+ try:
75
+ entries = os.listdir(path)
76
+ if not entries:
77
+ return f"{path}/\n(empty directory)"
78
+
79
+ lines = [f"{path}/"]
80
+ for entry in sorted(entries):
81
+ full_path = os.path.join(path, entry)
82
+ if os.path.isdir(full_path):
83
+ lines.append(f" {entry}/")
84
+ else:
85
+ lines.append(f" {entry}")
86
+
87
+ return '\n'.join(lines)
88
+ except PermissionError:
89
+ return f"Permission denied: {path}"
90
+ except Exception as e:
91
+ return f"Error listing directory: {e}"
92
+
93
+
94
+ class _HTMLTextExtractor(HTMLParser):
95
+ """Simple HTML parser to extract text content using stdlib only."""
96
+
97
+ def __init__(self):
98
+ super().__init__()
99
+ self.text_parts = []
100
+ self.skip_tags = {'script', 'style', 'head', 'meta', 'link'}
101
+ self.current_tag = None
102
+
103
+ def handle_starttag(self, tag, attrs):
104
+ self.current_tag = tag
105
+
106
+ def handle_endtag(self, tag):
107
+ self.current_tag = None
108
+
109
+ def handle_data(self, data):
110
+ if self.current_tag not in self.skip_tags:
111
+ text = data.strip()
112
+ if text:
113
+ self.text_parts.append(text)
114
+
115
+ def get_text(self):
116
+ return '\n'.join(self.text_parts)
117
+
118
+
119
+ def _strip_html(html_content: str) -> str:
120
+ """Strip HTML tags and extract text content.
121
+
122
+ Args:
123
+ html_content: HTML content as string
124
+
125
+ Returns:
126
+ Plain text content
127
+ """
128
+ try:
129
+ parser = _HTMLTextExtractor()
130
+ parser.feed(html_content)
131
+ text = parser.get_text()
132
+
133
+ # Clean up excessive whitespace
134
+ text = re.sub(r'\n{3,}', '\n\n', text)
135
+
136
+ return text if text else html_content # Fallback to raw if parsing fails
137
+ except Exception:
138
+ # If parsing fails, return raw content
139
+ return html_content
140
+
141
+
142
+ def _handle_url(url: str) -> str:
143
+ """Fetch content from a URL and extract text.
144
+
145
+ Args:
146
+ url: URL to fetch
147
+
148
+ Returns:
149
+ URL content as text or error message
150
+ """
151
+ try:
152
+ with urlopen(url, timeout=30) as response:
153
+ # Read content
154
+ content = response.read()
155
+
156
+ # Try to decode as text
157
+ try:
158
+ html = content.decode('utf-8')
159
+ except UnicodeDecodeError:
160
+ # Try other common encodings
161
+ for encoding in ['latin-1', 'iso-8859-1', 'windows-1252']:
162
+ try:
163
+ html = content.decode(encoding)
164
+ break
165
+ except UnicodeDecodeError:
166
+ continue
167
+ else:
168
+ return f"Binary content (cannot decode as text): {url}"
169
+
170
+ # Strip HTML tags if it looks like HTML
171
+ if '<html' in html.lower() or '<body' in html.lower():
172
+ return _strip_html(html)
173
+ else:
174
+ # Plain text content
175
+ return html
176
+
177
+ except HTTPError as e:
178
+ return f"HTTP Error {e.code}: {url}"
179
+ except URLError as e:
180
+ return f"URL Error: {e.reason}"
181
+ except TimeoutError:
182
+ return f"Request timed out: {url}"
183
+ except Exception as e:
184
+ return f"Error fetching URL: {e}"
@@ -0,0 +1,44 @@
1
+ from selenium import webdriver
2
+ from selenium.webdriver.firefox.service import Service as FirefoxService
3
+ from selenium.webdriver.firefox.options import Options as FirefoxOptions
4
+ from get_gecko_driver import GetGeckoDriver
5
+ from shutil import which
6
+ import os
7
+
8
+ def get_webdriver():
9
+ """Get a headless Firefox WebDriver instance.
10
+
11
+ Automatically installs geckodriver if not found in PATH.
12
+ Configures Firefox in headless mode with standard options.
13
+
14
+ Returns:
15
+ selenium.webdriver.Firefox: Configured headless Firefox WebDriver.
16
+
17
+ Note:
18
+ Requires Firefox browser to be installed on the system.
19
+ """
20
+ from .utils import root_join
21
+ tmp_path=root_join("tmp")
22
+ if not os.path.isdir(tmp_path):
23
+ os.makedirs(tmp_path)
24
+ os.environ['TMPDIR']=tmp_path
25
+ path=which('geckodriver')
26
+ if not path:
27
+ # Install the latest version of GeckoDriver
28
+ get_driver = GetGeckoDriver()
29
+ path=get_driver.install()
30
+ print(f"geckodriver successfully installed to {path}")
31
+ else:
32
+ path=os.path.dirname(path)
33
+ # Set the driver
34
+ service=FirefoxService(log_path=os.path.devnull,executable_path=os.path.join(path,'geckodriver'))
35
+ options = FirefoxOptions()
36
+ # Enable headless mode
37
+ options.add_argument('--headless')
38
+ # Disable GPU (not necessary for headless)
39
+ options.add_argument('--disable-gpu')
40
+ # Set the window size (some websites require a minimum window size)
41
+ options.add_argument('--window-size=1920,1080')
42
+ # Instantiate a headless Firefox WebDriver
43
+ driver = webdriver.Firefox(options=options, service=service)
44
+ return driver
codex_agent/image.py ADDED
@@ -0,0 +1,304 @@
1
+ from .message import Message, register_message_type
2
+ from .utils import guess_extension_from_bytes, runtime_join, short_id, timestamp
3
+ import base64
4
+ import binascii
5
+ from collections.abc import Mapping
6
+ from io import BytesIO
7
+ import os
8
+ import re
9
+
10
+ from PIL import Image as PILImage
11
+ import requests
12
+ from urllib.parse import urlparse
13
+
14
+
15
+ IMAGE_CACHE_ATTR = 'b64_string'
16
+ IMAGE_DIR = 'images'
17
+
18
+
19
+ def ext_to_mime(ext):
20
+ ext = ext.lower().lstrip('.')
21
+ if ext in ('jpg', 'jpeg'):
22
+ return 'image/jpeg'
23
+ if ext == 'png':
24
+ return 'image/png'
25
+ if ext == 'webp':
26
+ return 'image/webp'
27
+ if ext == 'gif':
28
+ return 'image/gif'
29
+ return None
30
+
31
+
32
+ def _data_url(data, mime_type):
33
+ b64 = base64.b64encode(data).decode('utf-8')
34
+ return f"data:{mime_type};base64,{b64}"
35
+
36
+
37
+ def data_url_from_file(path):
38
+ ext = os.path.splitext(path)[1]
39
+ mime_type = ext_to_mime(ext) or 'image/jpeg'
40
+ with open(path, 'rb') as f:
41
+ return _data_url(f.read(), mime_type)
42
+
43
+
44
+ def image_request_headers(url):
45
+ """Return browser-like headers for remote image fetches.
46
+
47
+ Some image hosts/CDNs reject the default python-requests user agent even
48
+ for public images. A small, stable header set makes observe() much more
49
+ reliable without changing semantics.
50
+ """
51
+ parsed = urlparse(url)
52
+ referer = f"{parsed.scheme}://{parsed.netloc}/" if parsed.scheme and parsed.netloc else None
53
+ headers = {
54
+ "User-Agent": (
55
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
56
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36 codex-agent/observe"
57
+ ),
58
+ "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
59
+ }
60
+ if referer:
61
+ headers["Referer"] = referer
62
+ return headers
63
+
64
+
65
+ def image_mime_from_source(source, content_type=None):
66
+ content_type = (content_type or '').split(';')[0].strip()
67
+ if content_type.startswith('image/'):
68
+ return content_type
69
+ if isinstance(source, str):
70
+ ext = os.path.splitext(source.split('?', 1)[0])[1]
71
+ return ext_to_mime(ext) or 'image/jpeg'
72
+ return 'image/png'
73
+
74
+
75
+ def fetch_image_bytes(url, timeout=15):
76
+ response = requests.get(url, timeout=timeout, headers=image_request_headers(url))
77
+ response.raise_for_status()
78
+
79
+ data = response.content
80
+ if not data:
81
+ raise ValueError(f"URL resolved to an empty image response: {url!r}")
82
+
83
+ content_type = response.headers.get('content-type')
84
+ content_mime = (content_type or '').split(';')[0].strip()
85
+ guessed_mime = ext_to_mime(guess_extension_from_bytes(data) or '')
86
+
87
+ if content_mime and not content_mime.startswith('image/'):
88
+ if not guessed_mime:
89
+ raise ValueError(f"URL did not resolve to an image: {url!r} ({content_mime})")
90
+ return data, guessed_mime
91
+
92
+ return data, guessed_mime or image_mime_from_source(url, content_type)
93
+
94
+
95
+ def data_url_from_url(url):
96
+ data, mime_type = fetch_image_bytes(url)
97
+ return _data_url(data, mime_type)
98
+
99
+
100
+ def persist_image_url(url):
101
+ data, mime_type = fetch_image_bytes(url)
102
+ ext = os.path.splitext(url.split('?', 1)[0])[1] or None
103
+ mime_ext = {
104
+ 'image/jpeg': '.jpg',
105
+ 'image/png': '.png',
106
+ 'image/webp': '.webp',
107
+ 'image/gif': '.gif',
108
+ }.get(mime_type)
109
+ if mime_ext and ext_to_mime(ext or '') != mime_type:
110
+ ext = mime_ext
111
+ return persist_image_bytes(data, name=f"remote{ext or ''}")
112
+
113
+
114
+ def get_data_url(source):
115
+ if isinstance(source, str) and os.path.isfile(source):
116
+ return data_url_from_file(source)
117
+ if isinstance(source, str) and source.startswith(('http://', 'https://')):
118
+ return data_url_from_url(source)
119
+ raise ValueError(f"Unsupported image source: {source!r}")
120
+
121
+
122
+ def persist_image_bytes(data, name=None):
123
+ _, ext = os.path.splitext(name) if isinstance(name, str) else ('', '')
124
+ ext = ext or guess_extension_from_bytes(data) or '.png'
125
+
126
+ folder = runtime_join(IMAGE_DIR)
127
+ os.makedirs(folder, exist_ok=True)
128
+
129
+ path = runtime_join(IMAGE_DIR, f"{timestamp()}_{short_id()}{ext}")
130
+ with open(path, 'wb') as f:
131
+ f.write(data)
132
+ return path
133
+
134
+
135
+ def persist_bytesio(source):
136
+ return persist_image_bytes(source.getvalue(), name=getattr(source, 'name', None))
137
+
138
+
139
+ def image_name_from_source(source):
140
+ if isinstance(source, BytesIO) and getattr(source, 'name', None):
141
+ return os.path.basename(source.name)
142
+ if isinstance(source, str) and os.path.isfile(source):
143
+ return os.path.basename(source)
144
+ if source:
145
+ return str(source)
146
+ return 'Unnamed image'
147
+
148
+
149
+ def normalize_image_content(content):
150
+ if isinstance(content, BytesIO):
151
+ return persist_bytesio(content)
152
+ if isinstance(content, bytes):
153
+ return persist_image_bytes(content)
154
+ if content is not None and not isinstance(content, str):
155
+ raise TypeError(f"Image content must be a path, URL, BytesIO, or bytes. Got {type(content).__name__}.")
156
+ return content
157
+
158
+
159
+ @register_message_type
160
+ class ImageMessage(Message):
161
+ """Image message with a lightweight serialized payload.
162
+
163
+ The session stores only reloadable ``content``. The base64 data URL is
164
+ cached as runtime metadata and generated only when formatting API input.
165
+ """
166
+
167
+ detail = "auto"
168
+ description = None
169
+ image_name = None
170
+ embedding = None
171
+ name = 'image'
172
+ role = "user"
173
+
174
+ def __init__(self, *args, **kwargs):
175
+ args, kwargs, data_url, content_name = self._prepare_init_payload(args, kwargs)
176
+ super().__init__(*args, **kwargs)
177
+
178
+ if not self.get('image_name'):
179
+ self.image_name = content_name or image_name_from_source(self.get('content'))
180
+
181
+ if data_url:
182
+ self.set_attr(IMAGE_CACHE_ATTR, data_url)
183
+
184
+ @classmethod
185
+ def _prepare_init_payload(cls, args, kwargs):
186
+ kwargs = dict(kwargs)
187
+ data_url = kwargs.pop(IMAGE_CACHE_ATTR, None)
188
+ content = kwargs.get('content')
189
+
190
+ if args and isinstance(args[0], Mapping):
191
+ data = dict(args[0])
192
+ data_url = data.pop(IMAGE_CACHE_ATTR, data_url)
193
+ if 'content' in data:
194
+ if 'content' not in kwargs:
195
+ content = data['content']
196
+ data['content'] = normalize_image_content(data['content'])
197
+ args = (data, *args[1:])
198
+
199
+ if 'content' in kwargs:
200
+ content = kwargs['content']
201
+ kwargs['content'] = normalize_image_content(content)
202
+
203
+ content_name = image_name_from_source(content) if content is not None else None
204
+ return args, kwargs, data_url, content_name
205
+
206
+ def get_base64(self):
207
+ data_url = self.get_attr(IMAGE_CACHE_ATTR, None)
208
+ if data_url:
209
+ return data_url
210
+
211
+ try:
212
+ data_url = get_data_url(self.get('content'))
213
+ except Exception:
214
+ return None
215
+
216
+ self.set_attr(IMAGE_CACHE_ATTR, data_url)
217
+ return data_url
218
+
219
+ def is_valid(self):
220
+ data_url = self.get_base64()
221
+ if not isinstance(data_url, str) or not data_url.startswith('data:image/'):
222
+ return False
223
+ match = re.match(r"^data:image/[^;]+;base64,(.*)$", data_url)
224
+ if not match:
225
+ return False
226
+ try:
227
+ image_bytes = base64.b64decode(match.group(1), validate=True)
228
+ except (binascii.Error, ValueError):
229
+ return False
230
+ try:
231
+ with PILImage.open(BytesIO(image_bytes)) as img:
232
+ img.verify()
233
+ except Exception:
234
+ return False
235
+ return True
236
+
237
+ def content_to_response_parts(self, output=False):
238
+ data_url = self.get_base64()
239
+ if not data_url:
240
+ return [{
241
+ 'type': 'input_text',
242
+ 'text': f"Unable to access the image ({self.image_name!r})",
243
+ }]
244
+
245
+ text = (
246
+ "The following image has been made available in context for visual analysis:\n"
247
+ + repr(self.image_name)
248
+ )
249
+ if self.get('description'):
250
+ text += f"\nAlong with the following description:\n{self.description}\n"
251
+
252
+ return [
253
+ {
254
+ 'type': 'input_image',
255
+ 'image_url': data_url,
256
+ 'detail': self.detail,
257
+ },
258
+ {
259
+ 'type': 'input_text',
260
+ 'text': text,
261
+ },
262
+ ]
263
+
264
+ def to_response_format(self):
265
+ return {
266
+ "type": "message",
267
+ "role": self.role,
268
+ "content": self.content_to_response_parts(),
269
+ }
270
+
271
+ def as_string_attrs(self):
272
+ attrs = self.extract('name', 'type', 'timestamp')
273
+ attrs["image_name"] = self.get("image_name")
274
+ attrs["detail"] = self.get("detail")
275
+ return self.format_as_string_attrs(attrs)
276
+
277
+ def as_string_body(self):
278
+ parts = []
279
+ if self.get("content"):
280
+ parts.append(f"<source>{self.content}</source>")
281
+ if self.get("description"):
282
+ parts.append(f"<description>{self.description}</description>")
283
+ return "\n".join(parts)
284
+
285
+ def as_bytesio(self):
286
+ data_url = self.get_base64()
287
+ if not data_url:
288
+ return None
289
+
290
+ match = re.match(r"^data:image/[^;]+;base64,(.*)$", data_url)
291
+ if not match:
292
+ return None
293
+ return BytesIO(base64.b64decode(match.group(1)))
294
+
295
+ def show(self):
296
+ img_bytes = self.as_bytesio()
297
+ if not img_bytes:
298
+ print("[ImageMessage.show] Image non disponible en mémoire.")
299
+ return
300
+
301
+ try:
302
+ PILImage.open(img_bytes).show()
303
+ except Exception as e:
304
+ print(f"[ImageMessage.show] Erreur lors de l'affichage : {e}")
codex_agent/latex.py ADDED
@@ -0,0 +1,165 @@
1
+ import re
2
+ from .stream_utils import Streamer, TokenStreamer, fenced
3
+
4
+ def escape_formula_specials(formula: str) -> str:
5
+ r"""
6
+ Dans une formule KaTeX, on veut transformer uniquement les séquences
7
+ d'escape type JSON/Python :
8
+
9
+ \n, \t, \r, \b, \f, \", \'
10
+
11
+ en :
12
+
13
+ \\n, \\t, \\r, \\b, \\f, \\", \'
14
+
15
+ SANS toucher :
16
+ - aux séquences déjà échappées (\\n, \\t, ...)
17
+ - aux commandes LaTeX (\frac, \alpha, \begin, ...)
18
+ - aux brackets ()[]{} et autres structures KaTeX.
19
+ """
20
+
21
+ # 1) Contrôles \n \t \r \b \f
22
+ # On ne touche que les séquences \x non suivies d'une autre lettre
23
+ # pour éviter \frac, \begin, etc.
24
+ control_chars = "ntrbf"
25
+ pattern_controls = re.compile(
26
+ rf'(?<!\\)\\([{control_chars}])(?=$|[^A-Za-z])'
27
+ )
28
+ formula = pattern_controls.sub(r'\\\1', formula)
29
+
30
+ # 2) Quotes : \" et \'
31
+ pattern_quotes = re.compile(r'(?<!\\)\\(["\'])')
32
+ formula = pattern_quotes.sub(r'\\\1', formula)
33
+
34
+ return formula
35
+
36
+ def _normalize_latex_delimiters(text: str) -> str:
37
+ r"""
38
+ Convertit :
39
+ \( ... \) -> $...$
40
+ \[ ... \] -> $$...$$
41
+
42
+ - Sur une seule ligne (pas de \n à l'intérieur)
43
+ - On supporte les backslashes normaux type LLM / JSON.
44
+ """
45
+
46
+ # Display math : \[ ... \] -> $$ ... $$
47
+ text = re.sub(
48
+ r'(?<!\\)\\\[(?P<disp>[^\]\n]+?)\\\]', # \[ ... \]
49
+ lambda m: f"$${m.group('disp')}$$",
50
+ text,
51
+ )
52
+
53
+ # Inline math : \( ... \) -> $ ... $
54
+ text = re.sub(
55
+ r'(?<!\\)\\\((?P<inl>[^\)\n]+?)\\\)', # \( ... \)
56
+ lambda m: f"${m.group('inl')}$",
57
+ text,
58
+ )
59
+
60
+ return text
61
+
62
+ def _escape_katex_formulas(text: str) -> str:
63
+ r"""
64
+ Traite uniquement les formules KaTeX dans `text` (hors blocs de code).
65
+
66
+ Gère :
67
+ - block triple ligne : $$ \n ... \n $$
68
+ - display inline : $$ ... $$
69
+ - inline : $ ... $
70
+ """
71
+
72
+ # D'abord normaliser \(..\) et \[..\] en $..$ / $$..$$
73
+ text = _normalize_latex_delimiters(text)
74
+
75
+ pattern = re.compile(
76
+ r"""
77
+ # 1) Bloc KaTeX sur plusieurs lignes :
78
+ (?P<block>^\s*\$\$\s*\n(?P<block_content>.*?)(?:\n)?\s*\$\$\s*$)
79
+ |
80
+ # 2) Inline display : $$...$$ sur une seule ligne
81
+ (?P<inline_display>(?<!\$)\$\$(?!\$)(?P<inline_display_content>[^\n$]+?)\$\$(?!\$))
82
+ |
83
+ # 3) Inline normal : $...$ sur une seule ligne
84
+ (?P<inline>(?<!\$)\$(?!\$)(?P<inline_content>[^$\n]+?)\$(?!\$))
85
+ """,
86
+ re.VERBOSE | re.DOTALL | re.MULTILINE,
87
+ )
88
+
89
+ def repl(match: re.Match) -> str:
90
+ # Bloc multi-ligne
91
+ if match.group("block_content") is not None:
92
+ content = match.group("block_content")
93
+ return "$$\n" + escape_formula_specials(content) + "\n$$"
94
+
95
+ # Inline display $$...$$
96
+ if match.group("inline_display_content") is not None:
97
+ content = match.group("inline_display_content")
98
+ return f"$${escape_formula_specials(content)}$$"
99
+
100
+ # Inline $...$
101
+ content = match.group("inline_content")
102
+ return f"${escape_formula_specials(content)}$"
103
+
104
+ return pattern.sub(repl, text)
105
+
106
+ def format_latex(md: str) -> str:
107
+ r"""
108
+ Traite les formules KaTeX dans un markdown *raw-like* :
109
+
110
+ - ignore complètement les blocs de code :
111
+ ```...``` ou ~~~...~~~
112
+ - convertit \(..\) en $..$, \[..] en $$..$$
113
+ - gère :
114
+ $$ \n ... \n $$ (block)
115
+ $$ ... $$ (inline display)
116
+ $ ... $ (inline)
117
+ - échappe les séquences d'escape foireuses dans les formules.
118
+ """
119
+
120
+ code_pattern = re.compile(
121
+ r"""
122
+ (
123
+ ```.*?``` # bloc ```...```
124
+ | ~~~.*?~~~ # bloc ~~~...~~~
125
+ )
126
+ """,
127
+ re.VERBOSE | re.DOTALL,
128
+ )
129
+
130
+ parts = code_pattern.split(md)
131
+ out_parts = []
132
+
133
+ for part in parts:
134
+ if not part:
135
+ continue
136
+ if part.startswith("```") or part.startswith("~~~"):
137
+ # bloc de code → on ne touche à rien
138
+ out_parts.append(part)
139
+ else:
140
+ # texte normal → traitement KaTeX
141
+ out_parts.append(_escape_katex_formulas(part))
142
+
143
+ return "".join(out_parts)
144
+
145
+ class LaTeXProcessor(Streamer):
146
+ """Stream processor for formatting LaTeX/KaTeX formulas in markdown text.
147
+
148
+ Escapes special characters in LaTeX formulas while preserving code blocks.
149
+ """
150
+
151
+ def __init__(self):
152
+ super().__init__()
153
+ self.token_streamer = TokenStreamer([
154
+ # ignore processing in markdown code blocs
155
+ (fenced('```','```',flags=re.DOTALL|re.MULTILINE), lambda m: f'{m.group()}'),
156
+ # Mute LaTeX formulas
157
+ (fenced(r'\$\$',r'\$\$',flags=re.DOTALL|re.MULTILINE), lambda m: f'{format_latex(m.group())}'),
158
+ (fenced(r'\\\[',r'\\\]',flags=re.DOTALL|re.MULTILINE), lambda m: f'{format_latex(m.group())}'),
159
+ (fenced(r'\\\(',r'\\\)',flags=re.DOTALL|re.MULTILINE), lambda m: f'{format_latex(m.group())}'),
160
+ (r'\$[^$\n]+?\$', lambda m: f'{format_latex(m.group())}'),
161
+ ], threaded=False)
162
+
163
+ def stream_processor(self, stream):
164
+ """Process stream and format LaTeX formulas."""
165
+ return self.token_streamer.process(stream)