texsmith 0.0.2.dev0__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 (252) hide show
  1. texsmith/__init__.py +107 -0
  2. texsmith/_alias.py +59 -0
  3. texsmith/adapters/__init__.py +6 -0
  4. texsmith/adapters/docker.py +258 -0
  5. texsmith/adapters/handlers/__init__.py +3 -0
  6. texsmith/adapters/handlers/_assets.py +363 -0
  7. texsmith/adapters/handlers/_helpers.py +62 -0
  8. texsmith/adapters/handlers/_mermaid.py +122 -0
  9. texsmith/adapters/handlers/admonitions.py +267 -0
  10. texsmith/adapters/handlers/basic.py +228 -0
  11. texsmith/adapters/handlers/blocks.py +851 -0
  12. texsmith/adapters/handlers/code.py +309 -0
  13. texsmith/adapters/handlers/inline.py +937 -0
  14. texsmith/adapters/handlers/links.py +239 -0
  15. texsmith/adapters/handlers/media.py +380 -0
  16. texsmith/adapters/latex/__init__.py +10 -0
  17. texsmith/adapters/latex/engines/__init__.py +726 -0
  18. texsmith/adapters/latex/engines/latex/__init__.py +26 -0
  19. texsmith/adapters/latex/engines/latex/log.py +720 -0
  20. texsmith/adapters/latex/engines/latex/runner.py +28 -0
  21. texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
  22. texsmith/adapters/latex/formatter.py +297 -0
  23. texsmith/adapters/latex/latexmk.py +148 -0
  24. texsmith/adapters/latex/partials/acronym.tex +1 -0
  25. texsmith/adapters/latex/partials/add.tex +1 -0
  26. texsmith/adapters/latex/partials/addition.tex +1 -0
  27. texsmith/adapters/latex/partials/blockquote.tex +3 -0
  28. texsmith/adapters/latex/partials/callout.tex +3 -0
  29. texsmith/adapters/latex/partials/choices.tex +5 -0
  30. texsmith/adapters/latex/partials/citation.tex +1 -0
  31. texsmith/adapters/latex/partials/codeblock.tex +7 -0
  32. texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
  33. texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
  34. texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
  35. texsmith/adapters/latex/partials/codeinline.tex +1 -0
  36. texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
  37. texsmith/adapters/latex/partials/comment.tex +1 -0
  38. texsmith/adapters/latex/partials/del.tex +1 -0
  39. texsmith/adapters/latex/partials/deletion.tex +1 -0
  40. texsmith/adapters/latex/partials/description_list.tex +5 -0
  41. texsmith/adapters/latex/partials/enquote.tex +1 -0
  42. texsmith/adapters/latex/partials/epigraph.tex +1 -0
  43. texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
  44. texsmith/adapters/latex/partials/figure.tex +33 -0
  45. texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
  46. texsmith/adapters/latex/partials/footnote.tex +3 -0
  47. texsmith/adapters/latex/partials/glossary.tex +1 -0
  48. texsmith/adapters/latex/partials/heading.tex +14 -0
  49. texsmith/adapters/latex/partials/highlight.tex +25 -0
  50. texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
  51. texsmith/adapters/latex/partials/href.tex +1 -0
  52. texsmith/adapters/latex/partials/icon.tex +1 -0
  53. texsmith/adapters/latex/partials/include.tex +1 -0
  54. texsmith/adapters/latex/partials/index.tex +1 -0
  55. texsmith/adapters/latex/partials/italic.tex +1 -0
  56. texsmith/adapters/latex/partials/keystroke.tex +46 -0
  57. texsmith/adapters/latex/partials/label.tex +1 -0
  58. texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
  59. texsmith/adapters/latex/partials/list_glossary.tex +8 -0
  60. texsmith/adapters/latex/partials/multicolumn.tex +3 -0
  61. texsmith/adapters/latex/partials/ordered_list.tex +5 -0
  62. texsmith/adapters/latex/partials/pagestyle.tex +1 -0
  63. texsmith/adapters/latex/partials/ref.tex +1 -0
  64. texsmith/adapters/latex/partials/regex.tex +1 -0
  65. texsmith/adapters/latex/partials/smallcaps.tex +1 -0
  66. texsmith/adapters/latex/partials/strikethrough.tex +1 -0
  67. texsmith/adapters/latex/partials/strong.tex +1 -0
  68. texsmith/adapters/latex/partials/subscript.tex +1 -0
  69. texsmith/adapters/latex/partials/substitution.tex +1 -0
  70. texsmith/adapters/latex/partials/superscript.tex +1 -0
  71. texsmith/adapters/latex/partials/tabbed.tex +3 -0
  72. texsmith/adapters/latex/partials/table.tex +48 -0
  73. texsmith/adapters/latex/partials/underline.tex +1 -0
  74. texsmith/adapters/latex/partials/unordered_list.tex +5 -0
  75. texsmith/adapters/latex/partials/url.tex +1 -0
  76. texsmith/adapters/latex/pygments.py +98 -0
  77. texsmith/adapters/latex/pyxindy.py +64 -0
  78. texsmith/adapters/latex/renderer.py +220 -0
  79. texsmith/adapters/latex/tectonic.py +366 -0
  80. texsmith/adapters/latex/utils.py +86 -0
  81. texsmith/adapters/markdown/__init__.py +342 -0
  82. texsmith/adapters/plugins/__init__.py +8 -0
  83. texsmith/adapters/plugins/material.py +174 -0
  84. texsmith/adapters/plugins/snippet.py +1734 -0
  85. texsmith/adapters/transformers/__init__.py +122 -0
  86. texsmith/adapters/transformers/base.py +140 -0
  87. texsmith/adapters/transformers/strategies.py +1456 -0
  88. texsmith/adapters/transformers/utils.py +59 -0
  89. texsmith/api/__init__.py +80 -0
  90. texsmith/api/_utils.py +56 -0
  91. texsmith/api/document.py +645 -0
  92. texsmith/api/pipeline.py +229 -0
  93. texsmith/api/service.py +648 -0
  94. texsmith/api/templates.py +287 -0
  95. texsmith/core/__init__.py +6 -0
  96. texsmith/core/bibliography/__init__.py +57 -0
  97. texsmith/core/bibliography/collection.py +354 -0
  98. texsmith/core/bibliography/doi.py +194 -0
  99. texsmith/core/bibliography/issues.py +15 -0
  100. texsmith/core/bibliography/parsing.py +45 -0
  101. texsmith/core/callouts.py +99 -0
  102. texsmith/core/config.py +191 -0
  103. texsmith/core/context.py +242 -0
  104. texsmith/core/conversion/__init__.py +103 -0
  105. texsmith/core/conversion/core.py +780 -0
  106. texsmith/core/conversion/debug.py +87 -0
  107. texsmith/core/conversion/inputs.py +474 -0
  108. texsmith/core/conversion/renderer.py +578 -0
  109. texsmith/core/conversion/templates.py +646 -0
  110. texsmith/core/conversion_contexts.py +95 -0
  111. texsmith/core/diagnostics.py +118 -0
  112. texsmith/core/exceptions.py +41 -0
  113. texsmith/core/fonts/__init__.py +3 -0
  114. texsmith/core/fragments/__init__.py +716 -0
  115. texsmith/core/fragments/base.py +117 -0
  116. texsmith/core/metadata.py +280 -0
  117. texsmith/core/mustache.py +86 -0
  118. texsmith/core/partials.py +20 -0
  119. texsmith/core/rules.py +394 -0
  120. texsmith/core/templates/__init__.py +58 -0
  121. texsmith/core/templates/base.py +343 -0
  122. texsmith/core/templates/builtins.py +68 -0
  123. texsmith/core/templates/context_usage.py +137 -0
  124. texsmith/core/templates/loader.py +303 -0
  125. texsmith/core/templates/manifest.py +861 -0
  126. texsmith/core/templates/runtime.py +347 -0
  127. texsmith/core/templates/text.py +14 -0
  128. texsmith/core/templates/wrapper.py +340 -0
  129. texsmith/core/user_dir.py +179 -0
  130. texsmith/devtools.py +28 -0
  131. texsmith/extensions/__init__.py +162 -0
  132. texsmith/extensions/index/__init__.py +21 -0
  133. texsmith/extensions/index/markdown.py +94 -0
  134. texsmith/extensions/index/mkdocs_plugin.py +136 -0
  135. texsmith/extensions/index/registry.py +57 -0
  136. texsmith/extensions/index/renderer.py +183 -0
  137. texsmith/extensions/index/templates/index.tex +1 -0
  138. texsmith/extensions/latex_raw.py +115 -0
  139. texsmith/extensions/latex_text.py +117 -0
  140. texsmith/extensions/mermaid.py +267 -0
  141. texsmith/extensions/missing_footnotes.py +125 -0
  142. texsmith/extensions/multi_citations.py +54 -0
  143. texsmith/extensions/progressbar/__init__.py +9 -0
  144. texsmith/extensions/progressbar/markdown.py +212 -0
  145. texsmith/extensions/progressbar/renderer.py +117 -0
  146. texsmith/extensions/smallcaps.py +48 -0
  147. texsmith/extensions/texlogos/__init__.py +10 -0
  148. texsmith/extensions/texlogos/markdown.py +236 -0
  149. texsmith/extensions/texlogos/renderer.py +81 -0
  150. texsmith/extensions/texlogos/specs.py +66 -0
  151. texsmith/fonts/__init__.py +57 -0
  152. texsmith/fonts/cache.py +46 -0
  153. texsmith/fonts/constants.py +60 -0
  154. texsmith/fonts/coverage.py +275 -0
  155. texsmith/fonts/downloader.py +103 -0
  156. texsmith/fonts/fallback.py +438 -0
  157. texsmith/fonts/html_scripts.py +168 -0
  158. texsmith/fonts/logging.py +127 -0
  159. texsmith/fonts/pipeline.py +338 -0
  160. texsmith/fonts/scripts.py +493 -0
  161. texsmith/fonts/ucharclasses.py +164 -0
  162. texsmith/fragments/__init__.py +11 -0
  163. texsmith/fragments/bibliography/__init__.py +65 -0
  164. texsmith/fragments/bibliography/fragment.toml +3 -0
  165. texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
  166. texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
  167. texsmith/fragments/callouts/__init__.py +88 -0
  168. texsmith/fragments/callouts/fragment.toml +3 -0
  169. texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
  170. texsmith/fragments/code/__init__.py +82 -0
  171. texsmith/fragments/code/fragment.toml +3 -0
  172. texsmith/fragments/code/ts-code.jinja.sty +140 -0
  173. texsmith/fragments/extra/__init__.py +180 -0
  174. texsmith/fragments/extra/fragment.toml +3 -0
  175. texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
  176. texsmith/fragments/fonts/__init__.py +880 -0
  177. texsmith/fragments/fonts/fragment.toml +3 -0
  178. texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
  179. texsmith/fragments/frame/__init__.py +170 -0
  180. texsmith/fragments/frame/fragment.toml +3 -0
  181. texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
  182. texsmith/fragments/geometry/__init__.py +169 -0
  183. texsmith/fragments/geometry/fragment.toml +3 -0
  184. texsmith/fragments/geometry/paper.py +526 -0
  185. texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
  186. texsmith/fragments/glossary/__init__.py +67 -0
  187. texsmith/fragments/glossary/fragment.toml +3 -0
  188. texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
  189. texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
  190. texsmith/fragments/index/__init__.py +66 -0
  191. texsmith/fragments/index/fragment.toml +3 -0
  192. texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
  193. texsmith/fragments/index/ts-index.jinja.sty +12 -0
  194. texsmith/fragments/keystrokes/__init__.py +69 -0
  195. texsmith/fragments/keystrokes/fragment.toml +3 -0
  196. texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
  197. texsmith/fragments/todolist/__init__.py +71 -0
  198. texsmith/fragments/todolist/fragment.toml +3 -0
  199. texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
  200. texsmith/fragments/typesetting/__init__.py +214 -0
  201. texsmith/fragments/typesetting/fragment.toml +3 -0
  202. texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
  203. texsmith/index.py +26 -0
  204. texsmith/plugins/__init__.py +14 -0
  205. texsmith/progressbar.py +8 -0
  206. texsmith/quotes.py +40 -0
  207. texsmith/smart_dashes.py +66 -0
  208. texsmith/templates/__init__.py +3 -0
  209. texsmith/templates/article/README.md +33 -0
  210. texsmith/templates/article/__init__.py +281 -0
  211. texsmith/templates/article/template/manifest.toml +125 -0
  212. texsmith/templates/article/template/mermaid-config.json +18 -0
  213. texsmith/templates/article/template/template.tex +74 -0
  214. texsmith/templates/book/README.md +26 -0
  215. texsmith/templates/book/__init__.py +75 -0
  216. texsmith/templates/book/overrides/codeblock.tex +7 -0
  217. texsmith/templates/book/overrides/codeinline.tex +1 -0
  218. texsmith/templates/book/template/fixtoc.sty +81 -0
  219. texsmith/templates/book/template/manifest.toml +198 -0
  220. texsmith/templates/book/template/template.tex +314 -0
  221. texsmith/templates/common/__init__.py +1 -0
  222. texsmith/templates/common/latexmkrc +46 -0
  223. texsmith/templates/letter/README.md +59 -0
  224. texsmith/templates/letter/__init__.py +495 -0
  225. texsmith/templates/letter/demo.md +31 -0
  226. texsmith/templates/letter/fonts/modernline bold.otf +0 -0
  227. texsmith/templates/letter/fonts/modernline.otf +0 -0
  228. texsmith/templates/letter/manifest.toml +197 -0
  229. texsmith/templates/letter/template/callouts.jinja.sty +290 -0
  230. texsmith/templates/letter/template/template.tex +123 -0
  231. texsmith/templates/snippet/README.md +21 -0
  232. texsmith/templates/snippet/__init__.py +80 -0
  233. texsmith/templates/snippet/template/manifest.toml +66 -0
  234. texsmith/templates/snippet/template/template.tex +47 -0
  235. texsmith/texlogos.py +15 -0
  236. texsmith/ui/__init__.py +6 -0
  237. texsmith/ui/cli/__init__.py +22 -0
  238. texsmith/ui/cli/_options.py +338 -0
  239. texsmith/ui/cli/app.py +65 -0
  240. texsmith/ui/cli/bibliography.py +300 -0
  241. texsmith/ui/cli/commands/__init__.py +14 -0
  242. texsmith/ui/cli/commands/render.py +1128 -0
  243. texsmith/ui/cli/commands/templates.py +397 -0
  244. texsmith/ui/cli/diagnostics.py +36 -0
  245. texsmith/ui/cli/presenter.py +663 -0
  246. texsmith/ui/cli/state.py +263 -0
  247. texsmith/ui/cli/utils.py +235 -0
  248. texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
  249. texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
  250. texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
  251. texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
  252. texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,125 @@
