diff --git a/docling/backend/msexcel_backend.py b/docling/backend/msexcel_backend.py index 37e9be5eb7..81c1c08e90 100644 --- a/docling/backend/msexcel_backend.py +++ b/docling/backend/msexcel_backend.py @@ -6,10 +6,12 @@ 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 decimal import ROUND_HALF_UP, Decimal, localcontext from io import BytesIO from pathlib import Path from tempfile import mkdtemp @@ -101,6 +103,7 @@ from openpyxl.packaging.relationship import get_dependents, get_rels_path from openpyxl.reader.excel import ExcelReader from openpyxl.styles import PatternFill + from openpyxl.styles.numbers import is_date_format from openpyxl.utils.cell import range_boundaries from openpyxl.worksheet.merge import MergedCellRange from openpyxl.worksheet.worksheet import Worksheet @@ -131,6 +134,181 @@ "data without it." ) +_GENERAL_NUMBER_FORMATS: Final[frozenset[str]] = frozenset({"general", "@", ""}) +_EXCEL_DATE_TOKEN_RE: Final[re.Pattern[str]] = re.compile( + r'"[^"]*"|\\.|am/pm|yyyy|yy|mmmmm|mmmm|mmm|mm|m|dddd|ddd|dd|d|hh|h|ss|s|[ :/.,-]', + re.IGNORECASE, +) + + +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 "" + return _format_excel_value(value, cell.number_format) + + +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 + + if number_format.lower() in _GENERAL_NUMBER_FORMATS: + return str(value) + if isinstance(value, (datetime, date, time)): + return _format_excel_date(value, number_format) + if isinstance(value, (int, float)): + 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_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) + 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: + """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) + 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(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", + "yy": "%y", + "mmm": "%b", + "mmmm": "%B", + "ddd": "%a", + "dddd": "%A", + }[token] + rendered.append(dt.strftime(directive)) + 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. @@ -1423,7 +1601,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) @@ -2262,7 +2440,9 @@ def _resolve_reference(self, ref: str, max_cells: int) -> list[str]: ``"'Duck Observations'!$B$2:$B$7"``. ``_chart_range`` finds the sheet and the bounds to read, and we 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. + numbers, 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. 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 f35085da07..9bb8c0d131 100644 --- a/tests/test_backend_msexcel.py +++ b/tests/test_backend_msexcel.py @@ -5,7 +5,7 @@ import tracemalloc import zipfile from collections.abc import Iterator -from datetime import datetime +from datetime import datetime, time, timedelta from io import BytesIO from pathlib import Path @@ -24,6 +24,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 @@ -32,6 +33,7 @@ ExcelCell, ExcelTable, MsExcelDocumentBackend, + _format_excel_value, _order_comment_thread, ) from docling.datamodel.backend_options import MsExcelBackendOptions @@ -454,6 +456,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" + assert chart_grid[(2, 0)] == "2025-03-01" + 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. @@ -1173,6 +1217,139 @@ 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\\ hh: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" + + +@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(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"), + (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_number_format_tokens_and_sections( + value, number_format, expected +) -> None: + assert _format_excel_value(value, number_format) == expected + + +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%"), + ("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 / "number_format_tokens.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() + 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 / "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.