diff --git a/Lib/fontTools/misc/fixedTools.py b/Lib/fontTools/misc/fixedTools.py index f0474abff..f87e3322f 100644 --- a/Lib/fontTools/misc/fixedTools.py +++ b/Lib/fontTools/misc/fixedTools.py @@ -17,7 +17,7 @@ functions for converting between fixed-point, float and string representations. The maximum value that can still fit in an F2Dot14. (1.99993896484375) """ -from .roundTools import otRound +from .roundTools import otRound, nearestMultipleShortestRepr import logging log = logging.getLogger(__name__) @@ -125,6 +125,7 @@ def fixedToStr(value, precisionBits): This is pretty slow compared to the simple division used in ``fixedToFloat``. Use sporadically when you need to serialize or print the fixed-point number in a human-readable form. + It uses nearestMultipleShortestRepr under the hood. Args: value (int): The fixed-point value to convert. @@ -133,27 +134,8 @@ def fixedToStr(value, precisionBits): Returns: str: A string representation of the value. """ - if not value: return "0.0" - scale = 1 << precisionBits - value /= scale - eps = .5 / scale - 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 + return nearestMultipleShortestRepr(value/scale, factor=1.0/scale) def strToFixed(string, precisionBits): @@ -214,7 +196,7 @@ def floatToFixedToStr(value, precisionBits): This uses the shortest decimal representation (ie. the least number of fractional decimal digits) to represent the equivalent fixed-point number with ``precisionBits`` fractional binary digits. - It uses fixedToStr under the hood. + It uses nearestMultipleShortestRepr under the hood. >>> floatToFixedToStr(-0.61883544921875, precisionBits=14) '-0.61884' @@ -227,8 +209,8 @@ def floatToFixedToStr(value, precisionBits): str: A string representation of the value. """ - fixed = otRound(value * (1 << precisionBits)) - return fixedToStr(fixed, precisionBits) + scale = 1 << precisionBits + return nearestMultipleShortestRepr(value, factor=1.0/scale) def ensureVersionIsLong(value): diff --git a/Lib/fontTools/misc/roundTools.py b/Lib/fontTools/misc/roundTools.py index c1d546f1f..6f4aa634e 100644 --- a/Lib/fontTools/misc/roundTools.py +++ b/Lib/fontTools/misc/roundTools.py @@ -56,3 +56,50 @@ def roundFunc(tolerance, round=otRound): return round return functools.partial(maybeRound, tolerance=tolerance, round=round) + + +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 = .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