How to search and extract text across a whole PDF document on iOS

Searching text across all pages of a PDF on iOS

This guide shows how to search text in PDF on iOS across every page of a document with RadaeePDF, and how to reuse the same text objects to extract page text and highlight matches.

Applies to: RadaeePDF Classic and Master SDK for iOS. All snippets use selectors declared in PDFViewer/PDFLib/PDFObjc.h, which are the same in both editions.

Short answer: For each page, get an RDPDFPage with [doc page:i] and load its text with [page objsStart:false]. Then call [page find:key :matchCase :wholeWord]. The returned RDPDFFinder gives you the match count (count) and the first character index of each match (objsIndex:). Run the loop on a background queue, the way the demo’s RDExtendedSearch class does. The same character indexes work with objsString:: to extract text and with objsCharRect:: to get rectangles for highlighting.

How the text API is organized

Text search and text extraction in RadaeePDF both work on a page’s text objects. You call objsStart: once per page. After that, every character on the page has a 0-based index from 0 to objsCount - 1, and the other calls use those indexes:

  • -(void)objsStart:(bool)rtol loads the page’s text objects. Pass true only for right-to-left text. The demo passes GLOBAL.g_sel_rtol. The header notes that this needs a standard license.
  • -(int)objsCount returns the number of characters on the page.
  • -(NSString *)objsString:(int)from :(int)to returns the text for a range of character indexes.
  • -(void)objsCharRect:(int)index :(PDF_RECT *)rect returns a character’s box in PDF page coordinates.
  • -(RDPDFFinder *)find:(NSString *)key :(bool)match_case :(bool)whole_word starts a find session on the page. It returns nil when there are no matches.
  • -(RDPDFFinder *)find2:(NSString *)key :(bool)match_case :(bool)whole_word :(bool)skip_blanks does the same but treats line breaks as blanks. Use it when a phrase can wrap across lines.

RDPDFFinder has three methods: count, objsIndex: (the first character of match n) and objsEnd: (the end character of match n).

The KB sample Search in whole pdf file (iOS) uses the older names PDFDoc, PDFPage, PDFFinder and an argument-less objsStart. In the current Classic and Master repositories, these classes are RDPDFDoc, RDPDFPage and RDPDFFinder, and objsStart: takes a bool.

Search text in PDF on iOS on a background queue

On a long document, searching every page can take noticeable time, so keep it off the main thread. This is how the demo’s RDExtendedSearch works: it loops over the pages inside dispatch_async(dispatch_get_global_queue(...)) and checks a stop flag on every page so the search can be cancelled. The class below does the same thing in a self-contained way.

Objective-C (Classic and Master)

#import "PDFObjc.h"

@interface DocSearcher : NSObject
@property (atomic) BOOL cancelled;
- (void)search:(RDPDFDoc *)doc
           key:(NSString *)key
     matchCase:(bool)matchCase
     wholeWord:(bool)wholeWord
    completion:(void (^)(NSArray<NSDictionary *> *hits))completion;
@end

@implementation DocSearcher

- (void)search:(RDPDFDoc *)doc
           key:(NSString *)key
     matchCase:(bool)matchCase
     wholeWord:(bool)wholeWord
    completion:(void (^)(NSArray<NSDictionary *> *hits))completion
{
    self.cancelled = NO;
    dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
        NSMutableArray<NSDictionary *> *hits = [NSMutableArray array];
        int pageCount = [doc pageCount];
        for (int pageno = 0; pageno < pageCount && !self.cancelled; pageno++) {
            RDPDFPage *page = [doc page:pageno];
            if (!page) break;
            [page objsStart:false];
            RDPDFFinder *finder = [page find:key :matchCase :wholeWord];
            int count = [finder count];          // 0 when finder is nil
            int chars = [page objsCount];
            for (int i = 0; i < count; i++) {
                int first = [finder objsIndex:i];
                // Context snippet around the hit, like RDExtendedSearch builds it
                int from = MAX(first - 25, 0);
                int to = MIN(first + (int)key.length + 25, chars - 1);
                from = [page objsAlignWord:from :-1];
                to = [page objsAlignWord:to :1] + 1;
                NSString *context = [page objsString:from :to];
                [hits addObject:@{ @"page": @(pageno),
                                   @"char": @(first),
                                   @"context": context ?: @"" }];
            }
            page = nil;
        }
        dispatch_async(dispatch_get_main_queue(), ^{
            completion(hits);
        });
    });
}

@end

Set cancelled = YES when the user changes the query or closes the document, and keep the RDPDFDoc open until the completion block has run.

Reusing the demo’s RDExtendedSearch

If you embed the demo’s PDFReader sources, you can call the singleton directly. It reads the case, whole-word and right-to-left options from GLOBAL.g_case_sensitive, GLOBAL.g_match_whole_word and GLOBAL.g_sel_rtol. Each result is an RDSearchResult with stringResult and a 1-based page. Both blocks are called on the background queue, which is why SearchResultViewController hops to the main queue:

#import "RDExtendedSearch.h"

