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 000000000000..5fb7e33b7a4c Binary files /dev/null and b/chart2/qa/extras/data/xlsx/SimpleHistogram.xlsx differ diff --git a/oox/inc/drawingml/chart/typegroupmodel.hxx b/oox/inc/drawingml/chart/typegroupmodel.hxx index 5a37797e1b25..5184908883d4 100644 --- a/oox/inc/drawingml/chart/typegroupmodel.hxx +++ b/oox/inc/drawingml/chart/typegroupmodel.hxx @@ -74,6 +74,8 @@ struct TypeGroupModel bool mbSmooth; /// True = smooth lines in line charts. bool mbVaryColors; /// True = different automatic colors for each point. bool mbWireframe; /// True = wireframe surface chart, false = filled surface chart. + /// True = this type group should be imported as histogram type - TYPEID_HISTO + bool mbForceHistogram = false; explicit TypeGroupModel( sal_Int32 nTypeId, bool bMSO2007Doc ); ~TypeGroupModel(); diff --git a/oox/source/drawingml/chart/plotareaconverter.cxx b/oox/source/drawingml/chart/plotareaconverter.cxx index c154f4a1330c..f5dc8c175c5b 100644 --- a/oox/source/drawingml/chart/plotareaconverter.cxx +++ b/oox/source/drawingml/chart/plotareaconverter.cxx @@ -112,11 +112,45 @@ ModelRef< AxisModel > 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: