diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py index 0452e02168..039dd5c7b5 100644 --- a/docling/backend/mspowerpoint_backend.py +++ b/docling/backend/mspowerpoint_backend.py @@ -77,7 +77,7 @@ _PPTX_IMPORT_ERROR: ImportError | None = None try: # pragma: no cover - import-time guard from pptx import Presentation, presentation - from pptx.chart.plot import PlotFactory, PlotTypeInspector + from pptx.chart.series import BarSeries, XySeries from pptx.enum.action import PP_ACTION from pptx.enum.shapes import MSO_SHAPE_TYPE, PP_PLACEHOLDER from pptx.exc import InvalidXmlError @@ -120,6 +120,25 @@ "image data." ) +_CHART_TAGNAME_TO_CLASSIFICATION: Final[dict[str, PictureClassificationLabel]] = { + "barChart": PictureClassificationLabel.BAR_CHART, + "bar3DChart": PictureClassificationLabel.BAR_CHART, + "lineChart": PictureClassificationLabel.LINE_CHART, + "line3DChart": PictureClassificationLabel.LINE_CHART, + "pieChart": PictureClassificationLabel.PIE_CHART, + "pie3DChart": PictureClassificationLabel.PIE_CHART, + "doughnutChart": PictureClassificationLabel.PIE_CHART, + "scatterChart": PictureClassificationLabel.SCATTER_CHART, + "areaChart": PictureClassificationLabel.OTHER_CHART, + "area3DChart": PictureClassificationLabel.OTHER_CHART, +} +"""Maps the tag-local name of a chart plot element, such as ``"bar3DChart"``, to +a docling picture-classification label. The Word and Excel backends use the same +mapping. Plot types not listed fall back to ``OTHER_CHART``.""" + +_XY_PLOT_TAGNAMES: Final = frozenset({"scatterChart", "bubbleChart"}) +"""Plot types whose series keep their values in ``c:yVal`` instead of ``c:val``.""" + _EXCEL_EPOCH: Final = datetime(1899, 12, 30) _EXCEL_1904_EPOCH: Final = datetime(1904, 1, 1) """Day zero of the date serials in a chart's data cache, for the 1900 and the @@ -1286,87 +1305,51 @@ def _handle_tables(self, shape, parent_slide, slide_ind, doc, slide_size): return @staticmethod - def _chart_type_to_classification(chart_type: Any) -> PictureClassificationLabel: - """Map a python-pptx ``XL_CHART_TYPE`` to a docling classification label. + def _chart_classifications(plots: list[Any]) -> list[PictureClassificationLabel]: + """Return the classification labels of a chart, one per plot type. - The type of a plot is a granular enum (``COLUMN_CLUSTERED``, - ``XY_SCATTER_LINES``, ``THREE_D_PIE``, ...). Rather than enumerate every - member, we match on the enum member's name by family, mirroring the - Excel backend's tagname mapping: column/bar variants become BAR_CHART, - line variants LINE_CHART, pie/doughnut PIE_CHART, scatter SCATTER_CHART, - and everything else (area, radar, stock, surface, ...) OTHER_CHART. - SCATTER is matched before LINE because ``XY_SCATTER_LINES`` contains both. + Each plot is classified by its tag, so a ``c:bar3DChart`` plot is a bar + chart. The labels follow the drawing order, so the back-most plot gives + the main label. A label is listed once, even when several plots share + it. Args: - chart_type: An ``XL_CHART_TYPE`` member, or None when python-pptx - cannot determine the type of the plot. + plots: The chart's plot elements, back-most first. Returns: - The matching PictureClassificationLabel. + The labels, or ``[OTHER_CHART]`` when the chart has no plot. """ - name = chart_type.name if chart_type is not None else "" - if "PIE" in name or "DOUGHNUT" in name: - return PictureClassificationLabel.PIE_CHART - if "SCATTER" in name: - return PictureClassificationLabel.SCATTER_CHART - if "LINE" in name: - return PictureClassificationLabel.LINE_CHART - if "BAR" in name or "COL" in name: - return PictureClassificationLabel.BAR_CHART - return PictureClassificationLabel.OTHER_CHART + labels: list[PictureClassificationLabel] = [] + for x_chart in plots: + label = _CHART_TAGNAME_TO_CLASSIFICATION.get( + etree.QName(x_chart).localname, PictureClassificationLabel.OTHER_CHART + ) + if label not in labels: + labels.append(label) + return labels or [PictureClassificationLabel.OTHER_CHART] @staticmethod - def _chart_plots(chart: Any) -> list[tuple[Any, Any]]: - """Return each plot of a chart with its plot element, back-most first. - - A combination chart has one plot per chart type, for example a column - plot with a line plot drawn over it. python-pptx has no plot class for - some types (3-D bar, line and pie, stock, surface, bar-of-pie). Such a - plot is returned as None, so that the other plots keep their data. - - Args: - chart: A python-pptx ``Chart`` object. - - Returns: - ``(plot element, plot)`` pairs in drawing order. The plot is None - when python-pptx cannot read its type. - """ - plots: list[tuple[Any, Any]] = [] - for x_chart in chart.element.chart.plotArea.xCharts: - try: - plots.append((x_chart, PlotFactory(x_chart, chart))) - except ValueError: - plots.append((x_chart, None)) - return plots + def _plot_series(x_chart: Any) -> list[Any]: + """Return the series of a plot, in the order of their ``c:order``. - @classmethod - def _chart_classifications( - cls, plots: list[tuple[Any, Any]] - ) -> list[PictureClassificationLabel]: - """Return the classification labels of a chart, one per plot type. - - The labels follow the drawing order, so the back-most plot gives the - main label. A plot that python-pptx cannot read counts as OTHER_CHART. - A label is listed once, even when several plots share it. + python-pptx has no plot class for some plot types (3-D bar, line and + pie, stock, surface, bar-of-pie) and no series class for 3-D area + plots. Their ``c:ser`` elements still have the same structure, so a + python-pptx series class reads the series of every plot type. Args: - plots: The chart's plots, as returned by ``_chart_plots``. + x_chart: The plot element, such as ``c:bar3DChart``. Returns: - The labels, or ``[OTHER_CHART]`` when the chart has no plot. + One python-pptx series per ``c:ser`` element. """ - labels: list[PictureClassificationLabel] = [] - for _, plot in plots: - chart_type = None - if plot is not None: - try: - chart_type = PlotTypeInspector.chart_type(plot) - except (ValueError, NotImplementedError): - chart_type = None - label = cls._chart_type_to_classification(chart_type) - if label not in labels: - labels.append(label) - return labels or [PictureClassificationLabel.OTHER_CHART] + series_class = ( + XySeries + if etree.QName(x_chart).localname in _XY_PLOT_TAGNAMES + else BarSeries + ) + sers = sorted(x_chart.iterchildren(qn("c:ser")), key=lambda ser: ser.order.val) + return [series_class(ser) for ser in sers] @staticmethod def _chart_title_text(chart: Any) -> Optional[str]: @@ -1419,14 +1402,16 @@ def _category_axis(chart: Any, x_chart: Any) -> Any: The ``c:catAx`` or ``c:dateAx`` element, or None for a plot without one, such as a pie or a scatter plot. """ - axis_ids = set(x_chart.xpath("./c:axId/@val")) + axis_ids = { + axis_id.get("val") for axis_id in x_chart.iterchildren(qn("c:axId")) + } for axis in chart.element.chart.plotArea.xpath("./c:catAx | ./c:dateAx"): if axis_ids.intersection(axis.xpath("./c:axId/@val")): return axis return None @staticmethod - def _categories_are_dates(axis: Any, x_chart: Any) -> bool: + def _categories_are_dates(axis: Any, ser: Any) -> bool: """Return True when an axis shows its categories as dates. A date axis always does. A text axis does when its number format is a @@ -1436,7 +1421,7 @@ def _categories_are_dates(axis: Any, x_chart: Any) -> bool: Args: axis: The plot's ``c:catAx`` or ``c:dateAx`` element. - x_chart: The plot element, such as ``c:barChart``. + ser: The first ``c:ser`` element of the plot. Returns: True when the categories are dates. @@ -1450,9 +1435,7 @@ def _categories_are_dates(axis: Any, x_chart: Any) -> bool: ): format_code = number_format.get("formatCode", "") else: - format_code = "".join( - x_chart.xpath("./c:ser[1]/c:cat//c:numCache/c:formatCode/text()") - ) + format_code = "".join(ser.xpath("./c:cat//c:numCache/c:formatCode/text()")) return _is_date_format(format_code) @staticmethod @@ -1478,45 +1461,49 @@ def _axis_title_text(axis: Any) -> str: text = "".join(axis.xpath("./c:title/c:tx/c:strRef//c:v/text()")) return text.strip() - def _category_labels( - self, chart: Any, axis: Any, x_chart: Any, plot: Any - ) -> list[str]: + def _category_labels(self, chart: Any, axis: Any, x_chart: Any) -> list[str]: """Return the category labels of a plot, with dates in ISO 8601. + The labels are read from the first series, as python-pptx's + ``plot.categories`` reads them, because python-pptx has no plot class + for some plot types. A multi-level axis gives the labels of its leaf + level. A category without a cached label gives "". + Args: chart: A python-pptx ``Chart`` object. axis: The plot's category axis, or None. x_chart: The plot element, such as ``c:barChart``. - plot: The python-pptx plot read from ``x_chart``. Returns: One label per category. """ - labels = [self._cell_text(category) for category in plot.categories] - if axis is None or not self._categories_are_dates(axis, x_chart): + ser = x_chart.find(qn("c:ser")) + if ser is None: + return [] + points = ser.xpath("./c:cat//c:lvl[1]/c:pt") or ser.xpath("./c:cat//c:pt") + texts = {point.idx: point.v.text or "" for point in points} + labels = [texts.get(idx, "") for idx in range(ser.cat_ptCount_val)] + if axis is None or not self._categories_are_dates(axis, ser): return labels date1904 = bool(chart.element.date_1904) return [_serial_to_iso(label, date1904) for label in labels] - def _chart_to_table_data( - self, chart: Any, plots: list[tuple[Any, Any]] - ) -> Optional[TableData]: + def _chart_to_table_data(self, chart: Any, plots: list[Any]) -> Optional[TableData]: """Reconstruct a chart's underlying data grid as a TableData. Unlike the Excel backend, python-pptx exposes the plotted numbers - directly on each series (``series.values``) and the shared category - labels on the plot (``plot.categories``) — no workbook reference + directly on each series (``series.values``), and the chart caches the + category labels with the first series — no workbook reference resolution is needed. ``build_chart_table`` lays the table out. The series of every plot become columns in drawing order, so a combination chart keeps its line series next to its column series. The categories come from the first plot that has them, and the title of - that plot's category axis goes in the top-left cell. A plot that - python-pptx cannot read is skipped with a warning. + that plot's category axis goes in the top-left cell. Args: chart: A python-pptx ``Chart`` object. - plots: The chart's plots, as returned by ``_chart_plots``. + plots: The chart's plot elements, back-most first. Returns: A TableData, or None if the chart exposes no usable series or no @@ -1525,19 +1512,12 @@ def _chart_to_table_data( categories: list[str] = [] corner = "" columns: list[tuple[str, list[str]]] = [] - for x_chart, plot in plots: - if plot is None: - _log.warning( - "Skipping the data of a chart plot that python-pptx cannot " - "read: %s", - etree.QName(x_chart).localname, - ) - continue + for x_chart in plots: if not categories: axis = self._category_axis(chart, x_chart) - categories = self._category_labels(chart, axis, x_chart, plot) + categories = self._category_labels(chart, axis, x_chart) corner = self._axis_title_text(axis) if axis is not None else "" - for series in plot.series: + for series in self._plot_series(x_chart): name = self._cell_text(series.name) if series.name is not None else "" values = [self._cell_text(v) for v in series.values] columns.append((name, values)) @@ -1576,7 +1556,7 @@ def _handle_chart(self, shape, parent_slide, slide_ind, doc, slide_size): return try: - plots = self._chart_plots(chart) + plots = list(chart.element.chart.plotArea.xCharts) except InvalidXmlError as exc: _log.warning("Could not read the plots of a chart: %s", exc) plots = [] @@ -1584,7 +1564,7 @@ def _handle_chart(self, shape, parent_slide, slide_ind, doc, slide_size): caption_text = self._chart_title_text(chart) try: table_data = self._chart_to_table_data(chart, plots) - except (AttributeError, ValueError, KeyError) as exc: + except (AttributeError, ValueError, KeyError, InvalidXmlError) as exc: _log.warning("Could not extract chart data: %s", exc) table_data = None diff --git a/tests/data/pptx/groundtruth/pptx_chart.pptx.json b/tests/data/pptx/groundtruth/pptx_chart.pptx.json index 6f4c0ba93b..03b7d6f252 100644 --- a/tests/data/pptx/groundtruth/pptx_chart.pptx.json +++ b/tests/data/pptx/groundtruth/pptx_chart.pptx.json @@ -615,9 +615,236 @@ "classification": { "predictions": [ { - "class_name": "other_chart" + "class_name": "bar_chart" } ] + }, + "tabular_chart": { + "chart_data": { + "table_cells": [ + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 0, + "end_row_offset_idx": 1, + "start_col_offset_idx": 0, + "end_col_offset_idx": 1, + "text": "", + "column_header": true, + "row_header": false, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 0, + "end_row_offset_idx": 1, + "start_col_offset_idx": 1, + "end_col_offset_idx": 2, + "text": "2026", + "column_header": true, + "row_header": false, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 1, + "end_row_offset_idx": 2, + "start_col_offset_idx": 0, + "end_col_offset_idx": 1, + "text": "Q1", + "column_header": false, + "row_header": true, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 1, + "end_row_offset_idx": 2, + "start_col_offset_idx": 1, + "end_col_offset_idx": 2, + "text": "10", + "column_header": false, + "row_header": false, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 2, + "end_row_offset_idx": 3, + "start_col_offset_idx": 0, + "end_col_offset_idx": 1, + "text": "Q2", + "column_header": false, + "row_header": true, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 2, + "end_row_offset_idx": 3, + "start_col_offset_idx": 1, + "end_col_offset_idx": 2, + "text": "20", + "column_header": false, + "row_header": false, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 3, + "end_row_offset_idx": 4, + "start_col_offset_idx": 0, + "end_col_offset_idx": 1, + "text": "Q3", + "column_header": false, + "row_header": true, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 3, + "end_row_offset_idx": 4, + "start_col_offset_idx": 1, + "end_col_offset_idx": 2, + "text": "30", + "column_header": false, + "row_header": false, + "row_section": false, + "fillable": false + } + ], + "num_rows": 4, + "num_cols": 2, + "orientation": "rot_0", + "grid": [ + [ + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 0, + "end_row_offset_idx": 1, + "start_col_offset_idx": 0, + "end_col_offset_idx": 1, + "text": "", + "column_header": true, + "row_header": false, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 0, + "end_row_offset_idx": 1, + "start_col_offset_idx": 1, + "end_col_offset_idx": 2, + "text": "2026", + "column_header": true, + "row_header": false, + "row_section": false, + "fillable": false + } + ], + [ + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 1, + "end_row_offset_idx": 2, + "start_col_offset_idx": 0, + "end_col_offset_idx": 1, + "text": "Q1", + "column_header": false, + "row_header": true, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 1, + "end_row_offset_idx": 2, + "start_col_offset_idx": 1, + "end_col_offset_idx": 2, + "text": "10", + "column_header": false, + "row_header": false, + "row_section": false, + "fillable": false + } + ], + [ + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 2, + "end_row_offset_idx": 3, + "start_col_offset_idx": 0, + "end_col_offset_idx": 1, + "text": "Q2", + "column_header": false, + "row_header": true, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 2, + "end_row_offset_idx": 3, + "start_col_offset_idx": 1, + "end_col_offset_idx": 2, + "text": "20", + "column_header": false, + "row_header": false, + "row_section": false, + "fillable": false + } + ], + [ + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 3, + "end_row_offset_idx": 4, + "start_col_offset_idx": 0, + "end_col_offset_idx": 1, + "text": "Q3", + "column_header": false, + "row_header": true, + "row_section": false, + "fillable": false + }, + { + "row_span": 1, + "col_span": 1, + "start_row_offset_idx": 3, + "end_row_offset_idx": 4, + "start_col_offset_idx": 1, + "end_col_offset_idx": 2, + "text": "30", + "column_header": false, + "row_header": false, + "row_section": false, + "fillable": false + } + ] + ] + } } }, "label": "picture", diff --git a/tests/data/pptx/groundtruth/pptx_chart.pptx.md b/tests/data/pptx/groundtruth/pptx_chart.pptx.md index 7594563e31..2f54a83609 100644 --- a/tests/data/pptx/groundtruth/pptx_chart.pptx.md +++ b/tests/data/pptx/groundtruth/pptx_chart.pptx.md @@ -17,4 +17,10 @@ Bar chart -Other chart \ No newline at end of file +Bar chart + +| | 2026 | +|----|--------| +| Q1 | 10 | +| Q2 | 20 | +| Q3 | 30 | \ No newline at end of file diff --git a/tests/test_backend_pptx.py b/tests/test_backend_pptx.py index 30bdf983ed..39c6ccab8b 100644 --- a/tests/test_backend_pptx.py +++ b/tests/test_backend_pptx.py @@ -352,9 +352,9 @@ def test_chart_parsed_as_classified_picture_with_data(): ... | 2022 | 175 | 130 | - * Slide 2 — a 3-D bar chart (``c:bar3DChart``) for which python-pptx has no - registered element class. It should degrade gracefully: the chart is - emitted as a PictureItem with no tabular data. + * Slide 2 — a 3-D column chart (``c:bar3DChart``) for which python-pptx has + no plot class. It is still classified as a bar chart and carries its + data: one series "2026" over the categories Q1 to Q3. """ converter = get_converter() doc = converter.convert(CHART_PPTX).document @@ -383,12 +383,19 @@ def test_chart_parsed_as_classified_picture_with_data(): assert grid[(4, 1)] == "175" assert grid[(4, 2)] == "130" - # --- slide 2: 3-D chart degrades gracefully (no tabular data, no crash) --- + # --- slide 2: 3-D chart with full data --- revenue_pic = next( p for p in pictures if p.caption_text(doc) != "Wild Duck Observations by Year" ) assert revenue_pic.meta is not None - assert revenue_pic.meta.tabular_chart is None + assert ( + revenue_pic.meta.classification.predictions[0].class_name + == PictureClassificationLabel.BAR_CHART + ) + assert [ + [cell.text for cell in row] + for row in revenue_pic.meta.tabular_chart.chart_data.grid + ] == [["", "2026"], ["Q1", "10"], ["Q2", "20"], ["Q3", "30"]] def test_chart_image_not_rendered_by_default(): diff --git a/tests/test_backend_pptx_charts.py b/tests/test_backend_pptx_charts.py index 60c98adf68..b0b42d25d8 100644 --- a/tests/test_backend_pptx_charts.py +++ b/tests/test_backend_pptx_charts.py @@ -1,12 +1,12 @@ # SPDX-FileCopyrightText: The Docling Contributors # SPDX-License-Identifier: MIT -"""Tests for PowerPoint combination charts: several plots in one chart.""" +"""Tests for PowerPoint chart plots: every plot type and combination charts.""" import datetime import logging from pathlib import Path -from typing import Optional +from typing import Any, Optional import pytest from docling_core.types.doc import PictureClassificationLabel, PictureItem @@ -125,6 +125,41 @@ def _save_combo_chart_deck( prs.save(path) +def _single_plot_deck(plot: str) -> tuple[Any, Any]: + """Return a deck whose chart has one ``c:`` plot, and that plot element. + + The plot has one series "2026" with 10, 20 and 30 over Q1 to Q3. python-pptx + writes only the plot types that it has a class for, so the series and axis + ids of a clustered column chart move into a new ``c:`` element. + """ + from lxml import etree + from pptx import Presentation + from pptx.chart.data import CategoryChartData + from pptx.enum.chart import XL_CHART_TYPE + from pptx.oxml.ns import qn + from pptx.util import Inches + + chart_data = CategoryChartData() + chart_data.categories = ["Q1", "Q2", "Q3"] + chart_data.add_series("2026", (10, 20, 30)) + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + chart = slide.shapes.add_chart( + XL_CHART_TYPE.COLUMN_CLUSTERED, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + chart_data, + ).chart + bar_chart = chart.element.chart.plotArea.find(qn("c:barChart")) + other_plot = etree.Element(qn(f"c:{plot}")) + other_plot.extend(bar_chart.findall(qn("c:ser")) + bar_chart.findall(qn("c:axId"))) + bar_chart.addnext(other_plot) + bar_chart.getparent().remove(bar_chart) + return prs, other_plot + + def _only_chart(path: Path) -> PictureItem: doc = get_converter().convert(path).document pictures = list(doc.pictures) @@ -168,27 +203,84 @@ def test_combo_chart_lists_each_plot_type_and_keeps_all_series(tmp_path: Path): assert len(grid) == 1 + len(_MONTHS) -def test_unreadable_plot_keeps_the_data_of_the_other_plots(tmp_path: Path, caplog): - """A plot type python-pptx cannot read does not drop the other plots' data. +def test_stock_plot_keeps_its_series_next_to_the_other_plots(tmp_path: Path): + """The series of a stock plot follow the column series in the data table. - python-pptx has no plot class for a stock plot. Before, the whole chart - lost its data table. Now the column series stay, the stock plot counts as - another chart type, and a warning names the plot that was skipped. + python-pptx has no plot class for a stock plot. Before, its series were + skipped with a warning. Now they are read, and the stock plot counts as + another chart type. """ path = tmp_path / "stock_combo_chart.pptx" _save_combo_chart_deck(path, second_plot="stockChart") - with caplog.at_level( - logging.WARNING, logger="docling.backend.mspowerpoint_backend" - ): - picture = _only_chart(path) + picture = _only_chart(path) assert _labels(picture) == [ PictureClassificationLabel.BAR_CHART.value, PictureClassificationLabel.OTHER_CHART.value, ] - assert _grid(picture)[0][1:] == list(_BARS) - assert "stockChart" in caplog.text + grid = _grid(picture) + assert grid[0][1:] == [*_BARS, *_LINES] + assert grid[1][1:] == ["85", "94", "91", "71", "0.126", "0.091"] + + +@pytest.mark.parametrize( + ("plot", "label"), + [ + ("bar3DChart", PictureClassificationLabel.BAR_CHART), + ("line3DChart", PictureClassificationLabel.LINE_CHART), + ("pie3DChart", PictureClassificationLabel.PIE_CHART), + ("area3DChart", PictureClassificationLabel.OTHER_CHART), + ("ofPieChart", PictureClassificationLabel.OTHER_CHART), + ("stockChart", PictureClassificationLabel.OTHER_CHART), + ("surfaceChart", PictureClassificationLabel.OTHER_CHART), + ("surface3DChart", PictureClassificationLabel.OTHER_CHART), + ], +) +def test_plot_without_a_python_pptx_class_keeps_its_data( + tmp_path: Path, plot: str, label: PictureClassificationLabel +): + """A plot that python-pptx has no class for is classified by its tag. + + Before, such a chart was "other_chart" with no data table, and a 3-D area + chart stopped the conversion of the whole presentation, because python-pptx + has a plot class but no series class for it. Now the chart keeps its type + and its data. + """ + prs, _ = _single_plot_deck(plot) + path = tmp_path / f"{plot}.pptx" + prs.save(path) + + picture = _only_chart(path) + + assert _labels(picture) == [label.value] + assert _grid(picture) == [["", "2026"], ["Q1", "10"], ["Q2", "20"], ["Q3", "30"]] + + +def test_malformed_series_drops_only_the_chart_data(tmp_path: Path, caplog): + """A series without ``c:order`` loses the chart's data, not the presentation. + + python-pptx raises ``InvalidXmlError`` when a required element is missing. + That error must not stop the conversion of the whole presentation. The + chart keeps its type without a data table, and a warning names the missing + element. + """ + from pptx.oxml.ns import qn + + prs, stock_plot = _single_plot_deck("stockChart") + series = stock_plot.find(qn("c:ser")) + series.remove(series.find(qn("c:order"))) + path = tmp_path / "stock_chart_without_order.pptx" + prs.save(path) + + with caplog.at_level( + logging.WARNING, logger="docling.backend.mspowerpoint_backend" + ): + picture = _only_chart(path) + + assert _labels(picture) == [PictureClassificationLabel.OTHER_CHART.value] + assert picture.meta is not None and picture.meta.tabular_chart is None + assert "c:order" in caplog.text def test_date_axis_categories_are_iso_dates(tmp_path: Path):