От десятичной строки до суммы прописью

Запись суммы прописью начинается с точного разбора числа. Если потерять копейку при преобразовании десятичной строки, правильные склонения уже ничего не исправят. Я предпочитаю разделять эту работу на два этапа: получить целое количество копеек по заданному правилу округления, затем выбрать слова, больше не меняя саму сумму.

К теме записи суммы прописью я обращался в библиотеке rubles в 2013 году. Учебная реализация и проверки в этой статье подготовлены в октябре 2026 года на современном JavaScript.

Возьмём строку 52 151,995. При округлении половины копейки вверх она должна превратиться в «пятьдесят две тысячи сто пятьдесят два рубля 00 копеек». Здесь есть и проверка пробела между разрядами, и перенос в следующий рубль, и женский род у тысяч. У каждого шага своё правило, поэтому проверять их удобнее отдельно.

Соберём функцию, которая принимает десятичную строку и возвращает сумму в российских рублях прописью. Заранее выберем допустимый диапазон и правило округления. Парсер и функции выбора слов ниже показаны полностью: вместе они составляют файл rubles.js без внешних зависимостей. Примеры ошибочных вычислений будем запускать отдельно.

Сначала договоримся о записи

parseFloat() выглядит подходящим началом: принимает строку, возвращает число. Однако он разбирает допустимое начало строки. Если после него стоит что-то другое, результат уже не меняется:

parseFloat('5abc');
parseFloat('52 151,995');

Первый вызов возвращает 5, второй возвращает 52. Это поведение описано в спецификации. Для чтения числового префикса оно полезно. Но в поле суммы нам нужно прочитать всё значение, а не только его начало.

Можно предварительно убрать пробелы и заменить запятую точкой. Тогда наш основной пример получится разобрать. Только вместе с ним строка 1 2 превратится в 12, а при удалении всех \s то же случится с цифрами на разных строках. Мы ещё не проверили запись, но уже уничтожили сведения, по которым могли отличить её от опечатки.

Я бы начал с правил конкретного поля. В нашем примере они такие:

Запись Что с ней делаем
52151, 52151.99, 52151,99 Принимаем целую сумму и дробь с любым из двух разделителей
52 151,995 Принимаем пробел между группами по три цифры
0, 0.004 Принимаем ноль, в том числе полученный при округлении
01, 0 001 Отклоняем ведущие нули в целой части
.5, 1. Отклоняем неполную запись дроби
1e3, +1, -1 Отклоняем экспоненту и знак
1 2, 1 000 Отклоняем неверное разделение групп
5abc, 5, 5 Отклоняем посторонние символы и пробелы на краях

Обычный пробел и его неразрывные варианты U+00A0 и U+202F разрешим внутри записи: такие суммы часто копируют из текста. Разные допустимые пробелы могут встречаться в одной строке. Но после любого из них обязана идти полная группа из трёх цифр. Табуляция и перенос строки разделителями не считаются.

Ведущие нули мы запрещаем по собственному выбору. Если полю подходит запись 001, правило можно изменить и в нём, и в функции, которая читает его значение. То же касается пробелов по краям: интерфейс может убирать их до передачи строки форматтеру, но сам форматтер этого не делает.

Отдельно ограничим результат: от нуля до 999999999999.99 рубля после округления. Этого хватит для словаря, который заканчивается миллиардами. Суммы выше границы функция отвергнет, а не обрежет до удобного размера.

Копейка теряется до округления

Теперь можно заменить запятую точкой и преобразовать всю строку в Number. Но именно здесь появляется ещё одна ошибка:

const amount = Number('1.005');
const scaled = amount * 100;
const kopecks = Math.round(scaled);

console.log(scaled);
console.log(kopecks);

Получаем 100.49999999999999 и 100. Для правила «половину копейки округляем вверх» мы ожидали 101.

Тип Number хранит числа в двоичном формате IEEE 754. Не каждой конечной десятичной дроби соответствует точное значение в этом формате. В данном вычислении к Math.round() приходит число чуть меньше 100.5, и функция правильно выбирает ближайшее целое. Сама она не знает, что до преобразования у нас была десятичная строка с пятёркой в третьем знаке. Устройство числового типа и правило Math.round() описывают именно такое поведение.

Для 1.005 легко найти поправку: немного округлить промежуточный результат, чтобы убрать двоичный хвост. Например, оставить 15 значащих цифр:

