Source code for fontTools.misc.roundTools

"""
Various round-to-integer helpers.
"""

import math
import functools
import logging
from collections.abc import Callable
from typing import TypeAlias

log = logging.getLogger(__name__)

__all__ = [
    "noRound",
    "otRound",
    "maybeRound",
    "roundFunc",
    "nearestMultipleShortestRepr",
]

RoundingFunction: TypeAlias = Callable[[float], float]
"""A rounding strategy for numbers; can return a float as well as an int, as in
some strategies rounding is conditional."""


[docs] def noRound(value: float) -> float: """Return float value unmodified; do not round. Args: value (float): The input floating-point value. Returns float: The same value. """ return value
[docs] def otRound(value: float) -> int: """Round float value to nearest integer towards ``+Infinity``. The OpenType spec (in the section on `"normalization" of OpenType Font Variations <https://docs.microsoft.com/en-us/typography/opentype/spec/otvaroverview#coordinate-scales-and-normalization>`_) defines the required method for converting floating point values to fixed-point. In particular it specifies the following rounding strategy: for fractional values of 0.5 and higher, take the next higher integer; for other fractional values, truncate. This function rounds the floating-point value according to this strategy in preparation for conversion to fixed-point. Args: value (float): The input floating-point value. Returns int: The rounded value. """ # See this thread for how we ended up with this implementation: # https://github.com/fonttools/fonttools/issues/1248#issuecomment-383198166 return int(math.floor(value + 0.5))
[docs] def maybeRound(v: float, tolerance: float, round: RoundingFunction = otRound) -> float: """Round only if rounding changes the value by no more than a given amount. Args: value (float): The input floating-point value. tolerance (float): The maximum absolute difference permitted. round (function): The rounding strategy, taking and giving a float. Returns float: A rounded value if within tolerance, otherwise the original. """ rounded = round(v) return rounded if abs(rounded - v) <= tolerance else v
[docs] def roundFunc(tolerance: float, round: RoundingFunction = otRound) -> RoundingFunction: """Make a conditional rounding strategy from a base strategy and tolerance. This preconfigures :func:`maybeRound` with fixed arguments, allowing it to be used anywhere a simple rounding function is accepted. The tolerance is validated to ensure it is absolute, and the cases where rounding would never or always occur are optimized. Rounding must never change a value by more than 0.5 for this to be sound (e.g. :py:func:`ceil` could change by too much). Args: tolerance (float): The maximum absolute difference permitted. round (function): The rounding strategy, taking and giving a float. Returns function: A strategy that conditionally rounds based on tolerance. """ if tolerance < 0: raise ValueError("Rounding tolerance must be positive") if tolerance == 0: return noRound if tolerance >= 0.5: return round return functools.partial(maybeRound, tolerance=tolerance, round=round)
[docs] def nearestMultipleShortestRepr(value: float, factor: float) -> str: """Round to nearest multiple of factor and return shortest decimal representation. This chooses the float that is closer to a multiple of the given factor while having the shortest decimal representation (the least number of fractional decimal digits). For example, given the following: >>> nearestMultipleShortestRepr(-0.61883544921875, 1.0/(1<<14)) '-0.61884' Useful when you need to serialize or print a fixed-point number (or multiples thereof, such as F2Dot14 fractions of 180 degrees in COLRv1 PaintRotate) in a human-readable form. Args: value (value): The value to be rounded and serialized. factor (float): The value which the result is a close multiple of. Returns: str: A compact string representation of the value. """ if not value: return "0.0" value = otRound(value / factor) * factor eps = 0.5 * factor lo = value - eps hi = value + eps # If the range of valid choices spans an integer, return the integer. if int(lo) != int(hi): return str(float(round(value))) fmt = "%.8f" lo = fmt % lo hi = fmt % hi assert len(lo) == len(hi) and lo != hi for i in range(len(lo)): if lo[i] != hi[i]: break period = lo.find(".") assert period < i fmt = "%%.%df" % (i - period) return fmt % value