Skip to content

Commit

Permalink
[layout] Insure that hyperlinks within HTML-enabled labels are export…
Browse files Browse the repository at this point in the history
…ed as such within PDFs
  • Loading branch information
nirvn committed May 25, 2023
1 parent 10a4850 commit e8db036
Show file tree
Hide file tree
Showing 12 changed files with 572 additions and 125 deletions.
7 changes: 7 additions & 0 deletions python/core/auto_generated/layout/qgslayoutitemhtml.sip.in
Expand Up @@ -45,6 +45,13 @@ Ownership is transferred to the layout.
static QgsLayoutItemHtml *create( QgsLayout *layout ) /Factory/;
%Docstring
Returns a new QgsLayoutItemHtml for the specified parent ``layout``.
%End

static QgsLayoutItemHtml *createFromLabel( QgsLayoutItemLabel *label ) /Factory/;
%Docstring
Returns a new QgsLayoutItemHtml matching the content and rendering of a given ``label``.

.. versionadded:: 3.32
%End

void setContentMode( ContentMode mode );
Expand Down
17 changes: 17 additions & 0 deletions src/core/layout/qgslayout.cpp
Expand Up @@ -16,6 +16,8 @@

#include "qgslayout.h"
#include "qgslayoutitem.h"
#include "qgslayoutitemhtml.h"
#include "qgslayoutitemlabel.h"
#include "qgslayoutmodel.h"
#include "qgslayoutpagecollection.h"
#include "qgslayoutguidecollection.h"
Expand Down Expand Up @@ -1106,6 +1108,21 @@ QList< QgsLayoutItem * > QgsLayout::addItemsFromXml( const QDomElement &parentEl
}
}

// When restoring items on project load saved with QGIS < 3.32, convert HTML-enabled labels into HTML items
if ( !position && QgsProjectVersion( 3, 31, 0 ) > mProject->lastSaveVersion() )
{
if ( QgsLayoutItemLabel *label = qobject_cast<QgsLayoutItemLabel *>( item.get() ) )
{
if ( label->mode() == QgsLayoutItemLabel::ModeHtml )
{
QgsLayoutMultiFrame *html = QgsLayoutItemHtml::createFromLabel( label );
addMultiFrame( html );
newMultiFrames << html;
continue;
}
}
}

QgsLayoutItem *layoutItem = item.get();
addLayoutItem( item.release() );
layoutItem->setZValue( layoutItem->zValue() + zOrderOffset );
Expand Down
28 changes: 28 additions & 0 deletions src/core/layout/qgslayoutitemhtml.cpp
Expand Up @@ -28,6 +28,7 @@
#include "qgsmapsettings.h"
#include "qgswebpage.h"
#include "qgswebframe.h"
#include "qgslayoutitemlabel.h"
#include "qgslayoutitemmap.h"
#include "qgslayoutreportcontext.h"
#include "qgslayoutrendercontext.h"
Expand Down Expand Up @@ -99,6 +100,33 @@ QgsLayoutItemHtml *QgsLayoutItemHtml::create( QgsLayout *layout )
return new QgsLayoutItemHtml( layout );
}

QgsLayoutItemHtml *QgsLayoutItemHtml::createFromLabel( QgsLayoutItemLabel *label )
{
QgsLayoutItemHtml *html = new QgsLayoutItemHtml( label->layout() );
QgsLayoutFrame *frame = new QgsLayoutFrame( label->layout(), html );
frame->setVisible( label->isVisible() );
frame->setLocked( label->isLocked() );
frame->setItemOpacity( label->itemOpacity() );
frame->setRotation( label->rotation() );
frame->setReferencePoint( label->referencePoint() );
frame->attemptMove( label->positionWithUnits() );
frame->attemptResize( label->sizeWithUnits() );
frame->setZValue( label->zValue() );
frame->setParentGroup( label->parentGroup() );
frame->setBackgroundColor( label->backgroundColor() );
frame->setFrameEnabled( label->frameEnabled() );
frame->setFrameJoinStyle( label->frameJoinStyle() );
frame->setFrameStrokeWidth( label->frameStrokeWidth() );
frame->setFrameStrokeColor( label->frameStrokeColor() );
html->addFrame( frame );
html->setContentMode( QgsLayoutItemHtml::ManualHtml );
html->setHtml( label->currentText() );
html->setUserStylesheetEnabled( true );
html->setUserStylesheet( label->createStylesheet() );
html->loadHtml();
return html;
}

