Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

## [0.4.7] - 2026-09-16

### Fixed

- `впродовж` and `упродовж` now govern the genitive case like `протягом`, so
counts use genitive readings («Впродовж 15 хвилин» → «Впродовж п'ятнадцяти хвилин»).
- Clock times after `о` / `об` now use the locative hour («о 10:30» →
«о десятій годині тридцять хвилин»); minutes and seconds are unchanged.

## [0.4.6] - 2026-09-15

### Changed
Expand Down Expand Up @@ -248,6 +257,7 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Bare ranges before sentence punctuation, ranges following a punctuation dash, and English `P.`/`pp.` page ranges
now honor `RangeStyle.FromTo`.

[0.4.7]: https://github.com/ThirdLetterC/normalize_uk-cpp/releases/tag/v0.4.7
[0.4.6]: https://github.com/ThirdLetterC/normalize_uk-cpp/releases/tag/v0.4.6
[0.4.5]: https://github.com/ThirdLetterC/normalize_uk-cpp/releases/tag/v0.4.5
[0.4.4]: https://github.com/ThirdLetterC/normalize_uk-cpp/releases/tag/v0.4.4
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20)

project(
normalize_uk_cpp
VERSION 0.4.6
VERSION 0.4.7
DESCRIPTION "Ukrainian text normalization and tokenization utilities"
LANGUAGES CXX
)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ print([token.text for token in nuk.tokenize("П'ять зв'язків.")])

More examples live in `examples/python/`.

Tags matching the version in `pyproject.toml` (for example, `v0.4.6`) trigger
Tags matching the version in `pyproject.toml` (for example, `v0.4.7`) trigger
wheel builds for supported Python versions. The workflow uploads the wheels to
GitHub Release Assets, then downloads those Assets and publishes them to PyPI.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"

