diff --git a/src/terminal/apc/glyph/request.zig b/src/terminal/apc/glyph/request.zig index a45feca28..7bf4b6ba9 100644 --- a/src/terminal/apc/glyph/request.zig +++ b/src/terminal/apc/glyph/request.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const Glyf = @import("../../../font/opentype/glyf.zig").Glyf; +const fraction = @import("../../fraction.zig"); /// Maximum decoded glyph payload size accepted by the protocol. /// This is documented in the spec. @@ -563,9 +564,7 @@ pub const Pad = struct { /// Top/bottom fractions are relative to cell height; left/right fractions /// are relative to render span width. fn parseFraction(value: []const u8) ?f64 { - const result = std.fmt.parseFloat(f64, value) catch return null; - if (!(result >= 0 and result <= 1)) return null; - return result; + return fraction.parse(value); } }; diff --git a/src/terminal/color.zig b/src/terminal/color.zig index 3df9a0e6a..ce4433bbc 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -3,6 +3,7 @@ const colorpkg = @This(); const std = @import("std"); const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; +const fraction = @import("fraction.zig"); const x11_color = @import("x11_color.zig"); /// The default palette. @@ -641,14 +642,10 @@ pub const RGB = packed struct(u24) { /// /// The value should be between 0.0 and 1.0, inclusive. fn fromIntensity(value: []const u8) error{InvalidFormat}!u8 { - const i = std.fmt.parseFloat(f64, value) catch { + const i = fraction.parse(value) orelse { @branchHint(.cold); return error.InvalidFormat; }; - if (i < 0.0 or i > 1.0) { - @branchHint(.cold); - return error.InvalidFormat; - } return @intFromFloat(i * std.math.maxInt(u8)); } diff --git a/src/terminal/fraction.zig b/src/terminal/fraction.zig new file mode 100644 index 000000000..671344195 --- /dev/null +++ b/src/terminal/fraction.zig @@ -0,0 +1,142 @@ +//! Parsing of decimal fractions in the unit interval [0, 1]. +//! +//! Escape sequences that carry fractional parameters (XTerm `rgbi:` +//! color specifications, glyph protocol padding) only ever need plain +//! decimal values such as "0.5". Using `std.fmt.parseFloat` for these +//! pulls the full correctly-rounded float parser and its lookup tables +//! into the binary (~26KB). This implements only the restricted subset +//! we need. +const std = @import("std"); +const assert = std.debug.assert; + +/// Parse a decimal fraction in the range [0, 1] inclusive. +/// +/// Accepts an optional leading `+` or `-` sign followed by decimal +/// digits with an optional decimal point ("0.5", ".5", "1.", "1"); at +/// least one digit is required. Exponent and hexadecimal float syntax +/// are not supported. Returns null for invalid syntax and for any value +/// outside [0, 1] (so negative values other than -0 are rejected). +/// +/// Results are correctly rounded for inputs of up to 15 fractional +/// digits; further digits are validated but ignored, which can move the +/// result by less than 1e-15. +pub fn parse(value: []const u8) ?f64 { + var v = value; + var negative = false; + if (v.len > 0) switch (v[0]) { + '+' => v = v[1..], + '-' => { + negative = true; + v = v[1..]; + }, + else => {}, + }; + + var i: usize = 0; + const int_part: f64 = int_part: { + var int_part: f64 = 0; + while (i < v.len and v[i] != '.') : (i += 1) { + const d = v[i]; + if (d < '0' or d > '9') return null; + int_part = int_part * 10 + @as(f64, @floatFromInt(d - '0')); + } + break :int_part int_part; + }; + + var digits: usize = i; + var frac: u64 = 0; + var scale: u64 = 1; + if (i < v.len) { + assert(v[i] == '.'); + i += 1; + while (i < v.len) : (i += 1) { + const d = v[i]; + if (d < '0' or d > '9') return null; + // Stop accumulating after 15 digits so that frac and scale + // both stay exactly representable in f64 (and cannot + // overflow), making the division below round only once. + if (scale < 1_000_000_000_000_000) { + frac = frac * 10 + (d - '0'); + scale *= 10; + } + digits += 1; + } + } + if (digits == 0) return null; + + // frac and scale are both exact in f64 (below 2^53 and a power of + // ten no greater than 1e15), so this division rounds only once. + const magnitude = int_part + + @as(f64, @floatFromInt(frac)) / @as(f64, @floatFromInt(scale)); + const result = if (negative) -magnitude else magnitude; + + // Range check. Written so that -0 passes and any out-of-range or + // non-finite value fails. + if (!(result >= 0 and result <= 1)) return null; + return result; +} + +test parse { + const testing = std.testing; + + // Plain values + try testing.expectEqual(0, parse("0").?); + try testing.expectEqual(1, parse("1").?); + try testing.expectEqual(0.5, parse("0.5").?); + try testing.expectEqual(0.25, parse("0.25").?); + try testing.expectEqual(1, parse("1.0").?); + try testing.expectEqual(1, parse("1.000000").?); + try testing.expectEqual(0, parse("0.0").?); + + // Partial forms + try testing.expectEqual(0.5, parse(".5").?); + try testing.expectEqual(1, parse("1.").?); + try testing.expectEqual(0, parse("00.00").?); + + // Signs + try testing.expectEqual(0.5, parse("+0.5").?); + try testing.expectEqual(0, parse("-0").?); + try testing.expectEqual(0, parse("-0.000").?); + try testing.expectEqual(null, parse("-0.5")); + try testing.expectEqual(null, parse("-1")); + + // Matches std.fmt.parseFloat rounding for typical inputs. + try testing.expectEqual( + std.fmt.parseFloat(f64, "0.3") catch unreachable, + parse("0.3").?, + ); + try testing.expectEqual( + std.fmt.parseFloat(f64, "0.123456789012345") catch unreachable, + parse("0.123456789012345").?, + ); + + // Digits past the 15th are ignored: long fractions stay finite and + // equal their 15-digit truncation. + try testing.expectEqual( + parse("0.333333333333333").?, + parse("0." ++ "3" ** 400).?, + ); + + // Out of range + try testing.expectEqual(null, parse("1.0000001")); + try testing.expectEqual(null, parse("2")); + try testing.expectEqual(null, parse("255")); + try testing.expectEqual(null, parse("1" ** 400)); + + // Invalid syntax + try testing.expectEqual(null, parse("")); + try testing.expectEqual(null, parse(".")); + try testing.expectEqual(null, parse("+")); + try testing.expectEqual(null, parse("-")); + try testing.expectEqual(null, parse("+.")); + try testing.expectEqual(null, parse("abc")); + try testing.expectEqual(null, parse("0.5x")); + try testing.expectEqual(null, parse("0x0.8")); + try testing.expectEqual(null, parse("0..5")); + try testing.expectEqual(null, parse("0.5.5")); + try testing.expectEqual(null, parse(" 0.5")); + try testing.expectEqual(null, parse("0.5 ")); + try testing.expectEqual(null, parse("1e-1")); + try testing.expectEqual(null, parse("nan")); + try testing.expectEqual(null, parse("inf")); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index fb846aeff..55438dde4 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -90,6 +90,7 @@ test { // Internals _ = @import("bitmap_allocator.zig"); _ = @import("compress.zig"); + _ = @import("fraction.zig"); _ = @import("hash_map.zig"); _ = @import("mem.zig"); _ = @import("ref_counted_set.zig");