1
+ """Extension to capture undefined Markdown footnotes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import MethodType
6
+ from typing import Any
7
+ import xml.etree.ElementTree as ElementTree
8
+
9
+ from markdown.extensions import Extension
10
+ from markdown.extensions.footnotes import FootnoteExtension
11
+
12
+
13
+ class MissingFootnotesExtension(Extension):
14
+ """Detect footnote references lacking explicit definitions."""
15
+
16
+ def __init__(self, **kwargs: Any) -> None:
17
+ self.config = {
18
+ "element": ["texsmith-missing-footnote", "Tag inserted for missing notes."],
19
+ "text_template": [
20
+ "{id}",
21
+ "Fallback text rendered for missing notes (can reference {id}).",
22
+ ],
23
+ "css_class": ["", "CSS class applied to placeholder nodes."],
24
+ "link_to_list": [
25
+ False,
26
+ "When true, link to the generated footnote list despite the absence.",
27
+ ],
28
+ "data_attribute": [
29
+ "data-footnote-id",
30
+ "Custom attribute storing the missing footnote identifier.",
31
+ ],
32
+ }
33
+ super().__init__(**kwargs)
34
+ self._footnotes_ext: FootnoteExtension | None = None
35
+ self._patched_pattern = False
36
+ self.missing_ids: set[str] = set()
37
+
38
+ def reset(self) -> None:
39
+ """Reset cached state before each Markdown conversion."""
40
+ self.missing_ids.clear()
41
+
42
+ # -- internals ---------------------------------------------------------
43
+ def _get_footnotes_extension(self, md: Any) -> FootnoteExtension | None:
44
+ if self._footnotes_ext is not None:
45
+ return self._footnotes_ext
46
+
47
+ for extension in getattr(md, "registeredExtensions", []):
48
+ if isinstance(extension, FootnoteExtension):
49
+ self._footnotes_ext = extension
50
+ break
51
+ return self._footnotes_ext
52
+
53
+ def extendMarkdown(self, md: Any) -> None: # noqa: N802 - markdown hook
54
+ """Patch the inline footnote processor to capture missing notes."""
55
+ md.registerExtension(self)
56
+ if self._patched_pattern:
57
+ return
58
+
59
+ pattern = self._resolve_footnote_pattern(md)
60
+ if pattern is None:
61
+ raise RuntimeError(
62
+ "MissingFootnotesExtension requires the 'footnotes' extension to be "
63
+ "registered beforehand."
64
+ )
65
+
66
+ original_handle = pattern.handleMatch
67
+ extension = self
68
+
69
+ def patched_handle(self_pattern: Any, match: Any, data: Any) -> Any:
70
+ result = original_handle(match, data)
71
+ if result and result[0] is not None:
72
+ return result
73
+
74
+ footnote_id = match.group(1)
75
+ extension.missing_ids.add(footnote_id)
76
+ node = extension.build_placeholder(footnote_id, self_pattern)
77
+ return node, match.start(0), match.end(0)
78
+
79
+ pattern.handleMatch = MethodType(patched_handle, pattern)
80
+ self._patched_pattern = True
81
+
82
+ def _resolve_footnote_pattern(self, md: Any) -> Any:
83
+ patterns = getattr(md.inlinePatterns, "items", None)
84
+ if callable(patterns):
85
+ for name, pattern in md.inlinePatterns.items():
86
+ if name == "footnote":
87
+ return pattern
88
+ else:
89
+ try:
90
+ return md.inlinePatterns["footnote"]
91
+ except KeyError:
92
+ return None
93
+ return None
94
+
95
+ def build_placeholder(self, identifier: str, pattern: Any) -> ElementTree.Element:
96
+ """Construct the XML placeholder inserted for missing footnotes."""
97
+ element_name = self.getConfig("element")
98
+ node = ElementTree.Element(element_name)
99
+
100
+ css_class = self.getConfig("css_class")
101
+ if css_class:
102
+ node.set("class", css_class)
103
+
104
+ data_attribute = self.getConfig("data_attribute")
105
+ if data_attribute:
106
+ node.set(data_attribute, identifier)
107
+
108
+ text = self.getConfig("text_template").format(id=identifier)
109
+ if self.getConfig("link_to_list"):
110
+ footnote_extension = self._get_footnotes_extension(pattern.md)
111
+ separator = footnote_extension.get_separator() if footnote_extension else ":"
112
+ anchor = ElementTree.SubElement(node, "a")
113
+ if css_class:
114
+ anchor.set("class", css_class)
115
+ anchor.set("href", f"#fn{separator}{identifier}")
116
+ anchor.text = text
117
+ else:
118
+ node.text = text
119
+
120
+ return node
121
+
122
+
123
+ def makeExtension(**kwargs: Any) -> MissingFootnotesExtension: # noqa: N802 - markdown hook
124
+ """Entry point exposed to Python-Markdown."""
125
+ return MissingFootnotesExtension(**kwargs)
@@ -0,0 +1,54 @@
1
+ """Markdown preprocessor normalising inline citation references."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from markdown import Markdown
8
+ from markdown.extensions import Extension
9
+ from markdown.preprocessors import Preprocessor
10
+
11
+
12
+ _DOI_PATTERN = r"10\.\d{4,9}/[^\s\]]+"
13
+ _TOKEN_RE = re.compile(rf"^(?:{_DOI_PATTERN}|[0-9A-Za-z_:-]+)$")
14
+ _PATTERN = re.compile(r"\^\[(?P<keys>[^\]]+)\]")
15
+
16
+
17
+ def _clean_keys(payload: str) -> list[str]:
18
+ candidates = [part.strip() for part in payload.split(",")]
19
+ cleaned = [key for key in candidates if key and _TOKEN_RE.match(key)]
20
+ return cleaned
21
+
22
+
23
+ class _MultiCitationPreprocessor(Preprocessor):
24
+ """Convert ``^[A,B]`` references into ``[^A,B]`` before footnote processing."""
25
+
26
+ def run(self, lines: list[str]) -> list[str]:
27
+ return [_PATTERN.sub(self._replace_match, line) for line in lines]
28
+
29
+ @staticmethod
30
+ def _replace_match(match: re.Match[str]) -> str:
31
+ cleaned = _clean_keys(match.group("keys"))
32
+ if not cleaned:
33
+ return match.group(0)
34
+ return f"[^{','.join(cleaned)}]"
35
+
36
+
37
+ class MultiCitationExtension(Extension):
38
+ """Register the multi-citation preprocessor before the footnotes extension."""
39
+
40
+ def extendMarkdown(self, md: Markdown) -> None: # type: ignore[override] # noqa: N802
41
+ md.preprocessors.register(
42
+ _MultiCitationPreprocessor(),
43
+ "texsmith_multi_citations",
44
+ priority=5,
45
+ )
46
+
47
+
48
+ def makeExtension( # noqa: N802
49
+ **kwargs: object,
50
+ ) -> MultiCitationExtension: # pragma: no cover - API hook
51
+ return MultiCitationExtension(**kwargs)
52
+
53
+
54
+ __all__ = ["MultiCitationExtension", "makeExtension"]
@@ -0,0 +1,9 @@
1
+ """Public entry points for the Markdown progress bar extension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .markdown import ProgressBarExtension, makeExtension
6
+ from .renderer import register as register_renderer
7
+
8
+
9
+ __all__ = ["ProgressBarExtension", "makeExtension", "register_renderer"]
@@ -0,0 +1,212 @@
1
+ """Markdown extension transforming `[=80% "Label"]` blocks into progress bars."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import math
7
+ import re
8
+ import shlex
9
+ from xml.etree import ElementTree
10
+
11
+ from markdown import Markdown
12
+ from markdown.extensions import Extension
13
+ from markdown.preprocessors import Preprocessor
14
+
15
+
16
+ PROGRESS_LINE = re.compile(
17
+ r"""
18
+ ^\s*
19
+ \[=
20
+ (?P<value>-?\d+(?:\.\d+)?)
21
+ \s*%
22
+ \s*
23
+ (?:
24
+ "(?P<label>[^"]*)"
25
+ )?
26
+ \]
27
+ (?P<attrs>\{:[^}]+\})?
28
+ \s*$
29
+ """,
30
+ re.VERBOSE,
31
+ )
32
+ ATTR_LINE = re.compile(r"^\s*\{:[^}]+\}\s*$")
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class _AttributePayload:
37
+ classes: list[str]
38
+ identifier: str | None
39
+ attributes: dict[str, str]
40
+
41
+
42
+ class _ProgressBarPreprocessor(Preprocessor):
43
+ """Convert the Pymdown-like shorthand into semantic HTML."""
44
+
45
+ def run(self, lines: list[str]) -> list[str]: # type: ignore[override]
46
+ result: list[str] = []
47
+ index = 0
48
+ length = len(lines)
49
+ in_fence = False
50
+ fence_char: str | None = None
51
+ fence_len = 0
52
+
53
+ while index < length:
54
+ line = lines[index]
55
+ stripped = line.lstrip()
56
+
57
+ if not in_fence and self._looks_like_fence_start(stripped):
58
+ fence_char = stripped[0]
59
+ fence_len = self._fence_length(stripped)
60
+ if fence_len >= 3:
61
+ in_fence = True
62
+ result.append(line)
63
+ index += 1
64
+ continue
65
+
66
+ if in_fence:
67
+ result.append(line)
68
+ if self._is_fence_end(stripped, fence_char, fence_len):
69
+ in_fence = False
70
+ index += 1
71
+ continue
72
+
73
+ match = PROGRESS_LINE.match(line)
74
+ attrs_text = None
75
+
76
+ if match:
77
+ attrs_text = match.group("attrs")
78
+ if attrs_text is None and index + 1 < length:
79
+ next_candidate = lines[index + 1]
80
+ if ATTR_LINE.match(next_candidate):
81
+ attrs_text = next_candidate.strip()
82
+ index += 1
83
+
84
+ payload = self._render_progressbar(match, attrs_text)
85
+ result.append(payload)
86
+ index += 1
87
+ continue
88
+
89
+ result.append(line)
90
+ index += 1
91
+
92
+ return result
93
+
94
+ def _looks_like_fence_start(self, stripped: str) -> bool:
95
+ if not stripped:
96
+ return False
97
+ char = stripped[0]
98
+ if char not in {"`", "~"}:
99
+ return False
100
+ return stripped.startswith(char * 3)
101
+
102
+ def _is_fence_end(
103
+ self,
104
+ stripped: str,
105
+ fence_char: str | None,
106
+ fence_len: int,
107
+ ) -> bool:
108
+ if not fence_char or fence_len < 3:
109
+ return False
110
+ return stripped.startswith(fence_char * fence_len)
111
+
112
+ def _fence_length(self, stripped: str) -> int:
113
+ if not stripped:
114
+ return 0
115
+ char = stripped[0]
116
+ count = 0
117
+ for ch in stripped:
118
+ if ch == char:
119
+ count += 1
120
+ else:
121
+ break
122
+ return count
123
+
124
+ def _render_progressbar(
125
+ self,
126
+ match: re.Match[str],
127
+ attrs_text: str | None,
128
+ ) -> str:
129
+ percent = float(match.group("value"))
130
+ percent = max(0.0, min(100.0, percent))
131
+ label = match.group("label")
132
+ if label is None or not label.strip():
133
+ label = f"{percent:g}%"
134
+ attrs = self._parse_attributes(attrs_text)
135
+
136
+ classes = ["progress", _progress_bucket_class(percent), *attrs.classes]
137
+ fraction = percent / 100.0
138
+ fraction_str = _format_decimal(fraction, precision=4)
139
+
140
+ container = ElementTree.Element("div")
141
+ container.set("class", " ".join(filter(None, classes)))
142
+ container.set("data-progress-percent", _format_decimal(percent, precision=2))
143
+ container.set("data-progress-fraction", fraction_str)
144
+ if attrs.identifier:
145
+ container.set("id", attrs.identifier)
146
+ for key, value in attrs.attributes.items():
147
+ container.set(key, value)
148
+
149
+ bar = ElementTree.SubElement(container, "div", {"class": "progress-bar"})
150
+ bar.set("style", f"width:{_format_decimal(percent, precision=2)}%;")
151
+ bar.set("data-progress-fraction", fraction_str)
152
+
153
+ label_node = ElementTree.SubElement(bar, "p", {"class": "progress-label"})
154
+ label_node.text = label
155
+
156
+ return ElementTree.tostring(container, encoding="unicode")
157
+
158
+ def _parse_attributes(self, raw: str | None) -> _AttributePayload:
159
+ if not raw:
160
+ return _AttributePayload([], None, {})
161
+ body = raw.strip()
162
+ if body.startswith("{"):
163
+ body = body[2:-1].strip()
164
+
165
+ classes: list[str] = []
166
+ attributes: dict[str, str] = {}
167
+ identifier: str | None = None
168
+
169
+ lexer = shlex.shlex(body, posix=True)
170
+ lexer.whitespace_split = True
171
+ lexer.commenters = ""
172
+
173
+ for token in lexer:
174
+ if not token:
175
+ continue
176
+ if token.startswith("."):
177
+ classes.append(token[1:])
178
+ elif token.startswith("#"):
179
+ identifier = token[1:] or identifier
180
+ elif "=" in token:
181
+ key, value = token.split("=", 1)
182
+ attributes[key] = value
183
+
184
+ return _AttributePayload(classes, identifier, attributes)
185
+
186
+
187
+ def _progress_bucket_class(percent: float) -> str:
188
+ bucket = int(math.floor(percent / 5.0) * 5)
189
+ bucket = max(0, min(100, bucket))
190
+ return f"progress-{bucket}plus"
191
+
192
+
193
+ def _format_decimal(value: float, *, precision: int) -> str:
194
+ formatted = f"{value:.{precision}f}"
195
+ return formatted.rstrip("0").rstrip(".") or "0"
196
+
197
+
198
+ class ProgressBarExtension(Extension):
199
+ """Register the preprocessor on the Markdown pipeline."""
200
+
201
+ def extendMarkdown(self, md: Markdown) -> None: # noqa: N802
202
+ processor = _ProgressBarPreprocessor(md)
203
+ md.preprocessors.register(processor, "texsmith_progressbar", 27)
204
+
205
+
206
+ def makeExtension( # noqa: N802
207
+ **kwargs: object,
208
+ ) -> ProgressBarExtension: # pragma: no cover - Markdown hook
209
+ return ProgressBarExtension(**kwargs)
210
+
211
+
212
+ __all__ = ["ProgressBarExtension", "makeExtension"]
@@ -0,0 +1,117 @@
1
+ """Renderer hooks converting HTML progress bars into LaTeX commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from bs4 import NavigableString, Tag
8
+
9
+ from texsmith.adapters.handlers._helpers import coerce_attribute, gather_classes, mark_processed
10
+ from texsmith.adapters.latex.utils import escape_latex_chars
11
+ from texsmith.core.context import RenderContext
12
+ from texsmith.core.rules import RenderPhase, renders
13
+
14
+
15
+ DEFAULT_HEIGHT = "12pt"
16
+ THIN_HEIGHT = "6pt"
17
+ DEFAULT_OPTIONS = {
18
+ "width": "9cm",
19
+ "heighta": DEFAULT_HEIGHT,
20
+ "roundnessr": "0.1",
21
+ "borderwidth": "1pt",
22
+ "linecolor": "black",
23
+ "filledcolor": "black!60",
24
+ "emptycolor": "black!10",
25
+ }
26
+
27
+
28
+ @renders(
29
+ "div",
30
+ phase=RenderPhase.BLOCK,
31
+ priority=58,
32
+ name="texsmith_progressbar",
33
+ nestable=False,
34
+ auto_mark=False,
35
+ )
36
+ def render_progressbar(element: Tag, context: RenderContext) -> None:
37
+ r"""Convert ``<div class="progress">`` nodes into ``\progressbar`` calls."""
38
+ classes = gather_classes(element.get("class"))
39
+ if "progress" not in classes:
40
+ return
41
+
42
+ bar = element.find("div", class_="progress-bar")
43
+ if bar is None:
44
+ return
45
+
46
+ percent = _extract_percent(element, bar)
47
+ fraction = max(0.0, min(1.0, percent / 100.0))
48
+
49
+ label_node = bar.find("p", class_="progress-label")
50
+ label_text = label_node.get_text(strip=True) if label_node else f"{percent:g}%"
51
+ legacy_accents = getattr(context.config, "legacy_latex_accents", False)
52
+ escaped_label = escape_latex_chars(label_text, legacy_accents=legacy_accents)
53
+
54
+ thin = "thin" in classes or "progress-thin" in classes
55
+ latex = _compose_latex(fraction, escaped_label, thin=thin)
56
+
57
+ node = mark_processed(NavigableString(latex))
58
+ context.mark_processed(element)
59
+ context.suppress_children(element)
60
+ element.replace_with(node)
61
+
62
+
63
+ def _extract_percent(element: Tag, bar: Tag) -> float:
64
+ for attr in ("data-progress-percent", "data-progress", "data-progress-value"):
65
+ value = coerce_attribute(element.get(attr)) or coerce_attribute(bar.get(attr))
66
+ if value:
67
+ try:
68
+ return float(value)
69
+ except ValueError:
70
+ continue
71
+
72
+ style = coerce_attribute(bar.get("style")) or ""
73
+ match = re.search(r"width:\s*([0-9.]+)", style)
74
+ if match:
75
+ try:
76
+ return float(match.group(1))
77
+ except ValueError:
78
+ return 0.0
79
+
80
+ fraction_attr = coerce_attribute(bar.get("data-progress-fraction"))
81
+ if fraction_attr:
82
+ try:
83
+ return float(fraction_attr) * 100.0
84
+ except ValueError:
85
+ return 0.0
86
+
87
+ return 0.0
88
+
89
+
90
+ def _compose_latex(fraction: float, label: str, *, thin: bool) -> str:
91
+ value = max(0.0, min(1.0, fraction))
92
+ height = THIN_HEIGHT if thin else DEFAULT_HEIGHT
93
+ options = dict(DEFAULT_OPTIONS)
94
+ options["heighta"] = height
95
+ options_payload = ",".join(f"{key}={value}" for key, value in options.items())
96
+ value_payload = _format_fraction(value)
97
+ return f"{{\\progressbar[{options_payload}]{{{value_payload}}} {label}}}\\par\n"
98
+
99
+
100
+ def _format_fraction(value: float) -> str:
101
+ formatted = f"{value:.4f}"
102
+ trimmed = formatted.rstrip("0").rstrip(".")
103
+ return trimmed or "0"
104
+
105
+
106
+ def register(renderer: object) -> None:
107
+ """Register the progress bar handler with the renderer."""
108
+ register_callable = getattr(renderer, "register", None)
109
+ if not callable(register_callable):
110
+ raise TypeError("Renderer does not expose a 'register' method.")
111
+ if getattr(renderer, "_texsmith_progressbar_registered", False):
112
+ return
113
+ register_callable(render_progressbar)
114
+ renderer._texsmith_progressbar_registered = True # noqa: SLF001
115
+
116
+
117
+ __all__ = ["register", "render_progressbar"]
@@ -0,0 +1,48 @@
1
+ """Markdown extension that maps double underscores to small caps spans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import xml.etree.ElementTree as ElementTree
6
+
7
+ from markdown import Markdown
8
+ from markdown.extensions import Extension
9
+ from markdown.inlinepatterns import InlineProcessor
10
+
11
+
12
+ _SMALL_CAPS_PATTERN = r"(?<!_)__(?!_)(.+?)__(?!_)"
13
+
14
+
15
+ class _SmallCapsInlineProcessor(InlineProcessor):
16
+ """Inline processor converting ``__text__`` into a span marker."""
17
+
18
+ def handleMatch( # type: ignore[override] # noqa: N802 - Markdown API requires camelCase
19
+ self,
20
+ match, # type: ignore[override] # noqa: ANN001
21
+ data: str,
22
+ ) -> tuple[ElementTree.Element | None, int | None, int | None]:
23
+ content = match.group(1)
24
+ if not content:
25
+ return None, None, None
26
+
27
+ element = ElementTree.Element("span", {"class": "texsmith-smallcaps"})
28
+ parser = getattr(self, "parser", None)
29
+ if parser is not None:
30
+ parser.parseInline(element, content)
31
+ else: # pragma: no cover - parser is provided by Markdown
32
+ element.text = content
33
+ return element, match.start(0), match.end(0)
34
+
35
+
36
+ class SmallCapsExtension(Extension):
37
+ """Register the ``__text__`` → ``<span class=\"texsmith-smallcaps\">`` processor."""
38
+
39
+ def extendMarkdown(self, md: Markdown) -> None: # type: ignore[override] # noqa: N802
40
+ processor = _SmallCapsInlineProcessor(_SMALL_CAPS_PATTERN, md)
41
+ md.inlinePatterns.register(processor, "texsmith_smallcaps", 185)
42
+
43
+
44
+ def makeExtension(**_: object) -> SmallCapsExtension: # pragma: no cover - API hook # noqa: N802
45
+ return SmallCapsExtension()
46
+
47
+
48
+ __all__ = ["SmallCapsExtension", "makeExtension"]
@@ -0,0 +1,10 @@
1
+ """TeXSmith extension providing extra TeX logo commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .markdown import TexLogosExtension, makeExtension
6
+ from .renderer import register as register_renderer
7
+ from .specs import LogoSpec, iter_specs
8
+
9
+
10
+ __all__ = ["LogoSpec", "TexLogosExtension", "iter_specs", "makeExtension", "register_renderer"]