[project]
name = "normalize-uk"
version = "0.4.6"
version = "0.4.7"
description = "Python bindings for Ukrainian text normalization and tokenization utilities"
requires-python = ">=3.10"
readme = "README.md"
Expand Down
4 changes: 2 additions & 2 deletions src/uktextnorm/internal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ const std::regex& date_spelled_re()
const std::regex& case_prep_re()
{
static const std::regex re(
R"((^|[^А-Яа-яЄєІіЇїҐґ-])(Близько|близько|Після|після|Протягом|протягом|Менше|менше|Більше|більше|Серед|серед|Перед|перед|Між|між|Над|над|Під|під|При|при|Без|без|Від|від|До|до|Із|із|З|з|Об|об|К|к|О|о)\s+(\d+)(?:\s*()" +
R"((^|[^А-Яа-яЄєІіЇїҐґ-])(Близько|близько|Після|після|Протягом|протягом|Впродовж|впродовж|Упродовж|упродовж|Менше|менше|Більше|більше|Серед|серед|Перед|перед|Між|між|Над|над|Під|під|При|при|Без|без|Від|від|До|до|Із|із|З|з|Об|об|К|к|О|о)\s+(\d+)(?:\s*()" +
unit_alt() + R"()(\.?))?(?!\s*%)(?![\d.,:%–—-])(?![A-Za-zА-Яа-яЄєІіЇїҐґ]))");
return re;
}
Expand All @@ -576,7 +576,7 @@ const std::regex& counted_genitive_re()
{
static const std::regex re(
"(^|[^А-Яа-яЄєІіЇїҐґ\\d])(Близько|близько|Більше|більше|Менше|менше|Серед|серед|До|до|Від|від|Без|"
"без|Після|після|Протягом|протягом|Із|із)\\s+([1-9]\\d{0,5})\\s+(" +
"без|Після|після|Протягом|протягом|Впродовж|впродовж|Упродовж|упродовж|Із|із)\\s+([1-9]\\d{0,5})\\s+(" +
counted_noun_alt() + R"()(?![А-Яа-яЄєІіЇїҐґ]))");
return re;
}
Expand Down
2 changes: 2 additions & 0 deletions src/uktextnorm/passes_numeric_context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ std::string normalize_case_context(std::string text)
{"без", "gen"},
{"після", "gen"},
{"протягом", "gen"},
{"впродовж", "gen"},
{"упродовж", "gen"},
{"перед", "instr"},
{"між", "instr"},
{"над", "instr"},
Expand Down
2 changes: 1 addition & 1 deletion src/uktextnorm/passes_numeric_ranges.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ std::string normalize_ordinals(std::string text)
return m[1].str() + number_to_ordinal_words(*value, form) + noun;
});
static const std::regex bare_cyrillic_century_before_start(
R"((Протягом|протягом)\s+((?:Х|І|X|I|V|M|C|D|L){1,8})\s+та\s+початку)");
R"((Протягом|протягом|Впродовж|впродовж|Упродовж|упродовж)\s+((?:Х|І|X|I|V|M|C|D|L){1,8})\s+та\s+початку)");
text = regex_sub(text, bare_cyrillic_century_before_start, [&](const std::smatch& m) {
const auto value = cyrillic_roman_value(m[2].str());
return value ? m[1].str() + " " + number_to_ordinal_words(*value, "gen") + " століття та початку" : m.str();
Expand Down
26 changes: 20 additions & 6 deletions src/uktextnorm/passes_numeric_units.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@

namespace uktextnorm::detail {

bool governed_by_o(const std::smatch& m)
{
const auto word = preceding_word(m.prefix().str() + m[1].str());
return word == "о" || word == "об";
}

std::string normalize_time(std::string text, ColonStyle colon_style)
{
auto clock_words = [](int hour, int minute, std::optional<int> second = std::nullopt) {
auto clock_words = [](int hour, int minute, std::optional<int> second = std::nullopt, bool locative = false) {
if ((hour == 0 || hour == 24) && minute == 0 && (!second || *second == 0)) {
return std::string("опівночі");
}
std::string out = hours_words(hour);
std::string out = locative ? number_to_ordinal_words(static_cast<unsigned long long>(hour), "loc_f") +
" годині"
: hours_words(hour);
if (minute) {
out += " " + minutes_words(minute);
}
Expand Down Expand Up @@ -42,7 +50,8 @@ std::string normalize_time(std::string text, ColonStyle colon_style)
return m[1].str() +
clock_words(clock_hour,
parse_int(m[3].str()),
m[4].matched ? std::optional<int>(parse_int(m[4].str())) : std::nullopt) +
m[4].matched ? std::optional<int>(parse_int(m[4].str())) : std::nullopt,
governed_by_o(m)) +
suffix;
});
static const std::regex zoned(
Expand Down Expand Up @@ -115,7 +124,8 @@ std::string normalize_time(std::string text, ColonStyle colon_style)
if (hour > 23) {
return m.str();
}
return m[1].str() + clock_words(hour, parse_int(m[3].str()), parse_int(m[4].str()));
return m[1].str() +
clock_words(hour, parse_int(m[3].str()), parse_int(m[4].str()), governed_by_o(m));
});
text = ctre_sub<R"((^|[^А-Яа-яЄєІіЇїҐґ\d])((?:О|о)(?:б)?) (\d{1,2})(?:-|–|—)?(?:й|ій|а|ої)(?![А-Яа-яЄєІіЇїҐґ]))">(
text, [](const auto& m) {
Expand All @@ -126,7 +136,10 @@ std::string normalize_time(std::string text, ColonStyle colon_style)
R"((^|[^\d:])(\d{1,2}):([0-5]\d)\s+(ранку|дня|вечора|ночі)(?![А-Яа-яЄєІіЇїҐґ\d:]))", std::regex::icase);
text = regex_sub(text, day_period, [&](const std::smatch& m) {
const auto hour = parse_int(m[2].str());
return hour <= 23 ? m[1].str() + clock_words(hour, parse_int(m[3].str())) + " " + m[4].str() : m.str();
return hour <= 23
? m[1].str() + clock_words(hour, parse_int(m[3].str()), std::nullopt, governed_by_o(m)) + " " +
m[4].str()
: m.str();
});
static const std::regex hm(R"((^|[^\d:])(\d{1,2}):([0-5]\d)(?![\d:]))");
text = regex_sub(text, hm, [&](const std::smatch& m) {
Expand All @@ -137,7 +150,8 @@ std::string normalize_time(std::string text, ColonStyle colon_style)
if (hour == 24 && parse_int(m[3].str()) == 0) {
return m[1].str() + clock_words(hour, 0);
}
return hour <= 23 ? m[1].str() + clock_words(hour, parse_int(m[3].str())) : m.str();
return hour <= 23 ? m[1].str() + clock_words(hour, parse_int(m[3].str()), std::nullopt, governed_by_o(m))
: m.str();
});
static const std::regex ratio(R"((^|[^\d:])(\d+):(\d+)(?![\d:]))");
return regex_sub(text, ratio, [&](const std::smatch& m) {
Expand Down
4 changes: 2 additions & 2 deletions tests/data/uktextnorm_sentence_golden.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ date Зустріч призначено на 14.09.2026. Зустріч при
date Період триває з 28.02.2024 до 01.03.2024. Період триває з двадцять восьмого лютого дві тисячі двадцять четвертого року до першого березня дві тисячі двадцять четвертого року.
calendar_period Звіт за II кв. 2026 року готовий. Звіт за другий квартал дві тисячі двадцять шостого року готовий.
calendar_period Компанія зростала на початку 2000-х. Компанія зростала на початку двотисячних.
time Поїзд прибуває о 06:05:09. Поїзд прибуває о шість годин п'ять хвилин дев'ять секунд.
time О 10:30 PM розпочнеться трансляція. О десять годин тридцять хвилин вечора розпочнеться трансляція.
time Поїзд прибуває о 06:05:09. Поїзд прибуває о шостій годині п'ять хвилин дев'ять секунд.
time О 10:30 PM розпочнеться трансляція. О десятій годині тридцять хвилин вечора розпочнеться трансляція.
duration Резервне копіювання тривало PT1H30M. Резервне копіювання тривало одна година тридцять хвилин.
duration Термін зберігання становить P2W. Термін зберігання становить два тижні.
range Температура буде 5–7 °C. Температура буде від п'яти до семи градусів Цельсія.
Expand Down
2 changes: 2 additions & 0 deletions tests/python_binding_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ def test_dates_times_and_scientific_notation(self) -> None:
"о десять годин тридцять хвилин за всесвітнім координованим часом"
),
"10:30 PM": "десять годин тридцять хвилин вечора",
"Зустріч о 10:30": "Зустріч о десятій годині тридцять хвилин",
"Впродовж 15 хвилин": "Впродовж п'ятнадцяти хвилин",
"6.02×10²³": "шість цілих і дві сотих помножити на десять у степені двадцять три",
}
for source, expected in cases.items():
Expand Down
13 changes: 11 additions & 2 deletions tests/uktextnorm_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ int main(int argc, char** argv)
expect_eq("decade without written suffix",
normalize_ukrainian("У 1940 роках створили перші системи."),
"У тисяча дев'ятсот сорокових роках створили перші системи.");
expect_eq("time", normalize_ukrainian("Зустріч о 06:06"), "Зустріч о шість годин шість хвилин");
expect_eq("time", normalize_ukrainian("Зустріч о 06:06"), "Зустріч о шостій годині шість хвилин");
expect_eq("currency", normalize_ukrainian("Ціна 12.50 грн"), "Ціна дванадцять гривень п'ятдесят копійок");
expect_eq("measure", normalize_ukrainian("5 кг і 2 хв"), "п'ять кілограмів і дві хвилини");
expect_eq("latin measurement symbols",
Expand Down Expand Up @@ -325,7 +325,7 @@ int main(int argc, char** argv)
expect_eq("time part", normalize_ukrainian("6:00 ранку"), "шість годин ранку");
expect_eq("time with seconds",
normalize_ukrainian("Зустріч о 12:34:56"),
"Зустріч о дванадцять годин тридцять чотири хвилини п'ятдесят шість секунд");
"Зустріч о дванадцятій годині тридцять чотири хвилини п'ятдесят шість секунд");
expect_eq("comma currency",
normalize_ukrainian("1 234,56 грн"),
"тисяча двісті тридцять чотири гривні п'ятдесят шість копійок");
Expand Down Expand Up @@ -1186,6 +1186,15 @@ int main(int argc, char** argv)
expect_eq("measurement after duration governor",
normalize_ukrainian("протягом 4 хвилин", audit_options),
"протягом чотирьох хвилин");
expect_eq("measurement after vprodovzh governor",
normalize_ukrainian("Впродовж 15 хвилин очікуємо відбій тривоги.", audit_options),
"Впродовж п'ятнадцяти хвилин очікуємо відбій тривоги.");
expect_eq("measurement after uprodovzh governor",
normalize_ukrainian("упродовж 3 днів", audit_options),
"упродовж трьох днів");
expect_eq("clock time after o takes locative",
normalize_ukrainian("Зустріч о 10:30", audit_options),
"Зустріч о десятій годині тридцять хвилин");
expect_eq("bare dot decimal",
normalize_ukrainian("Коефіцієнт 0.9996.", audit_options),
"Коефіцієнт нуль цілих і дев'ять тисяч дев'ятсот дев'яносто шість десятитисячних.");
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.