From cbc8570ae7c909c7793b41a114ab2903d1e60f08 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Mon, 18 Dec 2017 21:04:37 +1000 Subject: [PATCH 01/12] Fix unbalanced painter save/restore for map items --- src/core/layout/qgslayoutitemmap.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/layout/qgslayoutitemmap.cpp b/src/core/layout/qgslayoutitemmap.cpp index 895d636925f..f2dd99a4f7c 100644 --- a/src/core/layout/qgslayoutitemmap.cpp +++ b/src/core/layout/qgslayoutitemmap.cpp @@ -886,6 +886,7 @@ void QgsLayoutItemMap::paint( QPainter *painter, const QStyleOptionGraphicsItem painter->scale( 1 / dotsPerMM, 1 / dotsPerMM ); // scale painter from mm to dots painter->drawImage( std::round( -tl.x()* dotsPerMM ), std::round( -tl.y() * dotsPerMM ), image ); painter->scale( dotsPerMM, dotsPerMM ); + painter->restore(); } else { @@ -895,6 +896,7 @@ void QgsLayoutItemMap::paint( QPainter *painter, const QStyleOptionGraphicsItem drawMapBackground( painter ); } + painter->save(); painter->setClipRect( thisPaintRect ); painter->save(); painter->translate( mXOffset, mYOffset ); @@ -917,14 +919,13 @@ void QgsLayoutItemMap::paint( QPainter *painter, const QStyleOptionGraphicsItem mGridStack->drawItems( painter ); } drawAnnotations( painter ); - + painter->restore(); } if ( shouldDrawPart( Frame ) ) { drawMapFrame( painter ); } - painter->restore(); mDrawing = false; } } From 613b1584d16e0ec824770ac290e15d76c568179f Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Mon, 18 Dec 2017 21:11:43 +1000 Subject: [PATCH 02/12] Start restoring SVG export --- python/core/layout/qgslayoutexporter.sip | 49 ++++++ src/app/layout/qgslayoutdesignerdialog.cpp | 176 +++++++++++++++++++++ src/app/layout/qgslayoutdesignerdialog.h | 3 + src/core/layout/qgslayoutexporter.cpp | 98 ++++++++++++ src/core/layout/qgslayoutexporter.h | 48 ++++++ src/ui/composer/qgssvgexportoptions.ui | 13 +- 6 files changed, 386 insertions(+), 1 deletion(-) diff --git a/python/core/layout/qgslayoutexporter.sip b/python/core/layout/qgslayoutexporter.sip index 0e9b9e86bb2..9021744a7c1 100644 --- a/python/core/layout/qgslayoutexporter.sip +++ b/python/core/layout/qgslayoutexporter.sip @@ -236,6 +236,55 @@ Layout context flags, which control how the export will be created. %Docstring Exports the layout as a PDF to the a ``filePath``, using the specified export ``settings``. +Returns a result code indicating whether the export was successful or an +error was encountered. +%End + + + struct SvgExportSettings + { + SvgExportSettings(); +%Docstring +Constructor for SvgExportSettings +%End + + double dpi; +%Docstring +Resolution to export layout at. If dpi <= 0 the default layout dpi will be used. +%End + + bool forceVectorOutput; +%Docstring +Set to true to force vector object exports, even when the resultant appearance will differ +from the layout. If false, some items may be rasterized in order to maintain their +correct appearance in the output. + +This option is mutually exclusive with rasterizeWholeImage. +%End + + bool cropToContents; +%Docstring +Set to true if image should be cropped so only parts of the layout +containing items are exported. +%End + + QgsMargins cropMargins; +%Docstring +Crop to content margins, in layout units. These margins will be added +to the bounds of the exported layout if cropToContents is true. +%End + + QgsLayoutContext::Flags flags; +%Docstring +Layout context flags, which control how the export will be created. +%End + + }; + + ExportResult exportToSvg( const QString &filePath, const QgsLayoutExporter::SvgExportSettings &settings ); +%Docstring +Exports the layout as an SVG to the a ``filePath``, using the specified export ``settings``. + Returns a result code indicating whether the export was successful or an error was encountered. %End diff --git a/src/app/layout/qgslayoutdesignerdialog.cpp b/src/app/layout/qgslayoutdesignerdialog.cpp index 48f4eca9c35..ebf9859ff66 100644 --- a/src/app/layout/qgslayoutdesignerdialog.cpp +++ b/src/app/layout/qgslayoutdesignerdialog.cpp @@ -53,6 +53,7 @@ #include "qgsbusyindicatordialog.h" #include "qgslayoutundostack.h" #include "qgslayoutpagecollection.h" +#include "ui_qgssvgexportoptions.h" #include #include #include @@ -182,6 +183,7 @@ QgsLayoutDesignerDialog::QgsLayoutDesignerDialog( QWidget *parent, Qt::WindowFla connect( mActionExportAsImage, &QAction::triggered, this, &QgsLayoutDesignerDialog::exportToRaster ); connect( mActionExportAsPDF, &QAction::triggered, this, &QgsLayoutDesignerDialog::exportToPdf ); + connect( mActionExportAsSVG, &QAction::triggered, this, &QgsLayoutDesignerDialog::exportToSvg ); connect( mActionShowGrid, &QAction::triggered, this, &QgsLayoutDesignerDialog::showGrid ); connect( mActionSnapGrid, &QAction::triggered, this, &QgsLayoutDesignerDialog::snapToGrid ); @@ -1671,6 +1673,152 @@ void QgsLayoutDesignerDialog::exportToPdf() QApplication::restoreOverrideCursor(); } +void QgsLayoutDesignerDialog::exportToSvg() +{ + if ( containsWmsLayers() ) + { + showWmsPrintingWarning(); + } + + showSvgExportWarning(); + + QgsSettings settings; + QString lastUsedFile = settings.value( QStringLiteral( "UI/lastSaveAsSvgFile" ), QStringLiteral( "qgis.svg" ) ).toString(); + QFileInfo file( lastUsedFile ); + QString outputFileName; + +#if 0// TODO + if ( hasAnAtlas && !atlasOnASingleFile && + ( mode == QgsComposer::Atlas || mComposition->atlasMode() == QgsComposition::PreviewAtlas ) ) + { + outputFileName = QDir( file.path() ).filePath( atlasMap->currentFilename() ) + ".pdf"; + } + else + { +#endif + outputFileName = file.path(); +#if 0 //TODO + } +#endif + +#ifdef Q_OS_MAC + QgisApp::instance()->activateWindow(); + this->raise(); +#endif + outputFileName = QFileDialog::getSaveFileName( + this, + tr( "Export to SVG" ), + outputFileName, + tr( "SVG Format" ) + " (*.svg *.SVG)" ); + this->activateWindow(); + if ( outputFileName.isEmpty() ) + { + return; + } + + if ( !outputFileName.endsWith( QLatin1String( ".svg" ), Qt::CaseInsensitive ) ) + { + outputFileName += QLatin1String( ".svg" ); + } + + settings.setValue( QStringLiteral( "UI/lastSaveAsSvgFile" ), outputFileName ); + + bool groupLayers = false; + bool prevSettingLabelsAsOutlines = mLayout->project()->readBoolEntry( QStringLiteral( "PAL" ), QStringLiteral( "/DrawOutlineLabels" ), true ); + bool clipToContent = false; + double marginTop = 0.0; + double marginRight = 0.0; + double marginBottom = 0.0; + double marginLeft = 0.0; + bool previousForceVector = mLayout->customProperty( QStringLiteral( "forceVector" ), false ).toBool(); + + // open options dialog + QDialog dialog; + Ui::QgsSvgExportOptionsDialog options; + options.setupUi( &dialog ); + options.chkTextAsOutline->setChecked( prevSettingLabelsAsOutlines ); + options.chkMapLayersAsGroup->setChecked( mLayout->customProperty( QStringLiteral( "svgGroupLayers" ), false ).toBool() ); + options.mClipToContentGroupBox->setChecked( mLayout->customProperty( QStringLiteral( "svgCropToContents" ), false ).toBool() ); + options.mForceVectorCheckBox->setChecked( previousForceVector ); + options.mTopMarginSpinBox->setValue( mLayout->customProperty( QStringLiteral( "svgCropMarginTop" ), 0 ).toInt() ); + options.mRightMarginSpinBox->setValue( mLayout->customProperty( QStringLiteral( "svgCropMarginRight" ), 0 ).toInt() ); + options.mBottomMarginSpinBox->setValue( mLayout->customProperty( QStringLiteral( "svgCropMarginBottom" ), 0 ).toInt() ); + options.mLeftMarginSpinBox->setValue( mLayout->customProperty( QStringLiteral( "svgCropMarginLeft" ), 0 ).toInt() ); + + if ( dialog.exec() != QDialog::Accepted ) + return; + + groupLayers = options.chkMapLayersAsGroup->isChecked(); + clipToContent = options.mClipToContentGroupBox->isChecked(); + marginTop = options.mTopMarginSpinBox->value(); + marginRight = options.mRightMarginSpinBox->value(); + marginBottom = options.mBottomMarginSpinBox->value(); + marginLeft = options.mLeftMarginSpinBox->value(); + + //save dialog settings + mLayout->setCustomProperty( QStringLiteral( "svgGroupLayers" ), groupLayers ); + mLayout->setCustomProperty( QStringLiteral( "svgCropToContents" ), clipToContent ); + mLayout->setCustomProperty( QStringLiteral( "svgCropMarginTop" ), marginTop ); + mLayout->setCustomProperty( QStringLiteral( "svgCropMarginRight" ), marginRight ); + mLayout->setCustomProperty( QStringLiteral( "svgCropMarginBottom" ), marginBottom ); + mLayout->setCustomProperty( QStringLiteral( "svgCropMarginLeft" ), marginLeft ); + + //temporarily override label draw outlines setting + mLayout->project()->writeEntry( QStringLiteral( "PAL" ), QStringLiteral( "/DrawOutlineLabels" ), options.chkTextAsOutline->isChecked() ); + + mView->setPaintingEnabled( false ); + QApplication::setOverrideCursor( Qt::BusyCursor ); + + QgsLayoutExporter::SvgExportSettings svgSettings; + svgSettings.forceVectorOutput = mLayout->customProperty( QStringLiteral( "forceVector" ), false ).toBool(); + svgSettings.cropToContents = clipToContent; + svgSettings.cropMargins = QgsMargins( marginLeft, marginTop, marginRight, marginBottom ); + svgSettings.forceVectorOutput = options.mForceVectorCheckBox->isChecked(); + + // force a refresh, to e.g. update data defined properties, tables, etc + mLayout->refresh(); + + QFileInfo fi( outputFileName ); + QgsLayoutExporter exporter( mLayout ); + switch ( exporter.exportToSvg( outputFileName, svgSettings ) ) + { + case QgsLayoutExporter::Success: + { + mMessageBar->pushMessage( tr( "Export layout" ), + tr( "Successfully exported layout to %2" ).arg( QUrl::fromLocalFile( fi.path() ).toString(), outputFileName ), + QgsMessageBar::INFO, 0 ); + break; + } + + case QgsLayoutExporter::FileError: + QMessageBox::warning( this, tr( "Export to SVG" ), + tr( "Cannot write to %1.\n\nThis file may be open in another application." ).arg( outputFileName ), + QMessageBox::Ok, + QMessageBox::Ok ); + break; + + case QgsLayoutExporter::PrintError: + QMessageBox::warning( this, tr( "Export to SVG" ), + tr( "Could not create print device." ), + QMessageBox::Ok, + QMessageBox::Ok ); + break; + + + case QgsLayoutExporter::MemoryError: + QMessageBox::warning( this, tr( "Memory Allocation Error" ), + tr( "Exporting the SVG " + "resulted in a memory overflow.\n\n" + "Please try a lower resolution or a smaller paper size." ), + QMessageBox::Ok, QMessageBox::Ok ); + break; + } + + mView->setPaintingEnabled( true ); + mLayout->project()->writeEntry( QStringLiteral( "PAL" ), QStringLiteral( "/DrawOutlineLabels" ), prevSettingLabelsAsOutlines ); + QApplication::restoreOverrideCursor(); +} + void QgsLayoutDesignerDialog::paste() { QPointF pt = mView->mapFromGlobal( QCursor::pos() ); @@ -1816,6 +1964,34 @@ void QgsLayoutDesignerDialog::showWmsPrintingWarning() } } +void QgsLayoutDesignerDialog::showSvgExportWarning() +{ + QgsSettings settings; + + bool displaySVGWarning = settings.value( QStringLiteral( "/UI/displaySVGWarning" ), true ).toBool(); + + if ( displaySVGWarning ) + { + QgsMessageViewer m( this ); + m.setWindowTitle( tr( "Export as SVG" ) ); + m.setCheckBoxText( tr( "Don't show this message again" ) ); + m.setCheckBoxState( Qt::Unchecked ); + m.setCheckBoxVisible( true ); + m.setCheckBoxQgsSettingsLabel( QStringLiteral( "/UI/displaySVGWarning" ) ); + m.setMessageAsHtml( tr( "

The SVG export function in QGIS has several " + "problems due to bugs and deficiencies in the " ) + + tr( "underlying Qt SVG library. In particular, there are problems " + "with layers not being clipped to the map " + "bounding box.

" ) + + tr( "If you require a vector-based output file from " + "QGIS it is suggested that you try exporting " + "to PDF if the SVG output is not " + "satisfactory." + "

" ) ); + m.exec(); + } +} + bool QgsLayoutDesignerDialog::requiresRasterization() const { QList< QgsLayoutItem *> items; diff --git a/src/app/layout/qgslayoutdesignerdialog.h b/src/app/layout/qgslayoutdesignerdialog.h index 92b835b976a..ff95beaaa43 100644 --- a/src/app/layout/qgslayoutdesignerdialog.h +++ b/src/app/layout/qgslayoutdesignerdialog.h @@ -284,6 +284,7 @@ class QgsLayoutDesignerDialog: public QMainWindow, private Ui::QgsLayoutDesigner void deleteLayout(); void exportToRaster(); void exportToPdf(); + void exportToSvg(); private: @@ -379,6 +380,8 @@ class QgsLayoutDesignerDialog: public QMainWindow, private Ui::QgsLayoutDesigner //! Displays a warning because of possible min/max size in WMS void showWmsPrintingWarning(); + void showSvgExportWarning(); + //! True if the layout contains advanced effects, such as blend modes bool requiresRasterization() const; diff --git a/src/core/layout/qgslayoutexporter.cpp b/src/core/layout/qgslayoutexporter.cpp index 79959bca5c3..b0b869468d6 100644 --- a/src/core/layout/qgslayoutexporter.cpp +++ b/src/core/layout/qgslayoutexporter.cpp @@ -23,6 +23,7 @@ #include "qgslayoutguidecollection.h" #include #include +#include #include "gdal.h" #include "cpl_conv.h" @@ -401,6 +402,103 @@ QgsLayoutExporter::ExportResult QgsLayoutExporter::exportToPdf( const QString &f return result; } +QgsLayoutExporter::ExportResult QgsLayoutExporter::exportToSvg( const QString &filePath, const QgsLayoutExporter::SvgExportSettings &s ) +{ + if ( !mLayout ) + return PrintError; + + SvgExportSettings settings = s; + if ( settings.dpi <= 0 ) + settings.dpi = mLayout->context().dpi(); + + mErrorFileName.clear(); + + LayoutContextPreviewSettingRestorer restorer( mLayout ); + ( void )restorer; + LayoutContextSettingsRestorer contextRestorer( mLayout ); + ( void )contextRestorer; + mLayout->context().setDpi( settings.dpi ); + + mLayout->context().setFlag( QgsLayoutContext::FlagForceVectorOutput, settings.forceVectorOutput ); + + QFileInfo fi( filePath ); + PageExportDetails pageDetails; + pageDetails.directory = fi.path(); + pageDetails.baseName = fi.baseName(); + pageDetails.extension = fi.completeSuffix(); + + double inchesToLayoutUnits = mLayout->convertToLayoutUnits( QgsLayoutMeasurement( 1, QgsUnitTypes::LayoutInches ) ); + + for ( int i = 0; i < mLayout->pageCollection()->pageCount(); ++i ) + { + if ( !mLayout->pageCollection()->shouldExportPage( i ) ) + { + continue; + } + + pageDetails.page = i; + QString fileName = generateFileName( pageDetails ); + + QSvgGenerator generator; + generator.setTitle( mLayout->project()->title() ); + generator.setFileName( fileName ); + + QRectF bounds; + if ( settings.cropToContents ) + { + if ( mLayout->pageCollection()->pageCount() == 1 ) + { + // single page, so include everything + bounds = mLayout->layoutBounds( true ); + } + else + { + // multi page, so just clip to items on current page + bounds = mLayout->pageItemBounds( i, true ); + } + bounds = bounds.adjusted( -settings.cropMargins.left(), + -settings.cropMargins.top(), + settings.cropMargins.right(), + settings.cropMargins.bottom() ); + } + else + { + QgsLayoutItemPage *pageItem = mLayout->pageCollection()->page( i ); + bounds = QRectF( pageItem->pos().x(), pageItem->pos().y(), pageItem->rect().width(), pageItem->rect().height() ); + } + + //width in pixel + int width = ( int )( bounds.width() * settings.dpi / inchesToLayoutUnits ); + //height in pixel + int height = ( int )( bounds.height() * settings.dpi / inchesToLayoutUnits ); + if ( width == 0 || height == 0 ) + { + //invalid size, skip this page + continue; + } + generator.setSize( QSize( width, height ) ); + generator.setViewBox( QRect( 0, 0, width, height ) ); + generator.setResolution( settings.dpi ); + + QPainter p; + bool createOk = p.begin( &generator ); + if ( !createOk ) + { + mErrorFileName = fileName; + return FileError; + } + + if ( settings.cropToContents ) + renderRegion( &p, bounds ); + else + renderPage( &p, i ); + + p.end(); + } + + return Success; +} + void QgsLayoutExporter::preparePrintAsPdf( QPrinter &printer, const QString &filePath ) { printer.setOutputFileName( filePath ); diff --git a/src/core/layout/qgslayoutexporter.h b/src/core/layout/qgslayoutexporter.h index bd3f49b1500..5e315efc029 100644 --- a/src/core/layout/qgslayoutexporter.h +++ b/src/core/layout/qgslayoutexporter.h @@ -247,6 +247,54 @@ class CORE_EXPORT QgsLayoutExporter */ ExportResult exportToPdf( const QString &filePath, const QgsLayoutExporter::PdfExportSettings &settings ); + + //! Contains settings relating to exporting layouts to SVG + struct SvgExportSettings + { + //! Constructor for SvgExportSettings + SvgExportSettings() + : flags( QgsLayoutContext::FlagAntialiasing | QgsLayoutContext::FlagUseAdvancedEffects ) + {} + + //! Resolution to export layout at. If dpi <= 0 the default layout dpi will be used. + double dpi = -1; + + /** + * Set to true to force vector object exports, even when the resultant appearance will differ + * from the layout. If false, some items may be rasterized in order to maintain their + * correct appearance in the output. + * + * This option is mutually exclusive with rasterizeWholeImage. + */ + bool forceVectorOutput = false; + + /** + * Set to true if image should be cropped so only parts of the layout + * containing items are exported. + */ + bool cropToContents = false; + + /** + * Crop to content margins, in layout units. These margins will be added + * to the bounds of the exported layout if cropToContents is true. + */ + QgsMargins cropMargins; + + /** + * Layout context flags, which control how the export will be created. + */ + QgsLayoutContext::Flags flags = 0; + + }; + + /** + * Exports the layout as an SVG to the a \a filePath, using the specified export \a settings. + * + * Returns a result code indicating whether the export was successful or an + * error was encountered. + */ + ExportResult exportToSvg( const QString &filePath, const QgsLayoutExporter::SvgExportSettings &settings ); + /** * Returns the file name corresponding to the last error encountered during * an export. diff --git a/src/ui/composer/qgssvgexportoptions.ui b/src/ui/composer/qgssvgexportoptions.ui index 75f8821ea1e..1d27864fa5b 100644 --- a/src/ui/composer/qgssvgexportoptions.ui +++ b/src/ui/composer/qgssvgexportoptions.ui @@ -7,7 +7,7 @@ 0 0 489 - 282 + 319 @@ -46,6 +46,16 @@ + + + + If checked, the layout will always be kept as vector objects when exported to a compatible format, even if the appearance of the resultant file does not match the layouts settings. If unchecked, some elements in the layout may be rasterized in order to keep their appearance intact. + + + Always export as vectors + + + @@ -196,6 +206,7 @@ chkMapLayersAsGroup chkTextAsOutline mClipToContentGroupBox + mForceVectorCheckBox mTopMarginSpinBox mLeftMarginSpinBox mRightMarginSpinBox From 0a02ed4312d141c6bbe453c3897cb59c773723ee Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 09:40:23 +1000 Subject: [PATCH 03/12] Add tooltip for legend auto update checkbox (fix #13576) --- src/ui/layout/qgslayoutlegendwidgetbase.ui | 35 ++++++++++++---------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/ui/layout/qgslayoutlegendwidgetbase.ui b/src/ui/layout/qgslayoutlegendwidgetbase.ui index a4c097a0f6d..ed1413d68e3 100644 --- a/src/ui/layout/qgslayoutlegendwidgetbase.ui +++ b/src/ui/layout/qgslayoutlegendwidgetbase.ui @@ -183,6 +183,9 @@ + + Keeps the legend contents synchronized with the main application legend. Customisation is not possible and must be done in the main application. + Auto update @@ -1046,22 +1049,10 @@ - - QgsScrollArea - QScrollArea -
qgsscrollarea.h
- 1 -
- - QgsColorButton - QToolButton -
qgscolorbutton.h
- 1 -
QgsCollapsibleGroupBoxBasic QGroupBox -
qgscollapsiblegroupbox.h
+
qgscollapsiblegroupbox.h
1
@@ -1070,9 +1061,10 @@
qgsdoublespinbox.h
- QgsFontButton - QToolButton -
qgsfontbutton.h
+ QgsScrollArea + QScrollArea +
qgsscrollarea.h
+ 1
QgsSpinBox @@ -1084,6 +1076,17 @@ QComboBox
qgslayoutitemcombobox.h
+ + QgsColorButton + QToolButton +
qgscolorbutton.h
+ 1 +
+ + QgsFontButton + QToolButton +
qgsfontbutton.h
+
QgsPropertyOverrideButton QToolButton From c7bd7b3d67038e377c1a79a9fbbca3a28830e515 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 10:59:27 +1000 Subject: [PATCH 04/12] Add method to ensure file name string is safe --- python/core/qgsfileutils.sip | 8 ++++++++ src/core/qgsfileutils.cpp | 8 ++++++++ src/core/qgsfileutils.h | 8 ++++++++ tests/src/python/test_qgsfileutils.py | 3 +++ 4 files changed, 27 insertions(+) diff --git a/python/core/qgsfileutils.sip b/python/core/qgsfileutils.sip index 54ae1eb8291..1c04fec9aa1 100644 --- a/python/core/qgsfileutils.sip +++ b/python/core/qgsfileutils.sip @@ -65,6 +65,14 @@ will be returned unchanged. .. seealso:: :py:func:`extensionsFromFilter()` .. seealso:: :py:func:`ensureFileNameHasExtension()` +%End + + static QString stringToSafeFilename( const QString &string ); +%Docstring +Converts a ``string`` to a safe filename, replacing characters which are not safe +for filenames with an '_' character. + +This method should be called with file names only, not complete paths. %End }; diff --git a/src/core/qgsfileutils.cpp b/src/core/qgsfileutils.cpp index d1dd350d40f..2dc2efeef9e 100644 --- a/src/core/qgsfileutils.cpp +++ b/src/core/qgsfileutils.cpp @@ -69,3 +69,11 @@ QString QgsFileUtils::addExtensionFromFilter( const QString &fileName, const QSt const QStringList extensions = extensionsFromFilter( filter ); return ensureFileNameHasExtension( fileName, extensions ); } + +QString QgsFileUtils::stringToSafeFilename( const QString &string ) +{ + QRegularExpression rx( "[^\\w\\-. ]" ); + QString s = string; + s.replace( rx, QStringLiteral( "_" ) ); + return s; +} diff --git a/src/core/qgsfileutils.h b/src/core/qgsfileutils.h index 1a3455cad4a..a028e79811b 100644 --- a/src/core/qgsfileutils.h +++ b/src/core/qgsfileutils.h @@ -70,6 +70,14 @@ class CORE_EXPORT QgsFileUtils * \see ensureFileNameHasExtension() */ static QString addExtensionFromFilter( const QString &fileName, const QString &filter ); + + /** + * Converts a \a string to a safe filename, replacing characters which are not safe + * for filenames with an '_' character. + * + * This method should be called with file names only, not complete paths. + */ + static QString stringToSafeFilename( const QString &string ); }; #endif // QGSFILEUTILS_H diff --git a/tests/src/python/test_qgsfileutils.py b/tests/src/python/test_qgsfileutils.py index b2d7fdb8499..501b33e5569 100644 --- a/tests/src/python/test_qgsfileutils.py +++ b/tests/src/python/test_qgsfileutils.py @@ -55,6 +55,9 @@ class TestQgsFileUtils(unittest.TestCase): self.assertEqual(QgsFileUtils.addExtensionFromFilter('test.tif', 'All Files (*.*)'), 'test.tif') self.assertEqual(QgsFileUtils.addExtensionFromFilter('test', 'All Files (*.*)'), 'test') + def testStringToSafeFilename(self): + self.assertEqual(QgsFileUtils.stringToSafeFilename('my FiLe v2.0_new.tif'), 'my FiLe v2.0_new.tif') + self.assertEqual(QgsFileUtils.stringToSafeFilename('rendered map_final? rev (12-03-1017)_real@#$&*#%&*$!!@$%^&(*(.tif'), 'rendered map_final_ rev _12-03-1017__real____________________.tif') if __name__ == '__main__': unittest.main() From b25c48917f50f101554e69f2489a4123443f40fd Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 11:02:25 +1000 Subject: [PATCH 05/12] [layouts] Suggest export file names based on layout name (fix #9476) --- src/app/layout/qgslayoutdesignerdialog.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/layout/qgslayoutdesignerdialog.cpp b/src/app/layout/qgslayoutdesignerdialog.cpp index ebf9859ff66..c9fab79e6de 100644 --- a/src/app/layout/qgslayoutdesignerdialog.cpp +++ b/src/app/layout/qgslayoutdesignerdialog.cpp @@ -19,6 +19,7 @@ #include "qgslayoutitemregistry.h" #include "qgssettings.h" #include "qgisapp.h" +#include "qgsfileutils.h" #include "qgslogger.h" #include "qgslayout.h" #include "qgslayoutappmenuprovider.h" @@ -1476,7 +1477,7 @@ void QgsLayoutDesignerDialog::exportToRaster() QgsAtlasComposition *atlasMap = &mComposition->atlasComposition(); #endif - QString outputFileName; + QString outputFileName = QgsFileUtils::stringToSafeFilename( mLayout->name() ); #if 0 //TODO if ( atlasMap->enabled() && mComposition->atlasMode() == QgsComposition::PreviewAtlas ) { @@ -1596,7 +1597,7 @@ void QgsLayoutDesignerDialog::exportToPdf() else { #endif - outputFileName = file.path(); + outputFileName = file.path() + '/' + QgsFileUtils::stringToSafeFilename( mLayout->name() ) + QStringLiteral( ".pdf" ); #if 0 //TODO } #endif @@ -1685,7 +1686,7 @@ void QgsLayoutDesignerDialog::exportToSvg() QgsSettings settings; QString lastUsedFile = settings.value( QStringLiteral( "UI/lastSaveAsSvgFile" ), QStringLiteral( "qgis.svg" ) ).toString(); QFileInfo file( lastUsedFile ); - QString outputFileName; + QString outputFileName = QgsFileUtils::stringToSafeFilename( mLayout->name() ); #if 0// TODO if ( hasAnAtlas && !atlasOnASingleFile && @@ -1696,7 +1697,7 @@ void QgsLayoutDesignerDialog::exportToSvg() else { #endif - outputFileName = file.path(); + outputFileName = file.path() + '/' + QgsFileUtils::stringToSafeFilename( mLayout->name() ) + QStringLiteral( ".svg" ); #if 0 //TODO } #endif From 664025709ee4c8132dd08e865a317735475445f6 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 11:06:57 +1000 Subject: [PATCH 06/12] [layouts] Allow double-clicking to add layer to legend Fixes #14410 --- src/app/layout/qgslayoutlegendlayersdialog.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/layout/qgslayoutlegendlayersdialog.cpp b/src/app/layout/qgslayoutlegendlayersdialog.cpp index 8652760010a..5c6a6c131fe 100644 --- a/src/app/layout/qgslayoutlegendlayersdialog.cpp +++ b/src/app/layout/qgslayoutlegendlayersdialog.cpp @@ -30,6 +30,8 @@ QgsLayoutLegendLayersDialog::QgsLayoutLegendLayersDialog( QWidget *parent ) listMapLayers->setModel( mModel ); QModelIndex firstLayer = mModel->index( 0, 0 ); listMapLayers->selectionModel()->select( firstLayer, QItemSelectionModel::Select ); + + connect( listMapLayers, &QListView::doubleClicked, this, &QgsLayoutLegendLayersDialog::accept ); } QgsLayoutLegendLayersDialog::~QgsLayoutLegendLayersDialog() From d06e12743fc40f0d826682f0e898e033a75a180c Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 11:28:19 +1000 Subject: [PATCH 07/12] Add unit test for svg export --- tests/src/python/test_qgslayoutexporter.py | 71 ++++++++++++++++++ ...youtexporter_exporttopdfdpi_page1_mask.png | Bin 0 -> 3956 bytes ...youtexporter_exporttopdfdpi_page2_mask.png | Bin 0 -> 2682 bytes 3 files changed, 71 insertions(+) create mode 100644 tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page1/expected_layoutexporter_exporttopdfdpi_page1_mask.png create mode 100644 tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page2/expected_layoutexporter_exporttopdfdpi_page2_mask.png diff --git a/tests/src/python/test_qgslayoutexporter.py b/tests/src/python/test_qgslayoutexporter.py index ead67e144d8..a91a6316da3 100644 --- a/tests/src/python/test_qgslayoutexporter.py +++ b/tests/src/python/test_qgslayoutexporter.py @@ -36,6 +36,7 @@ from qgis.core import (QgsMultiRenderChecker, QgsFillSymbol) from qgis.PyQt.QtCore import QSize, QSizeF, QDir, QRectF, Qt from qgis.PyQt.QtGui import QImage, QPainter +from qgis.PyQt.QtSvg import QSvgRenderer, QSvgGenerator from qgis.testing import start_app, unittest @@ -91,6 +92,24 @@ def pdfToPng(pdf_file_path, rendered_file_path, page, dpi=96): "message: {2}".format(e.cmd, e.returncode, e.message)) +def svgToPng(svg_file_path, rendered_file_path, width): + svgr = QSvgRenderer(svg_file_path) + + height = width / svgr.viewBoxF().width() * svgr.viewBoxF().height() + + image = QImage(width, height, QImage.Format_ARGB32) + image.fill(Qt.transparent) + + p = QPainter(image) + p.setRenderHint(QPainter.Antialiasing, False) + svgr.render(p) + p.end() + + res = image.save(rendered_file_path, 'png') + if not res: + os.unlink(rendered_file_path) + + start_app() @@ -390,6 +409,58 @@ class TestQgsLayoutExporter(unittest.TestCase): 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 testExportToSvg(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.SvgExportSettings() + settings.dpi = 80 + settings.forceVectorOutput = False + + svg_file_path = os.path.join(self.basetestpath, 'test_exporttosvgdpi.svg') + svg_file_path_2 = os.path.join(self.basetestpath, 'test_exporttosvgdpi_2.svg') + self.assertEqual(exporter.exportToSvg(svg_file_path, settings), QgsLayoutExporter.Success) + self.assertTrue(os.path.exists(svg_file_path)) + self.assertTrue(os.path.exists(svg_file_path_2)) + + rendered_page_1 = os.path.join(self.basetestpath, 'test_exporttosvgdpi.png') + dpi = 80 + svgToPng(svg_file_path, rendered_page_1, width=936) + rendered_page_2 = os.path.join(self.basetestpath, 'test_exporttosvgdpi2.png') + svgToPng(svg_file_path_2, rendered_page_2, width=467) + + self.assertTrue(self.checkImage('exporttosvgdpi_page1', 'exporttopdfdpi_page1', rendered_page_1, size_tolerance=1)) + self.assertTrue(self.checkImage('exporttosvgdpi_page2', 'exporttopdfdpi_page2', rendered_page_2, size_tolerance=1)) + def testExportWorldFile(self): l = QgsLayout(QgsProject.instance()) l.initializeDefaults() diff --git a/tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page1/expected_layoutexporter_exporttopdfdpi_page1_mask.png b/tests/testdata/control_images/layout_exporter/expected_layoutexporter_exporttopdfdpi_page1/expected_layoutexporter_exporttopdfdpi_page1_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..2134c41e4da95b30fea55174caaa8d3e763a934b GIT binary patch literal 3956 zcmeAS@N?(olHy`uVBq!ia0y~yU|!C^z%-SE4JcxKb8Zrl;wfU^%#Vx`BY=qvdpg_?h5uoUtQDF+hBt~j^_Sz!32cpLduc$LH)Wk_Gzg{}^GsF5F z?|0iVGCcUa=PMHf!vtYqv@$$7q6s7|3xUz?;5TPfcr;8#6U1n47%d7$i^tKrVYFf# zZ550*iRjc!+-YiTEghfz?c6iQxWakQZO#YJXJq)0nF)%313la0fSJ+GVKg@A6dR*W eAR0A+cG|yS;m_3PR(S|a&iF%40&rLquf9JGGGP#D0 z;e&qtYIcSL`ocjB94#UYOp2@x4jzmO0>h4bW1Y>u`|pqEZI8BOOxX4Ov*o?_-(N}k zY%*E0{Bq`<_ur=qGc4PEH}7%L&NcH6s(3s9s@i+(@y7^|;=>O&R51zvR35HDGyEcZ z)JMZRGmpmaX#5W2x@w@3H?WSZvzveXYgH_$+I#$Q2e8`9Jti^#sxrfa@XA}G_1~~+ c88zu2E0Jz#FTXSu*qCJSboFyt=akR{04-74`~Uy| literal 0 HcmV?d00001 From 2007792f776fdff21620c931aff31884f7c9997a Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 12:06:29 +1000 Subject: [PATCH 08/12] Restore layered svg export option --- python/core/layout/qgslayoutexporter.sip | 8 + src/app/layout/qgslayoutdesignerdialog.cpp | 14 ++ src/core/layout/qgslayoutexporter.cpp | 193 +++++++++++++++++++-- src/core/layout/qgslayoutexporter.h | 14 +- tests/src/python/test_qgslayoutexporter.py | 18 +- 5 files changed, 227 insertions(+), 20 deletions(-) diff --git a/python/core/layout/qgslayoutexporter.sip b/python/core/layout/qgslayoutexporter.sip index 9021744a7c1..a6c9d04793c 100644 --- a/python/core/layout/qgslayoutexporter.sip +++ b/python/core/layout/qgslayoutexporter.sip @@ -123,6 +123,7 @@ Returns the rendered image, or a null QImage if the image does not fit into avai MemoryError, FileError, PrintError, + SvgLayerError, }; struct ImageExportSettings @@ -272,6 +273,13 @@ containing items are exported. %Docstring Crop to content margins, in layout units. These margins will be added to the bounds of the exported layout if cropToContents is true. +%End + + bool exportAsLayers; +%Docstring +Set to true to export as a layered SVG file. +Note that this option is considered experimental, and the generated +SVG may differ from the expected appearance of the layout. %End QgsLayoutContext::Flags flags; diff --git a/src/app/layout/qgslayoutdesignerdialog.cpp b/src/app/layout/qgslayoutdesignerdialog.cpp index c9fab79e6de..f51137a9656 100644 --- a/src/app/layout/qgslayoutdesignerdialog.cpp +++ b/src/app/layout/qgslayoutdesignerdialog.cpp @@ -1542,6 +1542,8 @@ void QgsLayoutDesignerDialog::exportToRaster() break; case QgsLayoutExporter::PrintError: + case QgsLayoutExporter::SvgLayerError: + // no meaning for raster exports, will not be encountered break; case QgsLayoutExporter::FileError: @@ -1668,6 +1670,10 @@ void QgsLayoutDesignerDialog::exportToPdf() "Please try a lower resolution or a smaller paper size." ), QMessageBox::Ok, QMessageBox::Ok ); break; + + case QgsLayoutExporter::SvgLayerError: + // no meaning for PDF exports, will not be encountered + break; } mView->setPaintingEnabled( true ); @@ -1775,6 +1781,7 @@ void QgsLayoutDesignerDialog::exportToSvg() svgSettings.cropToContents = clipToContent; svgSettings.cropMargins = QgsMargins( marginLeft, marginTop, marginRight, marginBottom ); svgSettings.forceVectorOutput = options.mForceVectorCheckBox->isChecked(); + svgSettings.exportAsLayers = groupLayers; // force a refresh, to e.g. update data defined properties, tables, etc mLayout->refresh(); @@ -1798,6 +1805,13 @@ void QgsLayoutDesignerDialog::exportToSvg() QMessageBox::Ok ); break; + case QgsLayoutExporter::SvgLayerError: + QMessageBox::warning( this, tr( "Export to SVG" ), + tr( "Cannot create layered SVG file %1." ).arg( outputFileName ), + QMessageBox::Ok, + QMessageBox::Ok ); + break; + case QgsLayoutExporter::PrintError: QMessageBox::warning( this, tr( "Export to SVG" ), tr( "Could not create print device." ), diff --git a/src/core/layout/qgslayoutexporter.cpp b/src/core/layout/qgslayoutexporter.cpp index b0b869468d6..7244c38b1a1 100644 --- a/src/core/layout/qgslayoutexporter.cpp +++ b/src/core/layout/qgslayoutexporter.cpp @@ -78,6 +78,39 @@ class LayoutGuideHider QHash< QgsLayoutGuide *, bool > mPrevVisibility; }; +class LayoutItemHider +{ + public: + explicit LayoutItemHider( const QList &items ) + { + for ( QGraphicsItem *item : items ) + { + mPrevVisibility[item] = item->isVisible(); + item->hide(); + } + } + + void hideAll() + { + for ( auto it = mPrevVisibility.constBegin(); it != mPrevVisibility.constEnd(); ++it ) + { + it.key()->hide(); + } + } + + ~LayoutItemHider() + { + for ( auto it = mPrevVisibility.constBegin(); it != mPrevVisibility.constEnd(); ++it ) + { + it.key()->setVisible( it.value() ); + } + } + + private: + + QHash mPrevVisibility; +}; + ///@endcond PRIVATE QgsLayoutExporter::QgsLayoutExporter( QgsLayout *layout ) @@ -240,6 +273,7 @@ class LayoutContextSettingsRestorer : mLayout( layout ) , mPreviousDpi( layout->context().dpi() ) , mPreviousFlags( layout->context().flags() ) + , mPreviousExportLayer( layout->context().currentExportLayer() ) { } @@ -247,12 +281,14 @@ class LayoutContextSettingsRestorer { mLayout->context().setDpi( mPreviousDpi ); mLayout->context().setFlags( mPreviousFlags ); + mLayout->context().setCurrentExportLayer( mPreviousExportLayer ); } private: QgsLayout *mLayout = nullptr; double mPreviousDpi = 0; QgsLayoutContext::Flags mPreviousFlags = 0; + int mPreviousExportLayer = 0; }; ///@endcond PRIVATE @@ -439,10 +475,7 @@ QgsLayoutExporter::ExportResult QgsLayoutExporter::exportToSvg( const QString &f pageDetails.page = i; QString fileName = generateFileName( pageDetails ); - QSvgGenerator generator; - generator.setTitle( mLayout->project()->title() ); - generator.setFileName( fileName ); - + QgsLayoutItemPage *pageItem = mLayout->pageCollection()->page( i ); QRectF bounds; if ( settings.cropToContents ) { @@ -463,7 +496,6 @@ QgsLayoutExporter::ExportResult QgsLayoutExporter::exportToSvg( const QString &f } else { - QgsLayoutItemPage *pageItem = mLayout->pageCollection()->page( i ); bounds = QRectF( pageItem->pos().x(), pageItem->pos().y(), pageItem->rect().width(), pageItem->rect().height() ); } @@ -476,24 +508,98 @@ QgsLayoutExporter::ExportResult QgsLayoutExporter::exportToSvg( const QString &f //invalid size, skip this page continue; } - generator.setSize( QSize( width, height ) ); - generator.setViewBox( QRect( 0, 0, width, height ) ); - generator.setResolution( settings.dpi ); - QPainter p; - bool createOk = p.begin( &generator ); - if ( !createOk ) + if ( settings.exportAsLayers ) { - mErrorFileName = fileName; - return FileError; + const QRectF paperRect = QRectF( pageItem->pos().x(), + pageItem->pos().y(), + pageItem->rect().width(), + pageItem->rect().height() ); + QDomDocument svg; + QDomNode svgDocRoot; + const QList items = mLayout->items( paperRect, + Qt::IntersectsItemBoundingRect, + Qt::AscendingOrder ); + + LayoutItemHider itemHider( items ); + ( void )itemHider; + + int layoutItemLayerIdx = 0; + auto it = items.constBegin(); + for ( unsigned svgLayerId = 1; it != items.constEnd(); ++svgLayerId ) + { + itemHider.hideAll(); + QgsLayoutItem *layoutItem = dynamic_cast( *it ); + QString layerName = QObject::tr( "Layer %1" ).arg( svgLayerId ); + if ( layoutItem && layoutItem->numberExportLayers() > 0 ) + { + layoutItem->show(); + mLayout->context().setCurrentExportLayer( layoutItemLayerIdx ); + ++layoutItemLayerIdx; + } + else + { + // show all items until the next item that renders on a separate layer + for ( ; it != items.constEnd(); ++it ) + { + layoutItem = dynamic_cast( *it ); + if ( layoutItem && layoutItem->numberExportLayers() > 0 ) + { + break; + } + else + { + ( *it )->show(); + } + } + } + + ExportResult result = renderToLayeredSvg( settings, width, height, i, bounds, fileName, svgLayerId, layerName, svg, svgDocRoot ); + if ( result != Success ) + return result; + + if ( layoutItem && layoutItem->numberExportLayers() > 0 && layoutItem->numberExportLayers() == layoutItemLayerIdx ) // restore and pass to next item + { + mLayout->context().setCurrentExportLayer( -1 ); + layoutItemLayerIdx = 0; + ++it; + } + } + + QFile out( fileName ); + bool openOk = out.open( QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate ); + if ( !openOk ) + { + mErrorFileName = fileName; + return FileError; + } + + out.write( svg.toByteArray() ); } - - if ( settings.cropToContents ) - renderRegion( &p, bounds ); else - renderPage( &p, i ); + { + QSvgGenerator generator; + generator.setTitle( mLayout->project()->title() ); + generator.setFileName( fileName ); + generator.setSize( QSize( width, height ) ); + generator.setViewBox( QRect( 0, 0, width, height ) ); + generator.setResolution( settings.dpi ); - p.end(); + QPainter p; + bool createOk = p.begin( &generator ); + if ( !createOk ) + { + mErrorFileName = fileName; + return FileError; + } + + if ( settings.cropToContents ) + renderRegion( &p, bounds ); + else + renderPage( &p, i ); + + p.end(); + } } return Success; @@ -622,6 +728,57 @@ void QgsLayoutExporter::updatePrinterPageSize( QPrinter &printer, int page ) printer.setPaperSize( pageSizeMM.toQSizeF(), QPrinter::Millimeter ); } +QgsLayoutExporter::ExportResult QgsLayoutExporter::renderToLayeredSvg( const SvgExportSettings &settings, double width, double height, int page, QRectF bounds, const QString &filename, int svgLayerId, const QString &layerName, QDomDocument &svg, QDomNode &svgDocRoot ) const +{ + QBuffer svgBuffer; + { + QSvgGenerator generator; + generator.setTitle( mLayout->name() ); + generator.setOutputDevice( &svgBuffer ); + generator.setSize( QSize( width, height ) ); + generator.setViewBox( QRect( 0, 0, width, height ) ); + generator.setResolution( settings.dpi ); //because the rendering is done in mm, convert the dpi + + QPainter svgPainter( &generator ); + if ( settings.cropToContents ) + renderRegion( &svgPainter, bounds ); + else + renderPage( &svgPainter, page ); + } + + // post-process svg output to create groups in a single svg file + // we create inkscape layers since it's nice and clean and free + // and fully svg compatible + { + svgBuffer.close(); + svgBuffer.open( QIODevice::ReadOnly ); + QDomDocument doc; + QString errorMsg; + int errorLine; + if ( ! doc.setContent( &svgBuffer, false, &errorMsg, &errorLine ) ) + { + mErrorFileName = filename; + return SvgLayerError; + } + if ( 1 == svgLayerId ) + { + svg = QDomDocument( doc.doctype() ); + svg.appendChild( svg.importNode( doc.firstChild(), false ) ); + svgDocRoot = svg.importNode( doc.elementsByTagName( QStringLiteral( "svg" ) ).at( 0 ), false ); + svgDocRoot.toElement().setAttribute( QStringLiteral( "xmlns:inkscape" ), QStringLiteral( "http://www.inkscape.org/namespaces/inkscape" ) ); + svg.appendChild( svgDocRoot ); + } + QDomNode mainGroup = svg.importNode( doc.elementsByTagName( QStringLiteral( "g" ) ).at( 0 ), true ); + mainGroup.toElement().setAttribute( QStringLiteral( "id" ), layerName ); + mainGroup.toElement().setAttribute( QStringLiteral( "inkscape:label" ), layerName ); + mainGroup.toElement().setAttribute( QStringLiteral( "inkscape:groupmode" ), QStringLiteral( "layer" ) ); + QDomNode defs = svg.importNode( doc.elementsByTagName( QStringLiteral( "defs" ) ).at( 0 ), true ); + svgDocRoot.appendChild( defs ); + svgDocRoot.appendChild( mainGroup ); + } + return Success; +} + std::unique_ptr QgsLayoutExporter::computeGeoTransform( const QgsLayoutItemMap *map, const QRectF ®ion, double dpi ) const { if ( !map ) diff --git a/src/core/layout/qgslayoutexporter.h b/src/core/layout/qgslayoutexporter.h index 5e315efc029..e0bb5c78f1c 100644 --- a/src/core/layout/qgslayoutexporter.h +++ b/src/core/layout/qgslayoutexporter.h @@ -132,6 +132,7 @@ class CORE_EXPORT QgsLayoutExporter MemoryError, //!< Unable to allocate memory required to export FileError, //!< Could not write to destination file, likely due to a lock held by another application PrintError, //!< Could not start printing to destination device + SvgLayerError, //!< Could not create layered SVG file }; //! Contains settings relating to exporting layouts to raster images @@ -280,6 +281,13 @@ class CORE_EXPORT QgsLayoutExporter */ QgsMargins cropMargins; + /** + * Set to true to export as a layered SVG file. + * Note that this option is considered experimental, and the generated + * SVG may differ from the expected appearance of the layout. + */ + bool exportAsLayers = false; + /** * Layout context flags, which control how the export will be created. */ @@ -347,7 +355,7 @@ class CORE_EXPORT QgsLayoutExporter QPointer< QgsLayout > mLayout; - QString mErrorFileName; + mutable QString mErrorFileName; QImage createImage( const ImageExportSettings &settings, int page, QRectF &bounds, bool &skipPage ) const; @@ -398,6 +406,10 @@ class CORE_EXPORT QgsLayoutExporter void updatePrinterPageSize( QPrinter &printer, int page ); + ExportResult renderToLayeredSvg( const SvgExportSettings &settings, double width, double height, int page, QRectF bounds, + const QString &filename, int svgLayerId, const QString &layerName, + QDomDocument &svg, QDomNode &svgDocRoot ) const; + friend class TestQgsLayout; }; diff --git a/tests/src/python/test_qgslayoutexporter.py b/tests/src/python/test_qgslayoutexporter.py index a91a6316da3..f866c3ca8ad 100644 --- a/tests/src/python/test_qgslayoutexporter.py +++ b/tests/src/python/test_qgslayoutexporter.py @@ -453,7 +453,6 @@ class TestQgsLayoutExporter(unittest.TestCase): self.assertTrue(os.path.exists(svg_file_path_2)) rendered_page_1 = os.path.join(self.basetestpath, 'test_exporttosvgdpi.png') - dpi = 80 svgToPng(svg_file_path, rendered_page_1, width=936) rendered_page_2 = os.path.join(self.basetestpath, 'test_exporttosvgdpi2.png') svgToPng(svg_file_path_2, rendered_page_2, width=467) @@ -461,6 +460,23 @@ class TestQgsLayoutExporter(unittest.TestCase): self.assertTrue(self.checkImage('exporttosvgdpi_page1', 'exporttopdfdpi_page1', rendered_page_1, size_tolerance=1)) self.assertTrue(self.checkImage('exporttosvgdpi_page2', 'exporttopdfdpi_page2', rendered_page_2, size_tolerance=1)) + # layered + settings.exportAsLayers = True + + svg_file_path = os.path.join(self.basetestpath, 'test_exporttosvglayered.svg') + svg_file_path_2 = os.path.join(self.basetestpath, 'test_exporttosvglayered_2.svg') + self.assertEqual(exporter.exportToSvg(svg_file_path, settings), QgsLayoutExporter.Success) + self.assertTrue(os.path.exists(svg_file_path)) + self.assertTrue(os.path.exists(svg_file_path_2)) + + rendered_page_1 = os.path.join(self.basetestpath, 'test_exporttosvglayered.png') + svgToPng(svg_file_path, rendered_page_1, width=936) + rendered_page_2 = os.path.join(self.basetestpath, 'test_exporttosvglayered2.png') + svgToPng(svg_file_path_2, rendered_page_2, width=467) + + self.assertTrue(self.checkImage('exporttosvglayered_page1', 'exporttopdfdpi_page1', rendered_page_1, size_tolerance=1)) + self.assertTrue(self.checkImage('exporttosvglayered_page2', 'exporttopdfdpi_page2', rendered_page_2, size_tolerance=1)) + def testExportWorldFile(self): l = QgsLayout(QgsProject.instance()) l.initializeDefaults() From 54acc80d146e71d8cce6fb58f6e29df8fe5c582b Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 13:00:49 +1000 Subject: [PATCH 09/12] Indentation --- tests/src/python/test_qgsfileutils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/src/python/test_qgsfileutils.py b/tests/src/python/test_qgsfileutils.py index 501b33e5569..5db01cd9ac7 100644 --- a/tests/src/python/test_qgsfileutils.py +++ b/tests/src/python/test_qgsfileutils.py @@ -57,7 +57,10 @@ class TestQgsFileUtils(unittest.TestCase): def testStringToSafeFilename(self): self.assertEqual(QgsFileUtils.stringToSafeFilename('my FiLe v2.0_new.tif'), 'my FiLe v2.0_new.tif') - self.assertEqual(QgsFileUtils.stringToSafeFilename('rendered map_final? rev (12-03-1017)_real@#$&*#%&*$!!@$%^&(*(.tif'), 'rendered map_final_ rev _12-03-1017__real____________________.tif') + self.assertEqual( + QgsFileUtils.stringToSafeFilename('rendered map_final? rev (12-03-1017)_real@#$&*#%&*$!!@$%^&(*(.tif'), + 'rendered map_final_ rev _12-03-1017__real____________________.tif') + if __name__ == '__main__': unittest.main() From b594ecd7c51e4594d65c9df0afe349c6dab38b12 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 13:03:10 +1000 Subject: [PATCH 10/12] Fix missing page shadows --- python/core/layout/qgslayoutitempage.sip | 3 ++- src/core/layout/qgslayoutitempage.cpp | 20 ++++++++++++++++---- src/core/layout/qgslayoutitempage.h | 3 ++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/python/core/layout/qgslayoutitempage.sip b/python/core/layout/qgslayoutitempage.sip index 47641e68dda..de271f82965 100644 --- a/python/core/layout/qgslayoutitempage.sip +++ b/python/core/layout/qgslayoutitempage.sip @@ -91,8 +91,9 @@ will be set to true if string could be successfully interpreted as a page orientation. %End - virtual void attemptResize( const QgsLayoutSize &size, bool includesFrame = false ); + virtual QRectF boundingRect() const; + virtual void attemptResize( const QgsLayoutSize &size, bool includesFrame = false ); virtual QgsAbstractLayoutUndoCommand *createCommand( const QString &text, int id, QUndoCommand *parent = 0 ) /Factory/; diff --git a/src/core/layout/qgslayoutitempage.cpp b/src/core/layout/qgslayoutitempage.cpp index b1f14f59a8f..928e0eee625 100644 --- a/src/core/layout/qgslayoutitempage.cpp +++ b/src/core/layout/qgslayoutitempage.cpp @@ -32,10 +32,11 @@ QgsLayoutItemPage::QgsLayoutItemPage( QgsLayout *layout ) setFlag( QGraphicsItem::ItemIsMovable, false ); setZValue( QgsLayout::ZPage ); - // use a hidden pen to specify the amount the page "bleeds" outside it's scene bounds, - // (it's a lot easier than reimplementing boundingRect() just to handle this) - QPen shadowPen( QBrush( Qt::transparent ), layout->pageCollection()->pageShadowWidth() * 2 ); - setPen( shadowPen ); + connect( this, &QgsLayoutItem::sizePositionChanged, this, [ = ] + { + mBoundingRect = QRectF(); + prepareGeometryChange(); + } ); QFont font; QFontMetrics fm( font ); @@ -123,6 +124,17 @@ QgsLayoutItemPage::Orientation QgsLayoutItemPage::decodePageOrientation( const Q return Landscape; } +QRectF QgsLayoutItemPage::boundingRect() const +{ + if ( mBoundingRect.isNull() ) + { + double shadowWidth = mLayout->pageCollection()->pageShadowWidth(); + mBoundingRect = rect(); + mBoundingRect.adjust( 0, 0, shadowWidth, shadowWidth ); + } + return mBoundingRect; +} + void QgsLayoutItemPage::attemptResize( const QgsLayoutSize &size, bool includesFrame ) { QgsLayoutItem::attemptResize( size, includesFrame ); diff --git a/src/core/layout/qgslayoutitempage.h b/src/core/layout/qgslayoutitempage.h index 4aa90f160fe..86bf7aeb3e1 100644 --- a/src/core/layout/qgslayoutitempage.h +++ b/src/core/layout/qgslayoutitempage.h @@ -121,8 +121,8 @@ class CORE_EXPORT QgsLayoutItemPage : public QgsLayoutItem */ static QgsLayoutItemPage::Orientation decodePageOrientation( const QString &string, bool *ok SIP_OUT = nullptr ); + QRectF boundingRect() const override; void attemptResize( const QgsLayoutSize &size, bool includesFrame = false ) override; - QgsAbstractLayoutUndoCommand *createCommand( const QString &text, int id, QUndoCommand *parent = nullptr ) override SIP_FACTORY; public slots: @@ -140,6 +140,7 @@ class CORE_EXPORT QgsLayoutItemPage : public QgsLayoutItem double mMaximumShadowWidth = -1; std::unique_ptr< QgsLayoutItemPageGrid > mGrid; + mutable QRectF mBoundingRect; friend class TestQgsLayoutPage; }; From e9c0d29ff19a947e7f0498b3b24be94cdc2fc0bf Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 13:26:32 +1000 Subject: [PATCH 11/12] Fix repositioning guides on pages > 1 --- src/app/layout/qgslayoutguidewidget.cpp | 8 ++++---- src/core/layout/qgslayoutguidecollection.cpp | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/layout/qgslayoutguidewidget.cpp b/src/app/layout/qgslayoutguidewidget.cpp index fdbdc706d89..74e3e707460 100644 --- a/src/app/layout/qgslayoutguidewidget.cpp +++ b/src/app/layout/qgslayoutguidewidget.cpp @@ -41,11 +41,11 @@ QgsLayoutGuideWidget::QgsLayoutGuideWidget( QWidget *parent, QgsLayout *layout, mVertGuidesTableView->setEditTriggers( QAbstractItemView::AllEditTriggers ); - mHozGuidesTableView->setItemDelegateForColumn( 0, new QgsLayoutGuidePositionDelegate( mHozGuidesTableView, mLayout, mHozProxyModel ) ); - mHozGuidesTableView->setItemDelegateForColumn( 1, new QgsLayoutGuideUnitDelegate( mHozGuidesTableView, mLayout, mHozProxyModel ) ); + mHozGuidesTableView->setItemDelegateForColumn( 0, new QgsLayoutGuidePositionDelegate( mHozGuidesTableView, mLayout, &mLayout->guides() ) ); + mHozGuidesTableView->setItemDelegateForColumn( 1, new QgsLayoutGuideUnitDelegate( mHozGuidesTableView, mLayout, &mLayout->guides() ) ); - mVertGuidesTableView->setItemDelegateForColumn( 0, new QgsLayoutGuidePositionDelegate( mVertGuidesTableView, mLayout, mVertProxyModel ) ); - mVertGuidesTableView->setItemDelegateForColumn( 1, new QgsLayoutGuideUnitDelegate( mVertGuidesTableView, mLayout, mVertProxyModel ) ); + mVertGuidesTableView->setItemDelegateForColumn( 0, new QgsLayoutGuidePositionDelegate( mVertGuidesTableView, mLayout, &mLayout->guides() ) ); + mVertGuidesTableView->setItemDelegateForColumn( 1, new QgsLayoutGuideUnitDelegate( mVertGuidesTableView, mLayout, &mLayout->guides() ) ); connect( mAddHozGuideButton, &QPushButton::clicked, this, &QgsLayoutGuideWidget::addHorizontalGuide ); connect( mAddVertGuideButton, &QPushButton::clicked, this, &QgsLayoutGuideWidget::addVerticalGuide ); diff --git a/src/core/layout/qgslayoutguidecollection.cpp b/src/core/layout/qgslayoutguidecollection.cpp index 48acb9a3bc1..514ab5a37c1 100644 --- a/src/core/layout/qgslayoutguidecollection.cpp +++ b/src/core/layout/qgslayoutguidecollection.cpp @@ -140,11 +140,11 @@ void QgsLayoutGuide::setLayoutPosition( double position ) switch ( mOrientation ) { case Qt::Horizontal: - p = mLineItem->mapFromScene( QPointF( 0, position ) ).y(); + p = mPage->mapFromScene( QPointF( 0, position ) ).y(); break; case Qt::Vertical: - p = mLineItem->mapFromScene( QPointF( position, 0 ) ).x(); + p = mPage->mapFromScene( QPointF( position, 0 ) ).x(); break; } mPosition = mLayout->convertFromLayoutUnits( p, mPosition.units() ); @@ -299,6 +299,9 @@ bool QgsLayoutGuideCollection::setData( const QModelIndex &index, const QVariant return false; QgsLayoutMeasurement m = guide->position(); + if ( m.length() == newPos ) + return true; + m.setLength( newPos ); mLayout->undoStack()->beginCommand( mPageCollection, tr( "Move Guide" ), Move + index.row() ); whileBlocking( guide )->setPosition( m ); From 4054a2bb9a7e16c52dc4215d223630f8d7df3547 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 19 Dec 2017 13:39:58 +1000 Subject: [PATCH 12/12] Fix ui weirdness when editing guide positions via widget --- src/app/layout/qgslayoutguidewidget.cpp | 39 ++++++++----------------- src/app/layout/qgslayoutguidewidget.h | 17 ++--------- 2 files changed, 14 insertions(+), 42 deletions(-) diff --git a/src/app/layout/qgslayoutguidewidget.cpp b/src/app/layout/qgslayoutguidewidget.cpp index 74e3e707460..1ac8c5b74c5 100644 --- a/src/app/layout/qgslayoutguidewidget.cpp +++ b/src/app/layout/qgslayoutguidewidget.cpp @@ -40,12 +40,11 @@ QgsLayoutGuideWidget::QgsLayoutGuideWidget( QWidget *parent, QgsLayout *layout, mHozGuidesTableView->setEditTriggers( QAbstractItemView::AllEditTriggers ); mVertGuidesTableView->setEditTriggers( QAbstractItemView::AllEditTriggers ); + mHozGuidesTableView->setItemDelegateForColumn( 0, new QgsLayoutGuidePositionDelegate( mHozGuidesTableView ) ); + mHozGuidesTableView->setItemDelegateForColumn( 1, new QgsLayoutGuideUnitDelegate( mHozGuidesTableView ) ); - mHozGuidesTableView->setItemDelegateForColumn( 0, new QgsLayoutGuidePositionDelegate( mHozGuidesTableView, mLayout, &mLayout->guides() ) ); - mHozGuidesTableView->setItemDelegateForColumn( 1, new QgsLayoutGuideUnitDelegate( mHozGuidesTableView, mLayout, &mLayout->guides() ) ); - - mVertGuidesTableView->setItemDelegateForColumn( 0, new QgsLayoutGuidePositionDelegate( mVertGuidesTableView, mLayout, &mLayout->guides() ) ); - mVertGuidesTableView->setItemDelegateForColumn( 1, new QgsLayoutGuideUnitDelegate( mVertGuidesTableView, mLayout, &mLayout->guides() ) ); + mVertGuidesTableView->setItemDelegateForColumn( 0, new QgsLayoutGuidePositionDelegate( mVertGuidesTableView ) ); + mVertGuidesTableView->setItemDelegateForColumn( 1, new QgsLayoutGuideUnitDelegate( mVertGuidesTableView ) ); connect( mAddHozGuideButton, &QPushButton::clicked, this, &QgsLayoutGuideWidget::addHorizontalGuide ); connect( mAddVertGuideButton, &QPushButton::clicked, this, &QgsLayoutGuideWidget::addVerticalGuide ); @@ -141,25 +140,23 @@ void QgsLayoutGuideWidget::applyToAll() } -QgsLayoutGuidePositionDelegate::QgsLayoutGuidePositionDelegate( QObject *parent, QgsLayout *layout, QAbstractItemModel *model ) +QgsLayoutGuidePositionDelegate::QgsLayoutGuidePositionDelegate( QObject *parent ) : QStyledItemDelegate( parent ) - , mLayout( layout ) - , mModel( model ) { } -QWidget *QgsLayoutGuidePositionDelegate::createEditor( QWidget *parent, const QStyleOptionViewItem &, const QModelIndex &index ) const +QWidget *QgsLayoutGuidePositionDelegate::createEditor( QWidget *parent, const QStyleOptionViewItem &, const QModelIndex & ) const { QgsDoubleSpinBox *spin = new QgsDoubleSpinBox( parent ); spin->setMinimum( 0 ); spin->setMaximum( 1000000 ); spin->setDecimals( 2 ); spin->setShowClearButton( false ); - connect( spin, static_cast < void ( QgsDoubleSpinBox::* )( double ) > ( &QgsDoubleSpinBox::valueChanged ), this, [ = ]( double v ) + connect( spin, static_cast < void ( QgsDoubleSpinBox::* )( double ) > ( &QgsDoubleSpinBox::valueChanged ), this, [ = ]( double ) { // we want to update on every spin change, not just the final - setModelData( index, v, QgsLayoutGuideCollection::PositionRole ); + const_cast< QgsLayoutGuidePositionDelegate * >( this )->emit commitData( spin ); } ); return spin; } @@ -170,26 +167,18 @@ void QgsLayoutGuidePositionDelegate::setModelData( QWidget *editor, QAbstractIte model->setData( index, spin->value(), QgsLayoutGuideCollection::PositionRole ); } -void QgsLayoutGuidePositionDelegate::setModelData( const QModelIndex &index, const QVariant &value, int role ) const -{ - mModel->setData( index, value, role ); -} - -QgsLayoutGuideUnitDelegate::QgsLayoutGuideUnitDelegate( QObject *parent, QgsLayout *layout, QAbstractItemModel *model ) +QgsLayoutGuideUnitDelegate::QgsLayoutGuideUnitDelegate( QObject *parent ) : QStyledItemDelegate( parent ) - , mLayout( layout ) - , mModel( model ) { - } -QWidget *QgsLayoutGuideUnitDelegate::createEditor( QWidget *parent, const QStyleOptionViewItem &, const QModelIndex &index ) const +QWidget *QgsLayoutGuideUnitDelegate::createEditor( QWidget *parent, const QStyleOptionViewItem &, const QModelIndex & ) const { QgsLayoutUnitsComboBox *unitsCb = new QgsLayoutUnitsComboBox( parent ); - connect( unitsCb, &QgsLayoutUnitsComboBox::changed, this, [ = ]( int unit ) + connect( unitsCb, &QgsLayoutUnitsComboBox::changed, this, [ = ]( int ) { // we want to update on every unit change, not just the final - setModelData( index, unit, QgsLayoutGuideCollection::UnitsRole ); + const_cast< QgsLayoutGuideUnitDelegate * >( this )->emit commitData( unitsCb ); } ); return unitsCb; } @@ -200,7 +189,3 @@ void QgsLayoutGuideUnitDelegate::setModelData( QWidget *editor, QAbstractItemMod model->setData( index, cb->unit(), QgsLayoutGuideCollection::UnitsRole ); } -void QgsLayoutGuideUnitDelegate::setModelData( const QModelIndex &index, const QVariant &value, int role ) const -{ - mModel->setData( index, value, role ); -} diff --git a/src/app/layout/qgslayoutguidewidget.h b/src/app/layout/qgslayoutguidewidget.h index e893591995a..2b07ee62574 100644 --- a/src/app/layout/qgslayoutguidewidget.h +++ b/src/app/layout/qgslayoutguidewidget.h @@ -62,18 +62,12 @@ class QgsLayoutGuidePositionDelegate : public QStyledItemDelegate public: - QgsLayoutGuidePositionDelegate( QObject *parent, QgsLayout *layout, QAbstractItemModel *model ); + QgsLayoutGuidePositionDelegate( QObject *parent ); protected: QWidget *createEditor( QWidget *parent, const QStyleOptionViewItem & /*option*/, const QModelIndex &index ) const override; void setModelData( QWidget *editor, QAbstractItemModel *model, const QModelIndex &index ) const override; - void setModelData( const QModelIndex &index, const QVariant &value, int role ) const; - - private: - - QgsLayout *mLayout = nullptr; - QAbstractItemModel *mModel = nullptr; }; class QgsLayoutGuideUnitDelegate : public QStyledItemDelegate @@ -82,19 +76,12 @@ class QgsLayoutGuideUnitDelegate : public QStyledItemDelegate public: - QgsLayoutGuideUnitDelegate( QObject *parent, QgsLayout *layout, QAbstractItemModel *model ); + QgsLayoutGuideUnitDelegate( QObject *parent ); protected: QWidget *createEditor( QWidget *parent, const QStyleOptionViewItem & /*option*/, const QModelIndex &index ) const override; void setModelData( QWidget *editor, QAbstractItemModel *model, const QModelIndex &index ) const override; - void setModelData( const QModelIndex &index, const QVariant &value, int role ) const; - - private: - - QgsLayout *mLayout = nullptr; - - QAbstractItemModel *mModel = nullptr; }; #endif // QGSLAYOUTGUIDEWIDGET_H