From a600b51badf72e67712cd621a4f6f6df297cd1fb Mon Sep 17 00:00:00 2001 From: Nyall Dawson <nyall.dawson@gmail.com> Date: Tue, 20 Mar 2018 12:24:00 +1000 Subject: [PATCH] [FEATURE][layouts] Export project metadata as SVG RDF metadata Adds an option to include project metadata into SVG exports generated from layouts, using the SVG RDF standard. Developed for Arpa Piemonte (Dipartimento Tematico Geologia e Dissesto) within ERIKUS project --- python/core/layout/qgslayoutexporter.sip.in | 2 + src/app/layout/qgslayoutdesignerdialog.cpp | 6 + src/core/layout/qgslayoutexporter.cpp | 146 +++++++++++++++++--- src/core/layout/qgslayoutexporter.h | 12 +- src/ui/layout/qgssvgexportoptions.ui | 20 ++- tests/src/python/test_qgslayoutexporter.py | 45 +++++- 6 files changed, 203 insertions(+), 28 deletions(-) diff --git a/python/core/layout/qgslayoutexporter.sip.in b/python/core/layout/qgslayoutexporter.sip.in index f754e2c46f1..e38b486d62d 100644 --- a/python/core/layout/qgslayoutexporter.sip.in +++ b/python/core/layout/qgslayoutexporter.sip.in @@ -276,6 +276,8 @@ Constructor for SvgExportSettings bool exportAsLayers; + bool exportMetadata; + QgsLayoutRenderContext::Flags flags; }; diff --git a/src/app/layout/qgslayoutdesignerdialog.cpp b/src/app/layout/qgslayoutdesignerdialog.cpp index 8ecd1c859cb..21241fec55b 100644 --- a/src/app/layout/qgslayoutdesignerdialog.cpp +++ b/src/app/layout/qgslayoutdesignerdialog.cpp @@ -3816,6 +3816,7 @@ bool QgsLayoutDesignerDialog::getSvgExportSettings( QgsLayoutExporter::SvgExport double rightMargin = 0.0; double bottomMargin = 0.0; double leftMargin = 0.0; + bool includeMetadata = true; if ( mLayout ) { mLayout->customProperty( QStringLiteral( "forceVector" ), false ).toBool(); @@ -3825,6 +3826,7 @@ bool QgsLayoutDesignerDialog::getSvgExportSettings( QgsLayoutExporter::SvgExport rightMargin = mLayout->customProperty( QStringLiteral( "svgCropMarginRight" ), 0 ).toInt(); bottomMargin = mLayout->customProperty( QStringLiteral( "svgCropMarginBottom" ), 0 ).toInt(); leftMargin = mLayout->customProperty( QStringLiteral( "svgCropMarginLeft" ), 0 ).toInt(); + includeMetadata = mLayout->customProperty( QStringLiteral( "svgIncludeMetadata" ), 1 ).toBool(); } // open options dialog @@ -3839,6 +3841,7 @@ bool QgsLayoutDesignerDialog::getSvgExportSettings( QgsLayoutExporter::SvgExport options.mRightMarginSpinBox->setValue( rightMargin ); options.mBottomMarginSpinBox->setValue( bottomMargin ); options.mLeftMarginSpinBox->setValue( leftMargin ); + options.mIncludeMetadataCheckbox->setChecked( includeMetadata ); if ( dialog.exec() != QDialog::Accepted ) return false; @@ -3849,6 +3852,7 @@ bool QgsLayoutDesignerDialog::getSvgExportSettings( QgsLayoutExporter::SvgExport marginRight = options.mRightMarginSpinBox->value(); marginBottom = options.mBottomMarginSpinBox->value(); marginLeft = options.mLeftMarginSpinBox->value(); + includeMetadata = options.mIncludeMetadataCheckbox->isChecked(); if ( mLayout ) { @@ -3859,12 +3863,14 @@ bool QgsLayoutDesignerDialog::getSvgExportSettings( QgsLayoutExporter::SvgExport mLayout->setCustomProperty( QStringLiteral( "svgCropMarginRight" ), marginRight ); mLayout->setCustomProperty( QStringLiteral( "svgCropMarginBottom" ), marginBottom ); mLayout->setCustomProperty( QStringLiteral( "svgCropMarginLeft" ), marginLeft ); + mLayout->setCustomProperty( QStringLiteral( "svgIncludeMetadata" ), includeMetadata ? 1 : 0 ); } settings.cropToContents = clipToContent; settings.cropMargins = QgsMargins( marginLeft, marginTop, marginRight, marginBottom ); settings.forceVectorOutput = options.mForceVectorCheckBox->isChecked(); settings.exportAsLayers = groupLayers; + settings.exportMetadata = includeMetadata; exportAsText = options.chkTextAsOutline->isChecked(); return true; diff --git a/src/core/layout/qgslayoutexporter.cpp b/src/core/layout/qgslayoutexporter.cpp index dd7ec332f7f..5e17d449d6c 100644 --- a/src/core/layout/qgslayoutexporter.cpp +++ b/src/core/layout/qgslayoutexporter.cpp @@ -849,7 +849,7 @@ QgsLayoutExporter::ExportResult QgsLayoutExporter::exportToSvg( const QString &f } } - ExportResult result = renderToLayeredSvg( settings, width, height, i, bounds, fileName, svgLayerId, layerName, svg, svgDocRoot ); + ExportResult result = renderToLayeredSvg( settings, width, height, i, bounds, fileName, svgLayerId, layerName, svg, svgDocRoot, settings.exportMetadata ); if ( result != Success ) return result; @@ -861,6 +861,9 @@ QgsLayoutExporter::ExportResult QgsLayoutExporter::exportToSvg( const QString &f } } + if ( settings.exportMetadata ) + appendMetadataToSvg( svg ); + QFile out( fileName ); bool openOk = out.open( QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate ); if ( !openOk ) @@ -873,27 +876,59 @@ QgsLayoutExporter::ExportResult QgsLayoutExporter::exportToSvg( const QString &f } else { - 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 ); - - QPainter p; - bool createOk = p.begin( &generator ); - if ( !createOk ) + QBuffer svgBuffer; { - mErrorFileName = fileName; - return FileError; + QSvgGenerator generator; + if ( settings.exportMetadata ) + { + generator.setTitle( mLayout->project()->metadata().title() ); + generator.setDescription( mLayout->project()->metadata().abstract() ); + } + generator.setOutputDevice( &svgBuffer ); + 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(); } + { + svgBuffer.close(); + svgBuffer.open( QIODevice::ReadOnly ); + QDomDocument svg; + QString errorMsg; + int errorLine; + if ( ! svg.setContent( &svgBuffer, false, &errorMsg, &errorLine ) ) + { + mErrorFileName = fileName; + return SvgLayerError; + } - if ( settings.cropToContents ) - renderRegion( &p, bounds ); - else - renderPage( &p, i ); + if ( settings.exportMetadata ) + appendMetadataToSvg( svg ); - p.end(); + QFile out( fileName ); + bool openOk = out.open( QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate ); + if ( !openOk ) + { + mErrorFileName = fileName; + return FileError; + } + + out.write( svg.toByteArray() ); + } } } @@ -1067,15 +1102,18 @@ void QgsLayoutExporter::updatePrinterPageSize( QgsLayout *layout, QPrinter &prin 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 +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, bool includeMetadata ) const { QBuffer svgBuffer; { QSvgGenerator generator; - if ( const QgsMasterLayoutInterface *l = dynamic_cast< const QgsMasterLayoutInterface * >( mLayout.data() ) ) - generator.setTitle( l->name() ); - else if ( mLayout->project() ) - generator.setTitle( mLayout->project()->title() ); + if ( includeMetadata ) + { + if ( const QgsMasterLayoutInterface *l = dynamic_cast< const QgsMasterLayoutInterface * >( mLayout.data() ) ) + generator.setTitle( l->name() ); + else if ( mLayout->project() ) + generator.setTitle( mLayout->project()->title() ); + } generator.setOutputDevice( &svgBuffer ); generator.setSize( QSize( width, height ) ); @@ -1122,6 +1160,68 @@ QgsLayoutExporter::ExportResult QgsLayoutExporter::renderToLayeredSvg( const Svg return Success; } +void QgsLayoutExporter::appendMetadataToSvg( QDomDocument &svg ) const +{ + const QgsProjectMetadata &metadata = mLayout->project()->metadata(); + QDomElement metadataElement = svg.createElement( QStringLiteral( "metadata" ) ); + metadataElement.setAttribute( QStringLiteral( "id" ), QStringLiteral( "qgismetadata" ) ); + QDomElement rdfElement = svg.createElement( QStringLiteral( "rdf:RDF" ) ); + QDomElement workElement = svg.createElement( QStringLiteral( "cc:Work" ) ); + + auto addTextNode = [&workElement, &svg]( const QString & tag, const QString & value ) + { + QDomElement element = svg.createElement( tag ); + QDomText t = svg.createTextNode( value ); + element.appendChild( t ); + workElement.appendChild( element ); + }; + + addTextNode( QStringLiteral( "dc:format" ), QStringLiteral( "image/svg+xml" ) ); + addTextNode( QStringLiteral( "dc:title" ), metadata.title() ); + addTextNode( QStringLiteral( "dc:date" ), metadata.creationDateTime().toString( Qt::ISODate ) ); + addTextNode( QStringLiteral( "dc:identifier" ), metadata.identifier() ); + addTextNode( QStringLiteral( "dc:description" ), metadata.abstract() ); + + auto addAgentNode = [&workElement, &svg]( const QString & tag, const QString & value ) + { + QDomElement element = svg.createElement( tag ); + QDomElement agentElement = svg.createElement( QStringLiteral( "cc:Agent" ) ); + QDomElement titleElement = svg.createElement( QStringLiteral( "dc:title" ) ); + QDomText t = svg.createTextNode( value ); + titleElement.appendChild( t ); + agentElement.appendChild( titleElement ); + element.appendChild( agentElement ); + workElement.appendChild( element ); + }; + + addAgentNode( QStringLiteral( "dc:creator" ), metadata.author() ); + addAgentNode( QStringLiteral( "dc:publisher" ), QStringLiteral( "QGIS %1" ).arg( Qgis::QGIS_VERSION ) ); + + // keywords + { + QDomElement element = svg.createElement( QStringLiteral( "dc:subject" ) ); + QDomElement bagElement = svg.createElement( QStringLiteral( "rdf:Bag" ) ); + QgsAbstractMetadataBase::KeywordMap keywords = metadata.keywords(); + for ( auto it = keywords.constBegin(); it != keywords.constEnd(); ++it ) + { + const QStringList words = it.value(); + for ( const QString &keyword : words ) + { + QDomElement liElement = svg.createElement( QStringLiteral( "rdf:li" ) ); + QDomText t = svg.createTextNode( keyword ); + liElement.appendChild( t ); + bagElement.appendChild( liElement ); + } + } + element.appendChild( bagElement ); + workElement.appendChild( element ); + } + + rdfElement.appendChild( workElement ); + metadataElement.appendChild( rdfElement ); + svg.documentElement().appendChild( metadataElement ); +} + std::unique_ptr<double[]> 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 b12a74a6b51..927e848c0f2 100644 --- a/src/core/layout/qgslayoutexporter.h +++ b/src/core/layout/qgslayoutexporter.h @@ -384,6 +384,14 @@ class CORE_EXPORT QgsLayoutExporter */ bool exportAsLayers = false; + /** + * Indicates whether SVG export should include RDF metadata generated + * from the layout's project's metadata. + * + * \since QGIS 3.2 + */ + bool exportMetadata = true; + /** * Layout context flags, which control how the export will be created. */ @@ -519,7 +527,9 @@ class CORE_EXPORT QgsLayoutExporter 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; + QDomDocument &svg, QDomNode &svgDocRoot, bool includeMetadata ) const; + + void appendMetadataToSvg( QDomDocument &svg ) const; friend class TestQgsLayout; diff --git a/src/ui/layout/qgssvgexportoptions.ui b/src/ui/layout/qgssvgexportoptions.ui index 7e6cfe841c6..27123629201 100644 --- a/src/ui/layout/qgssvgexportoptions.ui +++ b/src/ui/layout/qgssvgexportoptions.ui @@ -7,7 +7,7 @@ <x>0</x> <y>0</y> <width>489</width> - <height>319</height> + <height>351</height> </rect> </property> <property name="windowTitle"> @@ -23,7 +23,7 @@ <item> <widget class="QCheckBox" name="chkMapLayersAsGroup"> <property name="text"> - <string>Export map layers as svg groups (may affect label placement)</string> + <string>Export map layers as SVG groups (may affect label placement)</string> </property> <property name="checked"> <bool>false</bool> @@ -56,6 +56,19 @@ </property> </widget> </item> + <item> + <widget class="QCheckBox" name="mIncludeMetadataCheckbox"> + <property name="toolTip"> + <string>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.</string> + </property> + <property name="text"> + <string>Export RDF metadata</string> + </property> + <property name="checked"> + <bool>true</bool> + </property> + </widget> + </item> </layout> </widget> </item> @@ -205,8 +218,9 @@ <tabstops> <tabstop>chkMapLayersAsGroup</tabstop> <tabstop>chkTextAsOutline</tabstop> - <tabstop>mClipToContentGroupBox</tabstop> <tabstop>mForceVectorCheckBox</tabstop> + <tabstop>mIncludeMetadataCheckbox</tabstop> + <tabstop>mClipToContentGroupBox</tabstop> <tabstop>mTopMarginSpinBox</tabstop> <tabstop>mLeftMarginSpinBox</tabstop> <tabstop>mRightMarginSpinBox</tabstop> diff --git a/tests/src/python/test_qgslayoutexporter.py b/tests/src/python/test_qgslayoutexporter.py index 25c0e635c8e..a8635b151d3 100644 --- a/tests/src/python/test_qgslayoutexporter.py +++ b/tests/src/python/test_qgslayoutexporter.py @@ -18,6 +18,7 @@ import tempfile import shutil import os import subprocess +from xml.dom import minidom from qgis.core import (QgsMultiRenderChecker, QgsLayoutExporter, @@ -39,7 +40,7 @@ from qgis.core import (QgsMultiRenderChecker, QgsPrintLayout, QgsSingleSymbolRenderer, QgsReport) -from qgis.PyQt.QtCore import QSize, QSizeF, QDir, QRectF, Qt +from qgis.PyQt.QtCore import QSize, QSizeF, QDir, QRectF, Qt, QDateTime, QDate, QTime from qgis.PyQt.QtGui import QImage, QPainter from qgis.PyQt.QtPrintSupport import QPrinter from qgis.PyQt.QtSvg import QSvgRenderer, QSvgGenerator @@ -418,6 +419,14 @@ class TestQgsLayoutExporter(unittest.TestCase): self.assertTrue(self.checkImage('exporttopdfdpi_page2', 'exporttopdfdpi_page2', rendered_page_2, size_tolerance=1)) def testExportToSvg(self): + md = QgsProject.instance().metadata() + md.setTitle('proj title') + md.setAuthor('proj author') + md.setCreationDateTime(QDateTime(QDate(2011, 5, 3), QTime(9, 4, 5))) + md.setIdentifier('proj identifier') + md.setAbstract('proj abstract') + md.setKeywords({'kw': ['kw1', 'kw2']}) + QgsProject.instance().setMetadata(md) l = QgsLayout(QgsProject.instance()) l.initializeDefaults() @@ -453,6 +462,7 @@ class TestQgsLayoutExporter(unittest.TestCase): settings = QgsLayoutExporter.SvgExportSettings() settings.dpi = 80 settings.forceVectorOutput = False + settings.exportMetadata = True svg_file_path = os.path.join(self.basetestpath, 'test_exporttosvgdpi.svg') svg_file_path_2 = os.path.join(self.basetestpath, 'test_exporttosvgdpi_2.svg') @@ -460,6 +470,22 @@ class TestQgsLayoutExporter(unittest.TestCase): self.assertTrue(os.path.exists(svg_file_path)) self.assertTrue(os.path.exists(svg_file_path_2)) + # metadata + def checkMetadata(f, expected): + # ideally we'd check the path too - but that's very complex given that + # the output from Qt svg generator isn't valid XML, and no Python standard library + # xml parser handles invalid xml... + self.assertEqual('proj title' in open(f).read(), expected) + self.assertEqual('proj author' in open(f).read(), expected) + self.assertEqual('proj identifier' in open(f).read(), expected) + self.assertEqual('2011-05-03' in open(f).read(), expected) + self.assertEqual('proj abstract' in open(f).read(), expected) + self.assertEqual('kw1' in open(f).read(), expected) + self.assertEqual('kw2' in open(f).read(), expected) + + for f in [svg_file_path, svg_file_path_2]: + checkMetadata(f, True) + rendered_page_1 = os.path.join(self.basetestpath, 'test_exporttosvgdpi.png') svgToPng(svg_file_path, rendered_page_1, width=936) rendered_page_2 = os.path.join(self.basetestpath, 'test_exporttosvgdpi2.png') @@ -468,8 +494,15 @@ 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)) + # no metadata + settings.exportMetadata = False + self.assertEqual(exporter.exportToSvg(svg_file_path, settings), QgsLayoutExporter.Success) + for f in [svg_file_path, svg_file_path_2]: + checkMetadata(f, False) + # layered settings.exportAsLayers = True + settings.exportMetadata = 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') @@ -485,6 +518,16 @@ class TestQgsLayoutExporter(unittest.TestCase): 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)) + for f in [svg_file_path, svg_file_path_2]: + checkMetadata(f, True) + + # layered no metadata + settings.exportAsLayers = True + settings.exportMetadata = False + self.assertEqual(exporter.exportToSvg(svg_file_path, settings), QgsLayoutExporter.Success) + for f in [svg_file_path, svg_file_path_2]: + checkMetadata(f, False) + def testPrint(self): l = QgsLayout(QgsProject.instance()) l.initializeDefaults()