diff --git a/python/core/layout/qgslayoutguidecollection.sip b/python/core/layout/qgslayoutguidecollection.sip index 59fc346a4e1..0aedce04f2a 100644 --- a/python/core/layout/qgslayoutguidecollection.sip +++ b/python/core/layout/qgslayoutguidecollection.sip @@ -206,6 +206,11 @@ Resets all other pages' guides to match the guides from the specified ``sourcePa void update(); %Docstring Updates the position (and visibility) of all guide line items. +%End + + QList< QgsLayoutGuide * > guides(); +%Docstring +Returns a list of all guides contained in the collection. %End QList< QgsLayoutGuide * > guides( Qt::Orientation orientation, int page = -1 ); diff --git a/python/core/qgsmultirenderchecker.sip b/python/core/qgsmultirenderchecker.sip index 31fbae8781a..1d6b949b792 100644 --- a/python/core/qgsmultirenderchecker.sip +++ b/python/core/qgsmultirenderchecker.sip @@ -84,6 +84,16 @@ Default value is 0. :param colorTolerance: The maximum difference for each color component including alpha to be considered correct. +%End + + void setSizeTolerance( int xTolerance, int yTolerance ); +%Docstring +Sets the largest allowable difference in size between the rendered and the expected image. + +:param xTolerance: x tolerance in pixels +:param yTolerance: y tolerance in pixels + +.. versionadded:: 3.0 %End bool runTest( const QString &testName, unsigned int mismatchCount = 0 ); diff --git a/src/app/layout/qgslayoutdesignerdialog.cpp b/src/app/layout/qgslayoutdesignerdialog.cpp index 7ab0df3d46a..14029aab220 100644 --- a/src/app/layout/qgslayoutdesignerdialog.cpp +++ b/src/app/layout/qgslayoutdesignerdialog.cpp @@ -1529,11 +1529,12 @@ void QgsLayoutDesignerDialog::exportToRaster() if ( imageDlg.antialiasing() ) settings.flags |= QgsLayoutContext::FlagAntialiasing; + QFileInfo fi( fileNExt.first ); switch ( exporter.exportToImage( fileNExt.first, settings ) ) { case QgsLayoutExporter::Success: mMessageBar->pushMessage( tr( "Export layout" ), - tr( "Successfully exported layout to %2" ).arg( QUrl::fromLocalFile( fileNExt.first ).toString(), fileNExt.first ), + tr( "Successfully exported layout to %2" ).arg( QUrl::fromLocalFile( fi.path() ).toString(), fileNExt.first ), QgsMessageBar::INFO, 0 ); break; @@ -1630,13 +1631,14 @@ void QgsLayoutDesignerDialog::exportToPdf() // force a refresh, to e.g. update data defined properties, tables, etc mLayout->refresh(); + QFileInfo fi( outputFileName ); QgsLayoutExporter exporter( mLayout ); switch ( exporter.exportToPdf( outputFileName, pdfSettings ) ) { case QgsLayoutExporter::Success: { mMessageBar->pushMessage( tr( "Export layout" ), - tr( "Successfully exported layout to %2" ).arg( QUrl::fromLocalFile( outputFileName ).toString(), outputFileName ), + tr( "Successfully exported layout to %2" ).arg( QUrl::fromLocalFile( fi.path() ).toString(), outputFileName ), QgsMessageBar::INFO, 0 ); break; } diff --git a/src/core/layout/qgslayoutexporter.cpp b/src/core/layout/qgslayoutexporter.cpp index 7e198c458e9..79959bca5c3 100644 --- a/src/core/layout/qgslayoutexporter.cpp +++ b/src/core/layout/qgslayoutexporter.cpp @@ -20,6 +20,7 @@ #include "qgslayoutpagecollection.h" #include "qgsogrutils.h" #include "qgspaintenginehack.h" +#include "qgslayoutguidecollection.h" #include #include @@ -48,6 +49,34 @@ class LayoutContextPreviewSettingRestorer bool mPreviousSetting = false; }; +class LayoutGuideHider +{ + public: + + LayoutGuideHider( QgsLayout *layout ) + : mLayout( layout ) + { + const QList< QgsLayoutGuide * > guides = mLayout->guides().guides(); + for ( QgsLayoutGuide *guide : guides ) + { + mPrevVisibility.insert( guide, guide->item()->isVisible() ); + guide->item()->setVisible( false ); + } + } + + ~LayoutGuideHider() + { + for ( auto it = mPrevVisibility.constBegin(); it != mPrevVisibility.constEnd(); ++it ) + { + it.key()->item()->setVisible( it.value() ); + } + } + + private: + QgsLayout *mLayout = nullptr; + QHash< QgsLayoutGuide *, bool > mPrevVisibility; +}; + ///@endcond PRIVATE QgsLayoutExporter::QgsLayoutExporter( QgsLayout *layout ) @@ -150,18 +179,12 @@ void QgsLayoutExporter::renderRegion( QPainter *painter, const QRectF ®ion ) ( void )cacheRestorer; LayoutContextPreviewSettingRestorer restorer( mLayout ); ( void )restorer; - -#if 0 //TODO - setSnapLinesVisible( false ); -#endif + LayoutGuideHider guideHider( mLayout ); + ( void ) guideHider; painter->setRenderHint( QPainter::Antialiasing, mLayout->context().flags() & QgsLayoutContext::FlagAntialiasing ); mLayout->render( painter, QRectF( 0, 0, paintDevice->width(), paintDevice->height() ), region ); - -#if 0 // TODO - setSnapLinesVisible( true ); -#endif } QImage QgsLayoutExporter::renderRegionToImage( const QRectF ®ion, QSize imageSize, double dpi ) const diff --git a/src/core/layout/qgslayoutguidecollection.cpp b/src/core/layout/qgslayoutguidecollection.cpp index 366e2049855..48acb9a3bc1 100644 --- a/src/core/layout/qgslayoutguidecollection.cpp +++ b/src/core/layout/qgslayoutguidecollection.cpp @@ -89,7 +89,7 @@ void QgsLayoutGuide::update() } else { - mLineItem->setLine( 0, layoutPos, mPage->rect().width(), layoutPos ); + mLineItem->setLine( 0, layoutPos + mPage->y(), mPage->rect().width(), layoutPos + mPage->y() ); mLineItem->setVisible( showGuide ); } @@ -102,7 +102,7 @@ void QgsLayoutGuide::update() } else { - mLineItem->setLine( layoutPos, 0, layoutPos, mPage->rect().height() ); + mLineItem->setLine( layoutPos, mPage->y(), layoutPos, mPage->y() + mPage->rect().height() ); mLineItem->setVisible( showGuide ); } @@ -467,6 +467,11 @@ void QgsLayoutGuideCollection::update() } } +QList QgsLayoutGuideCollection::guides() +{ + return mGuides; +} + QList QgsLayoutGuideCollection::guides( Qt::Orientation orientation, int page ) { QList res; diff --git a/src/core/layout/qgslayoutguidecollection.h b/src/core/layout/qgslayoutguidecollection.h index 6ef71dea815..53c1bf7c9be 100644 --- a/src/core/layout/qgslayoutguidecollection.h +++ b/src/core/layout/qgslayoutguidecollection.h @@ -235,6 +235,11 @@ class CORE_EXPORT QgsLayoutGuideCollection : public QAbstractTableModel, public */ void update(); + /** + * Returns a list of all guides contained in the collection. + */ + QList< QgsLayoutGuide * > guides(); + /** * Returns the list of guides contained in the collection with the specified * \a orientation and on a matching \a page. diff --git a/src/core/layout/qgslayoutpagecollection.cpp b/src/core/layout/qgslayoutpagecollection.cpp index 9afb4410201..d069add1417 100644 --- a/src/core/layout/qgslayoutpagecollection.cpp +++ b/src/core/layout/qgslayoutpagecollection.cpp @@ -95,6 +95,7 @@ void QgsLayoutPageCollection::reflow() currentY += mLayout->convertToLayoutUnits( page->pageSize() ).height() + spaceBetweenPages(); p.setY( currentY ); } + mLayout->guides().update(); mLayout->updateBounds(); emit changed(); } @@ -193,7 +194,8 @@ int QgsLayoutPageCollection::predictPageNumberForPoint( QPointF point ) const QgsLayoutItemPage *QgsLayoutPageCollection::pageAtPoint( QPointF point ) const { - Q_FOREACH ( QGraphicsItem *item, mLayout->items( point ) ) + const QList< QGraphicsItem * > items = mLayout->items( point ); + for ( QGraphicsItem *item : items ) { if ( item->type() == QgsLayoutItemRegistry::LayoutPage ) { diff --git a/src/core/qgsmultirenderchecker.cpp b/src/core/qgsmultirenderchecker.cpp index 6116912f5e6..e5ee3db9dea 100644 --- a/src/core/qgsmultirenderchecker.cpp +++ b/src/core/qgsmultirenderchecker.cpp @@ -55,6 +55,7 @@ bool QgsMultiRenderChecker::runTest( const QString &testName, unsigned int misma QgsRenderChecker checker; checker.enableDashBuffering( true ); checker.setColorTolerance( mColorTolerance ); + checker.setSizeTolerance( mMaxSizeDifferenceX, mMaxSizeDifferenceY ); checker.setControlPathPrefix( mControlPathPrefix ); checker.setControlPathSuffix( suffix ); checker.setControlName( mControlName ); diff --git a/src/core/qgsmultirenderchecker.h b/src/core/qgsmultirenderchecker.h index d7f15304f25..7ff4ca7c805 100644 --- a/src/core/qgsmultirenderchecker.h +++ b/src/core/qgsmultirenderchecker.h @@ -94,6 +94,14 @@ class CORE_EXPORT QgsMultiRenderChecker */ void setColorTolerance( unsigned int colorTolerance ) { mColorTolerance = colorTolerance; } + /** + * Sets the largest allowable difference in size between the rendered and the expected image. + * \param xTolerance x tolerance in pixels + * \param yTolerance y tolerance in pixels + * \since QGIS 3.0 + */ + void setSizeTolerance( int xTolerance, int yTolerance ) { mMaxSizeDifferenceX = xTolerance; mMaxSizeDifferenceY = yTolerance; } + /** * Test using renderer to generate the image to be compared. * @@ -134,6 +142,8 @@ class CORE_EXPORT QgsMultiRenderChecker QString mControlName; QString mControlPathPrefix; unsigned int mColorTolerance = 0; + int mMaxSizeDifferenceX = 0; + int mMaxSizeDifferenceY = 0; QgsMapSettings mMapSettings; }; diff --git a/tests/src/python/test_qgslayoutexporter.py b/tests/src/python/test_qgslayoutexporter.py index 5f7063b7443..ead67e144d8 100644 --- a/tests/src/python/test_qgslayoutexporter.py +++ b/tests/src/python/test_qgslayoutexporter.py @@ -17,6 +17,7 @@ import sip import tempfile import shutil import os +import subprocess from qgis.core import (QgsMultiRenderChecker, QgsLayoutExporter, @@ -24,10 +25,13 @@ from qgis.core import (QgsMultiRenderChecker, QgsProject, QgsMargins, QgsLayoutItemShape, + QgsLayoutGuide, QgsRectangle, QgsLayoutItemPage, QgsLayoutItemMap, QgsLayoutPoint, + QgsLayoutMeasurement, + QgsUnitTypes, QgsSimpleFillSymbolLayer, QgsFillSymbol) from qgis.PyQt.QtCore import QSize, QSizeF, QDir, QRectF, Qt @@ -35,6 +39,58 @@ from qgis.PyQt.QtGui import QImage, QPainter from qgis.testing import start_app, unittest +from utilities import getExecutablePath + +# PDF-to-image utility +# look for Poppler w/ Cairo, then muPDF +# * Poppler w/ Cairo renders correctly +# * Poppler w/o Cairo does not always correctly render vectors in PDF to image +# * muPDF renders correctly, but sightly shifts colors +for util in [ + 'pdftocairo', + # 'mudraw', +]: + PDFUTIL = getExecutablePath(util) + if PDFUTIL: + break + +# noinspection PyUnboundLocalVariable +if not PDFUTIL: + raise Exception('PDF-to-image utility not found on PATH: ' + 'install Poppler (with Cairo)') + + +def pdfToPng(pdf_file_path, rendered_file_path, page, dpi=96): + if PDFUTIL.strip().endswith('pdftocairo'): + filebase = os.path.join( + os.path.dirname(rendered_file_path), + os.path.splitext(os.path.basename(rendered_file_path))[0] + ) + call = [ + PDFUTIL, '-png', '-singlefile', '-r', str(dpi), + '-x', '0', '-y', '0', '-f', str(page), '-l', str(page), + pdf_file_path, filebase + ] + elif PDFUTIL.strip().endswith('mudraw'): + call = [ + PDFUTIL, '-c', 'rgba', + '-r', str(dpi), '-f', str(page), '-l', str(page), + # '-b', '8', + '-o', rendered_file_path, pdf_file_path + ] + else: + return False, '' + + print("exportToPdf call: {0}".format(' '.join(call))) + try: + subprocess.check_call(call) + except subprocess.CalledProcessError as e: + assert False, ("exportToPdf failed!\n" + "cmd: {0}\n" + "returncode: {1}\n" + "message: {2}".format(e.cmd, e.returncode, e.message)) + + start_app() @@ -54,12 +110,13 @@ class TestQgsLayoutExporter(unittest.TestCase): with open(report_file_path, 'a') as report_file: report_file.write(self.report) - def checkImage(self, name, reference_image, rendered_image): + def checkImage(self, name, reference_image, rendered_image, size_tolerance=0): checker = QgsMultiRenderChecker() checker.setControlPathPrefix("layout_exporter") checker.setControlName("expected_layoutexporter_" + reference_image) checker.setRenderedImage(rendered_image) checker.setColorTolerance(2) + checker.setSizeTolerance(size_tolerance, size_tolerance) result = checker.runTest(name, 20) self.report += checker.report() print((self.report)) @@ -134,6 +191,10 @@ class TestQgsLayoutExporter(unittest.TestCase): l = QgsLayout(QgsProject.instance()) l.initializeDefaults() + # add a guide, to ensure it is not included in export + g1 = QgsLayoutGuide(Qt.Horizontal, QgsLayoutMeasurement(15, QgsUnitTypes.LayoutMillimeters), l.pageCollection().page(0)) + l.guides().addGuide(g1) + # add some items item1 = QgsLayoutItemShape(l) item1.attemptSetSceneRect(QRectF(10, 20, 100, 150)) @@ -278,6 +339,57 @@ class TestQgsLayoutExporter(unittest.TestCase): page2_path = os.path.join(self.basetestpath, 'test_exporttoimagesize_2.png') self.assertTrue(self.checkImage('exporttoimagesize_page2', 'exporttoimagesize_page2', page2_path)) + def testExportToPdf(self): + l = QgsLayout(QgsProject.instance()) + l.initializeDefaults() + + # add a second page + page2 = QgsLayoutItemPage(l) + page2.setPageSize('A5') + l.pageCollection().addPage(page2) + + # add some items + item1 = QgsLayoutItemShape(l) + item1.attemptSetSceneRect(QRectF(10, 20, 100, 150)) + fill = QgsSimpleFillSymbolLayer() + fill_symbol = QgsFillSymbol() + fill_symbol.changeSymbolLayer(0, fill) + fill.setColor(Qt.green) + fill.setStrokeStyle(Qt.NoPen) + item1.setSymbol(fill_symbol) + l.addItem(item1) + + item2 = QgsLayoutItemShape(l) + item2.attemptSetSceneRect(QRectF(10, 20, 100, 150)) + item2.attemptMove(QgsLayoutPoint(10, 20), page=1) + fill = QgsSimpleFillSymbolLayer() + fill_symbol = QgsFillSymbol() + fill_symbol.changeSymbolLayer(0, fill) + fill.setColor(Qt.cyan) + fill.setStrokeStyle(Qt.NoPen) + item2.setSymbol(fill_symbol) + l.addItem(item2) + + exporter = QgsLayoutExporter(l) + # setup settings + settings = QgsLayoutExporter.PdfExportSettings() + settings.dpi = 80 + settings.rasterizeWholeImage = False + settings.forceVectorOutput = False + + pdf_file_path = os.path.join(self.basetestpath, 'test_exporttopdfdpi.pdf') + self.assertEqual(exporter.exportToPdf(pdf_file_path, settings), QgsLayoutExporter.Success) + self.assertTrue(os.path.exists(pdf_file_path)) + + rendered_page_1 = os.path.join(self.basetestpath, 'test_exporttopdfdpi.png') + dpi = 80 + pdfToPng(pdf_file_path, rendered_page_1, dpi=dpi, page=1) + rendered_page_2 = os.path.join(self.basetestpath, 'test_exporttopdfdpi2.png') + pdfToPng(pdf_file_path, rendered_page_2, dpi=dpi, page=2) + + self.assertTrue(self.checkImage('exporttopdfdpi_page1', 'exporttopdfdpi_page1', rendered_page_1, size_tolerance=1)) + self.assertTrue(self.checkImage('exporttopdfdpi_page2', 'exporttopdfdpi_page2', rendered_page_2, size_tolerance=1)) + def testExportWorldFile(self): l = QgsLayout(QgsProject.instance()) l.initializeDefaults() diff --git a/tests/src/python/test_qgslayoutguides.py b/tests/src/python/test_qgslayoutguides.py index ba5527df660..49460006c32 100644 --- a/tests/src/python/test_qgslayoutguides.py +++ b/tests/src/python/test_qgslayoutguides.py @@ -65,6 +65,11 @@ class TestQgsLayoutGuide(unittest.TestCase): p = QgsProject() l = QgsLayout(p) l.initializeDefaults() # add a page + # add a second page + page2 = QgsLayoutItemPage(l) + page2.setPageSize('A5') + l.pageCollection().addPage(page2) + g = QgsLayoutGuide(Qt.Horizontal, QgsLayoutMeasurement(5, QgsUnitTypes.LayoutCentimeters), l.pageCollection().page(0)) g.setLayout(l) g.update() @@ -85,6 +90,19 @@ class TestQgsLayoutGuide(unittest.TestCase): self.assertEqual(g.item().line().y2(), 15) self.assertEqual(g.layoutPosition(), 15) + # guide on page2 + g1 = QgsLayoutGuide(Qt.Horizontal, QgsLayoutMeasurement(5, QgsUnitTypes.LayoutCentimeters), l.pageCollection().page(1)) + g1.setLayout(l) + g1.update() + g1.setPosition(QgsLayoutMeasurement(15, QgsUnitTypes.LayoutMillimeters)) + g1.update() + self.assertTrue(g1.item().isVisible()) + self.assertEqual(g1.item().line().x1(), 0) + self.assertEqual(g1.item().line().y1(), 235) + self.assertEqual(g1.item().line().x2(), 148) + self.assertEqual(g1.item().line().y2(), 235) + self.assertEqual(g1.layoutPosition(), 235) + # vertical guide g2 = QgsLayoutGuide(Qt.Vertical, QgsLayoutMeasurement(5, QgsUnitTypes.LayoutCentimeters), l.pageCollection().page(0)) g2.setLayout(l) @@ -109,6 +127,17 @@ class TestQgsLayoutGuide(unittest.TestCase): g.update() self.assertFalse(g.item().isVisible()) + # guide on page2 + g3 = QgsLayoutGuide(Qt.Vertical, QgsLayoutMeasurement(5, QgsUnitTypes.LayoutCentimeters), l.pageCollection().page(1)) + g3.setLayout(l) + g3.update() + self.assertTrue(g3.item().isVisible()) + self.assertEqual(g3.item().line().x1(), 50) + self.assertEqual(g3.item().line().y1(), 220) + self.assertEqual(g3.item().line().x2(), 50) + self.assertEqual(g3.item().line().y2(), 430) + self.assertEqual(g3.layoutPosition(), 50) + def testCollection(self): p = QgsProject() l = QgsLayout(p) diff --git a/tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page1/expected_layoutexporter_exporttopdfdpi_page1.png b/tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page1/expected_layoutexporter_exporttopdfdpi_page1.png new file mode 100644 index 00000000000..4ee4721dee6 Binary files /dev/null and b/tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page1/expected_layoutexporter_exporttopdfdpi_page1.png differ diff --git a/tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page2/expected_layoutexporter_exporttopdfdpi_page2.png b/tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page2/expected_layoutexporter_exporttopdfdpi_page2.png new file mode 100644 index 00000000000..37ead9bc4ab Binary files /dev/null and b/tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page2/expected_layoutexporter_exporttopdfdpi_page2.png differ