1// Copyright (C) 2016 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
3
4#include "helpviewer.h"
5#include "helpviewerimpl.h"
6
7#include "helpenginewrapper.h"
8#include "tracer.h"
9
10#include <QtCore/QFileInfo>
11#include <QtCore/QStringBuilder>
12#include <QtCore/QTemporaryFile>
13
14#include <QtGui/QDesktopServices>
15#if QT_CONFIG(clipboard)
16#include <QtGui/QClipboard>
17#endif
18#include <QtGui/QGuiApplication>
19#include <QtGui/QWheelEvent>
20
21#include <QtWidgets/QScrollBar>
22#include <QtWidgets/QVBoxLayout>
23
24#include <QtHelp/QHelpEngineCore>
25
26#include <qlitehtmlwidget.h>
27
28QT_BEGIN_NAMESPACE
29
30const int kMaxHistoryItems = 20;
31
32struct ExtensionMap {
33 const char *extension;
34 const char *mimeType;
35} extensionMap[] = {
36 { .extension: ".bmp", .mimeType: "image/bmp" },
37 { .extension: ".css", .mimeType: "text/css" },
38 { .extension: ".gif", .mimeType: "image/gif" },
39 { .extension: ".html", .mimeType: "text/html" },
40 { .extension: ".htm", .mimeType: "text/html" },
41 { .extension: ".ico", .mimeType: "image/x-icon" },
42 { .extension: ".jpeg", .mimeType: "image/jpeg" },
43 { .extension: ".jpg", .mimeType: "image/jpeg" },
44 { .extension: ".js", .mimeType: "application/x-javascript" },
45 { .extension: ".mng", .mimeType: "video/x-mng" },
46 { .extension: ".pbm", .mimeType: "image/x-portable-bitmap" },
47 { .extension: ".pgm", .mimeType: "image/x-portable-graymap" },
48 { .extension: ".pdf", .mimeType: nullptr },
49 { .extension: ".png", .mimeType: "image/png" },
50 { .extension: ".ppm", .mimeType: "image/x-portable-pixmap" },
51 { .extension: ".rss", .mimeType: "application/rss+xml" },
52 { .extension: ".svg", .mimeType: "image/svg+xml" },
53 { .extension: ".svgz", .mimeType: "image/svg+xml" },
54 { .extension: ".text", .mimeType: "text/plain" },
55 { .extension: ".tif", .mimeType: "image/tiff" },
56 { .extension: ".tiff", .mimeType: "image/tiff" },
57 { .extension: ".txt", .mimeType: "text/plain" },
58 { .extension: ".xbm", .mimeType: "image/x-xbitmap" },
59 { .extension: ".xml", .mimeType: "text/xml" },
60 { .extension: ".xpm", .mimeType: "image/x-xpm" },
61 { .extension: ".xsl", .mimeType: "text/xsl" },
62 { .extension: ".xhtml", .mimeType: "application/xhtml+xml" },
63 { .extension: ".wml", .mimeType: "text/vnd.wap.wml" },
64 { .extension: ".wmlc", .mimeType: "application/vnd.wap.wmlc" },
65 { .extension: "about:blank", .mimeType: nullptr },
66 { .extension: nullptr, .mimeType: nullptr }
67};
68
69static QByteArray getData(const QUrl &url)
70{
71 // TODO: this is just a hack for Qt documentation
72 // which decides to use a simpler CSS if the viewer does not have JavaScript
73 // which was a hack to decide if we are viewing in QTextBrowser or QtWebEngine et al
74 QUrl actualUrl = url;
75 QString path = url.path(options: QUrl::FullyEncoded);
76 static const char simpleCss[] = "/offline-simple.css";
77 if (path.endsWith(s: simpleCss)) {
78 path.replace(before: simpleCss, after: "/offline.css");
79 actualUrl.setPath(path);
80 }
81
82 if (actualUrl.isValid())
83 return HelpEngineWrapper::instance().fileData(url: actualUrl);
84
85 const bool isAbout = (actualUrl.toString() == QLatin1String("about:blank"));
86 return isAbout ? HelpViewerImpl::AboutBlank.toUtf8()
87 : HelpViewerImpl::PageNotFoundMessage.arg(a: url.toString()).toUtf8();
88}
89
90class HelpViewerPrivate
91{
92public:
93 struct HistoryItem
94 {
95 QUrl url;
96 QString title;
97 int vscroll;
98 };
99 HistoryItem currentHistoryItem() const;
100 void setSourceInternal(const QUrl &url, int *vscroll = nullptr, bool reload = false);
101 void incrementZoom(int steps);
102 void applyZoom(int percentage);
103
104 HelpViewer *q = nullptr;
105 QLiteHtmlWidget *m_viewer = nullptr;
106 std::vector<HistoryItem> m_backItems;
107 std::vector<HistoryItem> m_forwardItems;
108 int m_fontZoom = 100; // zoom percentage
109};
110
111HelpViewerPrivate::HistoryItem HelpViewerPrivate::currentHistoryItem() const
112{
113 return { .url: m_viewer->url(), .title: m_viewer->title(), .vscroll: m_viewer->verticalScrollBar()->value() };
114}
115
116void HelpViewerPrivate::setSourceInternal(const QUrl &url, int *vscroll, bool reload)
117{
118 QGuiApplication::setOverrideCursor(QCursor(Qt::WaitCursor));
119
120 const bool isHelp = (url.toString() == QLatin1String("help"));
121 const QUrl resolvedUrl = (isHelp ? HelpViewerImpl::LocalHelpFile
122 : HelpEngineWrapper::instance().findFile(url));
123
124 QUrl currentUrlWithoutFragment = m_viewer->url();
125 currentUrlWithoutFragment.setFragment(fragment: {});
126 QUrl newUrlWithoutFragment = resolvedUrl;
127 newUrlWithoutFragment.setFragment(fragment: {});
128
129 m_viewer->setUrl(resolvedUrl);
130 if (currentUrlWithoutFragment != newUrlWithoutFragment || reload)
131 m_viewer->setHtml(QString::fromUtf8(ba: getData(url: resolvedUrl)));
132 if (vscroll)
133 m_viewer->verticalScrollBar()->setValue(*vscroll);
134 else
135 m_viewer->scrollToAnchor(name: resolvedUrl.fragment(options: QUrl::FullyEncoded));
136
137 QGuiApplication::restoreOverrideCursor();
138
139 emit q->sourceChanged(url: q->source());
140 emit q->loadFinished();
141 emit q->titleChanged();
142}
143
144void HelpViewerPrivate::incrementZoom(int steps)
145{
146 const int incrementPercentage = 10 * steps; // 10 percent increase by single step
147 const int previousZoom = m_fontZoom;
148 applyZoom(percentage: previousZoom + incrementPercentage);
149}
150
151void HelpViewerPrivate::applyZoom(int percentage)
152{
153 const int newZoom = qBound(min: 10, val: percentage, max: 300);
154 if (newZoom == m_fontZoom)
155 return;
156 m_fontZoom = newZoom;
157 m_viewer->setZoomFactor(newZoom / 100.0);
158}
159
160HelpViewer::HelpViewer(qreal zoom, QWidget *parent)
161 : QWidget(parent)
162 , d(new HelpViewerPrivate)
163{
164 auto layout = new QVBoxLayout;
165 d->q = this;
166 d->m_viewer = new QLiteHtmlWidget(this);
167 d->m_viewer->setResourceHandler([](const QUrl &url) { return getData(url); });
168 d->m_viewer->viewport()->installEventFilter(filterObj: this);
169 const int zoomPercentage = zoom == 0 ? 100 : zoom * 100;
170 d->applyZoom(percentage: zoomPercentage);
171 connect(sender: d->m_viewer, signal: &QLiteHtmlWidget::linkClicked, context: this, slot: &HelpViewer::setSource);
172 connect(sender: d->m_viewer, signal: &QLiteHtmlWidget::linkHighlighted, context: this, slot: &HelpViewer::highlighted);
173#if QT_CONFIG(clipboard)
174 connect(sender: d->m_viewer, signal: &QLiteHtmlWidget::copyAvailable, context: this, slot: &HelpViewer::copyAvailable);
175#endif
176 setLayout(layout);
177 layout->setContentsMargins(left: 0, top: 0, right: 0, bottom: 0);
178 layout->addWidget(d->m_viewer, stretch: 10);
179
180 // Make docs' contents visible in dark theme
181 QPalette p = palette();
182 p.setColor(acg: QPalette::Inactive, acr: QPalette::Highlight,
183 acolor: p.color(cg: QPalette::Active, cr: QPalette::Highlight));
184 p.setColor(acg: QPalette::Inactive, acr: QPalette::HighlightedText,
185 acolor: p.color(cg: QPalette::Active, cr: QPalette::HighlightedText));
186 p.setColor(acr: QPalette::Base, acolor: Qt::white);
187 p.setColor(acr: QPalette::Text, acolor: Qt::black);
188 setPalette(p);
189}
190
191HelpViewer::~HelpViewer()
192{
193 delete d;
194}
195
196QFont HelpViewer::viewerFont() const
197{
198 return d->m_viewer->defaultFont();
199}
200
201void HelpViewer::setViewerFont(const QFont &font)
202{
203 d->m_viewer->setDefaultFont(font);
204}
205
206void HelpViewer::scaleUp()
207{
208 d->incrementZoom(steps: 1);
209}
210
211void HelpViewer::scaleDown()
212{
213 d->incrementZoom(steps: -1);
214}
215
216void HelpViewer::resetScale()
217{
218 d->applyZoom(percentage: 100);
219}
220
221qreal HelpViewer::scale() const
222{
223 return d->m_viewer->zoomFactor();
224}
225
226QString HelpViewer::title() const
227{
228 return d->m_viewer->title();
229}
230
231QUrl HelpViewer::source() const
232{
233 return d->m_viewer->url();
234}
235
236void HelpViewer::reload()
237{
238 doSetSource(url: source(), reload: true);
239}
240
241void HelpViewer::setSource(const QUrl &url)
242{
243 doSetSource(url, reload: false);
244}
245
246void HelpViewer::doSetSource(const QUrl &url, bool reload)
247{
248 if (launchWithExternalApp(url))
249 return;
250
251 d->m_forwardItems.clear();
252 emit forwardAvailable(enabled: false);
253 if (d->m_viewer->url().isValid()) {
254 d->m_backItems.push_back(x: d->currentHistoryItem());
255 while (d->m_backItems.size() > kMaxHistoryItems) // this should trigger only once anyhow
256 d->m_backItems.erase(position: d->m_backItems.begin());
257 emit backwardAvailable(enabled: true);
258 }
259
260 d->setSourceInternal(url, vscroll: nullptr, reload);
261}
262
263void HelpViewer::print(QPagedPaintDevice *printer)
264{
265 Q_UNUSED(printer);
266 // TODO: implement me
267}
268
269QString HelpViewer::selectedText() const
270{
271 return d->m_viewer->selectedText();
272}
273
274bool HelpViewer::isForwardAvailable() const
275{
276 return !d->m_forwardItems.empty();
277}
278
279bool HelpViewer::isBackwardAvailable() const
280{
281 return !d->m_backItems.empty();
282}
283
284static QTextDocument::FindFlags textDocumentFlagsForFindFlags(HelpViewer::FindFlags flags)
285{
286 QTextDocument::FindFlags textDocFlags;
287 if (flags & HelpViewer::FindBackward)
288 textDocFlags |= QTextDocument::FindBackward;
289 if (flags & HelpViewer::FindCaseSensitively)
290 textDocFlags |= QTextDocument::FindCaseSensitively;
291 return textDocFlags;
292}
293
294bool HelpViewer::findText(const QString &text, FindFlags flags, bool incremental, bool fromSearch)
295{
296 Q_UNUSED(fromSearch);
297 return d->m_viewer->findText(text, flags: textDocumentFlagsForFindFlags(flags), incremental);
298}
299
300#if QT_CONFIG(clipboard)
301void HelpViewer::copy()
302{
303 QGuiApplication::clipboard()->setText(selectedText());
304}
305#endif
306
307void HelpViewer::home()
308{
309 setSource(HelpEngineWrapper::instance().homePage());
310}
311
312void HelpViewer::forward()
313{
314 HelpViewerPrivate::HistoryItem nextItem = d->currentHistoryItem();
315 if (d->m_forwardItems.empty())
316 return;
317 d->m_backItems.push_back(x: nextItem);
318 nextItem = d->m_forwardItems.front();
319 d->m_forwardItems.erase(position: d->m_forwardItems.begin());
320
321 emit backwardAvailable(enabled: isBackwardAvailable());
322 emit forwardAvailable(enabled: isForwardAvailable());
323 d->setSourceInternal(url: nextItem.url, vscroll: &nextItem.vscroll);
324}
325
326void HelpViewer::backward()
327{
328 HelpViewerPrivate::HistoryItem previousItem = d->currentHistoryItem();
329 if (d->m_backItems.empty())
330 return;
331 d->m_forwardItems.insert(position: d->m_forwardItems.begin(), x: previousItem);
332 previousItem = d->m_backItems.back();
333 d->m_backItems.pop_back();
334
335 emit backwardAvailable(enabled: isBackwardAvailable());
336 emit forwardAvailable(enabled: isForwardAvailable());
337 d->setSourceInternal(url: previousItem.url, vscroll: &previousItem.vscroll);
338}
339
340bool HelpViewer::eventFilter(QObject *src, QEvent *event)
341{
342 if (event->type() == QEvent::Wheel) {
343 auto we = static_cast<QWheelEvent *>(event);
344 if (we->modifiers() == Qt::ControlModifier) {
345 we->accept();
346 const int deltaY = we->angleDelta().y();
347 if (deltaY != 0)
348 d->incrementZoom(steps: deltaY / 120);
349 return true;
350 }
351 }
352 return QWidget::eventFilter(watched: src, event);
353}
354
355bool HelpViewer::isLocalUrl(const QUrl &url)
356{
357 TRACE_OBJ
358 const QString &scheme = url.scheme();
359 return scheme.isEmpty()
360 || scheme == QLatin1String("file")
361 || scheme == QLatin1String("qrc")
362 || scheme == QLatin1String("data")
363 || scheme == QLatin1String("qthelp")
364 || scheme == QLatin1String("about");
365}
366
367bool HelpViewer::canOpenPage(const QString &path)
368{
369 TRACE_OBJ
370 return !mimeFromUrl(url: QUrl::fromLocalFile(localfile: path)).isEmpty();
371}
372
373QString HelpViewer::mimeFromUrl(const QUrl &url)
374{
375 TRACE_OBJ
376 const QString &path = url.path();
377 const int index = path.lastIndexOf(c: QLatin1Char('.'));
378 const QByteArray &ext = path.mid(position: index).toUtf8().toLower();
379
380 const ExtensionMap *e = extensionMap;
381 while (e->extension) {
382 if (ext == e->extension)
383 return QLatin1String(e->mimeType);
384 ++e;
385 }
386 return QLatin1String("application/octet-stream");
387}
388
389bool HelpViewer::launchWithExternalApp(const QUrl &url)
390{
391 TRACE_OBJ
392 if (isLocalUrl(url)) {
393 const HelpEngineWrapper &helpEngine = HelpEngineWrapper::instance();
394 const QUrl &resolvedUrl = helpEngine.findFile(url);
395 if (!resolvedUrl.isValid())
396 return false;
397
398 const QString& path = resolvedUrl.toLocalFile();
399 if (!canOpenPage(path)) {
400 QTemporaryFile tmpTmpFile;
401 if (!tmpTmpFile.open())
402 return false;
403
404 const QString &extension = QFileInfo(path).completeSuffix();
405 QFile actualTmpFile(tmpTmpFile.fileName() % QLatin1String(".")
406 % extension);
407 if (!actualTmpFile.open(flags: QIODevice::ReadWrite | QIODevice::Truncate))
408 return false;
409
410 actualTmpFile.write(data: helpEngine.fileData(url: resolvedUrl));
411 actualTmpFile.close();
412 return QDesktopServices::openUrl(url: QUrl::fromLocalFile(localfile: actualTmpFile.fileName()));
413 }
414 return false;
415 }
416 return QDesktopServices::openUrl(url);
417}
418
419QT_END_NAMESPACE
420

source code of qttools/src/assistant/assistant/helpviewer.cpp