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.
- texformules/__init__.py +51 -0
- texformules/figures/__init__.py +66 -0
- texformules/figures/_require.py +16 -0
- texformules/figures/axes.py +93 -0
- texformules/figures/export.py +70 -0
- texformules/figures/legacy.py +90 -0
- texformules/figures/style.py +135 -0
- texformules/formulas.py +595 -0
- texformules/fragments.py +382 -0
- texformules/py.typed +0 -0
- texformules-0.1.0.dist-info/METADATA +317 -0
- texformules-0.1.0.dist-info/RECORD +14 -0
- texformules-0.1.0.dist-info/WHEEL +4 -0
- texformules-0.1.0.dist-info/licenses/LICENSE +21 -0
texformules/__init__.py
ADDED
|
@@ -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
|