texformules 0.1.0__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,51 @@
1
+ """LaTeX formulas typeset in batches, cached as PNG and one-page PDF with their baseline.
2
+
3
+ Two steps, two modules:
4
+
5
+ - ``fragments`` cuts a line into text and LaTeX fragments, on latexdetok's tree
6
+ (:func:`split`), removes the comments and can set French spacing; the whole line
7
+ goes through latexdetok, and what it finds wrong follows each fragment
8
+ (:class:`Problem`);
9
+ - ``formulas`` typesets the fragments in a single compilation per batch, with the
10
+ document's preamble, and keeps them as PNG and as one-page PDF, with their baseline
11
+ (:class:`FormulaBook`, :class:`PdfBox`).
12
+
13
+ Only latexdetok and pypdfium2 are needed to import it; LaTeX is called at the first
14
+ missing formula. ``texformules.figures``, with the ``figures`` extra, styles matplotlib
15
+ figures for the same documents.
16
+
17
+ book = FormulaBook(default_cache(), dpi=600)
18
+ fragments = split(r"Result: $T_0=\\SI{2.0}{s}$, 2 marks")
19
+ formulas = book.render(f.text for f in fragments if f.latex)
20
+ for formula in formulas.values():
21
+ pdf, box = book.pdf(formula.key), formula.pdf_box # box in PDF points
22
+ """
23
+
24
+ from texformules.formulas import (
25
+ BP_PER_PT,
26
+ Formula,
27
+ FormulaBook,
28
+ PdfBox,
29
+ Runner,
30
+ default_cache,
31
+ default_preamble,
32
+ )
33
+ from texformules.fragments import Fragment, Problem, comments, french_spacing, split
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "BP_PER_PT",
39
+ "Formula",
40
+ "FormulaBook",
41
+ "Fragment",
42
+ "PdfBox",
43
+ "Problem",
44
+ "Runner",
45
+ "__version__",
46
+ "comments",
47
+ "default_cache",
48
+ "default_preamble",
49
+ "french_spacing",
50
+ "split",
51
+ ]
@@ -0,0 +1,66 @@
1
+ """matplotlib figures ready for a LaTeX document: size in cm, fonts, decimal comma,
2
+ insets, tight cropping. Needs the ``figures`` extra (matplotlib).
3
+
4
+ Usage::
5
+
6
+ import matplotlib.pyplot as plt
7
+ from texformules.figures import apply_style, save_figure, style_axes
8
+
9
+ apply_style(8, 6) # width, height in cm, before creating the figures
10
+ fig, ax = plt.subplots()
11
+ ax.plot(x, y)
12
+ style_axes(ax) # spines, ticks, decimal comma
13
+ save_figure(fig, "figure.pdf") # cropped by pdfcrop
14
+
15
+ Formerly the separate ``latex_figures`` package; its names are kept as deprecated
16
+ aliases (:mod:`texformules.figures.legacy`).
17
+ """
18
+
19
+ # First: a clear error, naming the extra, when matplotlib is missing.
20
+ import texformules.figures._require # noqa: F401 # isort: skip
21
+ from texformules.figures.axes import DecimalCommaFormatter, add_inset, style_axes
22
+ from texformules.figures.export import FORMATS, save_figure
23
+ from texformules.figures.legacy import (
24
+ PREAMBULE,
25
+ ScalarFormatterVirgule,
26
+ add_subplot_axes,
27
+ fig_size,
28
+ format_axes,
29
+ latexify,
30
+ parametres_latex,
31
+ restaurer,
32
+ save_fig,
33
+ )
34
+ from texformules.figures.style import (
35
+ INCH,
36
+ MAX_HEIGHT,
37
+ PREAMBLE,
38
+ apply_style,
39
+ figure_size,
40
+ restore_style,
41
+ style_params,
42
+ )
43
+
44
+ __all__ = [
45
+ "FORMATS",
46
+ "INCH",
47
+ "MAX_HEIGHT",
48
+ "PREAMBLE",
49
+ "PREAMBULE",
50
+ "DecimalCommaFormatter",
51
+ "ScalarFormatterVirgule",
52
+ "add_inset",
53
+ "add_subplot_axes",
54
+ "apply_style",
55
+ "fig_size",
56
+ "figure_size",
57
+ "format_axes",
58
+ "latexify",
59
+ "parametres_latex",
60
+ "restaurer",
61
+ "restore_style",
62
+ "save_fig",
63
+ "save_figure",
64
+ "style_axes",
65
+ "style_params",
66
+ ]
@@ -0,0 +1,16 @@
1
+ """Fails early, and says which extra to install, when matplotlib is missing.
2
+
3
+ ``texformules.figures`` is the only part of the package that needs matplotlib, a heavy
4
+ dependency the formulas do without: it comes with the ``figures`` extra. Without this
5
+ check, the error would name matplotlib alone, and not how to get it.
6
+ """
7
+
8
+ __all__: list[str] = []
9
+
10
+ try:
11
+ import matplotlib # noqa: F401
12
+ except ModuleNotFoundError as error: # pragma: no cover - depends on the environment
13
+ raise ModuleNotFoundError(
14
+ "texformules.figures needs matplotlib: pip install 'texformules[figures]'",
15
+ name=error.name,
16
+ ) from error
@@ -0,0 +1,93 @@
1
+ """Axes formatting: discreet spines, outward ticks, decimal comma."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ import matplotlib.pyplot as plt
7
+ from matplotlib.axes import Axes
8
+ from matplotlib.ticker import ScalarFormatter
9
+
10
+ __all__ = ["DecimalCommaFormatter", "add_inset", "style_axes"]
11
+
12
+
13
+ def _comma(text: str) -> str:
14
+ # In math mode (mathtext or LaTeX), "{,}" avoids the space that follows a
15
+ # punctuation comma.
16
+ return text.replace(".", "{,}")
17
+
18
+
19
+ class DecimalCommaFormatter(ScalarFormatter):
20
+ """A ``ScalarFormatter`` in math notation, with a decimal comma, and powers of 10
21
+ outside [10⁻², 10⁴].
22
+
23
+ The comma does not go through ``locale.setlocale("fr_FR")``, which would act on
24
+ the whole program and fails where no French locale is installed.
25
+ """
26
+
27
+ def __init__(self, **kwargs: Any) -> None:
28
+ kwargs.setdefault("useMathText", True)
29
+ super().__init__(**kwargs)
30
+ self.set_powerlimits((-2, 4))
31
+
32
+ def __call__(self, x: float, pos: int | None = None) -> str:
33
+ return _comma(super().__call__(x, pos))
34
+
35
+ def get_offset(self) -> str:
36
+ return _comma(super().get_offset())
37
+
38
+
39
+ def style_axes(ax: Axes | None = None, format_ticks: bool = True, decimal_comma: bool = True) -> Axes:
40
+ """Thin grey spines, outward ticks, and (``format_ticks``) a formatter of its own on
41
+ each linear axis: :class:`DecimalCommaFormatter`, or with ``decimal_comma=False`` a
42
+ mathtext ``ScalarFormatter`` with the same power limits. Calls ``tight_layout`` on
43
+ the figure of ``ax`` (the current axes by default).
44
+
45
+ Each axis gets its own formatter: a single shared formatter kept only one common
46
+ factor (the ×10⁵ of the x axis lost to the y axis's).
47
+ """
48
+ if ax is None:
49
+ ax = plt.gca()
50
+ for spine in ax.spines.values():
51
+ spine.set_visible(True)
52
+ spine.set_color("gray")
53
+ spine.set_linewidth(0.5)
54
+ ax.xaxis.set_ticks_position("bottom")
55
+ ax.yaxis.set_ticks_position("left")
56
+ for axis in (ax.xaxis, ax.yaxis):
57
+ axis.set_tick_params(direction="out", which="both", color="gray")
58
+ if format_ticks and axis.get_scale() == "linear":
59
+ axis.set_major_formatter(_formatter(decimal_comma))
60
+ figure = ax.get_figure()
61
+ if figure is not None:
62
+ figure.tight_layout() # type: ignore[union-attr]
63
+ return ax
64
+
65
+
66
+ def _formatter(decimal_comma: bool) -> ScalarFormatter:
67
+ if decimal_comma:
68
+ return DecimalCommaFormatter()
69
+ formatter = ScalarFormatter(useMathText=True)
70
+ formatter.set_powerlimits((-2, 4))
71
+ return formatter
72
+
73
+
74
+ def add_inset(ax: Axes, rect: Sequence[float]) -> Axes:
75
+ """Inset axes inside ``ax``; ``rect = [x0, y0, width, height]`` as fractions of
76
+ ``ax`` (for instance ``[0.6, 0.1, 0.3, 0.3]``).
77
+
78
+ The tick labels shrink as the square root of the relative size. The inset follows
79
+ ``ax`` if the layout changes (``Axes.inset_axes``).
80
+
81
+ Example::
82
+
83
+ inset = add_inset(ax, rect=[0.6, 0.1, 0.3, 0.3])
84
+ inset.plot(wavelengths, sines, "o", ms=3)
85
+ inset.set_xlim(0, 650)
86
+ """
87
+ x0, y0, width, height = rect
88
+ inset = ax.inset_axes((x0, y0, width, height))
89
+ for axis, factor in ((inset.xaxis, width), (inset.yaxis, height)):
90
+ labels = axis.get_ticklabels()
91
+ size = labels[0].get_fontsize() if labels else 10.0
92
+ axis.set_tick_params(labelsize=size * factor**0.5)
93
+ return inset
@@ -0,0 +1,70 @@
1
+ """Saving a figure, cropped as tight as can be by an external tool.
2
+
3
+ ``bbox_inches="tight"`` leaves a margin around the text boxes; pdfcrop (PDF),
4
+ ImageMagick (PNG) or epstool (EPS) crop to the ink. The figure goes through a temporary
5
+ file, deleted afterwards, and the tools run without a shell: a path with spaces breaks
6
+ nothing. Without the tool, the figure is saved uncropped, with a warning.
7
+ """
8
+
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import warnings
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from matplotlib.figure import Figure
17
+
18
+ __all__ = ["FORMATS", "save_figure"]
19
+
20
+ #: Format → possible cropping commands, the first one found is used ({e}: input, {s}: output).
21
+ FORMATS: dict[str, list[list[str]]] = {
22
+ "pdf": [["pdfcrop", "{e}", "{s}"]],
23
+ "png": [["magick", "{e}", "-trim", "{s}"], ["convert", "{e}", "-trim", "{s}"]],
24
+ "eps": [["epstool", "--bbox", "--copy", "{e}", "{s}"]],
25
+ }
26
+
27
+
28
+ def _command(fmt: str) -> list[str] | None:
29
+ for command in FORMATS[fmt]:
30
+ if shutil.which(command[0]):
31
+ return command
32
+ return None
33
+
34
+
35
+ def save_figure(
36
+ fig: Figure,
37
+ path: str | Path,
38
+ fmt: str | None = None,
39
+ dpi: int = 300,
40
+ tight: bool = True,
41
+ crop: bool = True,
42
+ ) -> Path:
43
+ """Saves ``fig`` as PDF, PNG or EPS (from the extension, or ``fmt``), cropped.
44
+
45
+ The extension is added to the name if it is missing. Returns the path written.
46
+
47
+ :raises ValueError: for a format outside :data:`FORMATS`.
48
+ :raises subprocess.CalledProcessError: when the cropping tool fails.
49
+ """
50
+ target = Path(path).expanduser().resolve()
51
+ fmt = fmt or target.suffix.lstrip(".").lower()
52
+ if fmt not in FORMATS:
53
+ raise ValueError(f"Unsupported format: {fmt!r} ({', '.join(FORMATS)}).")
54
+ if target.suffix.lower() != f".{fmt}":
55
+ target = target.with_name(target.name + f".{fmt}")
56
+ options: dict[str, Any] = {"dpi": dpi, "format": fmt, "bbox_inches": "tight" if tight else None}
57
+
58
+ command = _command(fmt) if crop else None
59
+ if crop and command is None:
60
+ tools = ", ".join(c[0] for c in FORMATS[fmt])
61
+ warnings.warn(f"{tools} not found: figure not cropped.", stacklevel=2)
62
+ if command is None:
63
+ fig.savefig(target, **options)
64
+ return target
65
+ with tempfile.TemporaryDirectory() as folder:
66
+ raw = Path(folder) / f"figure.{fmt}"
67
+ fig.savefig(raw, **options)
68
+ arguments = [a.format(e=raw, s=target) for a in command]
69
+ subprocess.run(arguments, check=True, capture_output=True)
70
+ return target
@@ -0,0 +1,90 @@
1
+ """The names of latex_figures, the package ``texformules.figures`` replaces. Deprecated.
2
+
3
+ latex_figures had French names (``restaurer``, ``ScalarFormatterVirgule``) and
4
+ keyword arguments (``famille``, ``fig_width``); the package is English now. The old
5
+ names stay so that existing scripts keep working unchanged after
6
+ ``from latex_figures import …`` becomes ``from texformules.figures import …``; new code
7
+ uses the new names. They will go in a future major release.
8
+
9
+ Where only the name changed, the alias is the new object itself. Where the arguments
10
+ changed too, a thin wrapper keeps the old signature, and the old defaults: the old
11
+ ``latexify`` and ``parametres_latex`` keep :data:`PREAMBULE`, which loads French babel,
12
+ where the new ones load nothing language-specific.
13
+ """
14
+
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from matplotlib.figure import Figure
20
+
21
+ from texformules.figures.axes import DecimalCommaFormatter, add_inset, style_axes
22
+ from texformules.figures.export import save_figure
23
+ from texformules.figures.style import apply_style, figure_size, restore_style, style_params
24
+
25
+ __all__ = [
26
+ "PREAMBULE",
27
+ "ScalarFormatterVirgule",
28
+ "add_subplot_axes",
29
+ "fig_size",
30
+ "format_axes",
31
+ "latexify",
32
+ "parametres_latex",
33
+ "restaurer",
34
+ "save_fig",
35
+ ]
36
+
37
+ #: latex_figures's default preamble: French babel and siunitx.
38
+ PREAMBULE = "\\usepackage[french]{babel}\n\\usepackage{siunitx}"
39
+
40
+ ScalarFormatterVirgule = DecimalCommaFormatter
41
+ add_subplot_axes = add_inset
42
+ format_axes = style_axes
43
+ restaurer = restore_style
44
+
45
+
46
+ def fig_size(
47
+ fig_width: float = 16.0, fig_height: float | None = None, ratio: float | None = None
48
+ ) -> tuple[float, float]:
49
+ """Deprecated: :func:`~texformules.figures.figure_size`."""
50
+ return figure_size(fig_width, fig_height, ratio)
51
+
52
+
53
+ def parametres_latex(
54
+ fig_width: float | None = None,
55
+ fig_height: float | None = None,
56
+ ratio: float | None = None,
57
+ usetex: bool = True,
58
+ famille: str = "sans-serif",
59
+ preamble: str | Sequence[str] | None = None,
60
+ ) -> dict[str, Any]:
61
+ """Deprecated: :func:`~texformules.figures.style_params`, French babel by default."""
62
+ return style_params(fig_width, fig_height, ratio, usetex, famille, _old_default(preamble))
63
+
64
+
65
+ def latexify(
66
+ fig_width: float | None = None,
67
+ fig_height: float | None = None,
68
+ ratio: float | None = None,
69
+ usetex: bool = True,
70
+ famille: str = "sans-serif",
71
+ preamble: str | Sequence[str] | None = None,
72
+ ) -> dict[str, Any]:
73
+ """Deprecated: :func:`~texformules.figures.apply_style`, French babel by default."""
74
+ return apply_style(fig_width, fig_height, ratio, usetex, famille, _old_default(preamble))
75
+
76
+
77
+ def save_fig(
78
+ fig: Figure,
79
+ file_name: str | Path,
80
+ fmt: str | None = None,
81
+ dpi: int = 300,
82
+ tight: bool = True,
83
+ crop: bool = True,
84
+ ) -> Path:
85
+ """Deprecated: :func:`~texformules.figures.save_figure`."""
86
+ return save_figure(fig, file_name, fmt, dpi, tight, crop)
87
+
88
+
89
+ def _old_default(preamble: str | Sequence[str] | None) -> str | Sequence[str]:
90
+ return PREAMBULE if preamble is None else preamble
@@ -0,0 +1,135 @@
1
+ """matplotlib settings for figures inserted in a LaTeX document.
2
+
3
+ Dimensions are in centimetres, like in the document. The settings start from those of
4
+ the SciPy "Cookbook" (Matplotlib/LaTeX_Examples). With ``usetex``, the LaTeX preamble
5
+ loads amsmath and siunitx, so that labels are written as in the document
6
+ (``\\qty{3}{\\m\\per\\s}``).
7
+
8
+ The settings saved before the first :func:`apply_style` live in a module variable, not
9
+ in a class: ``rcParams`` is itself global to matplotlib, and :func:`restore_style`
10
+ must undo every :func:`apply_style` since, whoever made it.
11
+ """
12
+
13
+ import copy
14
+ import math
15
+ import warnings
16
+ from collections.abc import Sequence
17
+ from typing import Any
18
+
19
+ import matplotlib as mpl
20
+
21
+ __all__ = [
22
+ "INCH",
23
+ "MAX_HEIGHT",
24
+ "PREAMBLE",
25
+ "apply_style",
26
+ "figure_size",
27
+ "restore_style",
28
+ "style_params",
29
+ ]
30
+
31
+ INCH = 2.54 # cm
32
+ MAX_HEIGHT = 22.0 # cm, the usable height of an A4 page
33
+ _GOLDEN_RATIO = (math.sqrt(5) - 1) / 2 # 0.618
34
+ #: Default LaTeX preamble (``text.latex.preamble``), used when ``usetex`` is true.
35
+ PREAMBLE = "\\usepackage{amsmath}\n\\usepackage{siunitx}"
36
+
37
+ # Settings from before the first ``apply_style``, given back by ``restore_style``.
38
+ _SAVED: dict[str, Any] | None = None
39
+
40
+
41
+ def figure_size(
42
+ width: float = 16.0, height: float | None = None, ratio: float | None = None
43
+ ) -> tuple[float, float]:
44
+ """Size (width, height) in inches of a figure given in cm.
45
+
46
+ Without a height, it is ``ratio`` × width (the golden ratio by default). Beyond
47
+ 22 cm, the height is brought down to 22 cm, with a warning.
48
+ """
49
+ if height is None:
50
+ height = width * (_GOLDEN_RATIO if ratio is None else ratio)
51
+ if height > MAX_HEIGHT:
52
+ warnings.warn(
53
+ f"Figure height {height} cm too tall: brought down to {MAX_HEIGHT} cm.",
54
+ stacklevel=2,
55
+ )
56
+ height = MAX_HEIGHT
57
+ return width / INCH, height / INCH
58
+
59
+
60
+ def style_params(
61
+ width: float | None = None,
62
+ height: float | None = None,
63
+ ratio: float | None = None,
64
+ usetex: bool = True,
65
+ family: str = "sans-serif",
66
+ preamble: str | Sequence[str] | None = None,
67
+ ) -> dict[str, Any]:
68
+ """The rc parameters of :func:`apply_style`, to pass to ``plt.rc_context`` as well.
69
+
70
+ ``width`` in cm (16 by default), ``height`` in cm or ``ratio`` × width; ``family``:
71
+ ``"sans-serif"`` or ``"serif"``; ``preamble``: the LaTeX preamble (a string, or one
72
+ line per item), :data:`PREAMBLE` by default, ``""`` for none; it only matters with
73
+ ``usetex``.
74
+ """
75
+ inches_wide, inches_high = figure_size(16.0 if width is None else width, height, ratio)
76
+ if preamble is None:
77
+ preamble = PREAMBLE
78
+ elif not isinstance(preamble, str):
79
+ preamble = "\n".join(preamble)
80
+ return {
81
+ "pgf.texsystem": "pdflatex",
82
+ "axes.labelsize": 11,
83
+ "axes.titlesize": 11,
84
+ "axes.formatter.use_mathtext": True,
85
+ "font.size": 11,
86
+ "text.usetex": usetex,
87
+ "text.latex.preamble": preamble,
88
+ "font.family": family,
89
+ "font.serif": ["Times New Roman", "Times", "DejaVu Serif"],
90
+ "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"],
91
+ "legend.fontsize": 8,
92
+ "xtick.labelsize": 10,
93
+ "ytick.labelsize": 10,
94
+ "lines.markersize": 3,
95
+ "figure.figsize": [inches_wide, inches_high],
96
+ "figure.subplot.left": 0.16,
97
+ "figure.subplot.right": 0.94,
98
+ "figure.subplot.bottom": 0.14,
99
+ "figure.subplot.top": 0.96,
100
+ "figure.subplot.wspace": 0.3,
101
+ "figure.subplot.hspace": 0.2,
102
+ }
103
+
104
+
105
+ def apply_style(
106
+ width: float | None = None,
107
+ height: float | None = None,
108
+ ratio: float | None = None,
109
+ usetex: bool = True,
110
+ family: str = "sans-serif",
111
+ preamble: str | Sequence[str] | None = None,
112
+ ) -> dict[str, Any]:
113
+ """Applies :func:`style_params` to matplotlib (before creating the figures) and returns them.
114
+
115
+ Figures already created keep their settings; no need to restart the console. No
116
+ locale is changed: the decimal comma comes from :func:`~texformules.figures.style_axes`.
117
+ :func:`restore_style` gives back the settings from before.
118
+ """
119
+ global _SAVED
120
+ params = style_params(width, height, ratio, usetex, family, preamble)
121
+ if _SAVED is None:
122
+ _SAVED = {key: copy.deepcopy(mpl.rcParams[key]) for key in params} # type: ignore[index]
123
+ # matplotlib ≥ 3.11's stubs only accept literal keys; matplotlib checks every key at
124
+ # run time anyway.
125
+ mpl.rcParams.update(params) # type: ignore[arg-type]
126
+ return params
127
+
128
+
129
+ def restore_style() -> None:
130
+ """Gives matplotlib back the settings from before the first :func:`apply_style` (its
131
+ inverse); does nothing if it was not called."""
132
+ global _SAVED
133
+ if _SAVED is not None:
134
+ mpl.rcParams.update(_SAVED) # type: ignore[arg-type]
135
+ _SAVED = None