Source code for dartwork_mpl.units

"""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``, ``standard``,
``wide``, ``slide``, … — the full registry is :data:`ASPECT_TOKENS`,
introspectable via :func:`list_aspect_tokens`) into a height/width
ratio.
"""

from __future__ import annotations

__all__ = [
    "ASPECT_TOKENS",
    "DEFAULT_ASPECT",
    "Length",
    "cm",
    "figsize",
    "figsize_grid",
    "inch",
    "length",
    "list_aspect_tokens",
    "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.
#
# Adding a token: keep the ratio meaningfully distinct from existing
# entries (≥ 5% apart) and document the use case in a trailing
# comment. The lint catalog treats every key here as a valid value
# for the `width-token-aspect` rule.
ASPECT_TOKENS: dict[str, float] = {
    "square": 1.0,  # 1:1 — Instagram square, symmetric scatter
    "portrait": 5.0 / 4.0,  # 4:5 portrait — Instagram tall
    "tall": 3.0 / 2.0,  # 2:3 portrait — Pinterest, mobile reading
    "standard": 3.0 / 4.0,  # 4:3 landscape — paper figures
    "golden": 1.0 / 1.618,  # 1:φ landscape — golden ratio
    "wide": 2.0 / 3.0,  # 3:2 landscape — DSLR sensor
    "a4": 1.0 / 1.4142,  # 1:√2 landscape — ISO 216 (A4, A5, …)
    "slide": 9.0 / 16.0,  # 16:9 landscape — Google Slides / Keynote
    "cinema": 1.0 / 2.0,  # 2:1 landscape — wide hero figures
    "panoramic": 1.0 / 3.0,  # 3:1 landscape — long banners / sparklines
}

DEFAULT_ASPECT: str = "standard"


[docs] def list_aspect_tokens() -> dict[str, float]: """Return a copy of the named aspect-token registry. The returned dict maps each token name to its ``height / width`` ratio (so ``square → 1.0``, ``standard → 0.75``, ``slide → 0.5625``, …). It is the same data :func:`figsize` consults when its second argument is a string. Mutating the returned copy does not affect dartwork-mpl's internal state. Use this for discovery in notebooks and for AI agents that want to enumerate valid aspect strings without scraping the source. Returns ------- dict[str, float] ``{token_name: height_over_width}``, sorted by ratio so the result reads as a visual ladder from "tall" to "panoramic". Examples -------- >>> import dartwork_mpl as dm >>> sorted(dm.list_aspect_tokens()) # just the names ['a4', 'cinema', 'golden', 'panoramic', 'portrait', 'slide', 'square', 'standard', 'tall', 'wide'] >>> dm.list_aspect_tokens()["slide"] 0.5625 """ # Sort by ratio (tall → wide) so the visual ladder reads naturally # when the dict is rendered. Python dicts preserve insertion order # so callers get the same view every time. return dict(sorted(ASPECT_TOKENS.items(), key=lambda kv: -kv[1]))
_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_grid( panel_width: str | Length, aspect: str | int | float = DEFAULT_ASPECT, *, ncols: int = 1, nrows: int = 1, gap: str | Length = "0.6cm", ) -> tuple[float, float]: """Return a whole-figure size for a grid of physical panels. Pair this with ``plt.subplots(ncols=..., nrows=..., figsize=dm.figsize_grid(...))`` and finish the figure with ``dm.simple_layout(fig)``. ``gap`` is a physical separation between panels, unlike matplotlib's relative ``wspace`` / ``hspace`` values. Matplotlib may still apply its own spacing, so the resulting panel size is an approximation; ``simple_layout`` is recommended for deterministic content-aware margins. """ if ncols < 1: msg = "ncols must be >= 1" raise ValueError(msg) if nrows < 1: msg = "nrows must be >= 1" raise ValueError(msg) panel_width_inches = parse_width(panel_width) gap_inches = parse_width(gap) ratio = parse_aspect(aspect) panel_height_inches = panel_width_inches * ratio fig_width = ncols * panel_width_inches + (ncols - 1) * gap_inches fig_height = nrows * panel_height_inches + (nrows - 1) * gap_inches return fig_width, fig_height
[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** — any key of :data:`ASPECT_TOKENS` (``"square"``, ``"standard"``, ``"wide"``, ``"slide"``, … — see :func:`list_aspect_tokens` for the full registry). 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): h = aspect._inch if not math.isfinite(h) or h <= 0: raise ValueError(f"height must be positive and finite (got {h})") return h 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 (any key of :data:`ASPECT_TOKENS` — ``"square"``, ``"standard"``, ``"wide"``, ``"slide"``, …; see :func:`list_aspect_tokens`) 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]