Skip to content
Open
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
41 changes: 35 additions & 6 deletions pageindex/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
87 changes: 87 additions & 0 deletions tests/test_roman_numerals.py
Original file line number Diff line number Diff line change
@@ -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"