[[RDExtendedSearch sharedInstance] searchText:@"invoice" inDoc:doc progress:^(NSMutableArray *occurrences, NSMutableArray *total) {
    dispatch_async(dispatch_get_main_queue(), ^{
        for (RDSearchResult *r in occurrences) {
            NSLog(@"page %d: %@", r.page, r.stringResult);
        }
    });
} finish:^{
    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"search finished");
    });
}];

// To stop a running search:
[[RDExtendedSearch sharedInstance] clearSearch];

Extract the full text of a page

To get a page’s full text, call objsStart: and then read the range from 0 to objsCount. This is what the Cordova plugin’s extractTextFromPage: does on iOS. To extract a whole document, run the same calls in a loop on a background queue:

- (NSString *)textOfDocument:(RDPDFDoc *)doc
{
    NSMutableString *all = [NSMutableString string];
    int pageCount = [doc pageCount];
    for (int pageno = 0; pageno < pageCount; pageno++) {
        RDPDFPage *page = [doc page:pageno];
        [page objsStart:false];
        int count = [page objsCount];
        if (count > 0) {
            NSString *text = [page objsString:0 :count];
            if (text) [all appendFormat:@"%@\n", text];
        }
        page = nil;
    }
    return all;
}

Get match rectangles for highlighting

A match can span several characters and even wrap onto a second line. The viewer’s RDVFinder gets the box of each character with objsCharRect:: and merges characters that share a line into a single rectangle. You can do the same to draw your own overlays:

// One rectangle per line covered by the match, in PDF page coordinates.
static NSArray<NSValue *> *MatchRects(RDPDFPage *page, int first, int length)
{
    NSMutableArray<NSValue *> *rects = [NSMutableArray array];
    PDF_RECT word, r;
    [page objsCharRect:first :&word];
    for (int i = first + 1; i < first + length; i++) {
        [page objsCharRect:i :&r];
        float gap = (r.bottom - r.top) / 2;
        if (word.top == r.top && word.bottom == r.bottom &&
            word.right + gap > r.left && word.left - gap < r.right) {
            if (word.left > r.left) word.left = r.left;
            if (word.right < r.right) word.right = r.right;
        } else {
            [rects addObject:[NSValue valueWithBytes:&word objCType:@encode(PDF_RECT)]];
            word = r;
        }
    }
    [rects addObject:[NSValue valueWithBytes:&word objCType:@encode(PDF_RECT)]];
    return rects;
}

Call this right after find: while the page’s text objects are still loaded, with first = [finder objsIndex:i] and length = key.length. That is the same end the viewer uses. PDF coordinates have the y axis pointing up, so the viewer maps a rectangle’s bottom to the top of the on-screen box. If you place overlays yourself at a zoom factor scale, use x = rect.left * scale and y = ([doc pageHeight:pageno] - rect.bottom) * scale.

To make the highlight a permanent annotation in the file instead of a temporary overlay, call [page addAnnotMarkup:first :first + length - 1 :0 :0xFFFFFF00] after objsStart:. Type 0 is Highlight, and the color is the ARGB value the demo uses by default for highlights. Then save the document. The header says this needs a professional or premium license.

If you build your own viewer on the demo’s PDFLayoutView, you get on-screen highlighting without any extra code. vFindStart::: starts a search, vFind: moves to the next (1) or previous (-1) match and paints it, and vFindEnd clears the highlight. PDFReaderCtrl uses these calls internally.

Swift equivalent

The repositories ship Objective-C sources only. Once you add PDFObjc.h to your bridging header, Swift imports the unlabeled selectors with _ parameters, int as Int32 and unannotated object returns as optionals:

func search(_ doc: RDPDFDoc, key: String,
            completion: @escaping ([(page: Int32, char: Int32)]) -> Void) {
    DispatchQueue.global(qos: .userInitiated).async {
        var hits: [(page: Int32, char: Int32)] = []
        for pageno in 0..<doc.pageCount() {
            guard let page = doc.page(pageno) else { break }
            page.objsStart(false)
            guard let finder = page.find(key, false, false) else { continue }
            for i in 0..<finder.count() {
                hits.append((page: pageno, char: finder.objsIndex(i)))
            }
        }
        DispatchQueue.main.async { completion(hits) }
    }
}

In Swift, check the result of find for nil with guard let. In Objective-C, sending count to nil simply returns 0.

Related questions

How do I make the search case-sensitive or whole-word only?

Pass true as the second or third argument of find:::, which are match_case and whole_word. In the demo viewer, these options come from GLOBAL.g_case_sensitive and GLOBAL.g_match_whole_word, and both RDExtendedSearch and PDFLayoutView read them.

Why does a phrase that wraps onto the next line not match?

find::: matches the characters exactly as they appear on the page, including the line break. Use find2:::: with skip_blanks set to true, which treats line breaks as blank characters.

Do I need to call objsStart before every find?

Call it once per RDPDFPage object, before find:, objsCount, objsString:: or objsCharRect::. The RDPDFFinder header notes that objsIndex: expects objsStart to have been called on the page.

Can I search a document that is open in PDFReaderCtrl?

Yes. Get the document with [reader getDoc] and run the background loop on it. To jump to a result, call [reader PDFGoto:pageno] with the 0-based page number.

Related on radaeepdf.com

Where to go next