plotstyle 0.1.0a1__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.
- plotstyle/__init__.py +121 -0
- plotstyle/_utils/__init__.py +0 -0
- plotstyle/_utils/io.py +113 -0
- plotstyle/_utils/warnings.py +86 -0
- plotstyle/_version.py +24 -0
- plotstyle/cli/__init__.py +0 -0
- plotstyle/cli/main.py +553 -0
- plotstyle/color/__init__.py +42 -0
- plotstyle/color/_rendering.py +86 -0
- plotstyle/color/accessibility.py +286 -0
- plotstyle/color/data/okabe_ito.json +5 -0
- plotstyle/color/data/safe_grayscale.json +7 -0
- plotstyle/color/data/tol_bright.json +5 -0
- plotstyle/color/data/tol_muted.json +5 -0
- plotstyle/color/data/tol_vibrant.json +5 -0
- plotstyle/color/grayscale.py +284 -0
- plotstyle/color/palettes.py +259 -0
- plotstyle/core/__init__.py +0 -0
- plotstyle/core/export.py +418 -0
- plotstyle/core/figure.py +394 -0
- plotstyle/core/migrate.py +579 -0
- plotstyle/core/style.py +394 -0
- plotstyle/engine/__init__.py +0 -0
- plotstyle/engine/fonts.py +309 -0
- plotstyle/engine/latex.py +287 -0
- plotstyle/engine/rcparams.py +352 -0
- plotstyle/integrations/__init__.py +0 -0
- plotstyle/integrations/seaborn.py +305 -0
- plotstyle/preview/__init__.py +50 -0
- plotstyle/preview/gallery.py +337 -0
- plotstyle/preview/print_size.py +304 -0
- plotstyle/py.typed +0 -0
- plotstyle/specs/__init__.py +304 -0
- plotstyle/specs/_templates.toml +48 -0
- plotstyle/specs/acs.toml +36 -0
- plotstyle/specs/cell.toml +35 -0
- plotstyle/specs/elsevier.toml +35 -0
- plotstyle/specs/ieee.toml +35 -0
- plotstyle/specs/nature.toml +35 -0
- plotstyle/specs/plos.toml +35 -0
- plotstyle/specs/prl.toml +35 -0
- plotstyle/specs/schema.py +1095 -0
- plotstyle/specs/science.toml +35 -0
- plotstyle/specs/springer.toml +35 -0
- plotstyle/specs/units.py +761 -0
- plotstyle/specs/wiley.toml +35 -0
- plotstyle/validation/__init__.py +94 -0
- plotstyle/validation/checks/__init__.py +95 -0
- plotstyle/validation/checks/_base.py +149 -0
- plotstyle/validation/checks/colors.py +394 -0
- plotstyle/validation/checks/dimensions.py +166 -0
- plotstyle/validation/checks/export.py +205 -0
- plotstyle/validation/checks/lines.py +147 -0
- plotstyle/validation/checks/typography.py +200 -0
- plotstyle/validation/report.py +293 -0
- plotstyle-0.1.0a1.dist-info/METADATA +271 -0
- plotstyle-0.1.0a1.dist-info/RECORD +60 -0
- plotstyle-0.1.0a1.dist-info/WHEEL +4 -0
- plotstyle-0.1.0a1.dist-info/entry_points.txt +2 -0
- plotstyle-0.1.0a1.dist-info/licenses/LICENSE +21 -0
plotstyle/specs/units.py
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
"""Physical dimension and font-size types with unit conversion.
|
|
2
|
+
|
|
3
|
+
This module provides strongly-typed, immutable value objects for representing
|
|
4
|
+
physical measurements (:class:`Dimension`) and typographic sizes
|
|
5
|
+
(:class:`FontSize`). Both types share a common base (:class:`_Measurement`)
|
|
6
|
+
to eliminate duplication while exposing domain-specific convenience methods
|
|
7
|
+
on each subclass.
|
|
8
|
+
|
|
9
|
+
Supported units
|
|
10
|
+
---------------
|
|
11
|
+
``mm``, ``cm``, ``in``, ``pt``, ``pica``
|
|
12
|
+
|
|
13
|
+
Public types
|
|
14
|
+
------------
|
|
15
|
+
:data:`Unit`
|
|
16
|
+
Literal type enumerating every supported unit string.
|
|
17
|
+
|
|
18
|
+
:class:`Dimension`
|
|
19
|
+
Spatial measurement (widths, heights, margins).
|
|
20
|
+
|
|
21
|
+
:class:`FontSize`
|
|
22
|
+
Typographic measurement (font sizes).
|
|
23
|
+
|
|
24
|
+
Exceptions
|
|
25
|
+
----------
|
|
26
|
+
:class:`DimensionError`
|
|
27
|
+
Base exception; all unit-related errors inherit from this.
|
|
28
|
+
|
|
29
|
+
:class:`UnsupportedUnitError`
|
|
30
|
+
Raised when an unrecognised unit string is encountered.
|
|
31
|
+
|
|
32
|
+
:class:`IncompatibleUnitsError`
|
|
33
|
+
Raised when two measurements of different concrete types are combined.
|
|
34
|
+
|
|
35
|
+
Typical usage
|
|
36
|
+
-------------
|
|
37
|
+
::
|
|
38
|
+
|
|
39
|
+
from plotstyle.specs.units import Dimension, FontSize
|
|
40
|
+
|
|
41
|
+
width = Dimension(210, "mm") # A4 width
|
|
42
|
+
body = FontSize(10, "pt")
|
|
43
|
+
|
|
44
|
+
print(width.to_inches()) # 8.267716535433071
|
|
45
|
+
print(body.to_mm()) # 3.527777777777778
|
|
46
|
+
|
|
47
|
+
Design notes
|
|
48
|
+
------------
|
|
49
|
+
* All values are stored internally as plain Python ``float`` — no external
|
|
50
|
+
numeric library is required.
|
|
51
|
+
* Conversion is performed via a single canonical intermediate unit
|
|
52
|
+
(millimetres) to keep the conversion table O(n) rather than O(n²).
|
|
53
|
+
* Both public classes are *frozen* dataclasses, making them hashable and
|
|
54
|
+
safe to use as dictionary keys or in sets.
|
|
55
|
+
* ``slots=True`` avoids the per-instance ``__dict__`` overhead, which
|
|
56
|
+
matters when thousands of measurement objects are created at once.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import math
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from typing import Final, Literal, TypeVar
|
|
64
|
+
|
|
65
|
+
__all__: list[str] = [
|
|
66
|
+
"Dimension",
|
|
67
|
+
"DimensionError",
|
|
68
|
+
"FontSize",
|
|
69
|
+
"IncompatibleUnitsError",
|
|
70
|
+
"Unit",
|
|
71
|
+
"UnsupportedUnitError",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Public type alias
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
#: Literal type enumerating every supported measurement unit.
|
|
79
|
+
Unit = Literal["mm", "cm", "in", "pt", "pica"]
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Conversion table
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
# All factors convert *from* the named unit *to* millimetres.
|
|
86
|
+
# Using millimetres as the canonical pivot keeps the table compact: adding a
|
|
87
|
+
# new unit requires only one new entry rather than N new entries.
|
|
88
|
+
_TO_MM: Final[dict[str, float]] = {
|
|
89
|
+
"mm": 1.0,
|
|
90
|
+
"cm": 10.0,
|
|
91
|
+
"in": 25.4,
|
|
92
|
+
"pt": 25.4 / 72.0, # 1 pt = 1/72 inch (PostScript point)
|
|
93
|
+
"pica": 25.4 / 72.0 * 12.0, # 1 pica = 12 pt
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Self-type for arithmetic operators defined on the base class.
|
|
97
|
+
_T = TypeVar("_T", bound="_Measurement")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Custom exceptions
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DimensionError(ValueError):
|
|
106
|
+
"""Base exception for errors raised by this module.
|
|
107
|
+
|
|
108
|
+
Inherits from :class:`ValueError` so callers that catch the built-in
|
|
109
|
+
exception continue to work without modification.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class UnsupportedUnitError(DimensionError):
|
|
114
|
+
"""Raised when an unrecognised or unsupported unit string is encountered.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
unit: The offending unit string.
|
|
118
|
+
|
|
119
|
+
Attributes
|
|
120
|
+
----------
|
|
121
|
+
unit
|
|
122
|
+
The unit string that was rejected.
|
|
123
|
+
|
|
124
|
+
Example::
|
|
125
|
+
|
|
126
|
+
raise UnsupportedUnitError("furlong")
|
|
127
|
+
# UnsupportedUnitError: Unknown unit 'furlong'. Supported units: cm, in, mm, pica, pt
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, unit: str) -> None:
|
|
131
|
+
supported = ", ".join(sorted(_TO_MM))
|
|
132
|
+
super().__init__(f"Unknown unit {unit!r}. Supported units: {supported}")
|
|
133
|
+
self.unit: str = unit
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class IncompatibleUnitsError(DimensionError):
|
|
137
|
+
"""Raised when two measurements with incompatible types are combined.
|
|
138
|
+
|
|
139
|
+
Guards against accidentally adding a :class:`Dimension` to a
|
|
140
|
+
:class:`FontSize` — they are semantically distinct even though both
|
|
141
|
+
store a numeric value and a unit.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
left: The left-hand operand type name.
|
|
145
|
+
right: The right-hand operand type name.
|
|
146
|
+
|
|
147
|
+
Attributes
|
|
148
|
+
----------
|
|
149
|
+
left
|
|
150
|
+
Type name of the left-hand operand.
|
|
151
|
+
right
|
|
152
|
+
Type name of the right-hand operand.
|
|
153
|
+
|
|
154
|
+
Example::
|
|
155
|
+
|
|
156
|
+
Dimension(10, "mm") + FontSize(5, "pt")
|
|
157
|
+
# IncompatibleUnitsError: Cannot combine 'Dimension' with 'FontSize': …
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(self, left: str, right: str) -> None:
|
|
161
|
+
super().__init__(
|
|
162
|
+
f"Cannot combine {left!r} with {right!r}: operands must be the same measurement type."
|
|
163
|
+
)
|
|
164
|
+
self.left: str = left
|
|
165
|
+
self.right: str = right
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Internal helpers
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _validate_unit(unit: str) -> Unit:
|
|
174
|
+
"""Return *unit* unchanged if it is supported, otherwise raise.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
unit: Arbitrary string to validate against the supported-unit table.
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
The same string, narrowed to the :data:`Unit` literal type.
|
|
182
|
+
|
|
183
|
+
Raises
|
|
184
|
+
------
|
|
185
|
+
UnsupportedUnitError: If *unit* is not a key in :data:`_TO_MM`.
|
|
186
|
+
|
|
187
|
+
Example::
|
|
188
|
+
|
|
189
|
+
>>> _validate_unit("mm")
|
|
190
|
+
'mm'
|
|
191
|
+
>>> _validate_unit("furlong")
|
|
192
|
+
UnsupportedUnitError: Unknown unit 'furlong'. Supported units: …
|
|
193
|
+
"""
|
|
194
|
+
if unit not in _TO_MM:
|
|
195
|
+
raise UnsupportedUnitError(unit)
|
|
196
|
+
return unit # type: ignore[return-value]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# Base class — not part of the public API
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True, slots=True)
|
|
205
|
+
class _Measurement:
|
|
206
|
+
"""Immutable base for physical measurements.
|
|
207
|
+
|
|
208
|
+
Stores a (value, unit) pair and provides the unit-conversion engine
|
|
209
|
+
shared by :class:`Dimension` and :class:`FontSize`. End users should
|
|
210
|
+
not instantiate this class directly.
|
|
211
|
+
|
|
212
|
+
Attributes
|
|
213
|
+
----------
|
|
214
|
+
value
|
|
215
|
+
The numeric magnitude of the measurement.
|
|
216
|
+
unit
|
|
217
|
+
The unit in which *value* is expressed.
|
|
218
|
+
|
|
219
|
+
Notes
|
|
220
|
+
-----
|
|
221
|
+
``slots=True`` is used for memory efficiency: slot-based dataclasses
|
|
222
|
+
avoid the per-instance ``__dict__`` overhead, which is meaningful when
|
|
223
|
+
thousands of measurement objects are created (e.g. in a layout engine).
|
|
224
|
+
|
|
225
|
+
The class is ``frozen`` (immutable) so instances are hashable and can
|
|
226
|
+
be used as dictionary keys or cached safely.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
value: float
|
|
230
|
+
unit: Unit
|
|
231
|
+
|
|
232
|
+
def __post_init__(self) -> None:
|
|
233
|
+
"""Validate *unit* and coerce *value* to ``float`` after construction.
|
|
234
|
+
|
|
235
|
+
Raises
|
|
236
|
+
------
|
|
237
|
+
UnsupportedUnitError: If *unit* is not a recognised unit string.
|
|
238
|
+
TypeError: If *value* cannot be converted to ``float``.
|
|
239
|
+
"""
|
|
240
|
+
# Validate and normalise the unit first so the error is descriptive.
|
|
241
|
+
object.__setattr__(self, "unit", _validate_unit(self.unit))
|
|
242
|
+
# Coerce value to float; this catches non-numeric inputs early.
|
|
243
|
+
object.__setattr__(self, "value", float(self.value))
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Core conversion
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def _to_mm_raw(self) -> float:
|
|
250
|
+
"""Return the measurement's value expressed in millimetres.
|
|
251
|
+
|
|
252
|
+
This is the single point where unit→mm conversion happens; all
|
|
253
|
+
other conversion methods delegate here to keep rounding errors
|
|
254
|
+
consistent across the unit system.
|
|
255
|
+
|
|
256
|
+
Returns
|
|
257
|
+
-------
|
|
258
|
+
The magnitude in millimetres as a ``float``.
|
|
259
|
+
"""
|
|
260
|
+
return self.value * _TO_MM[self.unit]
|
|
261
|
+
|
|
262
|
+
def to(self, target_unit: str) -> float:
|
|
263
|
+
"""Convert to any supported target unit.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
target_unit: Destination unit string (e.g. ``"in"``, ``"pt"``).
|
|
267
|
+
|
|
268
|
+
Returns
|
|
269
|
+
-------
|
|
270
|
+
The measurement's magnitude expressed in *target_unit*.
|
|
271
|
+
|
|
272
|
+
Raises
|
|
273
|
+
------
|
|
274
|
+
UnsupportedUnitError: If *target_unit* is not a recognised unit.
|
|
275
|
+
|
|
276
|
+
Example::
|
|
277
|
+
|
|
278
|
+
>>> Dimension(2.54, "cm").to("in")
|
|
279
|
+
1.0
|
|
280
|
+
>>> Dimension(72, "pt").to("in")
|
|
281
|
+
1.0
|
|
282
|
+
"""
|
|
283
|
+
validated: Unit = _validate_unit(target_unit)
|
|
284
|
+
return self._to_mm_raw() / _TO_MM[validated]
|
|
285
|
+
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
# Arithmetic operators
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _check_compatible(self, other: object) -> _Measurement:
|
|
291
|
+
"""Assert that *other* is the same concrete type as *self*.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
other: The right-hand operand to inspect.
|
|
295
|
+
|
|
296
|
+
Returns
|
|
297
|
+
-------
|
|
298
|
+
*other* cast to :class:`_Measurement` (for further use).
|
|
299
|
+
|
|
300
|
+
Raises
|
|
301
|
+
------
|
|
302
|
+
IncompatibleUnitsError: If *other* is a different measurement
|
|
303
|
+
subclass (e.g. mixing :class:`Dimension` with
|
|
304
|
+
:class:`FontSize`).
|
|
305
|
+
TypeError: If *other* is not a :class:`_Measurement` subclass
|
|
306
|
+
at all.
|
|
307
|
+
"""
|
|
308
|
+
if not isinstance(other, _Measurement):
|
|
309
|
+
return NotImplemented # type: ignore[return-value]
|
|
310
|
+
if type(self) is not type(other):
|
|
311
|
+
raise IncompatibleUnitsError(type(self).__name__, type(other).__name__)
|
|
312
|
+
return other
|
|
313
|
+
|
|
314
|
+
def __add__(self: _T, other: object) -> _T:
|
|
315
|
+
"""Return a new measurement equal to ``self + other``.
|
|
316
|
+
|
|
317
|
+
The result is expressed in *self*'s unit.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
other: A measurement of the same concrete type.
|
|
321
|
+
|
|
322
|
+
Returns
|
|
323
|
+
-------
|
|
324
|
+
A new instance of the same type with the summed value, in
|
|
325
|
+
*self*'s unit.
|
|
326
|
+
|
|
327
|
+
Raises
|
|
328
|
+
------
|
|
329
|
+
IncompatibleUnitsError: If *other* is a different measurement type.
|
|
330
|
+
TypeError: If *other* is not a measurement object.
|
|
331
|
+
|
|
332
|
+
Example::
|
|
333
|
+
|
|
334
|
+
>>> Dimension(10, "mm") + Dimension(1, "cm")
|
|
335
|
+
Dimension(value=20.0, unit='mm')
|
|
336
|
+
"""
|
|
337
|
+
rhs = self._check_compatible(other)
|
|
338
|
+
if rhs is NotImplemented:
|
|
339
|
+
return NotImplemented # type: ignore[return-value]
|
|
340
|
+
# Convert rhs to self's unit before adding to preserve self's unit.
|
|
341
|
+
return type(self)(self.value + rhs.to(self.unit), self.unit) # type: ignore[return-value]
|
|
342
|
+
|
|
343
|
+
def __sub__(self: _T, other: object) -> _T:
|
|
344
|
+
"""Return a new measurement equal to ``self - other``.
|
|
345
|
+
|
|
346
|
+
The result is expressed in *self*'s unit.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
other: A measurement of the same concrete type.
|
|
350
|
+
|
|
351
|
+
Returns
|
|
352
|
+
-------
|
|
353
|
+
A new instance of the same type with the difference, in
|
|
354
|
+
*self*'s unit.
|
|
355
|
+
|
|
356
|
+
Raises
|
|
357
|
+
------
|
|
358
|
+
IncompatibleUnitsError: If *other* is a different measurement type.
|
|
359
|
+
TypeError: If *other* is not a measurement object.
|
|
360
|
+
|
|
361
|
+
Example::
|
|
362
|
+
|
|
363
|
+
>>> Dimension(10, "cm") - Dimension(25, "mm")
|
|
364
|
+
Dimension(value=7.5, unit='cm')
|
|
365
|
+
"""
|
|
366
|
+
rhs = self._check_compatible(other)
|
|
367
|
+
if rhs is NotImplemented:
|
|
368
|
+
return NotImplemented # type: ignore[return-value]
|
|
369
|
+
return type(self)(self.value - rhs.to(self.unit), self.unit) # type: ignore[return-value]
|
|
370
|
+
|
|
371
|
+
def __mul__(self: _T, scalar: float) -> _T:
|
|
372
|
+
"""Scale a measurement by a dimensionless scalar.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
scalar: A real-valued multiplier.
|
|
376
|
+
|
|
377
|
+
Returns
|
|
378
|
+
-------
|
|
379
|
+
A new instance with ``value * scalar``, in the same unit.
|
|
380
|
+
|
|
381
|
+
Raises
|
|
382
|
+
------
|
|
383
|
+
TypeError: If *scalar* is not numeric.
|
|
384
|
+
|
|
385
|
+
Example::
|
|
386
|
+
|
|
387
|
+
>>> Dimension(5, "cm") * 3
|
|
388
|
+
Dimension(value=15.0, unit='cm')
|
|
389
|
+
"""
|
|
390
|
+
if not isinstance(scalar, (int, float)):
|
|
391
|
+
return NotImplemented # type: ignore[return-value]
|
|
392
|
+
return type(self)(self.value * scalar, self.unit) # type: ignore[return-value]
|
|
393
|
+
|
|
394
|
+
#: Support ``scalar * measurement`` as well as ``measurement * scalar``.
|
|
395
|
+
__rmul__ = __mul__
|
|
396
|
+
|
|
397
|
+
def __truediv__(self: _T, scalar: float) -> _T:
|
|
398
|
+
"""Divide a measurement by a dimensionless scalar.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
scalar: A non-zero real-valued divisor.
|
|
402
|
+
|
|
403
|
+
Returns
|
|
404
|
+
-------
|
|
405
|
+
A new instance with ``value / scalar``, in the same unit.
|
|
406
|
+
|
|
407
|
+
Raises
|
|
408
|
+
------
|
|
409
|
+
TypeError: If *scalar* is not numeric.
|
|
410
|
+
ZeroDivisionError: If *scalar* is zero.
|
|
411
|
+
|
|
412
|
+
Example::
|
|
413
|
+
|
|
414
|
+
>>> Dimension(30, "mm") / 2
|
|
415
|
+
Dimension(value=15.0, unit='mm')
|
|
416
|
+
"""
|
|
417
|
+
if not isinstance(scalar, (int, float)):
|
|
418
|
+
return NotImplemented # type: ignore[return-value]
|
|
419
|
+
if scalar == 0:
|
|
420
|
+
raise ZeroDivisionError("Cannot divide a measurement by zero.")
|
|
421
|
+
return type(self)(self.value / scalar, self.unit) # type: ignore[return-value]
|
|
422
|
+
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
# Comparison
|
|
425
|
+
# ------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
def __lt__(self, other: object) -> bool:
|
|
428
|
+
"""Return ``True`` if *self* is strictly less than *other*.
|
|
429
|
+
|
|
430
|
+
Comparison is performed in the canonical unit (mm) so that
|
|
431
|
+
``Dimension(1, "in") < Dimension(3, "cm")`` evaluates correctly.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
other: A measurement of the same concrete type.
|
|
435
|
+
|
|
436
|
+
Raises
|
|
437
|
+
------
|
|
438
|
+
IncompatibleUnitsError: If *other* is a different measurement type.
|
|
439
|
+
"""
|
|
440
|
+
rhs = self._check_compatible(other)
|
|
441
|
+
if rhs is NotImplemented:
|
|
442
|
+
return NotImplemented # type: ignore[return-value]
|
|
443
|
+
return self._to_mm_raw() < rhs._to_mm_raw()
|
|
444
|
+
|
|
445
|
+
def __le__(self, other: object) -> bool:
|
|
446
|
+
"""Return ``True`` if *self* ≤ *other* (cross-unit aware)."""
|
|
447
|
+
rhs = self._check_compatible(other)
|
|
448
|
+
if rhs is NotImplemented:
|
|
449
|
+
return NotImplemented # type: ignore[return-value]
|
|
450
|
+
return self._to_mm_raw() <= rhs._to_mm_raw()
|
|
451
|
+
|
|
452
|
+
def __gt__(self, other: object) -> bool:
|
|
453
|
+
"""Return ``True`` if *self* > *other* (cross-unit aware)."""
|
|
454
|
+
rhs = self._check_compatible(other)
|
|
455
|
+
if rhs is NotImplemented:
|
|
456
|
+
return NotImplemented # type: ignore[return-value]
|
|
457
|
+
return self._to_mm_raw() > rhs._to_mm_raw()
|
|
458
|
+
|
|
459
|
+
def __ge__(self, other: object) -> bool:
|
|
460
|
+
"""Return ``True`` if *self* ≥ *other* (cross-unit aware)."""
|
|
461
|
+
rhs = self._check_compatible(other)
|
|
462
|
+
if rhs is NotImplemented:
|
|
463
|
+
return NotImplemented # type: ignore[return-value]
|
|
464
|
+
return self._to_mm_raw() >= rhs._to_mm_raw()
|
|
465
|
+
|
|
466
|
+
# ``__eq__`` and ``__hash__`` are defined explicitly to compare by canonical
|
|
467
|
+
# mm value, keeping equality consistent with the comparison operators above.
|
|
468
|
+
|
|
469
|
+
def __eq__(self, other: object) -> bool:
|
|
470
|
+
if not isinstance(other, _Measurement) or type(self) is not type(other):
|
|
471
|
+
return NotImplemented
|
|
472
|
+
return math.isclose(self._to_mm_raw(), other._to_mm_raw(), rel_tol=1e-9)
|
|
473
|
+
|
|
474
|
+
def __hash__(self) -> int:
|
|
475
|
+
return hash(round(self._to_mm_raw(), 6))
|
|
476
|
+
|
|
477
|
+
# ------------------------------------------------------------------
|
|
478
|
+
# Utility
|
|
479
|
+
# ------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
def is_close(self, other: _Measurement, rel_tol: float = 1e-9) -> bool:
|
|
482
|
+
"""Return whether two measurements are approximately equal.
|
|
483
|
+
|
|
484
|
+
Uses :func:`math.isclose` on the millimetre representations so
|
|
485
|
+
that floating-point rounding differences across unit conversions
|
|
486
|
+
are handled gracefully.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
other: A measurement to compare against *self*.
|
|
490
|
+
rel_tol: Maximum allowed relative difference (default ``1e-9``).
|
|
491
|
+
|
|
492
|
+
Returns
|
|
493
|
+
-------
|
|
494
|
+
``True`` if the two values are within *rel_tol* of each other.
|
|
495
|
+
|
|
496
|
+
Raises
|
|
497
|
+
------
|
|
498
|
+
IncompatibleUnitsError: If *other* is a different measurement
|
|
499
|
+
type.
|
|
500
|
+
|
|
501
|
+
Example::
|
|
502
|
+
|
|
503
|
+
>>> a = Dimension(1, "in")
|
|
504
|
+
>>> b = Dimension(25.4, "mm")
|
|
505
|
+
>>> a.is_close(b)
|
|
506
|
+
True
|
|
507
|
+
"""
|
|
508
|
+
self._check_compatible(other)
|
|
509
|
+
return math.isclose(self._to_mm_raw(), other._to_mm_raw(), rel_tol=rel_tol)
|
|
510
|
+
|
|
511
|
+
def as_unit(self: _T, target_unit: str) -> _T:
|
|
512
|
+
"""Return a new measurement expressed in *target_unit*.
|
|
513
|
+
|
|
514
|
+
Unlike :meth:`to`, which returns a plain ``float``, this method
|
|
515
|
+
returns a fully typed measurement object — useful when normalising
|
|
516
|
+
a collection to a single unit.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
target_unit: The unit for the returned object.
|
|
520
|
+
|
|
521
|
+
Returns
|
|
522
|
+
-------
|
|
523
|
+
A new instance of the same type with the converted value.
|
|
524
|
+
|
|
525
|
+
Raises
|
|
526
|
+
------
|
|
527
|
+
UnsupportedUnitError: If *target_unit* is unrecognised.
|
|
528
|
+
|
|
529
|
+
Example::
|
|
530
|
+
|
|
531
|
+
>>> Dimension(2.54, "cm").as_unit("mm")
|
|
532
|
+
Dimension(value=25.4, unit='mm')
|
|
533
|
+
"""
|
|
534
|
+
return type(self)(self.to(target_unit), target_unit) # type: ignore[return-value]
|
|
535
|
+
|
|
536
|
+
def __repr__(self) -> str:
|
|
537
|
+
"""Return an unambiguous string representation.
|
|
538
|
+
|
|
539
|
+
Returns
|
|
540
|
+
-------
|
|
541
|
+
A string of the form ``ClassName(value=…, unit='…')``.
|
|
542
|
+
"""
|
|
543
|
+
return f"{type(self).__name__}(value={self.value!r}, unit={self.unit!r})"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
# Public measurement classes
|
|
548
|
+
# ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
552
|
+
class Dimension(_Measurement):
|
|
553
|
+
"""A physical spatial measurement (width, height, margin, etc.).
|
|
554
|
+
|
|
555
|
+
:class:`Dimension` is intended for layout measurements such as page
|
|
556
|
+
widths, margins, and gutter sizes. It inherits full unit-conversion
|
|
557
|
+
and arithmetic support from :class:`_Measurement`.
|
|
558
|
+
|
|
559
|
+
Attributes
|
|
560
|
+
----------
|
|
561
|
+
value
|
|
562
|
+
Numeric magnitude of the dimension.
|
|
563
|
+
unit
|
|
564
|
+
Unit of measurement: ``"mm"``, ``"cm"``, ``"in"``, ``"pt"``,
|
|
565
|
+
or ``"pica"``.
|
|
566
|
+
|
|
567
|
+
Example::
|
|
568
|
+
|
|
569
|
+
>>> page_width = Dimension(210, "mm") # A4 width
|
|
570
|
+
>>> page_width.to_inches()
|
|
571
|
+
8.267716535433071
|
|
572
|
+
>>> page_width.to_pt()
|
|
573
|
+
595.2755905511812
|
|
574
|
+
|
|
575
|
+
>>> margin = Dimension(0.5, "in")
|
|
576
|
+
>>> live_width = page_width - margin.as_unit("mm") * 2
|
|
577
|
+
>>> round(live_width.to_inches(), 4)
|
|
578
|
+
7.2677
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
# ------------------------------------------------------------------
|
|
582
|
+
# Named convenience converters
|
|
583
|
+
# ------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
def to_mm(self) -> float:
|
|
586
|
+
"""Return the dimension expressed in millimetres.
|
|
587
|
+
|
|
588
|
+
Returns
|
|
589
|
+
-------
|
|
590
|
+
Value in millimetres.
|
|
591
|
+
|
|
592
|
+
Example::
|
|
593
|
+
|
|
594
|
+
>>> Dimension(1, "in").to_mm()
|
|
595
|
+
25.4
|
|
596
|
+
"""
|
|
597
|
+
return self._to_mm_raw()
|
|
598
|
+
|
|
599
|
+
def to_cm(self) -> float:
|
|
600
|
+
"""Return the dimension expressed in centimetres.
|
|
601
|
+
|
|
602
|
+
Returns
|
|
603
|
+
-------
|
|
604
|
+
Value in centimetres.
|
|
605
|
+
|
|
606
|
+
Example::
|
|
607
|
+
|
|
608
|
+
>>> Dimension(100, "mm").to_cm()
|
|
609
|
+
10.0
|
|
610
|
+
"""
|
|
611
|
+
return self.to("cm")
|
|
612
|
+
|
|
613
|
+
def to_inches(self) -> float:
|
|
614
|
+
"""Return the dimension expressed in inches.
|
|
615
|
+
|
|
616
|
+
Returns
|
|
617
|
+
-------
|
|
618
|
+
Value in inches.
|
|
619
|
+
|
|
620
|
+
Example::
|
|
621
|
+
|
|
622
|
+
>>> Dimension(25.4, "mm").to_inches()
|
|
623
|
+
1.0
|
|
624
|
+
"""
|
|
625
|
+
return self.to("in")
|
|
626
|
+
|
|
627
|
+
def to_pt(self) -> float:
|
|
628
|
+
"""Return the dimension expressed in PostScript points (1 pt = 1/72 in).
|
|
629
|
+
|
|
630
|
+
Returns
|
|
631
|
+
-------
|
|
632
|
+
Value in points.
|
|
633
|
+
|
|
634
|
+
Example::
|
|
635
|
+
|
|
636
|
+
>>> Dimension(1, "in").to_pt()
|
|
637
|
+
72.0
|
|
638
|
+
"""
|
|
639
|
+
return self.to("pt")
|
|
640
|
+
|
|
641
|
+
def to_pica(self) -> float:
|
|
642
|
+
"""Return the dimension expressed in picas (1 pica = 12 pt).
|
|
643
|
+
|
|
644
|
+
Returns
|
|
645
|
+
-------
|
|
646
|
+
Value in picas.
|
|
647
|
+
|
|
648
|
+
Example::
|
|
649
|
+
|
|
650
|
+
>>> Dimension(1, "in").to_pica()
|
|
651
|
+
6.0
|
|
652
|
+
"""
|
|
653
|
+
return self.to("pica")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
657
|
+
class FontSize(_Measurement):
|
|
658
|
+
"""A typographic font size measurement.
|
|
659
|
+
|
|
660
|
+
:class:`FontSize` is semantically distinct from :class:`Dimension`
|
|
661
|
+
even though both wrap a (value, unit) pair. Keeping them separate
|
|
662
|
+
prevents accidental mixing (e.g. adding a font size to a page margin)
|
|
663
|
+
and allows each type to expose domain-appropriate helper methods.
|
|
664
|
+
|
|
665
|
+
Attributes
|
|
666
|
+
----------
|
|
667
|
+
value
|
|
668
|
+
Numeric magnitude of the font size.
|
|
669
|
+
unit
|
|
670
|
+
Unit of measurement: ``"mm"``, ``"cm"``, ``"in"``, ``"pt"``,
|
|
671
|
+
or ``"pica"``.
|
|
672
|
+
|
|
673
|
+
Notes
|
|
674
|
+
-----
|
|
675
|
+
In typography, ``"pt"`` (PostScript point) and ``"pica"`` are by far
|
|
676
|
+
the most common units. The :meth:`to_pt` and :meth:`to_pica` helpers
|
|
677
|
+
are therefore the primary API surface for this class.
|
|
678
|
+
|
|
679
|
+
Example::
|
|
680
|
+
|
|
681
|
+
>>> body = FontSize(10, "pt")
|
|
682
|
+
>>> body.to_mm()
|
|
683
|
+
3.527777777777778
|
|
684
|
+
>>> heading = body * 2.4
|
|
685
|
+
>>> round(heading.to_pt(), 1)
|
|
686
|
+
24.0
|
|
687
|
+
"""
|
|
688
|
+
|
|
689
|
+
# ------------------------------------------------------------------
|
|
690
|
+
# Named convenience converters
|
|
691
|
+
# ------------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
def to_mm(self) -> float:
|
|
694
|
+
"""Return the font size expressed in millimetres.
|
|
695
|
+
|
|
696
|
+
Returns
|
|
697
|
+
-------
|
|
698
|
+
Value in millimetres.
|
|
699
|
+
|
|
700
|
+
Example::
|
|
701
|
+
|
|
702
|
+
>>> FontSize(7, "pt").to_mm()
|
|
703
|
+
2.469444444444444
|
|
704
|
+
"""
|
|
705
|
+
return self._to_mm_raw()
|
|
706
|
+
|
|
707
|
+
def to_pt(self) -> float:
|
|
708
|
+
"""Return the font size expressed in PostScript points (1 pt = 1/72 in).
|
|
709
|
+
|
|
710
|
+
Returns
|
|
711
|
+
-------
|
|
712
|
+
Value in points.
|
|
713
|
+
|
|
714
|
+
Example::
|
|
715
|
+
|
|
716
|
+
>>> FontSize(1, "pica").to_pt()
|
|
717
|
+
12.0
|
|
718
|
+
"""
|
|
719
|
+
return self.to("pt")
|
|
720
|
+
|
|
721
|
+
def to_pica(self) -> float:
|
|
722
|
+
"""Return the font size expressed in picas (1 pica = 12 pt).
|
|
723
|
+
|
|
724
|
+
Returns
|
|
725
|
+
-------
|
|
726
|
+
Value in picas.
|
|
727
|
+
|
|
728
|
+
Example::
|
|
729
|
+
|
|
730
|
+
>>> FontSize(24, "pt").to_pica()
|
|
731
|
+
2.0
|
|
732
|
+
"""
|
|
733
|
+
return self.to("pica")
|
|
734
|
+
|
|
735
|
+
def to_inches(self) -> float:
|
|
736
|
+
"""Return the font size expressed in inches.
|
|
737
|
+
|
|
738
|
+
Returns
|
|
739
|
+
-------
|
|
740
|
+
Value in inches.
|
|
741
|
+
|
|
742
|
+
Example::
|
|
743
|
+
|
|
744
|
+
>>> FontSize(72, "pt").to_inches()
|
|
745
|
+
1.0
|
|
746
|
+
"""
|
|
747
|
+
return self.to("in")
|
|
748
|
+
|
|
749
|
+
def to_cm(self) -> float:
|
|
750
|
+
"""Return the font size expressed in centimetres.
|
|
751
|
+
|
|
752
|
+
Returns
|
|
753
|
+
-------
|
|
754
|
+
Value in centimetres.
|
|
755
|
+
|
|
756
|
+
Example::
|
|
757
|
+
|
|
758
|
+
>>> FontSize(10, "mm").to_cm()
|
|
759
|
+
1.0
|
|
760
|
+
"""
|
|
761
|
+
return self.to("cm")
|