Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qpdfsearchmodel.cpp
Go to the documentation of this file.
1// Copyright (C) 2020 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3
5#include "qpdflink.h"
6#include "qpdflink_p.h"
10
11#include "third_party/pdfium/public/fpdf_text.h"
12#include "third_party/pdfium/public/fpdfview.h"
13
14#include <QtCore/qelapsedtimer.h>
15#include <QtCore/QMetaEnum>
16
18
19Q_PDF_LOGGING_CATEGORY(qLcS, "qt.pdf.search")
20
21static const int UpdateTimerInterval = 100;
22static const int ContextChars = 64;
23
24/*!
25 \class QPdfSearchModel
26 \since 5.15
27 \inmodule QtPdf
28 \inherits QAbstractListModel
29
30 \brief The QPdfSearchModel class searches for a string in a PDF document
31 and holds the results.
32
33 This is used in the \l {Model/View Programming} paradigm to display
34 a list of search results, to highlight them on the rendered PDF pages,
35 and to iterate through them using the "search forward" / "search backward"
36 buttons and shortcuts that would be found in a typical document-viewing UI:
37
38 \image search-results.png {PDF viewer with search results panel showing
39 entries for "sand" and highlighted result on page}
40*/
41
42/*!
43 \enum QPdfSearchModel::Role
44
45 \value Page The page number where the search result is found (int).
46 \value IndexOnPage The index of the search result on the page (int).
47 \value Location The position of the search result on the page (QPointF).
48 \value ContextBefore The adjacent text on the page, before the search string (QString).
49 \value ContextAfter The adjacent text on the page, after the search string (QString).
50 \omitvalue NRoles
51
52 \sa QPdfLink
53*/
54
55/*!
56 Constructs a new search model with parent object \a parent.
57*/
58QPdfSearchModel::QPdfSearchModel(QObject *parent)
59 : QAbstractListModel(*(new QPdfSearchModelPrivate()), parent)
60{
61 const QMetaEnum rolesMetaEnum = QMetaEnum::fromType<Role>();
62 for (int r = Qt::UserRole; r < int(Role::NRoles); ++r) {
63 QByteArray roleName = QByteArray(rolesMetaEnum.valueToKey(r));
64 if (roleName.isEmpty())
65 continue;
66 roleName[0] = QChar::toLower(roleName[0]);
67 m_roleNames.insert(r, roleName);
68 }
69 connect(this, &QAbstractListModel::dataChanged, this, &QPdfSearchModel::countChanged);
70 connect(this, &QAbstractListModel::modelReset, this, &QPdfSearchModel::countChanged);
71 connect(this, &QAbstractListModel::rowsRemoved, this, &QPdfSearchModel::countChanged);
72 connect(this, &QAbstractListModel::rowsInserted, this, &QPdfSearchModel::countChanged);
73}
74
75/*!
76 Destroys the model.
77*/
78QPdfSearchModel::~QPdfSearchModel() {}
79
80/*!
81 \reimp
82*/
83QHash<int, QByteArray> QPdfSearchModel::roleNames() const
84{
85 return m_roleNames;
86}
87
88/*!
89 \reimp
90
91 The number of rows in the model is equal to the number of search results found.
92*/
93int QPdfSearchModel::rowCount(const QModelIndex &parent) const
94{
95 Q_D(const QPdfSearchModel);
96 Q_UNUSED(parent);
97 return d->rowCountSoFar;
98}
99
100/*!
101 \reimp
102*/
103QVariant QPdfSearchModel::data(const QModelIndex &index, int role) const
104{
105 Q_D(const QPdfSearchModel);
106 const auto pi = const_cast<QPdfSearchModelPrivate*>(d)->pageAndIndexForResult(index.row());
107 if (pi.page < 0)
108 return QVariant();
109 switch (Role(role)) {
110 case Role::Page:
111 return pi.page;
112 case Role::IndexOnPage:
113 return pi.index;
114 case Role::Location:
115 return d->searchResults[pi.page][pi.index].location();
116 case Role::ContextBefore:
117 return d->searchResults[pi.page][pi.index].contextBefore();
118 case Role::ContextAfter:
119 return d->searchResults[pi.page][pi.index].contextAfter();
120 case Role::NRoles:
121 break;
122 }
123 if (role == Qt::DisplayRole) {
124 const QPdfLink &result = d->searchResults[pi.page][pi.index];
125 // what was found, which is not always the search string: the case can
126 // differ, and pdfium can match across characters that it skips over
127 const QString found = d->matchedText(pi);
128 const QString ret = result.contextBefore() + QLatin1String("<b>")
129 + (found.isEmpty() ? d->searchString : found) + QLatin1String("</b>")
130 + result.contextAfter();
131 return ret;
132 }
133 return QVariant();
134}
135
136/*!
137 \since 6.8
138 \property QPdfSearchModel::count
139 \brief the number of search results found
140*/
141int QPdfSearchModel::count() const
142{
143 return rowCount(QModelIndex());
144}
145
146/*!
147 \since 6.12
148 \enum QPdfSearchModel::Status
149
150 This enum describes the current status of the search model.
151
152 \value Null The initial status before the first search has begun.
153 \value Searching The status while the search is being conducted.
154 \value Finished The status when all possible search results have been found on all pages.
155
156 \sa QPdfSearchModel::status()
157*/
158
159/*!
160 \since 6.12
161 \property QPdfSearchModel::status
162 \brief the search status
163
164 This property tells whether a search is in progress.
165
166 \sa count()
167*/
168QPdfSearchModel::Status QPdfSearchModel::status() const
169{
170 Q_D(const QPdfSearchModel);
171 return d->status;
172}
173
174void QPdfSearchModel::updatePage(int page)
175{
176 Q_D(QPdfSearchModel);
177 d->doSearch(page);
178}
179
180/*!
181 \property QPdfSearchModel::searchString
182 \brief the string to search for
183*/
184QString QPdfSearchModel::searchString() const
185{
186 Q_D(const QPdfSearchModel);
187 return d->searchString;
188}
189
190void QPdfSearchModel::setSearchString(const QString &searchString)
191{
192 Q_D(QPdfSearchModel);
193 if (d->searchString == searchString)
194 return;
195
196 d->searchString = searchString;
197 d->clearResults();
198 emit searchStringChanged();
199}
200
201/*!
202 Returns the list of all results found on the given \a page.
203*/
204QList<QPdfLink> QPdfSearchModel::resultsOnPage(int page) const
205{
206 Q_D(const QPdfSearchModel);
207 const_cast<QPdfSearchModelPrivate *>(d)->doSearch(page);
208 if (d->searchResults.size() <= page)
209 return {};
210 return d->searchResults[page];
211}
212
213/*!
214 Returns a result found by \a index in the \l document, regardless of the
215 page on which it was found. \a index must be less than \l rowCount.
216*/
217QPdfLink QPdfSearchModel::resultAtIndex(int index) const
218{
219 Q_D(const QPdfSearchModel);
220 const auto pi = const_cast<QPdfSearchModelPrivate*>(d)->pageAndIndexForResult(index);
221 if (pi.page < 0 || index < 0)
222 return {};
223 return d->searchResults[pi.page][pi.index];
224}
225
226/*!
227 \property QPdfSearchModel::document
228 \brief the document to search
229*/
230QPdfDocument *QPdfSearchModel::document() const
231{
232 Q_D(const QPdfSearchModel);
233 return d->document;
234}
235
236void QPdfSearchModel::setDocument(QPdfDocument *document)
237{
238 Q_D(QPdfSearchModel);
239 if (d->document == document)
240 return;
241
242 disconnect(d->documentConnection);
243 d->documentConnection = connect(document, &QPdfDocument::pageCountChanged, this,
244 [this]() { d_func()->clearResults(); });
245
246 d->document = document;
247 d->clearResults();
248 emit documentChanged();
249}
250
251void QPdfSearchModel::timerEvent(QTimerEvent *event)
252{
253 Q_D(QPdfSearchModel);
254 if (event->id() != d->updateTimerId)
255 return;
256 if (!d->document || d->nextPageToUpdate >= d->document->pageCount()) {
257 if (d->document)
258 qCDebug(qLcS) << "done updating search results on" << d->searchResults.size() << "pages";
259 killTimer(d->updateTimerId);
260 d->updateTimerId = Qt::TimerId::Invalid;
261 d->setStatus(QPdfSearchModel::Status::Finished);
262 } else if (!d->searchString.isEmpty()) {
263 d->setStatus(QPdfSearchModel::Status::Searching);
264 }
265 d->doSearch(d->nextPageToUpdate++);
266}
267
268QPdfSearchModelPrivate::QPdfSearchModelPrivate() : QAbstractItemModelPrivate()
269{
270}
271
273{
274 Q_Q(QPdfSearchModel);
275 // This throws away every row, so the model has to be reset: clearResults()
276 // is called when the document changes too, not only from setSearchString().
277 q->beginResetModel();
278 rowCountSoFar = 0;
279 searchResults.clear();
280 pagesSearched.clear();
281 if (document) {
282 searchResults.resize(document->pageCount());
283 pagesSearched.resize(document->pageCount());
284 }
286
287 if (updateTimerId == Qt::TimerId::Invalid)
288 updateTimerId = Qt::TimerId(q->startTimer(UpdateTimerInterval));
289 // Go to Searching right away rather than in the first timer event: until the
290 // sweep begins, the status would otherwise still claim that the _previous_
291 // search is Finished, while the results of that search are already gone.
292 if (document && !searchString.isEmpty())
293 setStatus(QPdfSearchModel::Status::Searching);
294 q->endResetModel();
295}
296
298{
299 if (page < 0 || page >= pagesSearched.size() || searchString.isEmpty())
300 return false;
301 if (pagesSearched[page])
302 return true;
303 Q_Q(QPdfSearchModel);
304
305 const QPdfMutexLocker lock;
306 QElapsedTimer timer;
307 timer.start();
308 FPDF_PAGE pdfPage = FPDF_LoadPage(document->d->doc, page);
309 if (!pdfPage) {
310 qWarning() << "failed to load page" << page;
311 return false;
312 }
313 FPDF_TEXTPAGE textPage = FPDFText_LoadPage(pdfPage);
314 if (!textPage) {
315 qWarning() << "failed to load text of page" << page;
316 FPDF_ClosePage(pdfPage);
317 return false;
318 }
319 FPDF_SCHHANDLE sh = FPDFText_FindStart(textPage, searchString.utf16(), 0, 0);
320 QList<QPdfLink> newSearchResults;
321 while (FPDFText_FindNext(sh)) {
322 // Where the match begins and ends, in the same character indices that
323 // FPDFText_CountRects() and FPDFText_GetText() below are given.
324 const int startIndex = FPDFText_GetSchResultIndex(sh);
325 const int matchLength = FPDFText_GetSchCount(sh);
326 const int endIndex = startIndex + matchLength - 1;
327 const int rectCount = FPDFText_CountRects(textPage, startIndex, matchLength);
328 QList<QRectF> rects;
329 for (int r = 0; r < rectCount; ++r) {
330 // get bounding box of search result in page coordinates
331 double left, top, right, bottom;
332 FPDFText_GetRect(textPage, r, &left, &top, &right, &bottom);
333 // deal with any internal PDF transforms and
334 // convert to the 1x (pixels = points) 4th-quadrant coordinate system
335 rects << document->d->mapPageToView(pdfPage, left, top, right, bottom);
336 qCDebug(qLcS) << rects.last() << "char idx" << startIndex << "->" << endIndex
337 << "from page rect" << left << top << right << bottom;
338 }
339 QString matchedText, contextBefore, contextAfter;
340 if (startIndex >= 0) {
341 int contextStart = qMax(0, startIndex - ContextChars);
342 int contextEnd = endIndex + ContextChars;
343 int count = contextEnd - contextStart + 1;
344 if (count > 0) {
345 QList<ushort> buf(count + 1);
346 int len = FPDFText_GetText(textPage, contextStart, count, buf.data());
347 Q_ASSERT(len - 1 <= count); // len is number of characters written, including the terminator
348 QString context = QString::fromUtf16(
349 reinterpret_cast<const char16_t *>(buf.constData()), len - 1);
350
351 const auto replaceNewlines = [&](QString &s) {
352 s.replace(QLatin1Char('\n'), QStringLiteral("\u23CE"));
353 s.remove(QLatin1Char('\r'));
354 };
355
356 matchedText = context.mid(startIndex - contextStart, matchLength);
357 contextBefore = context.mid(0, startIndex - contextStart);
358 contextAfter = context.mid(startIndex - contextStart + matchLength);
359 replaceNewlines(matchedText);
360 replaceNewlines(contextBefore);
361 replaceNewlines(contextAfter);
362 }
363 }
364 if (!rects.isEmpty())
365 newSearchResults << QPdfLink(page, rects, matchedText, contextBefore, contextAfter);
366 }
367 FPDFText_FindClose(sh);
368 FPDFText_ClosePage(textPage);
369 FPDF_ClosePage(pdfPage);
370 qCDebug(qLcS) << searchString << "took" << timer.elapsed() << "ms to find"
371 << newSearchResults.size() << "results on page" << page;
372
373 pagesSearched[page] = true;
374 searchResults[page] = newSearchResults;
375 if (newSearchResults.size() > 0) {
376 int rowsBefore = rowsBeforePage(page);
377 qCDebug(qLcS) << "from row" << rowsBefore << "rowCount" << rowCountSoFar << "increasing by" << newSearchResults.size();
378 rowCountSoFar += newSearchResults.size();
379 q->beginInsertRows(QModelIndex(), rowsBefore, rowsBefore + newSearchResults.size() - 1);
380 q->endInsertRows();
381 }
382 return true;
383}
384
386{
387 return searchResults[pi.page][pi.index].d->text;
388}
389
391{
392 if (pagesSearched.isEmpty())
393 return {-1, -1};
394 const int pageCount = document->pageCount();
395 int totalSoFar = 0;
396 int previousTotalSoFar = 0;
397 for (int page = 0; page < pageCount; ++page) {
398 if (!pagesSearched[page])
399 doSearch(page);
400 totalSoFar += searchResults[page].size();
401 if (totalSoFar > resultIndex)
402 return {page, resultIndex - previousTotalSoFar};
403 previousTotalSoFar = totalSoFar;
404 }
405 return {-1, -1};
406}
407
409{
410 int ret = 0;
411 for (int i = 0; i < page; ++i)
412 ret += searchResults[i].size();
413 return ret;
414}
415
416void QPdfSearchModelPrivate::setStatus(QPdfSearchModel::Status s)
417{
418 Q_Q(QPdfSearchModel);
419 if (status == s)
420 return;
421
422 status = s;
423 emit q->statusChanged(status);
424}
425
426QT_END_NAMESPACE
427
428#include "moc_qpdfsearchmodel.cpp"
QString matchedText(const PageAndIndex &pi) const
void setStatus(QPdfSearchModel::Status s)
PageAndIndex pageAndIndexForResult(int resultIndex)
Combined button and popup list for selecting options.
static const int ContextChars
#define Q_PDF_LOGGING_CATEGORY(name,...)