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