function rounded(input) {
  return Math.round(Number((input * 100).toPrecision(15)));
}

console.log(rounded(1.005));
console.log(rounded(1.0049999999999997));

Оба вызова возвращают 101. Первый случай мы исправили. Во втором сумма чуть меньше 1.005 рубля, а поправка передвинула её на границу округления. По нашему правилу строка '1.0049999999999997' должна дать 100.

По количеству значащих цифр нельзя отличить погрешность двоичного представления от точного значения во входе. Лучше сохранить исходную десятичную строку до получения целого количества копеек. Тогда нам не придётся угадывать, какая сумма была указана до преобразования.

Поэтому функция ниже намеренно не принимает number. Преобразовать такой аргумент обратно через String() можно, но это будет строковое представление уже имеющегося числа. Исходную запись пользователя мы таким способом не восстанавливаем.

Разбираем десятичную строку

Правило округления у нас простое: для неотрицательной суммы третья цифра дробной части от пяти до девяти добавляет одну копейку. Следующие цифры не влияют на это решение. Если третья цифра меньше пяти, никакое продолжение справа не сделает дробную часть копейки равной половине. Если она уже равна пяти, граница достигнута.

Для другого правила понадобились бы другие проверки. Например, при округлении половины к чётному нужно различать точную половину и значения выше неё. Для отрицательных сумм пришлось бы определить ещё и направление округления. Наша функция их не принимает.

Сначала проверим форму всей строки. Затем удалим только уже проверенные разделители тысяч и отдельно возьмём первые две цифры дробной части. Вот весь парсер:

const maximum = 99999999999999;
const spaces = /[ \u00a0\u202f]/gu;
const amount = /^(0|[1-9]\d*|[1-9]\d{0,2}(?:[ \u00a0\u202f]\d{3})+)(?:[.,](\d+))?$/u;

export function parseKopecks(input) {
  if (typeof input !== 'string') {
    throw new TypeError('Сумма должна быть строкой');
  }

  const match = amount.exec(input);

  if (match === null || match[0] !== input) {
    throw new SyntaxError('Неверный формат суммы');
  }

  const whole = match[1].replaceAll(spaces, '');
  const fraction = match[2] ?? '';

  if (whole.length > 12) {
    throw new RangeError('Сумма выходит за допустимый диапазон');
  }

  const cents = Number(fraction.padEnd(2, '0').slice(0, 2));
  const increment = fraction.length > 2 && fraction[2] >= '5' ? 1 : 0;
  const total = Number(whole) * 100 + cents + increment;

  if (total > maximum) {
    throw new RangeError('Сумма выходит за допустимый диапазон');
  }

  return total;
}

У длинного регулярного выражения довольно узкая работа. Первая захватывающая группа читает целую часть: отдельный ноль, число без разделителей или число с группировкой. В сгруппированной записи первая группа содержит от одной до трёх цифр, остальные содержат ровно по три. Вторая захватывающая группа читает цифры после точки или запятой. Все остальные скобки нужны только для устройства выражения и не добавляют элементы в результат.

Сравнение match[0] !== input явно требует совпадения со всей исходной строкой. Парсер не извлекает подходящий кусок и не пытается починить остальной ввод. Благодаря этому Number() ниже получает уже проверенные последовательности цифр.

Если дробной части нет, fraction содержит пустую строку. padEnd(2, '0') дополняет её до 00, а одну цифру до двух: строка '1' становится '10'. При этом целиком преобразовывать дробь в число мы не будем. Нам нужны только первые два разряда и решение об округлении по следующему.

Для нашей суммы вычисление выглядит так:

Этап Значение
Вход '52 151,995'
Целая часть без разделителей '52151'
Первые две цифры дробной части '99'
Поправка по третьей цифре 1
Общее количество копеек 52151 * 100 + 99 + 1, то есть 5215200

Перенос в следующий рубль происходит сам. Мы не прибавляем единицу к строке '99' и не получаем документ с «100 копеек». У нас одно целое количество копеек, из которого рубли будут выделены позже.

Точно так же '1.999' превращается в 200, а '0.005' в 1. Строка '0.004' даёт ноль. Для нашего форматтера это допустимая сумма, поэтому на нижней границе нет специального отказа.

Почему здесь хватает Number

Целые копейки можно хранить в Number: в выбранном диапазоне все целые представляются точно. Проблема с двоичной дробью здесь уже не возникает.

