euporie 2.8.1__py3-none-any.whl → 2.8.5__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.
Files changed (129) hide show
  1. euporie/console/_commands.py +143 -0
  2. euporie/console/_settings.py +58 -0
  3. euporie/console/app.py +25 -71
  4. euporie/console/tabs/console.py +267 -147
  5. euporie/core/__init__.py +1 -9
  6. euporie/core/__main__.py +31 -5
  7. euporie/core/_settings.py +104 -0
  8. euporie/core/app/__init__.py +3 -0
  9. euporie/core/app/_commands.py +70 -0
  10. euporie/core/app/_settings.py +427 -0
  11. euporie/core/{app.py → app/app.py} +214 -572
  12. euporie/core/app/base.py +51 -0
  13. euporie/core/{current.py → app/current.py} +13 -4
  14. euporie/core/app/cursor.py +35 -0
  15. euporie/core/app/dummy.py +12 -0
  16. euporie/core/app/launch.py +28 -0
  17. euporie/core/bars/__init__.py +11 -0
  18. euporie/core/bars/command.py +182 -0
  19. euporie/core/bars/menu.py +258 -0
  20. euporie/core/{widgets → bars}/search.py +154 -57
  21. euporie/core/{widgets → bars}/status.py +9 -26
  22. euporie/core/clipboard.py +19 -80
  23. euporie/core/comm/base.py +8 -6
  24. euporie/core/comm/ipywidgets.py +21 -12
  25. euporie/core/comm/registry.py +2 -1
  26. euporie/core/commands.py +11 -5
  27. euporie/core/completion.py +3 -2
  28. euporie/core/config.py +368 -341
  29. euporie/core/convert/__init__.py +0 -30
  30. euporie/core/convert/datum.py +131 -60
  31. euporie/core/convert/formats/__init__.py +31 -0
  32. euporie/core/convert/formats/ansi.py +46 -30
  33. euporie/core/convert/formats/common.py +11 -23
  34. euporie/core/convert/formats/html.py +45 -40
  35. euporie/core/convert/formats/pil.py +1 -1
  36. euporie/core/convert/formats/png.py +3 -5
  37. euporie/core/convert/formats/sixel.py +3 -3
  38. euporie/core/convert/registry.py +11 -8
  39. euporie/core/convert/utils.py +50 -23
  40. euporie/core/diagnostics.py +2 -2
  41. euporie/core/filters.py +72 -82
  42. euporie/core/format.py +13 -2
  43. euporie/core/ft/ansi.py +1 -1
  44. euporie/core/ft/html.py +36 -36
  45. euporie/core/ft/table.py +1 -3
  46. euporie/core/ft/utils.py +4 -1
  47. euporie/core/graphics.py +216 -124
  48. euporie/core/history.py +2 -2
  49. euporie/core/inspection.py +3 -2
  50. euporie/core/io.py +207 -28
  51. euporie/core/kernel/__init__.py +1 -0
  52. euporie/core/{kernel.py → kernel/client.py} +100 -139
  53. euporie/core/kernel/manager.py +114 -0
  54. euporie/core/key_binding/bindings/__init__.py +2 -8
  55. euporie/core/key_binding/bindings/basic.py +47 -7
  56. euporie/core/key_binding/bindings/completion.py +3 -8
  57. euporie/core/key_binding/bindings/micro.py +5 -7
  58. euporie/core/key_binding/bindings/mouse.py +26 -24
  59. euporie/core/key_binding/bindings/terminal.py +193 -0
  60. euporie/core/key_binding/bindings/vi.py +46 -0
  61. euporie/core/key_binding/key_processor.py +43 -2
  62. euporie/core/key_binding/registry.py +2 -0
  63. euporie/core/key_binding/utils.py +22 -2
  64. euporie/core/keys.py +7156 -93
  65. euporie/core/layout/cache.py +35 -25
  66. euporie/core/layout/containers.py +280 -74
  67. euporie/core/layout/decor.py +5 -5
  68. euporie/core/layout/mouse.py +1 -1
  69. euporie/core/layout/print.py +16 -3
  70. euporie/core/layout/scroll.py +26 -28
  71. euporie/core/log.py +75 -60
  72. euporie/core/lsp.py +118 -24
  73. euporie/core/margins.py +60 -31
  74. euporie/core/path.py +2 -1
  75. euporie/core/renderer.py +58 -17
  76. euporie/core/style.py +60 -40
  77. euporie/core/suggest.py +103 -85
  78. euporie/core/tabs/__init__.py +34 -0
  79. euporie/core/tabs/_settings.py +113 -0
  80. euporie/core/tabs/base.py +11 -435
  81. euporie/core/tabs/kernel.py +420 -0
  82. euporie/core/tabs/notebook.py +20 -54
  83. euporie/core/utils.py +98 -6
  84. euporie/core/validation.py +1 -1
  85. euporie/core/widgets/_settings.py +188 -0
  86. euporie/core/widgets/cell.py +90 -158
  87. euporie/core/widgets/cell_outputs.py +25 -36
  88. euporie/core/widgets/decor.py +11 -41
  89. euporie/core/widgets/dialog.py +55 -44
  90. euporie/core/widgets/display.py +27 -24
  91. euporie/core/widgets/file_browser.py +5 -26
  92. euporie/core/widgets/forms.py +16 -12
  93. euporie/core/widgets/inputs.py +37 -81
  94. euporie/core/widgets/layout.py +7 -6
  95. euporie/core/widgets/logo.py +49 -0
  96. euporie/core/widgets/menu.py +13 -11
  97. euporie/core/widgets/pager.py +8 -11
  98. euporie/core/widgets/palette.py +6 -6
  99. euporie/hub/app.py +52 -31
  100. euporie/notebook/_commands.py +24 -0
  101. euporie/notebook/_settings.py +107 -0
  102. euporie/notebook/app.py +109 -210
  103. euporie/notebook/filters.py +1 -1
  104. euporie/notebook/tabs/__init__.py +46 -7
  105. euporie/notebook/tabs/_commands.py +714 -0
  106. euporie/notebook/tabs/_settings.py +32 -0
  107. euporie/notebook/tabs/display.py +2 -2
  108. euporie/notebook/tabs/edit.py +12 -7
  109. euporie/notebook/tabs/json.py +3 -3
  110. euporie/notebook/tabs/log.py +1 -18
  111. euporie/notebook/tabs/notebook.py +21 -674
  112. euporie/notebook/widgets/_commands.py +11 -0
  113. euporie/notebook/widgets/_settings.py +19 -0
  114. euporie/notebook/widgets/side_bar.py +14 -34
  115. euporie/preview/_settings.py +104 -0
  116. euporie/preview/app.py +8 -30
  117. euporie/preview/tabs/notebook.py +15 -86
  118. euporie/web/tabs/web.py +4 -6
  119. euporie/web/widgets/webview.py +5 -12
  120. {euporie-2.8.1.dist-info → euporie-2.8.5.dist-info}/METADATA +11 -15
  121. euporie-2.8.5.dist-info/RECORD +172 -0
  122. {euporie-2.8.1.dist-info → euporie-2.8.5.dist-info}/WHEEL +1 -1
  123. {euporie-2.8.1.dist-info → euporie-2.8.5.dist-info}/entry_points.txt +2 -2
  124. {euporie-2.8.1.dist-info → euporie-2.8.5.dist-info}/licenses/LICENSE +1 -1
  125. euporie/core/launch.py +0 -59
  126. euporie/core/terminal.py +0 -527
  127. euporie-2.8.1.dist-info/RECORD +0 -146
  128. {euporie-2.8.1.data → euporie-2.8.5.data}/data/share/applications/euporie-console.desktop +0 -0
  129. {euporie-2.8.1.data → euporie-2.8.5.data}/data/share/applications/euporie-notebook.desktop +0 -0
@@ -1,31 +1 @@
1
1
  """Sub-module concerned with the conversion of data formats."""
2
-
3
- from euporie.core.convert.formats import (
4
- ansi,
5
- base64,
6
- ft,
7
- html,
8
- jpeg,
9
- markdown,
10
- pdf,
11
- pil,
12
- png,
13
- rich,
14
- sixel,
15
- svg,
16
- )
17
-
18
- __all__ = [
19
- "ansi",
20
- "base64",
21
- "jpeg",
22
- "html",
23
- "markdown",
24
- "ft",
25
- "pdf",
26
- "pil",
27
- "png",
28
- "rich",
29
- "sixel",
30
- "svg",
31
- ]
@@ -3,31 +3,31 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import hashlib
7
6
  import inspect
8
7
  import io
9
8
  import logging
10
9
  import threading
10
+ from hashlib import md5
11
11
  from typing import TYPE_CHECKING, Generic, TypeVar
12
12
  from weakref import ReferenceType, WeakValueDictionary, finalize, ref
13
13
 
14
- import imagesize
15
- from PIL.Image import Image as PilImage
16
14
  from prompt_toolkit.data_structures import Size
17
15
  from prompt_toolkit.layout.containers import WindowAlign
18
16
 
17
+ from euporie.core.app.current import get_app
19
18
  from euporie.core.convert.registry import (
20
19
  _CONVERTOR_ROUTE_CACHE,
21
20
  _FILTER_CACHE,
22
21
  converters,
23
22
  )
24
- from euporie.core.current import get_app
25
23
  from euporie.core.ft.utils import to_plain_text
26
24
 
27
25
  if TYPE_CHECKING:
26
+ from collections.abc import Coroutine
28
27
  from pathlib import Path
29
- from typing import Any, ClassVar, Coroutine
28
+ from typing import Any, ClassVar
30
29
 
30
+ from PIL.Image import Image as PilImage
31
31
  from prompt_toolkit.formatted_text.base import StyleAndTextTuples
32
32
  from rich.console import ConsoleRenderable
33
33
 
@@ -35,7 +35,7 @@ if TYPE_CHECKING:
35
35
  from euporie.core.style import ColorPaletteColor
36
36
 
37
37
 
38
- T = TypeVar("T", bytes, str, "StyleAndTextTuples", PilImage, "ConsoleRenderable")
38
+ T = TypeVar("T", bytes, str, "StyleAndTextTuples", "PilImage", "ConsoleRenderable")
39
39
 
40
40
 
41
41
  log = logging.getLogger(__name__)
@@ -139,6 +139,10 @@ class Datum(Generic[T], metaclass=_MetaDatum):
139
139
  self._conversions: dict[
140
140
  tuple[str, int | None, int | None, str | None, str | None, bool], T | None
141
141
  ] = {}
142
+ self._queue: dict[
143
+ tuple[str, int | None, int | None, str | None, str | None, bool],
144
+ asyncio.Event,
145
+ ] = {}
142
146
  self._finalizer = finalize(self, self._cleanup_datum_sizes, self.hash)
143
147
  self._finalizer.atexit = False
144
148
 
@@ -153,7 +157,10 @@ class Datum(Generic[T], metaclass=_MetaDatum):
153
157
  for key, (datum_ref, _size) in list(size_instances.items()):
154
158
  datum = datum_ref()
155
159
  if not datum or datum.hash == data_hash:
156
- del size_instances[key]
160
+ try:
161
+ del size_instances[key]
162
+ except KeyError:
163
+ pass
157
164
  del datum
158
165
 
159
166
  def to_bytes(self) -> bytes:
@@ -163,23 +170,31 @@ class Datum(Generic[T], metaclass=_MetaDatum):
163
170
  return data.encode()
164
171
  elif isinstance(data, list):
165
172
  return to_plain_text(data).encode()
166
- elif isinstance(data, PilImage):
167
- return data.tobytes()
168
173
  elif isinstance(data, bytes):
169
174
  return data
170
175
  else:
171
- return b"Error"
176
+ from PIL.Image import Image as PilImage
177
+
178
+ if isinstance(data, PilImage):
179
+ return data.tobytes()
180
+ else:
181
+ return b"Error"
172
182
 
173
183
  @staticmethod
174
184
  def get_hash(data: Any) -> str:
175
185
  """Calculate a hash of data."""
176
- if isinstance(data, str):
186
+ if isinstance(data, bytes):
187
+ hash_data = data
188
+ elif isinstance(data, str):
177
189
  hash_data = data.encode()
178
- elif isinstance(data, PilImage):
179
- hash_data = data.tobytes()
180
190
  else:
181
- hash_data = data
182
- return hashlib.sha1(hash_data).hexdigest() # noqa S324
191
+ from PIL.Image import Image as PilImage
192
+
193
+ if isinstance(data, PilImage):
194
+ hash_data = data.tobytes()
195
+ else:
196
+ hash_data = data
197
+ return md5(hash_data, usedforsecurity=False).hexdigest()
183
198
 
184
199
  @property
185
200
  def hash(self) -> str:
@@ -226,37 +241,56 @@ class Datum(Generic[T], metaclass=_MetaDatum):
226
241
  if not bg and hasattr(app := get_app(), "color_palette"):
227
242
  bg = self.bg or app.color_palette.bg.base_hex
228
243
 
229
- if (key := (to, cols, rows, fg, bg, extend)) in self._conversions:
230
- return self._conversions[key]
244
+ if (key_conv := (to, cols, rows, fg, bg, extend)) in self._queue:
245
+ await self._queue[key_conv].wait()
246
+ if key_conv in self._conversions:
247
+ return self._conversions[key_conv]
248
+
249
+ self._queue[key_conv] = event = asyncio.Event()
231
250
 
232
251
  routes = _CONVERTOR_ROUTE_CACHE[(self.format, to)]
233
- # log.debug(
234
- # "Converting from '%s' to '%s' using route: %s", self, to, routes
235
- # )
252
+ log.debug(
253
+ "Converting %s->'%s'@%s using routes: %s",
254
+ self,
255
+ to,
256
+ (cols, rows),
257
+ routes,
258
+ )
236
259
  output: T | None = None
237
260
  if routes:
238
261
  datum = self
239
262
  output = None
240
263
  for route in routes:
241
264
  for stage_a, stage_b in zip(route, route[1:]):
242
- key = (stage_b, cols, rows, fg, bg, extend)
243
- if key in self._conversions:
244
- output = self._conversions[key]
265
+ key_stage = (stage_b, cols, rows, fg, bg, extend)
266
+ if key_stage in self._conversions:
267
+ output = self._conversions[key_stage]
245
268
  else:
246
269
  # Find converter with lowest weight
247
- func = sorted(
270
+ for converter in sorted(
248
271
  [
249
272
  conv
250
273
  for conv in converters[stage_b][stage_a]
251
274
  if _FILTER_CACHE.get((conv,), conv.filter_)
252
275
  ],
253
276
  key=lambda x: x.weight,
254
- )[0].func
255
- try:
256
- output = await func(datum, cols, rows, fg, bg, extend)
257
- self._conversions[key] = output
258
- except Exception:
259
- log.exception("An error occurred during format conversion")
277
+ ):
278
+ try:
279
+ output = await converter.func(
280
+ datum, cols, rows, fg, bg, extend
281
+ )
282
+ self._conversions[key_stage] = output
283
+ except Exception:
284
+ log.debug(
285
+ "Conversion step %s failed",
286
+ converter,
287
+ exc_info=True,
288
+ )
289
+ continue
290
+ else:
291
+ break
292
+ else:
293
+ log.warning("An error occurred during format conversion")
260
294
  output = None
261
295
  if output is None:
262
296
  log.error(
@@ -290,6 +324,9 @@ class Datum(Generic[T], metaclass=_MetaDatum):
290
324
  if output is None:
291
325
  output = ERROR_OUTPUTS.get(to, "(Conversion Error)")
292
326
 
327
+ event.set()
328
+ del self._queue[key_conv]
329
+
293
330
  return output
294
331
 
295
332
  def _to_sync(self, coro: Coroutine) -> Any:
@@ -323,36 +360,68 @@ class Datum(Generic[T], metaclass=_MetaDatum):
323
360
  try:
324
361
  return self._pixel_size
325
362
  except AttributeError:
326
- px, py = self.px, self.py
363
+ pass
364
+
365
+ px, py = self.px, self.py
366
+ self_data = self.data
367
+ format = self.format
368
+ data: bytes
369
+
370
+ while px is None or py is None:
327
371
  # Do not bother trying if the format is ANSI
328
- if self.format != "ansi" and (px is None or py is None):
329
- # Try using imagesize to get the size of the output
330
- if (
331
- self.format not in {"png", "svg", "jpeg", "gif", "tiff"}
332
- and _CONVERTOR_ROUTE_CACHE[(self.format, "png")]
333
- ):
334
- data = await self.convert_async(to="png")
372
+ if format == "ansi":
373
+ break
374
+
375
+ from PIL.Image import Image as PilImage
376
+
377
+ if isinstance(self_data, PilImage):
378
+ px, py = self_data.size
379
+ break
380
+
381
+ # Decode base64 data
382
+ if format.startswith("base64-"):
383
+ data = await self.convert_async(to=format[7:])
384
+
385
+ # Encode string data
386
+ if isinstance(self_data, str):
387
+ data = self_data.encode()
388
+
389
+ if isinstance(self_data, bytes):
390
+ data = self_data
391
+
392
+ # Try using imagesize to get the size of the output
393
+ try:
394
+ import imagesize
395
+
396
+ px_calc, py_calc = imagesize.get(io.BytesIO(data))
397
+ except ValueError:
398
+ px_calc = py_calc = -1
399
+
400
+ if (
401
+ format != "png"
402
+ and px_calc <= 0
403
+ and py_calc <= 0
404
+ and _CONVERTOR_ROUTE_CACHE[(format, "png")]
405
+ ):
406
+ # Try converting to PNG on failure
407
+ self_data = await self.convert_async(to="png")
408
+ format = "png"
409
+ continue
410
+
411
+ if px is None and px_calc > 0:
412
+ if py is not None and py_calc > 0:
413
+ px = int(px_calc * py / py_calc)
335
414
  else:
336
- data = self.data
337
- if isinstance(data, str):
338
- data = data.encode()
339
- try:
340
- px_calc, py_calc = imagesize.get(io.BytesIO(data))
341
- except ValueError:
342
- pass
415
+ px = px_calc
416
+ if py is None and py_calc > 0:
417
+ if px is not None and px_calc > 0:
418
+ py = int(py_calc * px / px_calc)
343
419
  else:
344
- if px is None and px_calc > 0:
345
- if py is not None and py_calc > 0:
346
- px = px_calc * py / py_calc
347
- else:
348
- px = px_calc
349
- if py is None and py_calc > 0:
350
- if px is not None and px_calc > 0:
351
- py = py_calc * px / px_calc
352
- else:
353
- py = py_calc
354
- self._pixel_size = (px, py)
355
- return self._pixel_size
420
+ py = py_calc
421
+ break
422
+
423
+ self._pixel_size = (px, py)
424
+ return self._pixel_size
356
425
 
357
426
  def pixel_size(self) -> Any:
358
427
  """Get data dimensions synchronously."""
@@ -370,8 +439,8 @@ class Datum(Generic[T], metaclass=_MetaDatum):
370
439
  px, py = await self.pixel_size_async()
371
440
  if px is not None and py is not None:
372
441
  app = get_app()
373
- if hasattr(app, "term_info"):
374
- cell_px, cell_py = app.term_info.cell_size_px
442
+ if hasattr(app, "cell_size_px"):
443
+ cell_px, cell_py = app.cell_size_px
375
444
  else:
376
445
  cell_px, cell_py = 10, 20
377
446
  cols = max(1, int(px // cell_px))
@@ -381,7 +450,9 @@ class Datum(Generic[T], metaclass=_MetaDatum):
381
450
 
382
451
  def cell_size(self) -> Any:
383
452
  """Get cell width and aspect synchronously."""
384
- return self._to_sync(self.cell_size_async())
453
+ if self._cell_size is None:
454
+ return self._to_sync(self.cell_size_async())
455
+ return self._cell_size
385
456
 
386
457
  # def crop(self, bbox: DiInt) -> T:
387
458
  # """Crop displayable data."""
@@ -3,4 +3,35 @@
3
3
  They are grouped into sub-modules based on output format.
4
4
  """
5
5
 
6
+ from . import (
7
+ ansi,
8
+ base64,
9
+ ft,
10
+ html,
11
+ jpeg,
12
+ markdown,
13
+ pdf,
14
+ pil,
15
+ png,
16
+ rich,
17
+ sixel,
18
+ svg,
19
+ )
20
+
21
+ __all__ = [
22
+ "BASE64_FORMATS",
23
+ "ansi",
24
+ "base64",
25
+ "ft",
26
+ "html",
27
+ "jpeg",
28
+ "markdown",
29
+ "pdf",
30
+ "pil",
31
+ "png",
32
+ "rich",
33
+ "sixel",
34
+ "svg",
35
+ ]
36
+
6
37
  BASE64_FORMATS = {"png", "jpeg", "pdf", "gif"}
@@ -4,14 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  from functools import partial
7
- from math import ceil
8
7
  from typing import TYPE_CHECKING
9
8
 
9
+ from euporie.core.app.current import get_app
10
10
  from euporie.core.convert.formats.common import chafa_convert_cmd, chafa_convert_py
11
11
  from euporie.core.convert.formats.pil import set_background
12
12
  from euporie.core.convert.registry import register
13
- from euporie.core.convert.utils import call_subproc
14
- from euporie.core.current import get_app
13
+ from euporie.core.convert.utils import call_subproc, scale_to_fit
15
14
  from euporie.core.filters import command_exists, have_modules
16
15
 
17
16
  if TYPE_CHECKING:
@@ -169,9 +168,10 @@ async def html_to_ansi_py_htmlparser(
169
168
  @register(
170
169
  from_="latex",
171
170
  to="ansi",
172
- filter_=have_modules("flatlatex.latexfuntypes"),
171
+ filter_=command_exists("utftex"),
172
+ weight=0,
173
173
  )
174
- async def latex_to_ansi_py_flatlatex(
174
+ async def latex_to_ansi_utftex(
175
175
  datum: Datum,
176
176
  cols: int | None = None,
177
177
  rows: int | None = None,
@@ -179,16 +179,15 @@ async def latex_to_ansi_py_flatlatex(
179
179
  bg: str | None = None,
180
180
  extend: bool = True,
181
181
  ) -> str:
182
- """Convert LaTeX to ANSI using :py:mod:`flatlatex`."""
183
- import flatlatex
184
-
185
- return flatlatex.converter().convert(datum.data.strip().strip("$").strip())
182
+ """Render LaTeX maths as unicode."""
183
+ return (await call_subproc(datum.data, ["utftex"])).decode()
186
184
 
187
185
 
188
186
  @register(
189
187
  from_="latex",
190
188
  to="ansi",
191
189
  filter_=have_modules("pylatexenc"),
190
+ weight=0,
192
191
  )
193
192
  async def latex_to_ansi_py_pylatexenc(
194
193
  datum: Datum,
@@ -204,6 +203,36 @@ async def latex_to_ansi_py_pylatexenc(
204
203
  return LatexNodes2Text().latex_to_text(datum.data.strip().strip("$").strip())
205
204
 
206
205
 
206
+ @register(
207
+ from_="latex",
208
+ to="ansi",
209
+ filter_=have_modules("flatlatex.latexfuntypes"),
210
+ weight=0,
211
+ )
212
+ async def latex_to_ansi_py_flatlatex(
213
+ datum: Datum,
214
+ cols: int | None = None,
215
+ rows: int | None = None,
216
+ fg: str | None = None,
217
+ bg: str | None = None,
218
+ extend: bool = True,
219
+ ) -> str:
220
+ """Convert LaTeX to ANSI using :py:mod:`flatlatex`."""
221
+ import flatlatex
222
+ from flatlatex.latexfuntypes import latexfun
223
+
224
+ converter = flatlatex.converter()
225
+ for style in (
226
+ r"\textstyle",
227
+ r"\displaystyle",
228
+ r"\scriptstyle",
229
+ r"\scriptscriptstyle",
230
+ ):
231
+ converter._converter__cmds[style] = latexfun(lambda x: "", 0)
232
+
233
+ return converter.convert(datum.data.strip().strip("$").strip())
234
+
235
+
207
236
  @register(
208
237
  from_="latex",
209
238
  to="ansi",
@@ -244,27 +273,14 @@ async def pil_to_ansi_py_timg(
244
273
  """Convert a PIL image to ANSI text using :py:mod:`timg`."""
245
274
  import timg
246
275
 
247
- data = datum.data
248
- px, py = get_app().term_info.cell_size_px
249
-
250
- # Calculate rows based on image aspect ratio
251
- w, h = data.size
252
- if rows is None and cols is not None:
253
- w, h = data.size
254
- rows = ceil(cols / w * h)
255
- elif cols is None and rows is not None:
256
- w, h = data.size
257
- cols = ceil(rows / h * w)
258
- elif rows is None and cols is None:
259
- cols = ceil(w / px)
260
- rows = ceil(h / py)
261
- assert rows is not None
262
- assert cols is not None
263
-
264
- # `timg` assumes a 2x1 terminal cell aspect ratio, so we correct for while
265
- # resizing the image
266
- data = data.resize((cols, ceil(rows * 2 * (px / py) / 0.5)))
267
-
276
+ # Scale image to fit available space
277
+ cols, rows = await scale_to_fit(datum, cols, rows)
278
+ # `timg` assumes a 2x1 terminal cell aspect ratio, so we correct for this while
279
+ px, py = get_app().cell_size_px
280
+ rows = int(rows * 2 * (px / py) / 0.5)
281
+ # Resize the image
282
+ data = datum.data.resize((cols, rows))
283
+ # Set background if necessary
268
284
  if bg:
269
285
  data = set_background(data, bg)
270
286
  return timg.Ansi24HblockMethod(data).to_string()
@@ -6,8 +6,8 @@ import base64
6
6
  import logging
7
7
  from typing import TYPE_CHECKING
8
8
 
9
- from euporie.core.convert.utils import call_subproc
10
- from euporie.core.current import get_app
9
+ from euporie.core.app.current import get_app
10
+ from euporie.core.convert.utils import call_subproc, scale_to_fit
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from typing import Any, Literal
@@ -41,14 +41,15 @@ async def imagemagick_convert(
41
41
  extend: bool = True,
42
42
  ) -> str | bytes:
43
43
  """Convert image data to PNG bytes using ``imagemagick``."""
44
- cmd: list[Any] = ["convert", "-density", "300"]
44
+ cmd: list[Any] = ["magick"]
45
+ if bg:
46
+ cmd += ["-background", bg]
47
+ cmd.extend(["-[0]", "-density", "300"])
45
48
  app = get_app()
46
- if cols is not None and hasattr(app, "term_info"):
47
- px, _ = app.term_info.cell_size_px
49
+ if cols is not None and hasattr(app, "cell_size_px"):
50
+ px, _ = app.cell_size_px
48
51
  cmd += ["-geometry", f"{int(cols * px)}"]
49
- if bg:
50
- cmd += ["-background", bg, "-flatten"]
51
- cmd += ["-[0]", f"{output_format}:-"]
52
+ cmd += [f"{output_format}:-"]
52
53
  result: bytes | str = await call_subproc(datum.data, cmd)
53
54
 
54
55
  if output_format in {"sixel", "svg"} and isinstance(result, bytes):
@@ -90,7 +91,7 @@ async def chafa_convert_py(
90
91
  extend: bool = True,
91
92
  ) -> str | bytes:
92
93
  """Convert image data to ANSI text using ::`chafa.py`."""
93
- from chafa.chafa import Canvas, CanvasConfig, PixelMode, PixelType
94
+ from chafa import Canvas, CanvasConfig, PixelMode, PixelType
94
95
  from PIL import Image
95
96
 
96
97
  pil_mode_to_pixel_type = {
@@ -111,25 +112,12 @@ async def chafa_convert_py(
111
112
  # Always convert the image, as unconverted images sometime result in an off-by-one
112
113
  # line width errors resulting in diagonal image striping for some reason
113
114
  data = data.convert("RGBA", palette=Image.Palette.ADAPTIVE, colors=16)
114
-
115
115
  # Init canvas config
116
116
  config = CanvasConfig()
117
117
  # Set output mode
118
118
  config.pixel_mode = str_to_pixel_mode[output_format]
119
119
  # Configure the canvas geometry based on our cell size
120
- if hasattr(app := get_app(), "term_info"):
121
- px, py = app.term_info.cell_size_px
122
- else:
123
- px, py = 10, 20
124
- config.cell_width, config.cell_height = px, py
125
- # Set canvas height and width
126
- if cols:
127
- config.width = cols
128
- if rows:
129
- config.height = max(1, rows)
130
- # If we don't have specified, use the image's aspect
131
- else:
132
- config.height = max(1, int(cols / data.size[0] * data.size[1] * px / py))
120
+ config.width, config.height = await scale_to_fit(datum, cols, rows)
133
121
 
134
122
  # Set the foreground color
135
123
  if fg and (color := fg.lstrip("#")):
@@ -3,61 +3,66 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from functools import cache
6
7
  from typing import TYPE_CHECKING
7
8
 
8
- from markdown_it import MarkdownIt
9
- from mdit_py_plugins.amsmath import amsmath_plugin
10
- from mdit_py_plugins.dollarmath.index import dollarmath_plugin
11
- from mdit_py_plugins.texmath.index import texmath_plugin
12
- from pygments import highlight
13
- from pygments.formatters import HtmlFormatter
14
-
9
+ from euporie.core.app.current import get_app
15
10
  from euporie.core.convert.registry import register
16
- from euporie.core.current import get_app
17
11
  from euporie.core.lexers import detect_lexer
18
12
 
19
13
  if TYPE_CHECKING:
14
+ from markdown_it import MarkdownIt
15
+
20
16
  from euporie.core.convert.datum import Datum
21
17
 
22
18
  log = logging.getLogger(__name__)
23
19
 
24
20
 
25
- class MarkdownParser(MarkdownIt):
26
- """Subclas the markdown parser to allow ``file:`` URIs."""
21
+ @cache
22
+ def markdown_parser() -> MarkdownIt:
23
+ """Lazy-load a markdown parser."""
24
+ from markdown_it import MarkdownIt
25
+ from mdit_py_plugins.amsmath import amsmath_plugin
26
+ from mdit_py_plugins.dollarmath.index import dollarmath_plugin
27
+ from mdit_py_plugins.texmath.index import texmath_plugin
28
+ from pygments import highlight
29
+ from pygments.formatters import HtmlFormatter
27
30
 
28
- def validateLink(self, url: str) -> bool:
29
- """Allow all link URIs."""
30
- return True
31
+ class MarkdownParser(MarkdownIt):
32
+ """Subclas the markdown parser to allow ``file:`` URIs."""
31
33
 
34
+ def validateLink(self, url: str) -> bool:
35
+ """Allow all link URIs."""
36
+ return True
32
37
 
33
- markdown_parser = (
34
- (
35
- MarkdownParser(
36
- options_update={
37
- "highlight": lambda text, language, lang_args: highlight(
38
- text,
39
- detect_lexer(text, language=language),
40
- HtmlFormatter(
41
- nowrap=True,
42
- noclasses=True,
43
- style=(
44
- app.syntax_theme
45
- if hasattr((app := get_app()), "syntax_theme")
46
- else "default"
38
+ return (
39
+ (
40
+ MarkdownParser(
41
+ options_update={
42
+ "highlight": lambda text, language, lang_args: highlight(
43
+ text,
44
+ detect_lexer(text, language=language),
45
+ HtmlFormatter(
46
+ nowrap=True,
47
+ noclasses=True,
48
+ style=(
49
+ app.syntax_theme
50
+ if hasattr((app := get_app()), "syntax_theme")
51
+ else "default"
52
+ ),
47
53
  ),
48
- ),
49
- )
50
- }
54
+ )
55
+ }
56
+ )
57
+ .enable("linkify")
58
+ .enable("table")
59
+ .enable("strikethrough")
51
60
  )
52
- .enable("linkify")
53
- .enable("table")
54
- .enable("strikethrough")
61
+ .use(texmath_plugin)
62
+ .use(dollarmath_plugin)
63
+ .use(amsmath_plugin)
64
+ # .use(tasklists_plugin)
55
65
  )
56
- .use(texmath_plugin)
57
- .use(dollarmath_plugin)
58
- .use(amsmath_plugin)
59
- # .use(tasklists_plugin)
60
- )
61
66
 
62
67
 
63
68
  @register(from_="markdown", to="html")
@@ -70,7 +75,7 @@ async def markdown_to_html_markdown_it(
70
75
  extend: bool = True,
71
76
  ) -> str:
72
77
  """Convert markdown to HTML using :py:mod:`markdownit_py`."""
73
- assert markdown_parser is not None
78
+ parser = markdown_parser()
74
79
  data = datum.data
75
80
  markup = data.decode() if isinstance(data, bytes) else data
76
- return markdown_parser.render(markup)
81
+ return parser.render(markup)