From 4955a57bced1444ccb48812552abaa43053b7088 Mon Sep 17 00:00:00 2001 From: jipeng6036-del Date: Tue, 6 Oct 2026 21:24:47 -0700 Subject: [PATCH 1/3] fix(xlsx): keep currency, percent, and date number formats Excel cells were converted with str(value) after a data_only load, so $12.50 became 12.5, 10% became 0.1, and Feb-25 became a timestamp. Apply the stored number format for currency, percentages, and dates. Custom formats still use str(value). Fixes #4619 Signed-off-by: jipeng6036-del --- docling/backend/msexcel_backend.py | 151 ++++++++++++++++++++++++++++- tests/test_backend_msexcel.py | 54 +++++++++++ 2 files changed, 201 insertions(+), 4 deletions(-) diff --git a/docling/backend/msexcel_backend.py b/docling/backend/msexcel_backend.py index 0743b6687b..c73c38a81f 100644 --- a/docling/backend/msexcel_backend.py +++ b/docling/backend/msexcel_backend.py @@ -6,10 +6,11 @@ import collections import logging import posixpath +import re import shutil import warnings from copy import deepcopy -from datetime import datetime, timezone +from datetime import date, datetime, time, timezone from io import BytesIO from pathlib import Path from tempfile import mkdtemp @@ -83,7 +84,9 @@ ) from openpyxl.packaging.relationship import get_dependents, get_rels_path from openpyxl.styles import PatternFill + from openpyxl.styles.numbers import is_date_format from openpyxl.utils.cell import range_boundaries + from openpyxl.utils.datetime import from_excel from openpyxl.worksheet.worksheet import Worksheet from openpyxl.xml.constants import IMAGE_NS @@ -115,6 +118,145 @@ # Maps an openpyxl chart object's ``tagname`` (the DrawingML element name, e.g. # "barChart") to the docling picture-classification label we tag the emitted # PictureItem with. Chart types not listed fall back to OTHER_CHART. +_GENERAL_NUMBER_FORMATS: Final[frozenset[str]] = frozenset({"general", "@", ""}) +_EXCEL_DATE_TOKEN_RE: Final[re.Pattern[str]] = re.compile( + r"yyyy|yy|mmmm|mmm|mm|m|dddd|ddd|dd|d|hh|h|ss|s|am/pm" +) +_CURRENCY_NUMBER_RE: Final[re.Pattern[str]] = re.compile(r"[0#.,]+") + + +def _displayed_cell_text(cell: Cell | MergedCell) -> str: + """Return the cell text as Excel would display it. + + ``data_only=True`` gives the cached computed value. Applying the stored + number format keeps currency, percentages, and dates from collapsing to + ``str(value)`` (``12.5``, ``0.1``, ``2025-02-01 00:00:00``). + """ + value = cell.value + if value is None: + return "" + number_format = getattr(cell, "number_format", None) or "General" + return _format_excel_value(value, number_format) + + +def _plain_excel_format(number_format: str) -> str: + """Strip locale, color, quotes, and escape slashes from a number format.""" + primary = number_format.split(";")[0] + primary = re.sub(r"\[[^\]]*\]", "", primary) + primary = primary.replace('"', "") + primary = re.sub(r"\\(.)", r"\1", primary) + return primary.strip() + + +def _format_excel_value(value: Any, number_format: str) -> str: + """Apply well-known Excel formats; leave custom formats as ``str(value)``.""" + if isinstance(value, bool): + return str(value) + if isinstance(value, str): + return value + + normalized = _plain_excel_format(number_format) + if normalized.lower() in _GENERAL_NUMBER_FORMATS: + return str(value) + + date_like = isinstance(value, (datetime, date, time)) or ( + isinstance(value, (int, float)) and is_date_format(normalized) + ) + if date_like: + return _format_excel_date(value, normalized) + if isinstance(value, (int, float)): + if "%" in normalized: + return _format_excel_percent(float(value), normalized) + if "$" in normalized: + return _format_excel_currency(float(value), normalized) + return str(value) + + +def _format_excel_percent(value: float, fmt: str) -> str: + """Format a fraction as a percentage, matching ``0%`` / ``0.00%``.""" + match = re.search(r"\.([0#]+)%", fmt) + decimals = len(match.group(1)) if match else 0 + scaled = value * 100.0 + if decimals == 0: + body = str(round(scaled)) + else: + body = f"{scaled:.{decimals}f}" + return f"{body}%" + + +def _format_excel_currency(value: float, fmt: str) -> str: + """Format a number with a ``$`` currency marker and its decimal pattern.""" + match = _CURRENCY_NUMBER_RE.search(fmt.replace("?", "")) + if match is None: + return str(value) + numeric = match.group(0) + decimals = len(numeric.split(".", 1)[1].replace(",", "")) if "." in numeric else 0 + if "," in numeric: + body = f"{value:,.{decimals}f}" + else: + body = f"{value:.{decimals}f}" + if fmt.strip().endswith("$"): + return f"{body}$" + return f"${body}" + + +def _excel_date_format_to_strftime(fmt: str) -> str: + """Translate a simple Excel date/time format into strftime tokens. + + ``mm`` after ``h`` is minutes; remaining ``mm`` / ``m`` tokens are months. + """ + lowered = fmt.lower() + lowered = re.sub(r"(?<=h:)mm", "\x01", lowered) + lowered = re.sub(r"(?<=h:)m(?!m)", "\x01", lowered) + + def replace_token(match: re.Match[str]) -> str: + token = match.group(0) + mapping = { + "yyyy": "%Y", + "yy": "%y", + "mmmm": "%B", + "mmm": "%b", + "mm": "%m", + "m": "%m", + "dddd": "%A", + "ddd": "%a", + "dd": "%d", + "d": "%d", + "hh": "%H", + "h": "%H", + "ss": "%S", + "s": "%S", + "am/pm": "%p", + } + return mapping.get(token, token) + + return _EXCEL_DATE_TOKEN_RE.sub(replace_token, lowered).replace("\x01", "%M") + + +def _format_excel_date(value: Any, fmt: str) -> str: + """Format a date, time, or Excel serial with the cell's date format.""" + parsed: datetime | date | time | None + if isinstance(value, (datetime, date, time)): + parsed = value + else: + try: + parsed = from_excel(value) + except Exception: + return str(value) + if isinstance(parsed, datetime): + dt = parsed + elif isinstance(parsed, date): + dt = datetime(parsed.year, parsed.month, parsed.day) + elif isinstance(parsed, time): + dt = datetime.combine(date(1900, 1, 1), parsed) + else: + return str(value) + try: + return dt.strftime(_excel_date_format_to_strftime(fmt)) + except Exception: + return str(value) + + _CHART_TAGNAME_TO_CLASSIFICATION: Final[dict[str, PictureClassificationLabel]] = { "barChart": PictureClassificationLabel.BAR_CHART, "bar3DChart": PictureClassificationLabel.BAR_CHART, @@ -1150,7 +1292,7 @@ def has_content(r: int, c: int) -> bool: if merged_cell_index.is_shadow(cell): continue - cell_text = str(cell.value) if cell.value is not None else "" + cell_text = _displayed_cell_text(cell) # Compute Spans row_span, col_span = merged_cell_index.span_at(ri, rj) @@ -1913,8 +2055,9 @@ def _resolve_reference(self, ref: str) -> list[str]: values: list[str] = [] for row in range(min_row, max_row + 1): for col in range(min_col, max_col + 1): - value = target_sheet.cell(row=row, column=col).value - values.append("" if value is None else str(value)) + values.append( + _displayed_cell_text(target_sheet.cell(row=row, column=col)) + ) return values diff --git a/tests/test_backend_msexcel.py b/tests/test_backend_msexcel.py index 1e0a96cebd..b5e25ec6a2 100644 --- a/tests/test_backend_msexcel.py +++ b/tests/test_backend_msexcel.py @@ -30,6 +30,7 @@ ExcelCell, ExcelTable, MsExcelDocumentBackend, + _format_excel_value, _order_comment_thread, ) from docling.datamodel.backend_options import MsExcelBackendOptions @@ -1030,6 +1031,59 @@ def test_find_data_tables_handles_a_filled_last_excel_row(tmp_path): assert table.data.table_cells[0].text == "last row" +def test_format_excel_value_matches_excel_display() -> None: + """Currency, percent, and mmm-yy formats keep the text Excel shows.""" + assert _format_excel_value(12.5, '"$"#,##0.00') == "$12.50" + assert _format_excel_value(0.1, "0%") == "10%" + assert _format_excel_value(datetime(2025, 2, 1), "mmm-yy") == "Feb-25" + assert ( + _format_excel_value( + datetime(2024, 1, 2, 0, 0), + "yyyy\\-mm\\-dd\\ h:mm:ss", + ) + == "2024-01-02 00:00:00" + ) + assert _format_excel_value(12.5, "General") == "12.5" + assert _format_excel_value(1, "\\1\\.0#") == "1" + + +def test_xlsx_keeps_excel_number_formats(tmp_path: Path) -> None: + """Table cell text uses the stored number format, not str(value).""" + workbook = Workbook() + sheet = workbook.active + sheet.title = "Order" + sheet["A1"] = "Item" + sheet["B1"] = "Qty" + sheet["C1"] = "Price" + sheet["D1"] = "Line total" + sheet["A2"] = "Orange juice" + sheet["B2"] = 10 + sheet["C2"] = 12.5 + sheet["C2"].number_format = '"$"#,##0.00' + sheet["D2"] = 125 + sheet["D2"].number_format = '"$"#,##0.00' + sheet["C4"] = "Discount" + sheet["D4"] = 0.1 + sheet["D4"].number_format = "0%" + sheet["C5"] = "Order month" + sheet["D5"] = datetime(2025, 2, 1) + sheet["D5"].number_format = "mmm-yy" + file_path = tmp_path / "formulas_and_number_formats.xlsx" + workbook.save(file_path) + + converter = get_converter() + doc = converter.convert(file_path).document + cells = {cell.text for table in doc.tables for cell in table.data.table_cells} + + assert "$12.50" in cells + assert "$125.00" in cells + assert "10%" in cells + assert "Feb-25" in cells + assert "12.5" not in cells + assert "0.1" not in cells + assert "2025-02-01 00:00:00" not in cells + + def test_emf_images_in_xlsx(libreoffice_available): """Test that EMF images embedded in XLSX files are extracted via LibreOffice. From 3513b5d20a01de171dfab0aaf9600e1e45a95ee2 Mon Sep 17 00:00:00 2001 From: jipeng6036-del Date: Wed, 7 Oct 2026 19:24:01 -0700 Subject: [PATCH 2/3] fix(xlsx): preserve number format semantics from review Signed-off-by: jipeng6036-del --- docling/backend/msexcel_backend.py | 237 ++++++++++-------- .../xlsx_02_sample_sales_data.xlsm.json | 80 +++--- .../xlsx_02_sample_sales_data.xlsm.md | 40 +-- tests/test_backend_msexcel.py | 74 +++++- 4 files changed, 267 insertions(+), 164 deletions(-) diff --git a/docling/backend/msexcel_backend.py b/docling/backend/msexcel_backend.py index c73c38a81f..c52893fac8 100644 --- a/docling/backend/msexcel_backend.py +++ b/docling/backend/msexcel_backend.py @@ -11,6 +11,7 @@ import warnings from copy import deepcopy from datetime import date, datetime, time, timezone +from decimal import ROUND_HALF_UP, Decimal, localcontext from io import BytesIO from pathlib import Path from tempfile import mkdtemp @@ -120,9 +121,9 @@ # PictureItem with. Chart types not listed fall back to OTHER_CHART. _GENERAL_NUMBER_FORMATS: Final[frozenset[str]] = frozenset({"general", "@", ""}) _EXCEL_DATE_TOKEN_RE: Final[re.Pattern[str]] = re.compile( - r"yyyy|yy|mmmm|mmm|mm|m|dddd|ddd|dd|d|hh|h|ss|s|am/pm" + r'"[^"]*"|\\.|am/pm|yyyy|yy|mmmm|mmm|mm|m|dddd|ddd|dd|d|hh|h|ss|s|[ :/.,-]', + re.IGNORECASE, ) -_CURRENCY_NUMBER_RE: Final[re.Pattern[str]] = re.compile(r"[0#.,]+") def _displayed_cell_text(cell: Cell | MergedCell) -> str: @@ -135,17 +136,7 @@ def _displayed_cell_text(cell: Cell | MergedCell) -> str: value = cell.value if value is None: return "" - number_format = getattr(cell, "number_format", None) or "General" - return _format_excel_value(value, number_format) - - -def _plain_excel_format(number_format: str) -> str: - """Strip locale, color, quotes, and escape slashes from a number format.""" - primary = number_format.split(";")[0] - primary = re.sub(r"\[[^\]]*\]", "", primary) - primary = primary.replace('"', "") - primary = re.sub(r"\\(.)", r"\1", primary) - return primary.strip() + return _format_excel_value(value, cell.number_format) def _format_excel_value(value: Any, number_format: str) -> str: @@ -155,106 +146,148 @@ def _format_excel_value(value: Any, number_format: str) -> str: if isinstance(value, str): return value - normalized = _plain_excel_format(number_format) - if normalized.lower() in _GENERAL_NUMBER_FORMATS: + if number_format.lower() in _GENERAL_NUMBER_FORMATS: return str(value) - - date_like = isinstance(value, (datetime, date, time)) or ( - isinstance(value, (int, float)) and is_date_format(normalized) - ) - if date_like: - return _format_excel_date(value, normalized) + if isinstance(value, (datetime, date, time)): + return _format_excel_date(value, number_format) if isinstance(value, (int, float)): - if "%" in normalized: - return _format_excel_percent(float(value), normalized) - if "$" in normalized: - return _format_excel_currency(float(value), normalized) + if is_date_format(number_format): + # Workbooks are loaded by openpyxl with their own date epoch. Do not + # reinterpret a remaining numeric value with an assumed 1900 epoch. + return str(value) + return _format_excel_number(value, number_format) return str(value) -def _format_excel_percent(value: float, fmt: str) -> str: - """Format a fraction as a percentage, matching ``0%`` / ``0.00%``.""" - match = re.search(r"\.([0#]+)%", fmt) - decimals = len(match.group(1)) if match else 0 - scaled = value * 100.0 - if decimals == 0: - body = str(round(scaled)) - else: - body = f"{scaled:.{decimals}f}" - return f"{body}%" - - -def _format_excel_currency(value: float, fmt: str) -> str: - """Format a number with a ``$`` currency marker and its decimal pattern.""" - match = _CURRENCY_NUMBER_RE.search(fmt.replace("?", "")) - if match is None: +def _format_excel_number(value: int | float, fmt: str) -> str: + """Apply simple currency/percent sections, with Excel's rounding rule.""" + # Conditions, unsupported locale markers, optional decimals and scaling + # must not be silently removed and mistaken for a supported number format. + without_colors = re.sub( + r"\[(?:Black|Blue|Cyan|Green|Magenta|Red|White|Yellow)\]", + "", + fmt, + flags=re.IGNORECASE, + ) + currency_format = re.sub(r"\[\$\$-[0-9a-fA-F]+\]", '"$"', without_colors) + if "[" in currency_format or r"\;" in currency_format: return str(value) - numeric = match.group(0) - decimals = len(numeric.split(".", 1)[1].replace(",", "")) if "." in numeric else 0 - if "," in numeric: - body = f"{value:,.{decimals}f}" - else: - body = f"{value:.{decimals}f}" - if fmt.strip().endswith("$"): - return f"{body}$" - return f"${body}" - - -def _excel_date_format_to_strftime(fmt: str) -> str: - """Translate a simple Excel date/time format into strftime tokens. - - ``mm`` after ``h`` is minutes; remaining ``mm`` / ``m`` tokens are months. - """ - lowered = fmt.lower() - lowered = re.sub(r"(?<=h:)mm", "\x01", lowered) - lowered = re.sub(r"(?<=h:)m(?!m)", "\x01", lowered) - - def replace_token(match: re.Match[str]) -> str: - token = match.group(0) - mapping = { - "yyyy": "%Y", - "yy": "%y", - "mmmm": "%B", - "mmm": "%b", - "mm": "%m", - "m": "%m", - "dddd": "%A", - "ddd": "%a", - "dd": "%d", - "d": "%d", - "hh": "%H", - "h": "%H", - "ss": "%S", - "s": "%S", - "am/pm": "%p", - } - return mapping.get(token, token) - - return _EXCEL_DATE_TOKEN_RE.sub(replace_token, lowered).replace("\x01", "%M") + sections = re.split(r';(?=(?:[^"]*"[^"]*")*[^"]*$)', currency_format) + if len(sections) > 4: + return str(value) + section_index = ( + 1 + if value < 0 and len(sections) > 1 + else 2 + if value == 0 and len(sections) > 2 + else 0 + ) + section = sections[section_index] + if not section: + return "" + if re.fullmatch(r'"[^"]*"', section): + return section[1:-1] + if any( + re.search(r"[0#.,%]", literal) for literal in re.findall(r'"([^"]*)"', section) + ): + return str(value) + normalized = re.sub(r"_.", "", section).replace('"', "") + normalized = re.sub(r"\\([()$ -])", r"\1", normalized) + match = re.fullmatch( + r"(?P[$( -]*)(?P#,##0|0)(?:\.(?P0{1,15}))?(?P[$% )-]*)", + normalized, + ) + if match is None or normalized.count("%") > 1 or normalized.count("$") > 1: + return str(value) + if "$" not in normalized and "%" not in normalized: + return str(value) + amount = Decimal(str(abs(value))) + if not amount.is_finite(): + return str(value) + if "%" in normalized: + amount *= 100 + decimals = len(match.group("decimals") or "") + # A local context also keeps large finite values from failing quantize. + with localcontext() as context: + context.prec = max(28, amount.adjusted() + decimals + 2) + rounded = amount.quantize(Decimal(1).scaleb(-decimals), rounding=ROUND_HALF_UP) + grouping = "," if "," in match.group("number") else "" + sign = "-" if value < 0 and section_index == 0 else "" + return f"{sign}{match.group('prefix')}{rounded:{grouping}.{decimals}f}{match.group('suffix')}" def _format_excel_date(value: Any, fmt: str) -> str: - """Format a date, time, or Excel serial with the cell's date format.""" - parsed: datetime | date | time | None - if isinstance(value, (datetime, date, time)): - parsed = value - else: - try: - parsed = from_excel(value) - except Exception: - return str(value) - if isinstance(parsed, datetime): - dt = parsed - elif isinstance(parsed, date): - dt = datetime(parsed.year, parsed.month, parsed.day) - elif isinstance(parsed, time): - dt = datetime.combine(date(1900, 1, 1), parsed) - else: + """Render supported date tokens, preserving quoted and escaped literals.""" + if not isinstance(value, (datetime, date, time)): + return str(value) + if fmt.lower() == "mm-dd-yy" and isinstance(value, date): + return value.isoformat().split("T")[0] + if fmt.lower() == "m/d/yy h:mm" and isinstance(value, datetime): + return value.isoformat(sep=" ") + parts = _EXCEL_DATE_TOKEN_RE.findall(fmt) + if "".join(parts) != fmt: return str(value) - try: - return dt.strftime(_excel_date_format_to_strftime(fmt)) - except Exception: + tokens = [part.lower() for part in parts if part[0].isalpha()] + if isinstance(value, time) and any( + token.startswith(("y", "d")) for token in tokens + ): return str(value) + if not isinstance(value, (datetime, time)) and any( + token.startswith(("h", "s", "a")) for token in tokens + ): + return str(value) + twelve_hour = "am/pm" in tokens + dt = datetime.combine(date(1900, 1, 1), value) if isinstance(value, time) else value + rendered: list[str] = [] + token_index = 0 + for part in parts: + if part.startswith('"'): + rendered.append(part[1:-1]) + elif part.startswith("\\"): + rendered.append(part[1:]) + elif not part[0].isalpha(): + rendered.append(part) + else: + token = tokens[token_index] + before = tokens[token_index - 1] if token_index else "" + after = tokens[token_index + 1] if token_index + 1 < len(tokens) else "" + token_index += 1 + if token in ("m", "mm", "d", "dd", "h", "hh", "s", "ss"): + minute = token in ("m", "mm") and ( + before in ("h", "hh") or after in ("s", "ss") + ) + if isinstance(value, time) and token in ("m", "mm") and not minute: + return str(value) + if minute or token.startswith(("h", "s")): + if not isinstance(dt, datetime): + return str(value) + number = ( + dt.minute + if minute + else dt.second + if token.startswith("s") + else dt.hour + ) + if token.startswith("h") and twelve_hour: + number = number % 12 or 12 + else: + number = dt.day if token.startswith("d") else dt.month + rendered.append(str(number).zfill(len(token))) + elif token == "am/pm": + if not isinstance(dt, datetime): + return str(value) + rendered.append("AM" if dt.hour < 12 else "PM") + else: + directive = { + "yyyy": "%Y", + "yy": "%y", + "mmm": "%b", + "mmmm": "%B", + "ddd": "%a", + "dddd": "%A", + }[token] + rendered.append(dt.strftime(directive)) + return "".join(rendered) _CHART_TAGNAME_TO_CLASSIFICATION: Final[dict[str, PictureClassificationLabel]] = { diff --git a/tests/data/xlsx/groundtruth/xlsx_02_sample_sales_data.xlsm.json b/tests/data/xlsx/groundtruth/xlsx_02_sample_sales_data.xlsm.json index 4f9ced6b52..f5144a8255 100644 --- a/tests/data/xlsx/groundtruth/xlsx_02_sample_sales_data.xlsm.json +++ b/tests/data/xlsx/groundtruth/xlsx_02_sample_sales_data.xlsm.json @@ -145,7 +145,7 @@ "end_row_offset_idx": 2, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-01 00:00:00", + "text": "2024-01-01 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -197,7 +197,7 @@ "end_row_offset_idx": 3, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-02 00:00:00", + "text": "2024-01-02 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -249,7 +249,7 @@ "end_row_offset_idx": 4, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-03 00:00:00", + "text": "2024-01-03 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -301,7 +301,7 @@ "end_row_offset_idx": 5, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-04 00:00:00", + "text": "2024-01-04 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -353,7 +353,7 @@ "end_row_offset_idx": 6, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-05 00:00:00", + "text": "2024-01-05 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -405,7 +405,7 @@ "end_row_offset_idx": 7, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-06 00:00:00", + "text": "2024-01-06 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -457,7 +457,7 @@ "end_row_offset_idx": 8, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-07 00:00:00", + "text": "2024-01-07 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -509,7 +509,7 @@ "end_row_offset_idx": 9, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-08 00:00:00", + "text": "2024-01-08 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -561,7 +561,7 @@ "end_row_offset_idx": 10, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-09 00:00:00", + "text": "2024-01-09 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -613,7 +613,7 @@ "end_row_offset_idx": 11, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-10 00:00:00", + "text": "2024-01-10 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -665,7 +665,7 @@ "end_row_offset_idx": 12, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-11 00:00:00", + "text": "2024-01-11 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -717,7 +717,7 @@ "end_row_offset_idx": 13, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-12 00:00:00", + "text": "2024-01-12 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -769,7 +769,7 @@ "end_row_offset_idx": 14, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-13 00:00:00", + "text": "2024-01-13 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -821,7 +821,7 @@ "end_row_offset_idx": 15, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-14 00:00:00", + "text": "2024-01-14 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -873,7 +873,7 @@ "end_row_offset_idx": 16, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-15 00:00:00", + "text": "2024-01-15 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -925,7 +925,7 @@ "end_row_offset_idx": 17, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-16 00:00:00", + "text": "2024-01-16 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -977,7 +977,7 @@ "end_row_offset_idx": 18, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-17 00:00:00", + "text": "2024-01-17 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1029,7 +1029,7 @@ "end_row_offset_idx": 19, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-18 00:00:00", + "text": "2024-01-18 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1081,7 +1081,7 @@ "end_row_offset_idx": 20, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-19 00:00:00", + "text": "2024-01-19 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1133,7 +1133,7 @@ "end_row_offset_idx": 21, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-20 00:00:00", + "text": "2024-01-20 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1245,7 +1245,7 @@ "end_row_offset_idx": 2, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-01 00:00:00", + "text": "2024-01-01 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1299,7 +1299,7 @@ "end_row_offset_idx": 3, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-02 00:00:00", + "text": "2024-01-02 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1353,7 +1353,7 @@ "end_row_offset_idx": 4, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-03 00:00:00", + "text": "2024-01-03 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1407,7 +1407,7 @@ "end_row_offset_idx": 5, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-04 00:00:00", + "text": "2024-01-04 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1461,7 +1461,7 @@ "end_row_offset_idx": 6, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-05 00:00:00", + "text": "2024-01-05 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1515,7 +1515,7 @@ "end_row_offset_idx": 7, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-06 00:00:00", + "text": "2024-01-06 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1569,7 +1569,7 @@ "end_row_offset_idx": 8, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-07 00:00:00", + "text": "2024-01-07 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1623,7 +1623,7 @@ "end_row_offset_idx": 9, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-08 00:00:00", + "text": "2024-01-08 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1677,7 +1677,7 @@ "end_row_offset_idx": 10, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-09 00:00:00", + "text": "2024-01-09 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1731,7 +1731,7 @@ "end_row_offset_idx": 11, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-10 00:00:00", + "text": "2024-01-10 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1785,7 +1785,7 @@ "end_row_offset_idx": 12, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-11 00:00:00", + "text": "2024-01-11 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1839,7 +1839,7 @@ "end_row_offset_idx": 13, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-12 00:00:00", + "text": "2024-01-12 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1893,7 +1893,7 @@ "end_row_offset_idx": 14, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-13 00:00:00", + "text": "2024-01-13 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -1947,7 +1947,7 @@ "end_row_offset_idx": 15, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-14 00:00:00", + "text": "2024-01-14 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -2001,7 +2001,7 @@ "end_row_offset_idx": 16, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-15 00:00:00", + "text": "2024-01-15 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -2055,7 +2055,7 @@ "end_row_offset_idx": 17, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-16 00:00:00", + "text": "2024-01-16 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -2109,7 +2109,7 @@ "end_row_offset_idx": 18, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-17 00:00:00", + "text": "2024-01-17 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -2163,7 +2163,7 @@ "end_row_offset_idx": 19, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-18 00:00:00", + "text": "2024-01-18 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -2217,7 +2217,7 @@ "end_row_offset_idx": 20, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-19 00:00:00", + "text": "2024-01-19 0:00:00", "column_header": false, "row_header": false, "row_section": false, @@ -2271,7 +2271,7 @@ "end_row_offset_idx": 21, "start_col_offset_idx": 1, "end_col_offset_idx": 2, - "text": "2024-01-20 00:00:00", + "text": "2024-01-20 0:00:00", "column_header": false, "row_header": false, "row_section": false, diff --git a/tests/data/xlsx/groundtruth/xlsx_02_sample_sales_data.xlsm.md b/tests/data/xlsx/groundtruth/xlsx_02_sample_sales_data.xlsm.md index 5a78d56756..af72a812d0 100644 --- a/tests/data/xlsx/groundtruth/xlsx_02_sample_sales_data.xlsm.md +++ b/tests/data/xlsx/groundtruth/xlsx_02_sample_sales_data.xlsm.md @@ -2,23 +2,23 @@ | Product | Date | Quantity | Revenue | | - | - | - | - | -| Widget A | 2024-01-01 00:00:00 | 5 | 5000 | -| Widget B | 2024-01-02 00:00:00 | 10 | 12000 | -| Widget C | 2024-01-03 00:00:00 | 3 | 3000 | -| Widget D | 2024-01-04 00:00:00 | 8 | 8000 | -| Widget A | 2024-01-05 00:00:00 | 7 | 7000 | -| Widget B | 2024-01-06 00:00:00 | 6 | 6000 | -| Widget C | 2024-01-07 00:00:00 | 12 | 15000 | -| Widget D | 2024-01-08 00:00:00 | 9 | 9000 | -| Widget A | 2024-01-09 00:00:00 | 4 | 4000 | -| Widget B | 2024-01-10 00:00:00 | 11 | 11000 | -| Widget C | 2024-01-11 00:00:00 | 5 | 5000 | -| Widget D | 2024-01-12 00:00:00 | 8 | 8500 | -| Widget A | 2024-01-13 00:00:00 | 6 | 6200 | -| Widget B | 2024-01-14 00:00:00 | 7 | 7100 | -| Widget C | 2024-01-15 00:00:00 | 10 | 10500 | -| Widget D | 2024-01-16 00:00:00 | 3 | 3200 | -| Widget A | 2024-01-17 00:00:00 | 9 | 9400 | -| Widget B | 2024-01-18 00:00:00 | 12 | 12500 | -| Widget C | 2024-01-19 00:00:00 | 6 | 6100 | -| Widget D | 2024-01-20 00:00:00 | 8 | 8900 | \ No newline at end of file +| Widget A | 2024-01-01 0:00:00 | 5 | 5000 | +| Widget B | 2024-01-02 0:00:00 | 10 | 12000 | +| Widget C | 2024-01-03 0:00:00 | 3 | 3000 | +| Widget D | 2024-01-04 0:00:00 | 8 | 8000 | +| Widget A | 2024-01-05 0:00:00 | 7 | 7000 | +| Widget B | 2024-01-06 0:00:00 | 6 | 6000 | +| Widget C | 2024-01-07 0:00:00 | 12 | 15000 | +| Widget D | 2024-01-08 0:00:00 | 9 | 9000 | +| Widget A | 2024-01-09 0:00:00 | 4 | 4000 | +| Widget B | 2024-01-10 0:00:00 | 11 | 11000 | +| Widget C | 2024-01-11 0:00:00 | 5 | 5000 | +| Widget D | 2024-01-12 0:00:00 | 8 | 8500 | +| Widget A | 2024-01-13 0:00:00 | 6 | 6200 | +| Widget B | 2024-01-14 0:00:00 | 7 | 7100 | +| Widget C | 2024-01-15 0:00:00 | 10 | 10500 | +| Widget D | 2024-01-16 0:00:00 | 3 | 3200 | +| Widget A | 2024-01-17 0:00:00 | 9 | 9400 | +| Widget B | 2024-01-18 0:00:00 | 12 | 12500 | +| Widget C | 2024-01-19 0:00:00 | 6 | 6100 | +| Widget D | 2024-01-20 0:00:00 | 8 | 8900 | \ No newline at end of file diff --git a/tests/test_backend_msexcel.py b/tests/test_backend_msexcel.py index b5e25ec6a2..f4b338cbd2 100644 --- a/tests/test_backend_msexcel.py +++ b/tests/test_backend_msexcel.py @@ -3,7 +3,7 @@ import logging from collections.abc import Iterator -from datetime import datetime +from datetime import datetime, time, timedelta from io import BytesIO from pathlib import Path @@ -1039,7 +1039,7 @@ def test_format_excel_value_matches_excel_display() -> None: assert ( _format_excel_value( datetime(2024, 1, 2, 0, 0), - "yyyy\\-mm\\-dd\\ h:mm:ss", + "yyyy\\-mm\\-dd\\ hh:mm:ss", ) == "2024-01-02 00:00:00" ) @@ -1047,6 +1047,76 @@ def test_format_excel_value_matches_excel_display() -> None: assert _format_excel_value(1, "\\1\\.0#") == "1" +@pytest.mark.parametrize( + "value,number_format,expected", + [ + (datetime(2025, 2, 1), "mm-dd-yy", "2025-02-01"), + (datetime(2025, 2, 1, 13, 30), "m/d/yy h:mm", "2025-02-01 13:30:00"), + (time(0, 7, 45), "mm:ss", "07:45"), + (time(13, 30), "h:mm AM/PM", "1:30 PM"), + (time(0, 7), "hh:mm AM/PM", "12:07 AM"), + (time(13, 30), "h:mm", "13:30"), + (datetime(2025, 2, 1), "d-mmm-yy", "1-Feb-25"), + (datetime(2025, 2, 1), '"Due "mmm d', "Due Feb 1"), + (datetime(2025, 2, 1), '"Due; "mmm d', "Due; Feb 1"), + (datetime(2025, 2, 1), "yyyy-mm-dd", "2025-02-01"), + (12.5, "[$$-409]#,##0.00", "$12.50"), + (-5, '"$"#,##0.00_);\\("$"#,##0.00\\)', "($5.00)"), + (-5, '"$"#,##0.00;[Red]"$"-#,##0.00', "$-5.00"), + (-5, '"$"#,##0.00', "-$5.00"), + (0.125, "0%", "13%"), + (-0.125, "0%", "-13%"), + (0.0125, "0.00%", "1.25%"), + (12.5, '"$"#,##0', "$13"), + (-12.5, '"$"#,##0', "-$13"), + (1234.565, '"$"#,##0.00', "$1,234.57"), + (0, '"$"0;("$"0);"-"', "-"), + (0, '"$"0;("$"0);', ""), + (12.5, '0.00"$"', "12.50$"), + # Unsupported formats retain the value instead of dropping format semantics. + (timedelta(days=1, hours=2, minutes=7), "[h]:mm:ss", "1 day, 2:07:00"), + (datetime(2025, 2, 1), "[$-409]mmmm d, yyyy", "2025-02-01 00:00:00"), + (0.125, "[>=1]0%;0.0%", "0.125"), + (12.5, '"$"0.##', "12.5"), + (12.5, '"$0"0.00', "12.5"), + ], +) +def test_excel_review_number_format_regressions(value, number_format, expected) -> None: + assert _format_excel_value(value, number_format) == expected + + +def test_xlsx_review_formats_survive_document_conversion(tmp_path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + rows = [ + ("Date", datetime(2025, 2, 1), "mm-dd-yy", "2025-02-01"), + ("Minutes", time(0, 7, 45), "mm:ss", "07:45"), + ("Afternoon", time(13, 30), "h:mm AM/PM", "1:30 PM"), + ("Due", datetime(2025, 2, 1), '"Due "mmm d', "Due Feb 1"), + ("Negative", -5, '"$"#,##0.00_);\\("$"#,##0.00\\)', "($5.00)"), + ("Percent", 0.125, "0%", "13%"), + ("Currency", 12.5, "[$$-409]#,##0", "$13"), + ("Duration", timedelta(hours=26, minutes=7), "[h]:mm:ss", "1 day, 2:07:00"), + ] + sheet.append(["Format", "Value"]) + for row_index, (label, value, number_format, _) in enumerate(rows, start=2): + sheet.cell(row_index, 1, label) + sheet.cell(row_index, 2, value).number_format = number_format + path = tmp_path / "review_number_formats.xlsx" + workbook.save(path) + + document = get_converter().convert(path).document + table = document.tables[0].data + assert [ + (cell.start_row_offset_idx, cell.text) + for cell in table.table_cells + if cell.start_col_offset_idx == 1 + ] == [ + (0, "Value"), + *[(index, expected) for index, (_, _, _, expected) in enumerate(rows, start=1)], + ] + + def test_xlsx_keeps_excel_number_formats(tmp_path: Path) -> None: """Table cell text uses the stored number format, not str(value).""" workbook = Workbook() From bb39a3b96ecc94deb582965759ac24cd2fb0f973 Mon Sep 17 00:00:00 2001 From: jipeng6036-del Date: Wed, 14 Oct 2026 06:13:03 -0700 Subject: [PATCH 3/3] fix: preserve raw Excel chart data and format token semantics Signed-off-by: jipeng6036-del --- docling/backend/msexcel_backend.py | 23 ++++++----- tests/test_backend_msexcel.py | 61 ++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/docling/backend/msexcel_backend.py b/docling/backend/msexcel_backend.py index 64038806ca..c786e02231 100644 --- a/docling/backend/msexcel_backend.py +++ b/docling/backend/msexcel_backend.py @@ -88,7 +88,6 @@ from openpyxl.styles import PatternFill from openpyxl.styles.numbers import is_date_format from openpyxl.utils.cell import range_boundaries - from openpyxl.utils.datetime import from_excel from openpyxl.worksheet.worksheet import Worksheet from openpyxl.xml.constants import IMAGE_NS @@ -117,12 +116,9 @@ "data without it." ) -# Maps an openpyxl chart object's ``tagname`` (the DrawingML element name, e.g. -# "barChart") to the docling picture-classification label we tag the emitted -# PictureItem with. Chart types not listed fall back to OTHER_CHART. _GENERAL_NUMBER_FORMATS: Final[frozenset[str]] = frozenset({"general", "@", ""}) _EXCEL_DATE_TOKEN_RE: Final[re.Pattern[str]] = re.compile( - r'"[^"]*"|\\.|am/pm|yyyy|yy|mmmm|mmm|mm|m|dddd|ddd|dd|d|hh|h|ss|s|[ :/.,-]', + r'"[^"]*"|\\.|am/pm|yyyy|yy|mmmmm|mmmm|mmm|mm|m|dddd|ddd|dd|d|hh|h|ss|s|[ :/.,-]', re.IGNORECASE, ) @@ -277,7 +273,11 @@ def _format_excel_date(value: Any, fmt: str) -> str: elif token == "am/pm": if not isinstance(dt, datetime): return str(value) - rendered.append("AM" if dt.hour < 12 else "PM") + rendered.append(part[:2] if dt.hour < 12 else part[3:]) + elif token == "mmmmm": + if isinstance(value, time): + return str(value) + rendered.append(dt.strftime("%B")[:1]) else: directive = { "yyyy": "%Y", @@ -291,6 +291,9 @@ def _format_excel_date(value: Any, fmt: str) -> str: return "".join(rendered) +# Maps an openpyxl chart object's ``tagname`` (the DrawingML element name, e.g. +# "barChart") to the docling picture-classification label we tag the emitted +# PictureItem with. Chart types not listed fall back to OTHER_CHART. _CHART_TAGNAME_TO_CLASSIFICATION: Final[dict[str, PictureClassificationLabel]] = { "barChart": PictureClassificationLabel.BAR_CHART, "bar3DChart": PictureClassificationLabel.BAR_CHART, @@ -2053,7 +2056,8 @@ def _resolve_reference(self, ref: str) -> list[str]: sheet name, convert the range to bounds with openpyxl's ``range_boundaries``, and read the *cached* values from the workbook (the backend loads with ``data_only=True``, so we get computed numbers, - not formulas). Values are returned in row-major order. + not formulas). Values are returned in row-major order without display + formatting, so chart data keeps the precision of the cached values. Args: ref: A range reference string, optionally sheet-qualified. @@ -2089,9 +2093,8 @@ def _resolve_reference(self, ref: str) -> list[str]: values: list[str] = [] for row in range(min_row, max_row + 1): for col in range(min_col, max_col + 1): - values.append( - _displayed_cell_text(target_sheet.cell(row=row, column=col)) - ) + value = target_sheet.cell(row=row, column=col).value + values.append("" if value is None else str(value)) return values diff --git a/tests/test_backend_msexcel.py b/tests/test_backend_msexcel.py index f4b338cbd2..9a107182f4 100644 --- a/tests/test_backend_msexcel.py +++ b/tests/test_backend_msexcel.py @@ -22,6 +22,7 @@ ) from docling_core.types.doc.document import DEFAULT_CONTENT_LAYERS from openpyxl import Workbook, load_workbook +from openpyxl.chart import BarChart, Reference from openpyxl.comments import Comment from openpyxl.worksheet.merge import MergedCellRange @@ -451,6 +452,48 @@ def test_chartsheet_data_values(documents) -> None: assert found_310, "Should find the value 310 (total ducks for 2024) in the document" +def test_chart_data_keeps_raw_values_in_formatted_cells(tmp_path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.title = "Sales" + sheet.append(["Month", "Revenue"]) + sheet.append([datetime(2025, 2, 1), 1234.5]) + sheet.append([datetime(2025, 3, 1), 99.125]) + for row in (2, 3): + sheet.cell(row, 1).number_format = "mmm-yy" + sheet.cell(row, 2).number_format = '"$"#,##0.00' + chart = BarChart() + chart.add_data( + Reference(sheet, min_col=2, min_row=1, max_row=3), titles_from_data=True + ) + chart.set_categories(Reference(sheet, min_col=1, min_row=2, max_row=3)) + sheet.add_chart(chart, "D1") + path = tmp_path / "formatted_chart_data.xlsx" + workbook.save(path) + + document = get_converter().convert(path).document + assert len(document.tables) == 1 + worksheet_grid = { + (cell.start_row_offset_idx, cell.start_col_offset_idx): cell.text + for cell in document.tables[0].data.table_cells + } + assert worksheet_grid[(1, 0)] == "Feb-25" + assert worksheet_grid[(1, 1)] == "$1,234.50" + assert worksheet_grid[(2, 1)] == "$99.13" + assert len(document.pictures) == 1 + chart_data = document.pictures[0].meta.tabular_chart.chart_data + assert (chart_data.num_rows, chart_data.num_cols) == (3, 2) + chart_grid = { + (cell.start_row_offset_idx, cell.start_col_offset_idx): cell.text + for cell in chart_data.table_cells + } + assert chart_grid[(0, 1)] == "Revenue" + assert chart_grid[(1, 0)] == "2025-02-01 00:00:00" + assert chart_grid[(2, 0)] == "2025-03-01 00:00:00" + assert chart_grid[(1, 1)] == "1234.5" + assert chart_grid[(2, 1)] == "99.125" + + def test_chart_parsing_disabled() -> None: """Test that parse_charts=False suppresses chart pictures. @@ -1054,9 +1097,15 @@ def test_format_excel_value_matches_excel_display() -> None: (datetime(2025, 2, 1, 13, 30), "m/d/yy h:mm", "2025-02-01 13:30:00"), (time(0, 7, 45), "mm:ss", "07:45"), (time(13, 30), "h:mm AM/PM", "1:30 PM"), + (time(13, 30), "h:mm am/pm", "1:30 pm"), + (time(0, 7), "hh:mm am/pm", "12:07 am"), (time(0, 7), "hh:mm AM/PM", "12:07 AM"), (time(13, 30), "h:mm", "13:30"), (datetime(2025, 2, 1), "d-mmm-yy", "1-Feb-25"), + (datetime(2025, 2, 1), "mmmmm", "F"), + (datetime(2025, 3, 1), "mmmmm d", "M 1"), + (datetime(2025, 2, 1), '"mmmmm "mmmmm', "mmmmm F"), + (time(13, 30), "mmmmm", "13:30:00"), (datetime(2025, 2, 1), '"Due "mmm d', "Due Feb 1"), (datetime(2025, 2, 1), '"Due; "mmm d', "Due; Feb 1"), (datetime(2025, 2, 1), "yyyy-mm-dd", "2025-02-01"), @@ -1081,17 +1130,21 @@ def test_format_excel_value_matches_excel_display() -> None: (12.5, '"$0"0.00', "12.5"), ], ) -def test_excel_review_number_format_regressions(value, number_format, expected) -> None: +def test_excel_number_format_tokens_and_sections( + value, number_format, expected +) -> None: assert _format_excel_value(value, number_format) == expected -def test_xlsx_review_formats_survive_document_conversion(tmp_path: Path) -> None: +def test_xlsx_number_formats_survive_document_conversion(tmp_path: Path) -> None: workbook = Workbook() sheet = workbook.active rows = [ ("Date", datetime(2025, 2, 1), "mm-dd-yy", "2025-02-01"), ("Minutes", time(0, 7, 45), "mm:ss", "07:45"), ("Afternoon", time(13, 30), "h:mm AM/PM", "1:30 PM"), + ("Lowercase afternoon", time(13, 30), "h:mm am/pm", "1:30 pm"), + ("Month initial", datetime(2025, 2, 1), "mmmmm", "F"), ("Due", datetime(2025, 2, 1), '"Due "mmm d', "Due Feb 1"), ("Negative", -5, '"$"#,##0.00_);\\("$"#,##0.00\\)', "($5.00)"), ("Percent", 0.125, "0%", "13%"), @@ -1102,7 +1155,7 @@ def test_xlsx_review_formats_survive_document_conversion(tmp_path: Path) -> None for row_index, (label, value, number_format, _) in enumerate(rows, start=2): sheet.cell(row_index, 1, label) sheet.cell(row_index, 2, value).number_format = number_format - path = tmp_path / "review_number_formats.xlsx" + path = tmp_path / "number_format_tokens.xlsx" workbook.save(path) document = get_converter().convert(path).document @@ -1138,7 +1191,7 @@ def test_xlsx_keeps_excel_number_formats(tmp_path: Path) -> None: sheet["C5"] = "Order month" sheet["D5"] = datetime(2025, 2, 1) sheet["D5"].number_format = "mmm-yy" - file_path = tmp_path / "formulas_and_number_formats.xlsx" + file_path = tmp_path / "number_formats.xlsx" workbook.save(file_path) converter = get_converter()