Перед преобразованием проверяется длина целой части. Ведущие нули уже запрещены грамматикой, поэтому более 12 цифр означают выход за выбранную верхнюю границу. Строка из сотен цифр будет отвергнута до Number(), даже если преобразование могло бы дать бесконечность или потерять младшие разряды.

Самая большая разрешённая целая часть содержит 999999999999 рублей. После умножения на сто, добавления копеек и возможного переноса мы не поднимемся выше 100000000000000. Это меньше Number.MAX_SAFE_INTEGER, равного 9007199254740991. Все участвующие здесь целые помещаются в точный диапазон. Значение и назначение границы приведены в описании Number.MAX_SAFE_INTEGER.

Проверка после округления всё равно нужна. Вход '999999999999.995' содержит допустимое количество цифр до точки, но перенос даёт уже триллион рублей. Словарь такого результата не поддерживает, и парсер выбрасывает RangeError.

Можно хранить копейки в BigInt. Тогда диапазон целых будет другим, но ограничение словаря никуда не денется: для триллионов всё равно понадобятся новые слова. В этом примере мне достаточно Number, потому что размер каждого промежуточного целого известен заранее. Если менять верхнюю границу, эти вычисления придётся пересмотреть вместе с ней.

Делим рубли на группы

С арифметикой разобрались. Из 5215200 копеек получаются 52152 рубля и остаток 00. Теперь можно выбирать слова.

Разбирать число удобно справа налево: сначала единицы рублей, выше тысячи, потом миллионы и миллиарды. Каждая группа содержит от нуля до 999. Остаток от деления на тысячу даёт её значение, целая часть частного позволяет перейти к следующей:

function splitGroups(value) {
  const groups = [];
  let rest = value;

  while (rest > 0) {
    groups.push(rest % 1000);
    rest = Math.floor(rest / 1000);
  }

  return groups;
}

Для 52152 результатом будет [152, 52]. Индекс группы сразу сообщает её разряд: нулевой отвечает за рубли, первый за тысячи. При чтении пойдём в обратном порядке, от старшей группы к младшей.

Нулевые группы удалять из этого массива нельзя. В числе 1001000 получаются [0, 1, 1]: ноль рублей, одна тысяча и один миллион. Если отфильтровать ноль, обе единицы сдвинутся и окажутся в других разрядах. Пустую группу мы пропустим только при произнесении, сохранив её место в числе.

Сам splitGroups(0) возвращает пустой массив. Это удобно для обхода, но в итоговом тексте ноль должен остаться. Его обработаем при сборке всей суммы.

Словарь меньше, чем кажется

Для чисел до двадцати нужны отдельные слова. После этого достаточно названий десятков и единиц, а сотни добавляются перед ними. Вот словарь целиком:

const ones = [
  '',
  'один',
  'два',
  'три',
  'четыре',
  'пять',
  'шесть',
  'семь',
  'восемь',
  'девять',
  'десять',
  'одиннадцать',
  'двенадцать',
  'тринадцать',
  'четырнадцать',
  'пятнадцать',
  'шестнадцать',
  'семнадцать',
  'восемнадцать',
  'девятнадцать'
];

const tens = [
  '',
  '',
  'двадцать',
  'тридцать',
  'сорок',
  'пятьдесят',
  'шестьдесят',
  'семьдесят',
  'восемьдесят',
  'девяносто'
];

const hundreds = [
  '',
  'сто',
  'двести',
  'триста',
  'четыреста',
  'пятьсот',
  'шестьсот',
  'семьсот',
  'восемьсот',
  'девятьсот'
];

const feminine = ['', 'одна', 'две'];
const currency = ['рубль', 'рубля', 'рублей'];
const coins = ['копейка', 'копейки', 'копеек'];
const scales = [
  null,
  ['тысяча', 'тысячи', 'тысяч'],
  ['миллион', 'миллиона', 'миллионов'],
  ['миллиард', 'миллиарда', 'миллиардов']
];

Пустые строки сохраняют соответствие индекса значению цифры. Они не попадут в результат: перед добавлением слова проверяется, что соответствующая часть числа ненулевая. У scales[0] стоит null, потому что название валюты мы добавим отдельно, после всех разрядов.

Тысячи потребуют женского рода: «одна тысяча», «две тысячи». Для рублей и миллионов остаются «один» и «два». Меняется только последняя единица в группе, а не все похожие слова в готовом тексте. Поэтому заменять один на одна через replace() уже после сборки я бы не стал.

