"""Free-form length and aspect parsing helpers.
dartwork-mpl 0.4+ accepts user-supplied widths in physical units
(cm/in/mm/pt) rather than fixed tokens. This module is the parser
that converts those inputs into a :class:`Length` value matplotlib's
``figsize=`` (and friends) can ultimately consume.
It also resolves named aspect tokens (square/portrait/standard/
golden/wide/cinema) into a height/width ratio.
"""
from __future__ import annotations
__all__ = [
"ASPECT_TOKENS",
"DEFAULT_ASPECT",
"Length",
"cm",
"figsize",
"inch",
"length",
"mm",
"parse_aspect",
"parse_width",
"pt",
]
import difflib
import math
import re
CM_PER_INCH: float = 2.54
MM_PER_INCH: float = 25.4
PT_PER_INCH: float = 72.0 # PostScript point — matplotlib font/linewidth unit.
_KNOWN_WIDTH_UNITS: tuple[str, ...] = ("cm", "in", "mm", "pt")
# Common spellings AI agents emit when they meant the canonical short
# form. Looked up before the difflib fallback so that obvious synonyms
# resolve regardless of edit distance.
_WIDTH_UNIT_SYNONYMS: dict[str, str] = {
"centi": "cm",
"centimeter": "cm",
"centimeters": "cm",
"cms": "cm",
"inch": "in",
"inches": "in",
"milli": "mm",
"millimeter": "mm",
"millimeters": "mm",
"mms": "mm",
"point": "pt",
"points": "pt",
"pts": "pt",
}
# Named aspect tokens: ratio = height / width.
ASPECT_TOKENS: dict[str, float] = {
"square": 1.0,
"portrait": 5.0 / 4.0,
"standard": 3.0 / 4.0,
"golden": 1.0 / 1.618,
"wide": 2.0 / 3.0,
"cinema": 1.0 / 2.0,
}
DEFAULT_ASPECT: str = "standard"
_WIDTH_RE = re.compile(
r"""
^\s*
(?P<value>[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)
\s*
(?P<unit>cm|in|mm|pt)?
\s*$
""",
re.IGNORECASE | re.VERBOSE,
)
[docs]
class Length:
"""Physical length with multi-unit views.
Mirrors the :class:`~dartwork_mpl.colors.Color` design: an opaque
wrapper with a single canonical store (inches) and per-unit
property views (``length.cm``, ``length.mm``, ``length.inch``,
``length.pt``). Deliberately **not** a ``float`` subclass —
passing a :class:`Length` directly to a matplotlib API would
silently mis-interpret the unit at non-inch boundaries
(``fontsize=`` / ``linewidth=`` are pt; transform offsets are
px), so callers must always pick a unit explicitly when crossing
out of dartwork-mpl. For figsize specifically, use
:func:`dartwork_mpl.figsize` (the recommended idiom) or the
explicit ``length.inch`` view.
DPI-dependent units (``px``) are not exposed on the class itself
— a caller that needs pixels can write ``length.inch * fig.dpi``
and keep the dependency explicit at the call site.
Parameters
----------
value : str | Length
Either a parseable unit string (``"13cm"``, ``"5in"``,
``"170mm"``, ``"24pt"``) or another :class:`Length`. Bare
``int``/``float`` are rejected — they carry no unit and the
cm/inch ambiguity is exactly the bug this class exists to
prevent. Use :meth:`from_cm` / :meth:`from_inch` /
:meth:`from_mm` / :meth:`from_pt` (or the top-level wrappers
:func:`cm` / :func:`inch` / :func:`mm` / :func:`pt`) for
already-typed numeric input.
Examples
--------
>>> import dartwork_mpl as dm
>>> w = dm.Length("13cm")
>>> w.cm, w.mm, w.inch, w.pt
(13.0, 130.0, 5.118110236220472, 368.5039370078739)
>>> # Crossing into matplotlib — pick the unit explicitly:
>>> import matplotlib.pyplot as plt
>>> fig, ax = plt.subplots(figsize=dm.figsize("13cm", "wide"))
>>> ax.set_xlabel("x", fontsize=dm.pt(10).pt)
"""
__slots__ = ("_inch",)
def __init__(self, value: str | Length) -> None:
if isinstance(value, Length):
self._inch: float = value._inch
return
if isinstance(value, (bool, int, float)):
# ``bool`` is an ``int`` subclass; trap before the numeric
# branch so ``Length(True)`` and ``Length(1)`` produce the
# same TypeError.
raise TypeError(
f"Length(value) requires a unit string like '13cm' / "
f"'5in' / '170mm' / '24pt' or another Length. Got "
f"{type(value).__name__} {value!r} — bare numbers carry "
f"no unit. For 13 cm write Length('13cm') or dm.cm(13); "
f"for 13 inches write Length('13in') or dm.inch(13)."
)
if not isinstance(value, str):
raise TypeError(
f"Length(value) accepts str or Length (got "
f"{type(value).__name__})"
)
self._inch = _parse_unit_string(value)
# ------------------------------------------------------------------ #
# Constructors #
# ------------------------------------------------------------------ #
[docs]
@classmethod
def from_cm(cls, value: float) -> Length:
"""Construct from centimeters."""
return cls._from_inch(_validate_positive(value) / CM_PER_INCH)
[docs]
@classmethod
def from_mm(cls, value: float) -> Length:
"""Construct from millimeters."""
return cls._from_inch(_validate_positive(value) / MM_PER_INCH)
[docs]
@classmethod
def from_inch(cls, value: float) -> Length:
"""Construct from inches."""
return cls._from_inch(_validate_positive(value))
[docs]
@classmethod
def from_pt(cls, value: float) -> Length:
"""Construct from PostScript points (1 pt = 1/72 in)."""
return cls._from_inch(_validate_positive(value) / PT_PER_INCH)
@classmethod
def _from_inch(cls, inch_value: float) -> Length:
# Internal fast-path that skips str-parsing in __init__.
obj = cls.__new__(cls)
obj._inch = float(inch_value)
return obj
# ------------------------------------------------------------------ #
# Unit views #
# ------------------------------------------------------------------ #
@property
def cm(self) -> float:
"""Length expressed in centimeters."""
return self._inch * CM_PER_INCH
@property
def mm(self) -> float:
"""Length expressed in millimeters."""
return self._inch * MM_PER_INCH
@property
def inch(self) -> float:
"""Length expressed in inches (the canonical internal unit)."""
return self._inch
@property
def pt(self) -> float:
"""Length expressed in PostScript points (1 pt = 1/72 in)."""
return self._inch * PT_PER_INCH
# ------------------------------------------------------------------ #
# Arithmetic — preserve the Length tag for Length operands; reject #
# bare scalars at every binary op so callers must always pick a #
# unit explicitly when leaving dartwork-mpl. The cm/inch guard #
# the class exists for sits at every boundary, not just the parser. #
# ------------------------------------------------------------------ #
def __add__(self, other: Length) -> Length:
if not isinstance(other, Length):
return NotImplemented
return Length._from_inch(self._inch + other._inch)
def __radd__(self, other: Length) -> Length:
if not isinstance(other, Length):
return NotImplemented
return Length._from_inch(other._inch + self._inch)
def __sub__(self, other: Length) -> Length:
if not isinstance(other, Length):
return NotImplemented
return Length._from_inch(self._inch - other._inch)
def __rsub__(self, other: Length) -> Length:
if not isinstance(other, Length):
return NotImplemented
return Length._from_inch(other._inch - self._inch)
def __mul__(self, other: float) -> Length:
# ``Length * Length`` (area) has no representation here.
# ``bool`` is an ``int`` subclass — reject before the numeric
# branch so ``cm(9) * True`` doesn't silently scale by 1.
if isinstance(other, Length):
return NotImplemented
if isinstance(other, bool) or not isinstance(other, (int, float)):
return NotImplemented
return Length._from_inch(self._inch * float(other))
def __rmul__(self, other: float) -> Length:
return self.__mul__(other)
def __truediv__(self, other: float | Length) -> Length | float:
if isinstance(other, Length):
# Ratio of two lengths — dimensionless float.
return self._inch / other._inch
if isinstance(other, bool) or not isinstance(other, (int, float)):
return NotImplemented
return Length._from_inch(self._inch / float(other))
def __neg__(self) -> Length:
return Length._from_inch(-self._inch)
def __abs__(self) -> Length:
return Length._from_inch(abs(self._inch))
# ------------------------------------------------------------------ #
# Comparison & hashing #
# ------------------------------------------------------------------ #
def __eq__(self, other: object) -> bool:
if isinstance(other, Length):
return self._inch == other._inch
return NotImplemented
def __lt__(self, other: Length) -> bool:
if not isinstance(other, Length):
return NotImplemented
return self._inch < other._inch
def __le__(self, other: Length) -> bool:
if not isinstance(other, Length):
return NotImplemented
return self._inch <= other._inch
def __gt__(self, other: Length) -> bool:
if not isinstance(other, Length):
return NotImplemented
return self._inch > other._inch
def __ge__(self, other: Length) -> bool:
if not isinstance(other, Length):
return NotImplemented
return self._inch >= other._inch
def __hash__(self) -> int:
return hash((Length, self._inch))
def __repr__(self) -> str:
# Show cm at sub-decimeter scales, otherwise prefer inches —
# matches how users typically thought about the value at input.
if self._inch < 1.0:
return f"Length({self.cm:.4f}cm)"
return f"Length({self._inch:.4f}in)"
# ---------------------------------------------------------------------- #
# Internal helpers #
# ---------------------------------------------------------------------- #
def _validate_positive(value: float) -> float:
"""Reject non-finite or non-positive numeric input with a clear message."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(
f"length value must be int or float (got {type(value).__name__})"
)
v = float(value)
if not math.isfinite(v) or v <= 0:
raise ValueError(f"length must be positive and finite (got {v})")
return v
def _parse_unit_string(value: str) -> float:
"""Parse a unit string like ``"13cm"`` into inches."""
text = value.strip().strip('"').strip("'")
match = _WIDTH_RE.match(text)
if match is None:
raise ValueError(
f"could not parse length {value!r}; expected '<number>' "
f"with optional unit suffix (cm, in, mm, pt)."
f"{_suggest_width_correction(text)}"
)
number = float(match.group("value"))
raw_unit = match.group("unit")
if raw_unit is None:
# A unitless numeric string (e.g. "13") carries no unit, exactly
# like a bare int/float — interpreting it as cm reintroduces the
# cm/inch ambiguity the Length type exists to prevent (#226).
raise ValueError(
f"length {value!r} has no unit; add a unit suffix "
f"(e.g. '{match.group('value')}cm', '{match.group('value')}in', "
f"'{match.group('value')}mm', '{match.group('value')}pt') "
f"or use dm.cm(...) / dm.inch(...)."
)
unit = raw_unit.lower()
if not math.isfinite(number) or number <= 0:
raise ValueError(f"length must be positive and finite (got {number})")
if unit == "cm":
return number / CM_PER_INCH
if unit == "in":
return number
if unit == "mm":
return number / MM_PER_INCH
return number / PT_PER_INCH # pt
# ---------------------------------------------------------------------- #
# Top-level wrappers (mirror the Color module's oklab/oklch/rgb/hex) #
# ---------------------------------------------------------------------- #
[docs]
def cm(value: float) -> Length:
"""Construct a :class:`Length` from centimeters."""
return Length.from_cm(value)
[docs]
def inch(value: float) -> Length:
"""Construct a :class:`Length` from inches."""
return Length.from_inch(value)
[docs]
def mm(value: float) -> Length:
"""Construct a :class:`Length` from millimeters."""
return Length.from_mm(value)
[docs]
def pt(value: float) -> Length:
"""Construct a :class:`Length` from PostScript points."""
return Length.from_pt(value)
[docs]
def length(value: str | Length) -> Length:
"""Parse a unit string (or pass through a Length) into :class:`Length`.
The string-parser counterpart to :func:`cm` / :func:`inch` /
:func:`mm` / :func:`pt`. Mirrors :func:`dartwork_mpl.colors.hex`.
"""
return Length(value)
# ---------------------------------------------------------------------- #
# Width / aspect / figsize parsers #
# ---------------------------------------------------------------------- #
def _suggest_width_correction(text: str) -> str:
"""Build a one-sentence "did you mean" suffix for a malformed width.
The goal is that an AI agent reading the ``ValueError.message`` can
infer the corrected call without a second probing call. Returns
either a leading-space-prefixed suggestion or an empty string.
"""
letters = re.search(r"[A-Za-z]+", text)
if letters is not None:
unit_word = letters.group(0).lower()
canonical = _WIDTH_UNIT_SYNONYMS.get(unit_word)
if canonical is None:
close = difflib.get_close_matches(
unit_word, _KNOWN_WIDTH_UNITS, n=1, cutoff=0.4
)
canonical = close[0] if close else None
if canonical is not None:
number = re.sub(r"[A-Za-z]", "", text).strip() or "<number>"
return f" Did you mean {canonical!r}-style, e.g. '{number}{canonical}'?"
return (
f" Supported units are {list(_KNOWN_WIDTH_UNITS)} "
f"(got unit-like fragment {unit_word!r})."
)
return f" Use '<number>{_KNOWN_WIDTH_UNITS[0]}', '<number>in', or '<number>mm'."
[docs]
def parse_width(value: str | Length) -> float:
"""Parse a width specification into inches.
Parameters
----------
value : str | Length
A unit string like ``"9cm"``, ``"6.7in"``, ``"170mm"``,
``"24pt"`` or a :class:`Length` value. Surrounding whitespace
and matched quote characters are stripped from string inputs.
Bare ``int``/``float`` are rejected: matplotlib's ``figsize``
is in inches but dartwork-mpl widths are typically given in
cm, so an unannotated number has no safe interpretation.
Returns
-------
float
Width in inches. Always strictly positive.
Raises
------
TypeError
If ``value`` is a bare ``int``/``float``/``bool`` (no unit).
ValueError
If the input cannot be parsed, has an unknown unit, or is
non-positive.
"""
if isinstance(value, Length):
v = value._inch
if not math.isfinite(v) or v <= 0:
raise ValueError(f"width must be positive and finite (got {v})")
return v
# bool is a subclass of int; trap it before the int/float branch so
# the message is the same as for any other unit-less number.
if isinstance(value, (bool, int, float)):
raise TypeError(
f"width must be a unit string like '13cm' / '5in' / '170mm' "
f"or a Length value (dm.cm(13), dm.col1). Got "
f"{type(value).__name__} {value!r} — bare numbers carry no "
f"unit. For 13 cm write '13cm' or dm.cm(13); for 13 inches "
f"write '13in' or dm.inch(13)."
)
if not isinstance(value, str):
raise TypeError(
f"width must be a unit string or a Length value "
f"(got {type(value).__name__})"
)
return _parse_unit_string(value)
[docs]
def figsize(
width: str | Length, aspect: str | int | float | Length = DEFAULT_ASPECT
) -> tuple[float, float]:
"""Return a matplotlib ``figsize`` tuple from a physical width and an
aspect-or-height specifier.
Drop-in replacement for inline ``figsize=(w, h)`` literals. Pairs
cleanly with ``plt.subplots`` and ``plt.figure``::
fig, ax = plt.subplots(figsize=dm.figsize("13cm", "wide"))
fig = plt.figure(figsize=dm.figsize("15cm", "9cm")) # explicit height
fig = plt.figure(figsize=dm.figsize(dm.col1, "standard"))
Parameters
----------
width : str | Length
Physical width — either a unit string (``"13cm"``, ``"5in"``,
``"170mm"``, ``"24pt"``) or a :class:`Length` value
(``dm.cm(13)``, ``dm.col1``, ``dm.col2``). Bare
``int``/``float`` are rejected — the unit must always be
explicit.
aspect : str | int | float | Length, optional
Specifies the figure's *height* in one of four forms; the
first matching form wins:
1. **Aspect token** — one of
``{"square", "portrait", "standard", "golden", "wide",
"cinema"}``. Sets ``height = width * ratio`` where the
ratio is taken from :data:`ASPECT_TOKENS`. Default
``"standard"`` (3 : 4).
2. **Numeric ratio** (positive ``int`` / ``float``,
non-``bool``) — interpreted as ``height / width``.
3. **Unit-suffix string** — ``"12cm"``, ``"5in"``, ``"170mm"``,
``"24pt"``. Interpreted as a literal height. The unit
need not match the width's unit.
4. **Length value** — ``dm.cm(12)``, ``dm.col1``, etc.
Interpreted as a literal height.
Bare numeric strings (``"0.5"``) and unknown aspect tokens
raise :class:`ValueError` with a "did-you-mean" hint.
Returns
-------
tuple[float, float]
``(width_in_inches, height_in_inches)`` — plain floats ready
to hand to matplotlib's ``figsize=`` argument.
Examples
--------
All four forms produce the same 13 cm by 8 cm figure (within
floating-point tolerance), letting callers pick whichever
notation reads most naturally for the call site:
>>> import dartwork_mpl as dm
>>> dm.figsize("13cm", 8 / 13) # ratio
(5.118..., 3.149...)
>>> dm.figsize("13cm", "8cm") # height (str)
(5.118..., 3.149...)
>>> dm.figsize("13cm", dm.cm(8)) # height (Length)
(5.118..., 3.149...)
>>> dm.figsize("13cm", "wide") # token (≈ 8.67 cm)
(5.118..., 3.412...)
Common idioms:
>>> dm.figsize("17cm", 0.6) # journal two-column
>>> dm.figsize(dm.col1, "golden") # academic, golden ratio
>>> dm.figsize("15cm", "12cm") # explicit dimensions
>>> dm.figsize("100mm", "75mm") # all-mm
"""
w_in = parse_width(width)
h_in = _resolve_height(w_in, aspect)
return (w_in, h_in)
def _resolve_height(w_in: float, aspect: str | int | float | Length) -> float:
"""Resolve the second :func:`figsize` argument to inches.
Discrimination order:
1. ``Length`` instance → literal height.
2. ``str`` with a recognised aspect token → ``w_in * ratio``.
3. ``str`` with a unit suffix (cm/in/mm/pt) → literal height.
4. ``int`` / ``float`` → ``w_in * ratio`` via :func:`parse_aspect`.
5. Anything else → :func:`parse_aspect` for the standard error.
"""
if isinstance(aspect, Length):
return aspect._inch
if isinstance(aspect, str):
# Strip whitespace + matched quotes once and reuse — same
# canonicalisation _parse_unit_string applies internally.
text = aspect.strip().strip('"').strip("'")
if text.lower() in ASPECT_TOKENS:
return w_in * ASPECT_TOKENS[text.lower()]
# Unit-suffix string → height. Detect by checking the regex
# for a non-None unit group; otherwise fall through to
# parse_aspect so the caller gets the existing self-correction
# hint for quoted numerics ("0.5") and typos ("widee").
match = _WIDTH_RE.match(text)
if match is not None and match.group("unit"):
return _parse_unit_string(aspect)
return w_in * parse_aspect(aspect)
def _suggest_aspect_correction(value: str) -> str:
"""Build a one-sentence "did you mean" suffix for an unknown aspect.
Recognises three failure shapes: numeric literals quoted as strings
(``"0.75"``), close-but-misspelt token names (``"sqaure"``), and
everything else (no suggestion appended). Returns either a leading-
space-prefixed suggestion or an empty string.
"""
try:
as_number = float(value)
except ValueError:
as_number = math.nan
if math.isfinite(as_number) and as_number > 0:
return f" To pass a numeric ratio, drop the quotes: aspect={as_number}."
close = difflib.get_close_matches(
value.strip().lower(), list(ASPECT_TOKENS), n=1, cutoff=0.5
)
if close:
return f" Did you mean {close[0]!r}?"
return ""
[docs]
def parse_aspect(value: str | int | float) -> float:
"""Resolve an aspect specification to a height/width ratio.
Parameters
----------
value : str | int | float
Either a known aspect token (``"square"``, ``"portrait"``,
``"standard"``, ``"golden"``, ``"wide"``, ``"cinema"``) or a
positive number interpreted directly as ``height / width``.
Returns
-------
float
The height/width ratio. Always strictly positive.
"""
if isinstance(value, bool):
# bool is a subclass of int — reject before the int/float branch.
raise ValueError(
"aspect must be a positive number; bool is not accepted "
"(use a token like 'standard' or a float like 0.5)"
)
if isinstance(value, (int, float)):
ratio = float(value)
if not math.isfinite(ratio) or ratio <= 0:
raise ValueError(
f"aspect must be positive and finite (got {ratio})"
)
return ratio
if not isinstance(value, str):
raise ValueError(
f"aspect must be str, int, or float (got {type(value).__name__})"
)
key = value.strip().lower()
if key not in ASPECT_TOKENS:
raise ValueError(
f"unknown aspect token {value!r}; known: "
f"{sorted(ASPECT_TOKENS)}.{_suggest_aspect_correction(value)}"
)
return ASPECT_TOKENS[key]