void QgsLayoutItemHtml::setUrl( const QUrl &url )
{
if ( !mWebPage )
Expand Down
8 changes: 8 additions & 0 deletions src/core/layout/qgslayoutitemhtml.h
Expand Up @@ -25,6 +25,7 @@

class QgsWebPage;
class QImage;
class QgsLayoutItemLabel;
class QgsVectorLayer;
class QgsNetworkContentFetcher;

Expand Down Expand Up @@ -63,6 +64,13 @@ class CORE_EXPORT QgsLayoutItemHtml: public QgsLayoutMultiFrame
*/
static QgsLayoutItemHtml *create( QgsLayout *layout ) SIP_FACTORY;

/**
* Returns a new QgsLayoutItemHtml matching the content and rendering of a given \a label.
*
* \since QGIS 3.32
*/
static QgsLayoutItemHtml *createFromLabel( QgsLayoutItemLabel *label ) SIP_FACTORY;

/**
* Sets the source \a mode for item's HTML content.
* \see contentMode()
Expand Down
177 changes: 60 additions & 117 deletions src/core/layout/qgslayoutitemlabel.cpp
Expand Up @@ -33,16 +33,11 @@
#include "qgslayoutrendercontext.h"
#include "qgslayoutreportcontext.h"

#include "qgswebpage.h"
#include "qgswebframe.h"

#include <QCoreApplication>
#include <QDate>
#include <QDomElement>
#include <QPainter>
#include <QTimer>
#include <QEventLoop>
#include <QThread>
#include <QTextDocument>

QgsLayoutItemLabel::QgsLayoutItemLabel( QgsLayout *layout )
: QgsLayoutItem( layout )
Expand Down Expand Up @@ -70,32 +65,6 @@ QgsLayoutItemLabel::QgsLayoutItemLabel( QgsLayout *layout )
//a label added while atlas preview is enabled needs to have the expression context set,
//otherwise fields in the label aren't correctly evaluated until atlas preview feature changes (#9457)
refreshExpressionContext();

// only possible on the main thread!
if ( QThread::currentThread() == QApplication::instance()->thread() )
{
mWebPage.reset( new QgsWebPage( this ) );
}
else
{
QgsMessageLog::logMessage( QObject::tr( "Cannot load HTML based item label in background threads" ) );
}
if ( mWebPage )
{
mWebPage->setIdentifier( tr( "Layout label item" ) );
mWebPage->setNetworkAccessManager( QgsNetworkAccessManager::instance() );

//This makes the background transparent. Found on http://blog.qt.digia.com/blog/2009/06/30/transparent-qwebview-or-qwebpage/
QPalette palette = mWebPage->palette();
palette.setBrush( QPalette::Base, Qt::transparent );
mWebPage->setPalette( palette );

mWebPage->mainFrame()->setZoomFactor( 10.0 );
mWebPage->mainFrame()->setScrollBarPolicy( Qt::Horizontal, Qt::ScrollBarAlwaysOff );
mWebPage->mainFrame()->setScrollBarPolicy( Qt::Vertical, Qt::ScrollBarAlwaysOff );

connect( mWebPage.get(), &QWebPage::loadFinished, this, &QgsLayoutItemLabel::loadingHtmlFinished );
}
}

QgsLayoutItemLabel *QgsLayoutItemLabel::create( QgsLayout *layout )
Expand All @@ -118,42 +87,49 @@ void QgsLayoutItemLabel::draw( QgsLayoutItemRenderContext &context )
QPainter *painter = context.renderContext().painter();
const QgsScopedQPainterState painterState( painter );

double rectScale = 1.0;
const double penWidth = frameEnabled() ? ( pen().widthF() / 2.0 ) : 0;
const double xPenAdjust = mMarginX < 0 ? -penWidth : penWidth;
const double yPenAdjust = mMarginY < 0 ? -penWidth : penWidth;

QRectF painterRect;
if ( mMode == QgsLayoutItemLabel::ModeFont )
{
rectScale = context.renderContext().scaleFactor();
const double rectScale = context.renderContext().scaleFactor();
painterRect = QRectF( ( xPenAdjust + mMarginX ) * rectScale,
( yPenAdjust + mMarginY ) * rectScale,
( rect().width() - 2 * xPenAdjust - 2 * mMarginX ) * rectScale,
( rect().height() - 2 * yPenAdjust - 2 * mMarginY ) * rectScale );
}
else
{
// painter is scaled to dots, so scale back to layout units
painter->scale( context.renderContext().scaleFactor(), context.renderContext().scaleFactor() );
// The 3.77 adjustment value was found through trial and error, the author has however no clue as to where it comes from
const double adjustmentFactor = 3.77;
const double rectScale = context.renderContext().scaleFactor() * adjustmentFactor;
// The left/right margin is handled by the stylesheet while the top/bottom margin is ignored by QTextDocument
painterRect = QRectF( 0, 0,
( rect().width() ) * rectScale,
( rect().height() - yPenAdjust - mMarginY ) * rectScale );
painter->translate( 0, ( yPenAdjust + mMarginY ) * context.renderContext().scaleFactor() );
painter->scale( context.renderContext().scaleFactor() / adjustmentFactor, context.renderContext().scaleFactor() / adjustmentFactor );
}

const double penWidth = frameEnabled() ? ( pen().widthF() / 2.0 ) : 0;
const double xPenAdjust = mMarginX < 0 ? -penWidth : penWidth;
const double yPenAdjust = mMarginY < 0 ? -penWidth : penWidth;
const QRectF painterRect( ( xPenAdjust + mMarginX ) * rectScale,
( yPenAdjust + mMarginY ) * rectScale,
( rect().width() - 2 * xPenAdjust - 2 * mMarginX ) * rectScale,
( rect().height() - 2 * yPenAdjust - 2 * mMarginY ) * rectScale );

switch ( mMode )
{
case ModeHtml:
{
if ( mFirstRender )
{
contentChanged();
mFirstRender = false;
}
QTextDocument document;
document.setDocumentMargin( 0 );
document.setPageSize( QSizeF( painterRect.width() / context.renderContext().scaleFactor(), painterRect.height() / context.renderContext().scaleFactor() ) );
document.setDefaultStyleSheet( createStylesheet() );

if ( mWebPage )
{
painter->scale( 1.0 / mHtmlUnitsToLayoutUnits / 10.0, 1.0 / mHtmlUnitsToLayoutUnits / 10.0 );
mWebPage->setViewportSize( QSize( painterRect.width() * mHtmlUnitsToLayoutUnits * 10.0, painterRect.height() * mHtmlUnitsToLayoutUnits * 10.0 ) );
mWebPage->settings()->setUserStyleSheetUrl( createStylesheetUrl() );
mWebPage->mainFrame()->render( painter );
}
document.setDefaultFont( createDefaultFont() );

QTextOption textOption = document.defaultTextOption();
textOption.setAlignment( mHAlignment );
document.setDefaultTextOption( textOption );

document.setHtml( QStringLiteral( "<body>%1</body>" ).arg( currentText() ) );
document.drawContents( painter, painterRect );
break;
}

Expand All @@ -179,44 +155,7 @@ void QgsLayoutItemLabel::contentChanged()
{
case ModeHtml:
{
const QString textToDraw = currentText();
if ( !mWebPage )
{
mHtmlLoaded = true;
return;
}

//mHtmlLoaded tracks whether the QWebPage has completed loading
//its html contents, set it initially to false. The loadingHtmlFinished slot will
//set this to true after html is loaded.
mHtmlLoaded = false;

const QUrl baseUrl = mLayout ? QUrl::fromLocalFile( mLayout->project()->absoluteFilePath() ) : QUrl();
mWebPage->mainFrame()->setHtml( textToDraw, baseUrl );

//For very basic html labels with no external assets, the html load will already be
//complete before we even get a chance to start the QEventLoop. Make sure we check
//this before starting the loop

// important -- we CAN'T do this when it's a render inside the designer, otherwise the
// event loop will mess with the paint event and cause it to be deleted, and BOOM!
if ( !mHtmlLoaded && ( !mLayout || !mLayout->renderContext().isPreviewRender() ) )
{
//Setup event loop and timeout for rendering html
QEventLoop loop;

//Connect timeout and webpage loadFinished signals to loop
connect( mWebPage.get(), &QWebPage::loadFinished, &loop, &QEventLoop::quit );

// Start a 20 second timeout in case html loading will never complete
QTimer timeoutTimer;
timeoutTimer.setSingleShot( true );
connect( &timeoutTimer, &QTimer::timeout, &loop, &QEventLoop::quit );
timeoutTimer.start( 20000 );

// Pause until html is loaded
loop.exec( QEventLoop::ExcludeUserInputEvents );
}
invalidateCache();
break;
}
case ModeFont:
Expand All @@ -225,25 +164,6 @@ void QgsLayoutItemLabel::contentChanged()
}
}

void QgsLayoutItemLabel::loadingHtmlFinished( bool result )
{
Q_UNUSED( result )
mHtmlLoaded = true;
invalidateCache();
update();
}

double QgsLayoutItemLabel::htmlUnitsToLayoutUnits()
{
if ( !mLayout )
{
return 1.0;
}

//TODO : fix this more precisely so that the label's default text size is the same with or without "display as html"
return mLayout->convertToLayoutUnits( QgsLayoutMeasurement( mLayout->renderContext().dpi() / 72.0, Qgis::LayoutUnit::Millimeters ) ); //webkit seems to assume a standard dpi of 72
}

void QgsLayoutItemLabel::setText( const QString &text )
{
mText = text;
Expand Down Expand Up @@ -663,10 +583,8 @@ void QgsLayoutItemLabel::itemShiftAdjustSize( double newWidth, double newHeight,
}
}

QUrl QgsLayoutItemLabel::createStylesheetUrl() const
QFont QgsLayoutItemLabel::createDefaultFont() const
{
QString stylesheet;
stylesheet += QStringLiteral( "body { margin: %1 %2;" ).arg( std::max( mMarginY * mHtmlUnitsToLayoutUnits, 0.0 ) ).arg( std::max( mMarginX * mHtmlUnitsToLayoutUnits, 0.0 ) );
QFont f = mFormat.font();
switch ( mFormat.sizeUnit() )
{
Expand All @@ -688,13 +606,38 @@ QUrl QgsLayoutItemLabel::createStylesheetUrl() const
case Qgis::RenderUnit::MapUnits:
break;
}
return f;
}

double QgsLayoutItemLabel::htmlUnitsToLayoutUnits()
{
if ( !mLayout )
{
return 1.0;
}

//TODO : fix this more precisely so that the label's default text size is the same with or without "display as html"
return mLayout->convertToLayoutUnits( QgsLayoutMeasurement( mLayout->renderContext().dpi() / 72.0, Qgis::LayoutUnit::Millimeters ) ); //webkit seems to assume a standard dpi of 72
}

QString QgsLayoutItemLabel::createStylesheet() const
{
QString stylesheet;
stylesheet += QStringLiteral( "body { margin: %1 %2;" ).arg( std::max( mMarginY * mHtmlUnitsToLayoutUnits, 0.0 ) ).arg( std::max( mMarginX * mHtmlUnitsToLayoutUnits, 0.0 ) );

QFont f = createDefaultFont();
stylesheet += QgsFontUtils::asCSS( f, 0.352778 * mHtmlUnitsToLayoutUnits );

stylesheet += QStringLiteral( "color: rgba(%1,%2,%3,%4);" ).arg( mFormat.color().red() ).arg( mFormat.color().green() ).arg( mFormat.color().blue() ).arg( QString::number( mFormat.color().alphaF(), 'f', 4 ) );
stylesheet += QStringLiteral( "text-align: %1; }" ).arg( mHAlignment == Qt::AlignLeft ? QStringLiteral( "left" ) : mHAlignment == Qt::AlignRight ? QStringLiteral( "right" ) : mHAlignment == Qt::AlignHCenter ? QStringLiteral( "center" ) : QStringLiteral( "justify" ) );

return stylesheet;
}

QUrl QgsLayoutItemLabel::createStylesheetUrl() const
{
QByteArray ba;
ba.append( stylesheet.toUtf8() );
ba.append( createStylesheet().toUtf8() );
QUrl cssFileURL = QUrl( QString( "data:text/css;charset=utf-8;base64," + ba.toBase64() ) );

return cssFileURL;
Expand Down

0 comments on commit e8db036

Please sign in to comment.