Например, в 21001 одновременно нужны «двадцать одна тысяча» и «один рубль». У каждого слова свой разряд. Если передать род при чтении отдельной группы, это различие остаётся в коде явно:

function groupWords(value, female) {
  const parts = [];
  const first = Math.floor(value / 100);
  let rest = value % 100;

  if (first > 0) {
    parts.push(hundreds[first]);
  }

  if (rest >= 20) {
    parts.push(tens[Math.floor(rest / 10)]);
    rest %= 10;
  }

  if (rest > 0) {
    const word = female && rest < 3 ? feminine[rest] : ones[rest];
    parts.push(word);
  }

  return parts;
}

Значение 152 сначала даёт «сто». Остаток 52 добавляет «пятьдесят» и превращается в 2; для рублей это «два». А 52 в группе тысяч получает тот же десяток, но заканчивается словом «две».

У 112 путь другой: после сотни остаётся 12, поэтому ветка с десятками пропускается. Из ones[12] берётся целиком «двенадцать». Женский род здесь ничего не меняет. Важна именно проверка остатка меньше трёх, а не последней цифры исходной группы.

Последней цифры недостаточно

Теперь числительные получаются, но рядом с ними пока нет названий разрядов. Для выбора между «рубль» и «рублей» одной последней цифры мало: 1 требует «рубль», а 11 требует «рублей». То же повторяется в 111.

Поэтому сначала проверим последние две цифры. Значения от 11 до 14 получают форму «рублей». В остальных случаях единица в конце выбирает «рубль», цифры от двух до четырёх выбирают «рубля», остальные выбирают «рублей».

function plural(value, forms) {
  const lastTwo = value % 100;
  const last = value % 10;

  if (lastTwo >= 11 && lastTwo <= 14) {
    return forms[2];
  }

  if (last === 1) {
    return forms[0];
  }

  if (last >= 2 && last <= 4) {
    return forms[1];
  }

  return forms[2];
}

В value всегда приходит неотрицательное целое из уже проверенного количества копеек. Поэтому внутри plural() не нужно заново разбирать дроби, знаки и неверный ввод.

Одно правило подходит и названиям разрядов. В 21000 группа тысяч равна 21, поэтому получаются «двадцать одна тысяча». В 11000 она равна 11, и нужны «одиннадцать тысяч». Название разряда выбирается по его группе, а не по целому числу рублей.

Само слово «рублей» остаётся в конце даже тогда, когда младшая группа пустая. 1001000 читается как «один миллион одна тысяча рублей», а не заканчивается на слове «тысяча». Поэтому валюту мы и отделили от словаря разрядов.

Собираем всю сумму

Осталось соединить готовые части. Сначала получаем целые рубли и остаток копеек. Старшие разряды произносим раньше младших, нулевые пропускаем, а в самом конце добавляем валюту и двузначную запись копеек:

export function rubles(input) {
  const total = parseKopecks(input);
  const whole = Math.floor(total / 100);
  const fraction = total % 100;
  const groups = splitGroups(whole);
  const parts = [];

  if (whole === 0) {
    parts.push('ноль');
  }

  for (let index = groups.length - 1; index >= 0; index--) {
    const value = groups[index];

    if (value === 0) {
      continue;
    }

    parts.push(...groupWords(value, index === 1));

    if (index > 0) {
      parts.push(plural(value, scales[index]));
    }
  }

  parts.push(plural(whole, currency));
  parts.push(String(fraction).padStart(2, '0'), plural(fraction, coins));

  return parts.join(' ');
}

У groupWords() женский род включается только при index === 1, то есть для тысяч. У рублей название выбирается по всему whole: последние две цифры у него те же, что у младшей группы. Даже если эта группа нулевая, функция вернёт нужную форму.

Количество копеек берётся через % 100, поэтому после переноса оно снова лежит в пределах от нуля до 99. padStart() отвечает только за запись из двух цифр. Форму слова выбирает исходное число: для 1 получаем «01 копейка», для 11 получаем «11 копеек».

При нулевых рублях я оставил их в тексте явно: '0.01' превращается в «ноль рублей 01 копейка». Благодаря этому рубли и копейки присутствуют в каждой записи, независимо от суммы.

Теперь наш пример проходит весь путь:

52 151,995
5215200 копеек
52152 рубля и 00 копеек
[152, 52]
пятьдесят две тысячи сто пятьдесят два рубля 00 копеек

Функция rubles() работает только с российскими рублями. Для другой валюты недостаточно заменить её название: нужно проверить число дробных разрядов и грамматику.

Проверяем места, где меняется правило

Случайная большая сумма хорошо показывает длину результата, но плохо проверяет логику. Мне полезнее значения по обе стороны границы: 11 рядом с 21, точная половина копейки рядом с числом чуть ниже неё, тысяча с пустой младшей группой рядом с тысячей и одним рублём.

Соберём небольшой файл rubles.test.js рядом с модулем. Встроенного тестового запуска Node.js здесь достаточно:

import assert from 'node:assert/strict';
import test from 'node:test';
import { parseKopecks, rubles } from './rubles.js';

test('округление происходит по десятичной записи', () => {
  assert.equal(parseKopecks('52 151,995'), 5215200);
  assert.equal(parseKopecks('1.005'), 101);
  assert.equal(parseKopecks('1.0049999999999997'), 100);
  assert.equal(parseKopecks('1.999'), 200);
  assert.equal(parseKopecks('0.004'), 0);
  assert.equal(parseKopecks('999999999999.99'), 99999999999999);
});

test('формы слов зависят от группы и её последних цифр', () => {
  const cases = [
    ['11.11', 'одиннадцать рублей 11 копеек'],
    ['21.21', 'двадцать один рубль 21 копейка'],
    ['111.11', 'сто одиннадцать рублей 11 копеек'],
    ['121.21', 'сто двадцать один рубль 21 копейка'],
    ['21000', 'двадцать одна тысяча рублей 00 копеек'],
    ['21001', 'двадцать одна тысяча один рубль 00 копеек'],
    ['1001000.02', 'один миллион одна тысяча рублей 02 копейки'],
    ['0.01', 'ноль рублей 01 копейка']
  ];

  for (const [input, expected] of cases) {
    assert.equal(rubles(input), expected, input);
  }
});

test('ошибка ввода не становится суммой', () => {
  assert.throws(() => parseKopecks('1 2'), SyntaxError);
  assert.throws(() => parseKopecks('1\n2'), SyntaxError);
  assert.throws(() => parseKopecks('5abc'), SyntaxError);
  assert.throws(() => parseKopecks(1.005), TypeError);
  assert.throws(() => parseKopecks('999999999999.995'), RangeError);
});

Запуск из папки с файлами:

node --test rubles.test.js

В Node.js эти файлы должны исполняться как ES-модули: можно указать "type": "module" в package.json либо использовать расширение .mjs у обоих файлов и в импорте. Сам форматтер не обращается к возможностям Node.js и может работать в браузере.

По такому набору проще найти причину ошибки. Сравнение количества копеек проверяет арифметику, полные строки проверяют согласование и порядок разрядов, а проверки исключений обнаруживают неверный ввод, который функция по ошибке приняла за сумму.

Набор стоит дополнить границами конкретного поля. Для нашего контракта это разрешённые виды пробелов и запрещённые ведущие нули. Для словаря полезны 12 и 14, переход 999.995 в тысячу, несколько пустых групп подряд и максимальная сумма целиком. Такой выбор проверяет места, в которых алгоритм меняет поведение; количество тестов само по себе здесь мало о чём говорит.

Что происходит до форматтера

Функция округляет одну полученную сумму. Если документ содержит несколько строк, решение о том, когда округлять их стоимость, остаётся у расчёта документа.

Например, два учебных значения по '0.005' после отдельного округления дают по копейке. Их точная десятичная сумма равна '0.010' и после одного округления даёт одну копейку. Оба пути соответствуют выбранному правилу, но применяют его в разные моменты. Форматтеру неоткуда узнать, какой из них нужен вашему расчёту.

Поэтому я бы передавал сюда уже рассчитанную сумму документа. Её запись цифрами лучше строить из тех же целых копеек, что и слова. Тогда рядом с «пятьдесят две тысячи сто пятьдесят два рубля» будет стоять 52 152,00, а не исходное 52 151,995.

После получения 5215200 копеек арифметика заканчивается. Дальше мы выбираем слова, не меняя саму сумму. Такое разделение позволяет исправлять грамматику отдельно от округления и проверять каждую часть на своих примерах.