From b0c9dce8221b7a5f6769a1bb2071de97dd403b6d Mon Sep 17 00:00:00 2001 From: denis-samatov Date: Sat, 5 Sep 2026 16:35:21 +0700 Subject: [PATCH] feat: add support for roman numeral page numbers in TOC - Add roman_to_int helper to parse roman numeral page numbers (e.g. 'i', 'ii', 'iv', 'xii', 'IV') - Update convert_page_to_int to convert roman numerals into integers - Prevents roman-numeral preface and introductory sections from falling back to unindexed/None pages - Add comprehensive test suite in tests/test_roman_numerals.py Closes #164 --- pageindex/utils.py | 41 ++++++++++++++--- tests/test_roman_numerals.py | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 tests/test_roman_numerals.py diff --git a/pageindex/utils.py b/pageindex/utils.py index f23995057..74088b7f1 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -691,14 +691,43 @@ def convert_physical_index_to_int(data): return data +_ROMAN_REGEX = re.compile(r"^m{0,4}(cm|cd|d?c{0,3})(xc|xl|l?x{0,3})(ix|iv|v?i{0,3})$", re.IGNORECASE) +_ROMAN_VALUES = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000} + + +def roman_to_int(s: str) -> int | None: + """Convert a Roman numeral string (e.g. 'iv', 'xii', 'IV') to an integer. + Returns None if s is not a valid Roman numeral.""" + if not isinstance(s, str): + return None + cleaned = s.strip() + if not cleaned or not _ROMAN_REGEX.match(cleaned): + return None + total = 0 + prev = 0 + for ch in reversed(cleaned.lower()): + val = _ROMAN_VALUES.get(ch, 0) + if val < prev: + total -= val + else: + total += val + prev = val + return total if total > 0 else None + + def convert_page_to_int(data): for item in data: - if 'page' in item and isinstance(item['page'], str): - try: - item['page'] = int(item['page']) - except ValueError: - # Keep original value if conversion fails - pass + if 'page' in item and item['page'] is not None: + if isinstance(item['page'], int): + continue + if isinstance(item['page'], str): + cleaned = item['page'].strip() + try: + item['page'] = int(cleaned) + except ValueError: + roman_val = roman_to_int(cleaned) + if roman_val is not None: + item['page'] = roman_val return data diff --git a/tests/test_roman_numerals.py b/tests/test_roman_numerals.py new file mode 100644 index 000000000..b741b7ba6 --- /dev/null +++ b/tests/test_roman_numerals.py @@ -0,0 +1,87 @@ +import pytest +from pageindex.utils import roman_to_int, convert_page_to_int + + +@pytest.mark.parametrize( + "roman_str,expected", + [ + ("i", 1), + ("ii", 2), + ("iii", 3), + ("iv", 4), + ("v", 5), + ("vi", 6), + ("vii", 7), + ("viii", 8), + ("ix", 9), + ("x", 10), + ("xi", 11), + ("xii", 12), + ("xiv", 14), + ("xv", 15), + ("xix", 19), + ("xx", 20), + ("xl", 40), + ("l", 50), + ("xc", 90), + ("c", 100), + ("cd", 400), + ("d", 500), + ("cm", 900), + ("m", 1000), + ("MCMLIV", 1954), + ("mmxxvi", 2026), + # Mixed casing and whitespace + (" IV ", 4), + ("Xii", 12), + ("vIi", 7), + ], +) +def test_roman_to_int_valid(roman_str, expected): + assert roman_to_int(roman_str) == expected + + +@pytest.mark.parametrize( + "invalid_str", + [ + "", + " ", + "abc", + "123", + "iv2", + "iiii", # invalid roman syntax + "vx", # invalid subtraction + "ll", # invalid repeated 50 + None, + 123, + [], + ], +) +def test_roman_to_int_invalid(invalid_str): + assert roman_to_int(invalid_str) is None + + +def test_convert_page_to_int_handles_roman_and_arabic_pages(): + toc = [ + {"title": "Title Page", "page": "i"}, + {"title": "Dedication", "page": "ii"}, + {"title": "Table of Contents", "page": " iv "}, + {"title": "Preface", "page": "VII"}, + {"title": "Introduction", "page": "1"}, + {"title": "Chapter 1", "page": 5}, + {"title": "Chapter 2", "page": " 12 "}, + {"title": "Appendix", "page": None}, + {"title": "Unnumbered Note", "page": "not_a_page"}, + ] + + converted = convert_page_to_int(toc) + + assert converted[0]["page"] == 1 + assert converted[1]["page"] == 2 + assert converted[2]["page"] == 4 + assert converted[3]["page"] == 7 + assert converted[4]["page"] == 1 + assert converted[5]["page"] == 5 + assert converted[6]["page"] == 12 + assert converted[7]["page"] is None + assert converted[8]["page"] == "not_a_page"