From 5e836dfec517894e0b41d08d1234ca734366cb2b Mon Sep 17 00:00:00 2001 From: Devansh Varshney Date: Sat, 18 Apr 2026 22:18:15 +0530 Subject: [PATCH] oox: support histogram OOXML import and export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve histogram charts when importng and exporting XLSX file. Import histogram charts from XLSX, keep their source data and settings, and export them back that keeps them working as histogram charts. Change-Id: I9c3e39fd3b429b9f61c249dd952bf70d9113ed7f (cherry picked from commit 330fa62f1ef7b7c848c2da98d18a8bb453e82201) Signed-off-by: Devansh Varshney Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/1882 Tested-by: Jenkins CPCI Reviewed-by: Tomaž Vajngerl (cherry picked from commit 2ed0256704eb3ca34ff0a217b330849a3f7952da) Reviewed-on: https://gerrit.libreoffice.org/c/core/+/203895 Reviewed-by: Xisco Fauli Tested-by: Jenkins --- chart2/qa/extras/chart2import2.cxx | 58 +++++ .../qa/extras/data/xlsx/SimpleHistogram.xlsx | Bin 0 -> 12936 bytes oox/inc/drawingml/chart/typegroupmodel.hxx | 2 + .../drawingml/chart/plotareaconverter.cxx | 47 +++- oox/source/drawingml/chart/seriescontext.cxx | 6 +- .../drawingml/chart/typegroupconverter.cxx | 76 ++++++- oox/source/export/chartexport.cxx | 215 +++++++++++++++++- 7 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 chart2/qa/extras/data/xlsx/SimpleHistogram.xlsx diff --git a/chart2/qa/extras/chart2import2.cxx b/chart2/qa/extras/chart2import2.cxx index 156ec13df02c..6a11a41b9782 100644 --- a/chart2/qa/extras/chart2import2.cxx +++ b/chart2/qa/extras/chart2import2.cxx @@ -1022,6 +1022,64 @@ CPPUNIT_TEST_FIXTURE(Chart2ImportTest2, testHistogramODSRoundtrip) CPPUNIT_ASSERT_EQUAL(3.5, fBinWidth); } +CPPUNIT_TEST_FIXTURE(Chart2ImportTest2, testHistogramXLSXRoundtrip) +{ + // See tdf#163727 + // OOXML chartex represents histograms as + // with a child. + // On import that should be detected as a HistogramChartType and on export the binning + // markup should stay preserved. + + loadFromFile(u"xlsx/SimpleHistogram.xlsx"); + + // Round trip 1: chart type identity + binning element preserved. + saveAndReload(TestFilter::XLSX); + + xmlDocUniquePtr pXmlDoc = parseExport(u"xl/charts/chartEx1.xml"_ustr); + CPPUNIT_ASSERT(pXmlDoc); + + assertXPath(pXmlDoc, "/cx:chartSpace/cx:chart/cx:plotArea/cx:plotAreaRegion/cx:series", + "layoutId", u"clusteredColumn"); + assertXPath(pXmlDoc, + "/cx:chartSpace/cx:chart/cx:plotArea/cx:plotAreaRegion/cx:series/cx:layoutPr/" + "cx:binning", + "intervalClosed", u"r"); + + uno::Reference xChartDoc = getChartDocFromSheet(0); + CPPUNIT_ASSERT(xChartDoc.is()); + + Reference xChartType = getChartTypeFromDoc(xChartDoc, 0, 0); + CPPUNIT_ASSERT(xChartType.is()); + CPPUNIT_ASSERT_EQUAL(u"com.sun.star.chart2.HistogramChartType"_ustr, + xChartType->getChartType()); + + // Round trip 2: non-default binning parameters survive save + reload. + Reference xProperties(xChartType, uno::UNO_QUERY_THROW); + xProperties->setPropertyValue(u"FrequencyType"_ustr, uno::Any(sal_Int32(1))); + xProperties->setPropertyValue(u"BinWidth"_ustr, uno::Any(2.5)); + + saveAndReload(TestFilter::XLSX); + + pXmlDoc = parseExport(u"xl/charts/chartEx1.xml"_ustr); + CPPUNIT_ASSERT(pXmlDoc); + assertXPath(pXmlDoc, + "/cx:chartSpace/cx:chart/cx:plotArea/cx:plotAreaRegion/cx:series/cx:layoutPr/" + "cx:binning/cx:binSize", + "val", u"2.5"); + + xChartDoc = getChartDocFromSheet(0); + xChartType = getChartTypeFromDoc(xChartDoc, 0, 0); + Reference xReloadedProperties(xChartType, uno::UNO_QUERY_THROW); + + sal_Int32 nFrequencyType = -1; + CPPUNIT_ASSERT(xReloadedProperties->getPropertyValue(u"FrequencyType"_ustr) >>= nFrequencyType); + CPPUNIT_ASSERT_EQUAL(sal_Int32(1), nFrequencyType); + + double fBinWidth = 0.0; + CPPUNIT_ASSERT(xReloadedProperties->getPropertyValue(u"BinWidth"_ustr) >>= fBinWidth); + CPPUNIT_ASSERT_EQUAL(2.5, fBinWidth); +} + CPPUNIT_TEST_FIXTURE(Chart2ImportTest2, testTdf60316) { loadFromFile(u"pptx/tdf60316.pptx"); diff --git a/chart2/qa/extras/data/xlsx/SimpleHistogram.xlsx b/chart2/qa/extras/data/xlsx/SimpleHistogram.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5fb7e33b7a4ca70649f88c3e4e587527a87ad6ce GIT binary patch literal 12936 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 lclGetOrCreateAxis( const AxesSetModel::AxisMap& rFromAxes return xAxis; } +bool lclHasTypeGroupWithLayout(const AxesSetModel::TypeGroupVector& rTypeGroups, sal_Int32 nToken) +{ + for (auto const& typeGroup : rTypeGroups) + if (typeGroup && typeGroup->mnTypeId == nToken) + return true; + return false; +} + void AxesSetConverter::convertFromModel( const Reference< XDiagram >& rxDiagram, View3DModel& rView3DModel, sal_Int32 nAxesSetIdx, DataSourceCxModel::DataMap& raSourceMap, bool bSupportsVaryColorsByPoint, bool bUseFixedInnerSize) { + // chartex clusteredColumn + is how a histogram is stored in OOXML. + // But the same markup also appears on the clusteredColumn sub-chart of a + // paretoLine, which must not be reclassified. The difference is visible only at + // this level, where we see all sibling type groups. + { + const bool bHasParetoSibling + = lclHasTypeGroupWithLayout(mrModel.maTypeGroups, CX_TOKEN(paretoLine)); + if (!bHasParetoSibling) + { + for (auto const& typeGroup : mrModel.maTypeGroups) + { + if (!typeGroup || typeGroup->mnTypeId != CX_TOKEN(clusteredColumn)) + continue; + for (auto const& rxSeries : typeGroup->maSeries) + { + if (rxSeries && rxSeries->mxLayoutPr.is() + && rxSeries->mxLayoutPr->mxBinning.is()) + { + typeGroup->mbForceHistogram = true; + break; + } + } + } + } + } + // create type group converter objects for all type groups typedef RefVector< TypeGroupConverter > TypeGroupConvVector; TypeGroupConvVector aTypeGroups; @@ -172,18 +206,17 @@ void AxesSetConverter::convertFromModel( const Reference< XDiagram >& rxDiagram, to the data provider attached to the chart document. */ if( xCoordSystem.is() ) { + // Transfer any (chartex) data, specified at the chartSpace level, + // into the appropriate series. Must happen before convertFromModel + // so the series data is available when data series are created. + for (auto const& typeGroup : aTypeGroups) + typeGroup->moveDataToSeries(raSourceMap); + // convert all chart type groups, this converts all series data and formatting for (auto const& typeGroup : aTypeGroups) typeGroup->convertFromModel( rxDiagram, xCoordSystem, nAxesSetIdx,bSupportsVaryColorsByPoint ); - // Transfer any (chartex) data, specified at the chartSpace level, - // into the appropriate series. This needs to happen before the - // calls to AxisConverter::convertFromModel() below. - for (auto const& typeGroup : aTypeGroups) { - typeGroup->moveDataToSeries(raSourceMap); - } - bool bMSO2007Doc = getFilter().isMSO2007Document(); // convert all axes (create missing axis models) ModelRef< AxisModel > xXAxis = lclGetOrCreateAxis( mrModel.maAxes, API_X_AXIS, rFirstTypeGroup.getTypeInfo().mbCategoryAxis ? C_TOKEN( catAx ) : C_TOKEN( valAx ), bMSO2007Doc ); diff --git a/oox/source/drawingml/chart/seriescontext.cxx b/oox/source/drawingml/chart/seriescontext.cxx index 065d3205d2c7..9a33be007266 100644 --- a/oox/source/drawingml/chart/seriescontext.cxx +++ b/oox/source/drawingml/chart/seriescontext.cxx @@ -794,7 +794,7 @@ ContextHandlerRef BinningContext::onCreateContext( sal_Int32 nElement, const Att { switch( getCurrentElement() ) { - case CX_TOKEN( layoutPr ): + case CX_TOKEN(binning): switch( nElement ) { case CX_TOKEN( binSize ): @@ -807,6 +807,7 @@ ContextHandlerRef BinningContext::onCreateContext( sal_Int32 nElement, const Att // TODO return nullptr; } + break; } return ContextBase::onCreateContext( nElement, rAttribs ); } @@ -854,9 +855,8 @@ ContextHandlerRef LayoutPropsContext::onCreateContext( sal_Int32 nElement, const rB.meIntervalClosed = BinningModel::ClosedSide::L; } else if (rAttribs.getString( XML_intervalClosed ) == "r") { rB.meIntervalClosed = BinningModel::ClosedSide::R; - } else { - assert(false); // no other options } + // intervalClosed is optional in OOXML chartex // TODO: Also handle attributes underflow, overflow return new BinningContext( *this, rB ); } diff --git a/oox/source/drawingml/chart/typegroupconverter.cxx b/oox/source/drawingml/chart/typegroupconverter.cxx index 3e731c0091ce..afb77caa5d4f 100644 --- a/oox/source/drawingml/chart/typegroupconverter.cxx +++ b/oox/source/drawingml/chart/typegroupconverter.cxx @@ -19,6 +19,10 @@ #include +#include +#include +#include +#include #include #include #include @@ -90,7 +94,7 @@ const TypeGroupInfo spTypeInfos[] = // type-id type-category service varied-point-color default label pos polar area2d 1stvis xcateg swap stack picopt { TYPEID_BAR, TYPECATEGORY_BAR, SERVICE_CHART2_COLUMN, VARPOINTMODE_SINGLE, csscd::OUTSIDE, false, true, false, true, false, true, true }, { TYPEID_HORBAR, TYPECATEGORY_BAR, SERVICE_CHART2_COLUMN, VARPOINTMODE_SINGLE, csscd::OUTSIDE, false, true, false, true, true, true, true }, - { TYPEID_HISTO, TYPECATEGORY_HISTO, SERVICE_CHART2_HISTO, VARPOINTMODE_SINGLE, csscd::OUTSIDE, false, true, false, true, true, true, true }, + { TYPEID_HISTO, TYPECATEGORY_HISTO, SERVICE_CHART2_HISTO, VARPOINTMODE_SINGLE, csscd::OUTSIDE, false, true, false, true, false, true, true }, { TYPEID_LINE, TYPECATEGORY_LINE, SERVICE_CHART2_LINE, VARPOINTMODE_SINGLE, csscd::RIGHT, false, false, false, true, false, true, false }, { TYPEID_AREA, TYPECATEGORY_LINE, SERVICE_CHART2_AREA, VARPOINTMODE_NONE, csscd::CENTER, false, true, false, true, false, true, false }, { TYPEID_STOCK, TYPECATEGORY_LINE, SERVICE_CHART2_CANDLE, VARPOINTMODE_NONE, csscd::RIGHT, false, false, false, true, false, true, false }, @@ -217,6 +221,15 @@ TypeGroupConverter::TypeGroupConverter( const ConverterRoot& rParent, TypeGroupM default:; } + // In OOXML chartex, histogram is represented as clusteredColumn with + // . The decision whether this + // is a histogram or the clusteredColumn half of a paretoLine compound chart + // is made by the plot-area converter, which has sibling type-group visibility. + if (eTypeId == TYPEID_CLUSTEREDCOLUMN && mrModel.mbForceHistogram) + { + eTypeId = TYPEID_HISTO; + } + // set the chart type info struct for the current chart type maTypeInfo = lclGetTypeInfoFromTypeId( eTypeId ); } @@ -367,6 +380,50 @@ void TypeGroupConverter::convertFromModel( const Reference< XDiagram >& rxDiagra // additional properties PropertySet aDiaProp( rxDiagram ); PropertySet aTypeProp( xChartType ); + + if (maTypeInfo.meTypeId == TYPEID_HISTO) + { + Reference xTypePropSet(xChartType, UNO_QUERY); + if (xTypePropSet.is()) + { + for (const auto& rxSeriesModel : mrModel.maSeries) + { + if (!rxSeriesModel || !rxSeriesModel->mxLayoutPr.is() + || !rxSeriesModel->mxLayoutPr->mxBinning.is()) + { + continue; + } + + const BinningModel& rBinning = *rxSeriesModel->mxLayoutPr->mxBinning; + + if (rBinning.maBinSizeOrCount) + { + if (std::holds_alternative(*rBinning.maBinSizeOrCount)) + { + xTypePropSet->setPropertyValue( + u"BinWidth"_ustr, + uno::Any(std::get(*rBinning.maBinSizeOrCount))); + xTypePropSet->setPropertyValue( + u"FrequencyType"_ustr, + uno::Any(sal_Int32(1))); + } + else + { + xTypePropSet->setPropertyValue( + u"BinCount"_ustr, + uno::Any(static_cast( + std::get(*rBinning.maBinSizeOrCount)))); + xTypePropSet->setPropertyValue( + u"FrequencyType"_ustr, + uno::Any(sal_Int32(2))); + } + } + + break; + } + } + } + switch( maTypeInfo.meTypeCategory ) { case TYPECATEGORY_BAR: @@ -511,6 +568,23 @@ void TypeGroupConverter::convertFromModel( const Reference< XDiagram >& rxDiagra xChartTypeCont->addChartType(xChartType); } + if (maTypeInfo.meTypeId == TYPEID_HISTO) + { + Reference const& xChartDoc = getChartDocument(); + Reference xChartTypeManager( + xChartDoc.is() ? xChartDoc->getChartTypeManager() : nullptr); + Reference xTemplateFactory( + xChartTypeManager, UNO_QUERY); + if (xTemplateFactory.is()) + { + Reference xTemplate( + xTemplateFactory->createInstance(u"com.sun.star.chart2.template.Histogram"_ustr), + UNO_QUERY); + if (xTemplate.is()) + xTemplate->changeDiagram(rxDiagram); + } + } + // set existence of bar connector lines at diagram (only in stacked 2D bar charts) if( mrModel.mxSerLines.is() && !mb3dChart && (maTypeInfo.meTypeCategory == TYPECATEGORY_BAR) && (isStacked() || isPercent()) ) aDiaProp.setProperty( PROP_ConnectBars, true ); diff --git a/oox/source/export/chartexport.cxx b/oox/source/export/chartexport.cxx index f1f2c5cf2c1c..0b83ede1a475 100644 --- a/oox/source/export/chartexport.cxx +++ b/oox/source/export/chartexport.cxx @@ -31,6 +31,8 @@ #include #include +#include +#include #include #include @@ -230,6 +232,133 @@ std::vector > > splitDataSeriesByAxis(co return aSplitSeries; } +bool lclGetAutoHistogramBinning(const css::uno::Reference& xSeries, + double& rfBinWidth, sal_Int32& rnBinCount) +{ + rfBinWidth = 0.0; + rnBinCount = 0; + + css::uno::Reference xSource(xSeries, css::uno::UNO_QUERY); + if (!xSource.is()) + { + return false; + } + + css::uno::Sequence> aSeqs + = xSource->getDataSequences(); + + css::uno::Reference xValuesY; + for (const auto& xLabeledSeq : aSeqs) + { + if (!xLabeledSeq.is()) + { + continue; + } + + css::uno::Reference xValues = xLabeledSeq->getValues(); + if (!xValues.is()) + { + continue; + } + + css::uno::Reference xSeqProp(xValues, css::uno::UNO_QUERY); + if (!xSeqProp.is()) + { + continue; + } + + OUString aRole; + xSeqProp->getPropertyValue(u"Role"_ustr) >>= aRole; + if (aRole == "values-y") + { + xValuesY = xValues; + break; + } + } + + if (!xValuesY.is()) + { + return false; + } + + std::vector aRawData; + css::uno::Sequence aValues = xValuesY->getData(); + aRawData.reserve(aValues.getLength()); + + for (const auto& rAny : aValues) + { + double fValue = 0.0; + if (rAny >>= fValue) + aRawData.push_back(fValue); + } + + if (aRawData.empty()) + { + return false; + } + + double fSum = 0.0; + double fSquareSum = 0.0; + double fMinValue = aRawData[0]; + double fMaxValue = aRawData[0]; + sal_Int32 nValidCount = 0; + + for (double fValue : aRawData) + { + if (std::isfinite(fValue)) + { + fSum += fValue; + fSquareSum += fValue * fValue; + fMinValue = std::min(fMinValue, fValue); + fMaxValue = std::max(fMaxValue, fValue); + ++nValidCount; + } + } + + if (nValidCount == 0) + { + return false; + } + + if (nValidCount < 2 || fMinValue == fMaxValue) + { + rfBinWidth = 1.0; + rnBinCount = 1; + return true; + } + + const double fMean = fSum / nValidCount; + const double fVariance = (fSquareSum - fSum * fMean) / (nValidCount - 1); + const double fStdDev = std::sqrt(fVariance); + + if (!std::isfinite(fStdDev) || fStdDev <= 0.0) + { + rfBinWidth = 1.0; + rnBinCount = 1; + return true; + } + + rfBinWidth = (3.5 * fStdDev) / std::cbrt(static_cast(nValidCount)); + if (!std::isfinite(rfBinWidth) || rfBinWidth <= 0.0) + { + rfBinWidth = 1.0; + rnBinCount = 1; + return true; + } + + rfBinWidth = std::round(rfBinWidth * 100.0) / 100.0; + if (rfBinWidth <= 0.0) + { + rfBinWidth = 1.0; + rnBinCount = 1; + return true; + } + rnBinCount = static_cast(std::ceil((fMaxValue - fMinValue) / rfBinWidth)); + rnBinCount = std::max(rnBinCount, 1); + + return true; +} + } // unnamed namespace static Reference< chart2::data::XLabeledDataSequence > lcl_getCategories( @@ -552,6 +681,8 @@ constexpr auto constChartTypeMap = frozen::make_unordered_map& break; } case chart::TYPEID_CLUSTEREDCOLUMN: + case chart::TYPEID_HISTO: { exportChartex( xChartType, "clusteredColumn" ); break; @@ -3996,9 +4130,12 @@ void ChartExport::exportSeries_chartex( const Reference& xCh exportShapeProps( xOldPropSet, XML_cx ); } - DataLabelsRange aDLblsRange; - // export data labels - exportDataLabels(rSeries, nSeriesLength, eChartType, aDLblsRange, true); + if (aChartType != "com.sun.star.chart2.HistogramChartType") + { + DataLabelsRange aDLblsRange; + // export data labels + exportDataLabels(rSeries, nSeriesLength, eChartType, aDLblsRange, true); + } // dataId links to the correct data set in the . See // DATA_ID_COMMENT @@ -4041,8 +4178,27 @@ void ChartExport::exportSeries_chartex( const Reference& xCh bool bHasGeography = false; xSeriesProp->getPropertyValue(u"HasGeography"_ustr) >>= bHasGeography; + sal_Int32 nFrequencyType = 0; + double fBinWidth = 0.0; + sal_Int32 nBinCount = 0; + bool bHasBinWidth = false; + bool bHasBinCount = false; + if (aChartType == "com.sun.star.chart2.HistogramChartType") + { + Reference xChartTypePropSet(xChartType, uno::UNO_QUERY); + if (xChartTypePropSet.is()) + { + xChartTypePropSet->getPropertyValue(u"FrequencyType"_ustr) >>= nFrequencyType; + bHasBinWidth = + (xChartTypePropSet->getPropertyValue(u"BinWidth"_ustr) >>= fBinWidth); + bHasBinCount = + (xChartTypePropSet->getPropertyValue(u"BinCount"_ustr) >>= nBinCount); + } + } + bool bHasAny = bHasParentLL || bHasRegionLL || bHasVisibility - || bHasQM || bHasSubtotals || bHasIC || bHasGeography; + || bHasQM || bHasSubtotals || bHasIC || bHasGeography + || aChartType == "com.sun.star.chart2.HistogramChartType"; if (bHasAny) { pFS->startElement(FSNS(XML_cx, XML_layoutPr)); @@ -4078,8 +4234,54 @@ void ChartExport::exportSeries_chartex( const Reference& xCh if (bHasGeography) oox::drawingml::chart::exportGeography(xSeriesProp, pFS); - if (bHasIC) + if (aChartType == "com.sun.star.chart2.HistogramChartType") { + // histogram wraps bin-size/bin-count children + const char* pIntervalClosed = nullptr; + if (bHasIC) + { + sal_uInt32 nIntervalClosedChar = '\0'; + aIntervalClosed >>= nIntervalClosedChar; + if (nIntervalClosedChar == 'l') + pIntervalClosed = "l"; + else if (nIntervalClosedChar == 'r') + pIntervalClosed = "r"; + } + + if (pIntervalClosed) + pFS->startElement(FSNS(XML_cx, XML_binning), + XML_intervalClosed, pIntervalClosed); + else + pFS->startElement(FSNS(XML_cx, XML_binning)); + + if (nFrequencyType == 1 && bHasBinWidth) + { + pFS->singleElement(FSNS(XML_cx, XML_binSize), XML_val, + OString::number(fBinWidth)); + } + else if (nFrequencyType == 2 && bHasBinCount) + { + pFS->singleElement(FSNS(XML_cx, XML_binCount), XML_val, + OString::number(nBinCount)); + } + else if (nFrequencyType == 0) + { + double fAutoBinWidth = 0.0; + sal_Int32 nAutoBinCount = 0; + + if (lclGetAutoHistogramBinning(rSeries, fAutoBinWidth, nAutoBinCount) + && fAutoBinWidth > 0.0) + { + pFS->singleElement(FSNS(XML_cx, XML_binSize), XML_val, + OString::number(fAutoBinWidth)); + } + } + + pFS->endElement(FSNS(XML_cx, XML_binning)); + } + else if (bHasIC) + { + // non-histogram (BoxWhisker): self-closing sal_uInt32 nIntervalClosedChar = '\0'; aIntervalClosed >>= nIntervalClosedChar; @@ -6448,6 +6650,7 @@ bool ChartExport::isChartexNotChartNS(NamespaceAbbrev *peNS) const break; case chart::TYPEID_BOXWHISKER: case chart::TYPEID_CLUSTEREDCOLUMN: + case chart::TYPEID_HISTO: case chart::TYPEID_PARETOLINE: case chart::TYPEID_SUNBURST: case chart::TYPEID_TREEMAP: