Commit 8ec324c8 authored by Igor Kushnir's avatar Igor Kushnir
Browse files

Documentation view: fix overriding CSS on Web Engine pages

The current runJavaScript()-based code doesn't work for two reasons:
1. The URL passed to StandardDocumentationView::setOverrideCss() always
points to a local file (file:///) in practice. Qt WebEngine does not
allow loading such local resources.
2. The JavaScript code passed to runJavaScript() attempts to manipulate
DOM but runs before the HTML document is loaded, and therefore has no
effect.

Fix the local file security issue (1) by embedding CSS inline instead of
referencing an external CSS file.

Fix the JavaScript execution timing issue (2) by replacing
runJavaScript() with QWebEngineScript injected at DocumentReady point.

Don't override CSS in ManPageDocumentation::documentationWidget() if the
CSS file was not found.

This gets rid of the following warnings in KDevelop's output:
  js: Not allowed to load local resource: file:///tmp/kdevelop.qWHueg
  js: Not allowed to load local resource: file:///usr/share/kdevmanpage/manpagedocumentation.css

manpagedocumentation.css is applied to man pages now: the top banner and
the extra margins are removed.
parent 1cc6f7d6
Pipeline #195830 passed with stage
in 26 minutes and 56 seconds
......@@ -18,6 +18,7 @@
#include <QContextMenuEvent>
#include <QMouseEvent>
#include <QMenu>
#include <QUrl>
#ifdef USE_QTWEBKIT
#include <QFontDatabase>
......@@ -25,6 +26,8 @@
#include <QWebFrame>
#include <QWebSettings>
#else
#include <util/kdevstringhandler.h>
#include <QFile>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QPointer>
......@@ -35,6 +38,8 @@
#include <QWebEngineUrlSchemeHandler>
#include <QWebEngineUrlRequestJob>
#include <QWebEngineProfile>
#include <QWebEngineScript>
#include <QWebEngineScriptCollection>
#endif
using namespace KDevelop;
......@@ -288,21 +293,64 @@ void StandardDocumentationView::update()
qCDebug(DOCUMENTATION) << "calling StandardDocumentationView::update() on an uninitialized view";
}
void KDevelop::StandardDocumentationView::setOverrideCss(const QUrl& url)
void KDevelop::StandardDocumentationView::setOverrideCssFile(const QString& cssFilePath)
{
#ifdef USE_QTWEBKIT
Q_D(StandardDocumentationView);
d->m_view->settings()->setUserStyleSheetUrl(QUrl::fromLocalFile(cssFilePath));
#else
QFile file(cssFilePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qCWarning(DOCUMENTATION) << "cannot read CSS file" << cssFilePath << ':' << file.error() << file.errorString();
return;
}
const auto cssCode = file.readAll();
setOverrideCssCode(cssCode);
#endif
}
void StandardDocumentationView::setOverrideCssCode(const QByteArray& cssCode)
{
Q_D(StandardDocumentationView);
#ifdef USE_QTWEBKIT
d->m_view->settings()->setUserStyleSheetUrl(url);
// Experiments show that Base64UrlEncoding or Base64UrlEncoding|OmitTrailingEquals flags
// must not be passed to the QByteArray::toBase64() call here: when the difference between
// these encoding variants matters and the flag(s) are passed, the CSS code is not applied.
const QByteArray dataUrl = "data:text/css;charset=utf-8;base64," + cssCode.toBase64();
d->m_view->settings()->setUserStyleSheetUrl(QUrl::fromEncoded(dataUrl));
#else
d->m_view->page()->runJavaScript(QLatin1String(
"var link = document.createElement( 'link' );"
"link.href = '") + url.toString() + QLatin1String("';"
"link.type = 'text/css';"
"link.rel = 'stylesheet';"
"link.media = 'screen,print';"
"document.getElementsByTagName( 'head' )[0].appendChild( link );")
);
const auto scriptName = QStringLiteral("OverrideCss");
auto& scripts = d->m_view->page()->scripts();
const auto oldScript = scripts.findScript(scriptName);
scripts.remove(oldScript);
if (cssCode.isEmpty()) {
return;
}
// The loading of CSS via JavaScript has a downside: pages are first loaded as is, then
// reloaded with the style applied. When a page is large, the reloading is conspicuous
// or causes flickering. For example, this can be seen on cmake-modules man page.
// This cannot be fixed by specifying an earlier injection point - DocumentCreation -
// because, according to QWebEngineScript documentation, this is not suitable for any
// DOM operation. So with the DocumentCreation injection point the CSS style is not
// applied and the following error appears in KDevelop's output:
// js: Uncaught TypeError: Cannot read property 'appendChild' of null
QWebEngineScript script;
script.setInjectionPoint(QWebEngineScript::DocumentReady);
script.setName(scriptName);
script.setRunsOnSubFrames(false);
script.setSourceCode(QLatin1String("const css = document.createElement('style');"
"css.innerText = '%1';"
"document.head.appendChild(css);")
.arg(QString::fromUtf8(escapeJavaScriptString(cssCode))));
script.setWorldId(QWebEngineScript::ApplicationWorld);
scripts.insert(script);
#endif
}
......
......@@ -45,7 +45,18 @@ public:
void setDocumentation(const IDocumentation::Ptr& doc);
void setOverrideCss(const QUrl &url);
/**
* Specifies the location of a user stylesheet to load with every web page
*
* @note each call to this function or setOverrideCssCode() overwrites any previously specified style
*/
void setOverrideCssFile(const QString& cssFilePath);
/**
* Inject the specified UTF-8-encoded CSS code into each web page
*
* @note each call to this function or setOverrideCssFile() overwrites any previously specified style
*/
void setOverrideCssCode(const QByteArray& cssCode);
void load(const QUrl &url);
void setHtml(const QString &html);
......
......@@ -117,6 +117,49 @@ QString htmlToPlainText(const QString& s, HtmlToPlainTextMode mode)
}
return QString(); // never reached
}
QByteArray escapeJavaScriptString(const QByteArray& str)
{
// The special symbols that have to be escaped are listed e.g. here:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#escape_sequences
QByteArray result;
result.reserve(str.size());
for (char ch : str) {
switch (ch) {
case '\n':
result += "\\n";
break;
case '\r':
result += "\\r";
break;
case '\t':
result += "\\t";
break;
case '\b':
result += "\\b";
break;
case '\f':
result += "\\f";
break;
case '\v':
result += "\\v";
break;
case '\0':
result += "\\0";
break;
case '\'':
case '"':
case '\\':
result += '\\';
[[fallthrough]];
default:
result += ch;
}
}
return result;
}
}
int KDevelop::findAsciiIdentifierLength(const QStringRef& str)
......
......@@ -52,6 +52,13 @@ enum HtmlToPlainTextMode {
*/
KDEVPLATFORMUTIL_EXPORT QString htmlToPlainText(const QString& s, HtmlToPlainTextMode mode = FastMode);
/**
* Replace special JavaScript characters with escape sequences
*
* @return a string ready to be enclosed in single or double quotes and used in JavaScript
*/
KDEVPLATFORMUTIL_EXPORT QByteArray escapeJavaScriptString(const QByteArray& str);
/**
* Match a prefix of @p str to an ASCII-only identifier, i.e. [a-zA-Z_][a-zA-Z0-9_]*
*
......
......@@ -46,6 +46,39 @@ void TestStringHandler::testHtmlToPlainText_data()
<< KDevelop::CompleteMode << "bar() \na\nfoo";
}
void TestStringHandler::testEscapeJavaScriptString()
{
QFETCH(QByteArray, unescaped);
QFETCH(QByteArray, escaped);
const auto actual = escapeJavaScriptString(unescaped);
QCOMPARE(actual, escaped);
}
void TestStringHandler::testEscapeJavaScriptString_data()
{
QTest::addColumn<QByteArray>("unescaped");
QTest::addColumn<QByteArray>("escaped");
const auto nothingToEscape = QByteArrayLiteral("html { background: white !important; }");
QTest::newRow("nothing to escape") << nothingToEscape << nothingToEscape;
QTest::newRow("newlines and single quotes")
<< QByteArrayLiteral("body {\nfont-family: 'Liberation Serif', sans-serif;\n }\n")
<< QByteArrayLiteral("body {\\nfont-family: \\'Liberation Serif\\', sans-serif;\\n }\\n");
QTest::newRow("HTML and double quotes") << QByteArrayLiteral(R"(<img src="my-icon (2).png" alt="[app icon]">)")
<< QByteArrayLiteral(R"(<img src=\"my-icon (2).png\" alt=\"[app icon]\">)");
// Prevent '\0' from terminating the string.
constexpr char allUnescaped[] = "\\ \0\" \b\f\n\r\t\v '";
const auto allUnescapedSize = sizeof(allUnescaped) / sizeof(char) - 1;
constexpr char allEscaped[] = "\\\\ \\0\\\" \\b\\f\\n\\r\\t\\v \\'";
const auto allEscapedSize = sizeof(allEscaped) / sizeof(char) - 1;
QTest::newRow("all special characters") << QByteArray(allUnescaped, allUnescapedSize)
<< QByteArray(allEscaped, allEscapedSize);
}
namespace {
void addAsciiIdentifierData()
{
......
......@@ -19,6 +19,9 @@ private Q_SLOTS:
void testHtmlToPlainText();
void testHtmlToPlainText_data();
void testEscapeJavaScriptString();
void testEscapeJavaScriptString_data();
void testFindAsciiIdentifierLength();
void testFindAsciiIdentifierLength_data();
void testFindAsciiIdentifierLengthNoMatch();
......
......@@ -59,7 +59,10 @@ QWidget* ManPageDocumentation::documentationWidget(KDevelop::DocumentationFindWi
// apply custom style-sheet to normalize look of the page
const QString cssFile = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("kdevmanpage/manpagedocumentation.css"));
view->setOverrideCss(QUrl::fromLocalFile(cssFile));
if (!cssFile.isEmpty()) {
view->setOverrideCssFile(cssFile);
}
return view;
}
......
......@@ -15,7 +15,6 @@
#include <QHeaderView>
#include <QMenu>
#include <QMouseEvent>
#include <QTemporaryFile>
#include <QRegularExpression>
#include <KLocalizedString>
......@@ -70,11 +69,6 @@ QtHelpDocumentation::QtHelpDocumentation(const QString& name, const QMap<QString
{ Q_ASSERT(m_current!=m_info.constEnd()); }
#endif
QtHelpDocumentation::~QtHelpDocumentation()
{
delete m_lastStyleSheet.data();
}
QString QtHelpDocumentation::description() const
{
const QUrl url = currentUrl();
......@@ -196,20 +190,12 @@ QString QtHelpDocumentation::description() const
void QtHelpDocumentation::setUserStyleSheet(StandardDocumentationView* view, const QUrl& url)
{
auto* file = new QTemporaryFile(view);
file->open();
QTextStream ts(file);
ts << "html { background: white !important; }\n";
auto cssCode = QByteArrayLiteral("html { background: white !important; }\n");
if (url.scheme() == QLatin1String("qthelp") && url.host().startsWith(QLatin1String("com.trolltech.qt."))) {
ts << ".content .toc + .title + p { clear:left; }\n"
<< "#qtdocheader .qtref { position: absolute !important; top: 5px !important; right: 0 !important; }\n";
cssCode += ".content .toc + .title + p { clear:left; }\n"
"#qtdocheader .qtref { position: absolute !important; top: 5px !important; right: 0 !important; }\n";
}
file->close();
view->setOverrideCss(QUrl::fromLocalFile(file->fileName()));
delete m_lastStyleSheet.data();
m_lastStyleSheet = file;
view->setOverrideCssCode(cssCode);
}
QWidget* QtHelpDocumentation::documentationWidget(DocumentationFindWidget* findWidget, QWidget* parent)
......
......@@ -12,7 +12,6 @@
#include <QList>
#include <QMap>
#include <QUrl>
#include <QPointer>
#include <QAction>
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
#include <QHelpLink>
......@@ -24,7 +23,6 @@ namespace KDevelop { class StandardDocumentationView; }
class QModelIndex;
class QNetworkAccessManager;
class QtHelpProviderAbstract;
class QTemporaryFile;
class QtHelpDocumentation : public KDevelop::IDocumentation
{
......@@ -38,8 +36,6 @@ class QtHelpDocumentation : public KDevelop::IDocumentation
QtHelpDocumentation(const QString& name, const QMap<QString, QUrl>& info, const QString& key);
#endif
~QtHelpDocumentation() override;
QString name() const override { return m_name; }
QString description() const override;
......@@ -84,7 +80,6 @@ class QtHelpDocumentation : public KDevelop::IDocumentation
#endif
KDevelop::StandardDocumentationView* lastView;
QPointer<QTemporaryFile> m_lastStyleSheet;
};
class HomeDocumentation : public KDevelop::IDocumentation
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment