Intl.NumberFormat: режим округления и показ знака
Один зонд по формату чисел: какой режим округления по умолчанию, что каждый режим делает на границе .5 и как знак или конечный ноль исчезают.
BotBrowser Team
Нужна структурированная документация по теме Отпечатки?
Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.
BotBrowser позволяет воспроизвести авторизованный фикстур того же источника под объявленным профилем и сохранить ограниченную запись точной строки, которую страница вывела для числа, но он не может заставить форматтер округлять так, как предполагает читатель, не может вернуть минус, который убрала опция, и не может заставить две валюты показать одинаковое число знаков после запятой. Этот разбор запускает один зонд по одиннадцати фиксированным случаям и фиксирует, какой режим округления по умолчанию на границе .5, что каждый явный режим делает с теми же четырьмя значениями, как signDisplay и trailingZeroDisplay меняют выводимую строку, как roundingPriority решает между двумя пределами, почему знаки после запятой по умолчанию у валюты берутся из её кода и что сообщают resolvedOptions() и formatToParts() о том форматтере, который реально работал.
Кратко
- Округление по умолчанию это half-expand, от нуля: зонд печатает
default -> 3,4,-3,1,1для2.5, 3.5, -2.5, 0.5, 1.45при нуле знаков после запятой, то есть2.5становится3, а не2. halfEvenэто режим, который дал бы2.5 -> 2, и зонд печатаетhalfEven-vs-expand -> 2,4 vs 3,4, то есть режимы различаются только там, где цифра перед границей чётная.trunc,ceilиfloorдвигают отрицательное число в разные стороны:-2.5становится-2приtrunc,-2приceilи-3приfloor.signDisplay: 'never'убирает минус у отрицательного числа, поэтому зонд печатаетsign:never -> 5,0,5, и потребитель, читающий эту строку, не может узнать, что она пришла из-5.signDisplay: 'always'ставит+и на ноль, поэтому зонд печатаетsign:always -> +5,+0,-5.trailingZeroDisplay: 'stripIfInteger'сохраняет два знака у3.10, но убирает те, что существовали только из-за минимума, поэтому3.00выводится как3.roundingPriority: 'morePrecision'расширяет вывод, чтобы удовлетворить оба предела, поэтому12.345печатает12.35приmorePrecision, аautoиlessPrecisionпечатают12.3.- Знаки после запятой по умолчанию у валюты берутся из её кода, поэтому те же опции печатают
$5.00и¥5. - Установка
maximumFractionDigits: 0у форматтера валюты задаёт иminimumFractionDigitsв0, иresolvedOptions()сообщаетmin=0 max=0 mode=halfExpand.
Содержание
- Какой режим округления по умолчанию
- Что каждый режим делает на границе
- Как знак и конечный ноль меняют строку
- Где сталкиваются два предела
- Запись одного показания формата чисел
Какой режим округления по умолчанию
Форматтер чисел округляет, называет ли вызывающий режим или нет, и умолчание это конкретный выбор, который читатель почти никогда не проверяет. Зонд форматирует пять значений при нуле знаков после запятой без режима:
node=v26.9.0
default -> 3,4,-3,1,1
mode:halfExpand -> 3,4,-3,2
mode:halfEven -> 2,4,-2,2
mode:trunc -> 2,3,-2,1
mode:ceil -> 3,4,-2,2
mode:floor -> 2,3,-3,1
mode:expand -> 3,4,-3,2
mode:halfTrunc -> 2,3,-2,2
halfEven-vs-expand -> 2,4 vs 3,4
sign:auto -> 5,0,-5
sign:never -> 5,0,5
sign:always -> +5,+0,-5
sign:exceptZero -> +5,0,-5
sign:negative -> 5,0,-5
trailing:default -> 3.00 3.10
trailing:strip -> 3 3.10
priority -> 12.345:auto=12.3 more=12.35 less=12.3 | 1234.5:auto=1230 more=1234.5 less=1230 | 456.78:auto=457 more=456.78 less=457
currency -> $5.00 ¥5
currency:min0 -> $5
percent -> 13% 13%
compact -> 1.2M 1.2M
digits -> ar-EG=١٢٣٤ ar-EG-latn=1234
grouping -> de-DE=1.234.567,89 en-US=1,234,567.89
resolved -> min=0 max=0 mode=halfExpand
parts -> minusSign:-|currency:USD|literal: |integer:5|decimal:.|fraction:00
Первая строка это ответ на вопрос, который чаще всего понимают неверно. default -> 3,4,-3,1,1 значит, что 2.5 округляется до 3, а -2.5 до -3, то есть умолчание это half-expand, который округляет ничью от нуля. Это не правило «округление к чётному», которое многие предполагают из других языков или из курса финансов, и разница видна на каждом значении .5, а это именно тот вид числа, который часто содержит тестовый фикстур.
Второе значение показывает вторую половину правила. 1.45 при нуле знаков печатает 1, а не 2, потому что 1.45 не равно точно 1.45 в двоичной плавающей запятой; это значение чуть ниже середины, поэтому ничьей нет и результат ближайшее целое снизу. Страница, которая предполагает, что десятичный литерал точен, увидит, что .45 округляется вниз, а .5 вверх, и оба поведения из одного правила на разных входах.
Последнее значение в строке удивляет отчёт. 0.5 становится 1, то есть малое положительное значение округляется до целой единицы, хотя это наименьшее число с единственной ненулевой цифрой. Страница, которая использует ноль знаков для счёта, поэтому покажет 1 для любого значения из [0.5, 1.5), а страница, которая использует его для малой доли, покажет 1 для всего от половины вверх, что читается как ошибка в 100% на самых малых значениях.
Данные локали, которые управляют разделителями и формами цифр, это отдельная тема, и данные локали API Intl фиксирует, какие части тега локали читает форматтер. Здесь нет зависимости от локали. Зонд передаёт en-US, и режим это свойство форматтера, а не локали; de-DE или ja-JP округляют так же и различаются только разделителем и формами цифр, которые показывают поздние строки. Страница, которая проверяет округление только в одной локали, поэтому увидела всё правило, а страница, которая предполагает, что другая локаль округляет иначе, читает разницу группировки как разницу арифметики.
Умолчание видно и в resolvedOptions(), и это проверка, которую стоит записывать. Зонд печатает resolved -> min=0 max=0 mode=halfExpand для форматтера, который задал только maximumFractionDigits: 0, то есть разрешённый режим называет умолчание, которого вызывающий не писал. Запись, которая хранит входные опции, но не разрешённые, не может потом объяснить, почему 2.5 вывелось как 3, потому что причина живёт в поле, которое вызывающий не задал.
Что каждый режим делает на границе
Затем зонд задаёт каждый режим из спецификации и форматирует те же четыре значения, что превращает вопрос в таблицу. Каждая строка это режим и его четыре результата для 2.5, 3.5, -2.5, 1.6.
mode:halfExpand -> 3,4,-3,2 это умолчание, сделанное явным. mode:halfEven -> 2,4,-2,2 отправляет 2.5 к чётному соседу 2 и оставляет 3.5 у чётного соседа 4, то есть ничья всегда попадает на значение, чья последняя цифра чётная. Строка зонда делает разницу конкретной: halfEven-vs-expand -> 2,4 vs 3,4, то есть режимы совпадают на 3.5 и расходятся на 2.5.
mode:trunc -> 2,3,-2,1 отбрасывает каждую цифру за границей, насколько бы близко она ни была, поэтому 1.6 становится 1, а -2.5 становится -2. mode:ceil -> 3,4,-2,2 округляет к плюс бесконечности, поэтому 1.6 становится 2, а -2.5 становится -2, потому что -2 больше -2.5. mode:floor -> 2,3,-3,1 округляет к минус бесконечности, поэтому 1.6 становится 1, а -2.5 становится -3. mode:expand -> 3,4,-3,2 округляет от нуля на каждом значении, поэтому совпадает с ceil на положительных и с floor на отрицательных.
Три направленных режима это место, где ошибку обработки знака легче всего внести. Страница, которая хочет «округлить вверх» и берёт ceil, получает -2 для -2.5, что больше, но ближе к нулю, поэтому «округлить вверх» в смысле «от нуля» нуждается в expand. Страница, которая хочет осторожный выбор для величины, которую нельзя преувеличить, нуждается в floor на положительном и ceil на отрицательном, что две правила, а не одно, и имена режимов их не объединяют.
mode:halfTrunc -> 2,3,-2,2 округляет ничью к нулю, а остальное к ближайшему, поэтому 2.5 становится 2, 3.5 становится 4, а -2.5 становится -2. Это противоположное направление ничьей от halfExpand, и это режим для случая, когда страница сообщает итог, который нельзя округлить вверх на ничьей, например счёт, который нижележащая система сравнит с пределом.
Значение 1.6 это контроль в каждой строке. Это не ничья, поэтому каждый режим, округляющий к ближайшему, даёт 2, и только усекающие дают 1. Тест, который форматирует одно не-ничейное и одно ничейное значение, поэтому отделяет режим, меняющий только ничьи, от режима, меняющего каждое значение, а страница, которая тестирует только 1.6, не может отличить halfExpand от halfEven вовсе.
Следствие для записи в том, что режим принадлежит ей. Два прогона одного приложения могут напечатать 2 и 3 для одного 2.5, если форматтер построен раз с halfEven, а раз с умолчанием, и сохранённая строка не может сказать, какой режим её произвёл. Запись, которая хранит разрешённый roundingMode рядом с каждым отформатированным значением, делает разницу воспроизводимой, а не загадкой, которая появляется только на значениях, оказавшихся ничьими.
Как знак и конечный ноль меняют строку
Две опции показа меняют ширину и знак вывода без изменения значения, и строки знака показывают случай, где знак теряется.
sign:auto -> 5,0,-5 это умолчание: без знака на положительном и на нуле, минус на отрицательном. sign:always -> +5,+0,-5 ставит плюс и на положительном, и на нуле, поэтому читатель видит, что число отформатировано со знаком, а сравнение двух отчётов отличает неизменное положительное от неизменного отрицательного. sign:exceptZero -> +5,0,-5 ставит плюс на положительном, но оставляет ноль без знака, что и есть опция для случая, когда ноль это реальное значение, которое не должно выглядеть со знаком.
sign:never -> 5,0,5 опасна. Она убирает минус у -5 и печатает 5, поэтому потребитель, разбирающий строку обратно в число, получает положительное значение из отрицательного входа. Страница, которая выводит дельту с sign:never в предположении, что «знак показан в другом месте», даёт две одинаковые строки для +5 и -5, и последующий разбор не может восстановить, какая была. sign:negative -> 5,0,-5 это опция, которая показывает минус только когда значение отрицательно, что обычно и есть намерение за обращением к never, и она сохраняет знак на отрицательном.
Опция конечного нуля меняет знаки, которые существуют только из-за минимума. Зонд печатает trailing:default -> 3.00 3.10 для форматтера с минимумом и максимумом в два знака, и trailing:strip -> 3 3.10 после stripIfInteger. Значит целое значение теряет дробь из нулей, а 3.10 сохраняет конечный ноль, потому что 3.10 не целое и только целый случай урезается. Колонка цен с умолчанием показывает 3.00 и 4.00, а та же колонка с опцией обрезки показывает 3 рядом с соседним 3.10, то есть колонку смешанной ширины, которую таблица может выровнять плохо.
Обе опции взаимодействуют со знаком и дробной частью так, как список опций не объявляет. stripIfInteger на -3.00 даёт -3, то есть минус сохраняется; sign:always на -3.00 даёт -3.00, то есть знак и дробь независимы. Страница, которая составляет обе, должна решить, что она хочет для -0, и +0 зонда при sign:always показывает, что форматтер напечатает знак на нуле, а это значение, которое часто даёт вычитание равных чисел.
Ширина вывода поэтому не свойство только числа. Один и тот же 5 выводится как 5, +5, 5.00 или +5.00 в зависимости от трёх опций, и вёрстка, которая резервирует ширину колонки, измерив один вариант, обрежет или сдвинет другие. Страница, которая форматирует таблицу, должна задать опции один раз и использовать их для каждой ячейки, а не позволять умолчанию и явной опции встретиться в одной колонке.
Где сталкиваются два предела
Две опции могут ограничивать цифры одновременно, и какая побеждает, зависит от третьей опции, которую почти никакой код не задаёт. Зонд сочетает maximumSignificantDigits: 3 с maximumFractionDigits: 2 и форматирует три значения при каждом roundingPriority; запись фиксирует эти результаты в строке priority ->.
auto и lessPrecision совпадают на каждом значении: 12.345 становится 12.3, 1234.5 становится 1230, 456.78 становится 457. Обе соблюдают предел значащих цифр и считают предел дроби удовлетворённым результатом, поэтому правило трёх цифр побеждает, а правило двух знаков никогда не связывает. morePrecision это исключение: он печатает 12.35, 1234.5 и 456.78, что хранит больше цифр, чем позволил бы любой предел в одиночку, потому что округляет так, чтобы оба предела были удовлетворены, а не чтобы один доминировал.
Случай 1234.5 делает удивление видимым. При auto число становится 1230, три значащие цифры и без дроби, тогда как при morePrecision остаётся 1234.5, четыре значащие цифры и одна дробная. Страница, которая задаёт оба предела, ожидая «не более 3 значащих цифр и не более 2 знаков», получает 1230 при умолчании, а страница, которая задаёт morePrecision, ожидая «более строгий из двух», получает более широкий результат. Ни одно прочтение опций не соответствует поведению, поэтому разрешённый вывод принадлежит записи, а не намерению.
Форматтер, которому нужен один предел, должен задать один предел. Задав оба и полагаясь на умолчание, получаем поведение значащих цифр, поэтому страница, которой нужны только «два знака», должна задать только maximumFractionDigits, а страница, которой нужны только «три значащие цифры», должна задать только maximumSignificantDigits. Сочетание для случая, когда оба должны соблюдаться, и это morePrecision заявляет явно вместо того, чтобы оставлять умолчанию.
Строки валюты показывают второй предел, который вызывающий никогда не задаёт. currency -> $5.00 ¥5 печатает две дроби для доллара США и ни одной для японской иены, потому что знаки по умолчанию берутся из кода валюты, а не из локали и не из опций. currency:min0 -> $5 показывает, что minimumFractionDigits: 0 отменяет умолчание кода, то есть символ и знаки это два отдельных решения. Страница, которая печатает таблицу цен между валютами, поэтому получает разную ширину для каждой валюты, и единственный способ узнать какую это прочитать resolvedOptions() для каждого кода.
percent -> 13% 13% и compact -> 1.2M 1.2M показывают ту же форму удивления от другой опции. 0.125 в процентах это 12.5%, и умолчание округляет до 13%, поэтому доля, которую читатель ждал как половину процента, становится целым процентом. Компактная нотация округляет к значащим цифрам, а не к дробям, поэтому 1234567 становится 1.2M и при умолчании, и при явном maximumFractionDigits: 1, потому что нотация уже ограничивает цифры перед суффиксом.
Строки digits и grouping напоминают, что значение и строка это разные объекты, и те же данные локали решают, как выводится часовой пояс рядом с отформатированным числом. digits -> ar-EG=١٢٣٤ ar-EG-latn=1234 выводит один и тот же 1234 арабо-индийскими цифрами в одной локали и латинскими с расширением Unicode в другой, а grouping -> de-DE=1.234.567,89 en-US=1,234,567.89 показывает смену разделителя, которая идёт от одной локали. Страница, которая разбирает отформатированную строку обратно в число, должна знать обе, а страница, которая хранит только строку, уже потеряла число.
resolvedOptions() это поле, которое замыкает круг. Зонд печатает resolved -> min=0 max=0 mode=halfExpand после установки только максимума, то есть минимум последовал за максимумом до нуля, хотя вызывающий его не просил. Это правило чаще всего пропускают, когда задают максимум на валютном форматтере: валютный минимум по умолчанию в два знака заменяется, а не сохраняется, и разница между $5.00 и $5 это одна опция, которую вызывающий не писал.
formatToParts() это другая половина записи. Зонд печатает parts -> minusSign:-|currency:USD|literal: |integer:5|decimal:.|fraction:00, поэтому каждая часть вывода названа, и потребитель может пересобрать строку или переставить части, по именованным типам частей. Страница, которая выводит цену с валютой после числа в одной локали и перед в другой, может делать это по частям, а не по всей строке, что и есть надёжный способ для локали, где порядок другой.
Запись одного показания формата чисел
Показание формата чисел подтверждает узкое утверждение: эта среда, эта локаль, эти опции дали эту строку. Четыре поля держат его проверяемым:
- Разрешённые опции, потому что
resolvedOptions()называет режим округления, минимум и максимум, которые реально работали, а намерение вызывающего не в строке. - Режим округления и входные значения, потому что ничья и не-ничья могут различаться на целую единицу, и запись без входа не может сказать, что было.
- Опции показа знака и дроби, потому что
sign:neverстирает минус, аstripIfIntegerстирает дробь из нулей, и ни одна не восстанавливается из вывода. - Части, потому что порядок и части вывода локали различаются, и сохранённая строка не разбирается обратно без локали, которая её произвела.
Публичные источники
- MDN: Intl.NumberFormat
- MDN: конструктор Intl.NumberFormat()
- MDN: formatToParts()
- MDN: Math.round()
- ECMAScript: NumberFormat и округление
- Unicode: режимы округления в UTS #35
- BotBrowser: справочник согласованности автоматизации
Практический вывод
Задайте режим округления вместо доверия умолчанию, потому что умолчание это half-expand и значение .5 округлится не так, как ждёт читатель «округления к чётному». Сохраняйте минус через sign:negative, а не sign:never, потому что never превращает отрицательное в положительную строку, которую разбор не восстановит. Выберите один предел цифр вместо двух, если оба не должны связывать, и читайте resolvedOptions() после построения валютного форматтера, потому что максимум понижает и минимум. О данных локали, которые управляют разделителями и формами цифр, читайте данные локали API Intl; о половине с часовыми поясами того же семейства форматтеров читайте часовые пояса и язык локали.
Эта статья входит в руководство: Что такое отпечаток браузера?
Похожие статьи
Intl.Collator: порядок локали против кодовых единиц
ОтпечаткиUnicode: четыре идентичности одного текста
ПлатформаРегиональные данные Intl и переносимое форматирование в браузере
ОтпечаткиЛенивые и одноразовые помощники итератора: take и drop
ОтпечаткиМикрозадачи и таймеры: реальный порядок кода
ОтпечаткиICU: псевдонимы часовых поясов и TZ, который вы отдаете
Переведите BotBrowser из исследований в продакшн
Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.