Search found the right PDF. It just never landed anywhere near the actual answer. Here's how I fixed that.
A support message summed up the problem better than I could: search finds the right PDF, but the link always opens page one, even when the match is on page 84. Technically, the search worked. Practically, the person still had to do the work themselves once the file opened.
This post walks through how I actually added page-level linking to search results, the schema change it needed, and the part where a browser fragment does most of the heavy lifting for free.
The starting point: one row, one file, no page data
The original index table looked roughly like this:
CREATE TABLE wp_pdf_search_index (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
attachment_id BIGINT UNSIGNED NOT NULL,
extracted_text LONGTEXT NOT NULL,
indexed_at DATETIME NOT NULL,
FULLTEXT KEY pdf_text_index (extracted_text)
);
All of a PDF's text goes into one extracted_text column. A search matches the row, the row maps to a file, done. This works fine if all you need is to know that this PDF contains the term. It has nothing to say about where in the PDF that term actually is, because every page's text got concatenated into one blob before it was ever stored.
Step 1: storing content per page instead of per file
To know which page a match came from, the page boundary has to survive into storage. That meant splitting into two tables instead of one:
CREATE TABLE wp_pdf_search_files (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
attachment_id BIGINT UNSIGNED NOT NULL,
total_pages INT UNSIGNED DEFAULT 0,
KEY attachment_id (attachment_id)
);
CREATE TABLE wp_pdf_search_pages (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
file_id BIGINT UNSIGNED NOT NULL,
page_number INT UNSIGNED NOT NULL,
page_text MEDIUMTEXT NOT NULL,
KEY file_id (file_id),
FULLTEXT KEY page_text_ft (page_text)
);
During indexing, instead of concatenating every page into one string, each page gets its own row:
function index_pdf_by_page( $attachment_id, $file_path ) {
global $wpdb;
$parser = new PdfParser();
$pdf = $parser->parseFile( $file_path );
$pages = $pdf->getPages();
$wpdb->insert(
'wp_pdf_search_files',
array(
'attachment_id' => $attachment_id,
'total_pages' => count( $pages ),
)
);
$file_id = $wpdb->insert_id;
foreach ( $pages as $index => $page ) {
$wpdb->insert(
'wp_pdf_search_pages',
array(
'file_id' => $file_id,
'page_number' => $index + 1,
'page_text' => $page->getText(),
)
);
}
}
Now a 120-page document produces 120 rows instead of 1, and every row knows exactly which page it came from.
Step 2: finding the best matching page, not just the best matching file
A search term can match several pages in the same PDF. Since results show one card per file, I needed to collapse multiple page matches down to a single best match:
function search_pdf_pages( $term ) {
global $wpdb;
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT file_id, page_number,
MATCH(page_text) AGAINST (%s IN NATURAL LANGUAGE MODE) AS relevance
FROM wp_pdf_search_pages
WHERE MATCH(page_text) AGAINST (%s IN NATURAL LANGUAGE MODE)
ORDER BY relevance DESC",
$term,
$term
)
);
$best_per_file = array();
foreach ( $results as $row ) {
if ( ! isset( $best_per_file[ $row->file_id ] )
|| $row->relevance > $best_per_file[ $row->file_id ]->relevance ) {
$best_per_file[ $row->file_id ] = $row;
}
}
return array_values( $best_per_file );
}
This is the step that actually decides which page a visitor lands on. Get the ordering wrong and someone gets sent to a page where the term appears once in passing instead of the page it's actually about.
Step 3: turning a page number into a working link
Once a result has a file_id and page_number, generating a URL that opens the PDF directly on that page is almost anticlimactic:
function pdf_page_url( $attachment_id, $page_number ) {
$file_url = wp_get_attachment_url( $attachment_id );
return $file_url . '#page=' . absint( $page_number );
}
The hash-page fragment is standard behavior that most browsers and PDF viewer scripts already honor; it's not something I had to build. The actual work was everything upstream of this function, making sure a real page number existed to plug into it in the first place.
What this actually looks like in the result
$results = search_pdf_pages( $search_term );
foreach ( $results as $result ) {
$url = pdf_page_url( $result->file_id, $result->page_number );
printf(
'<a href="%s">Open on page %d</a>',
esc_url( $url ),
$result->page_number
);
}
Someone searching a 120-page handbook now clicks straight through to the page with the answer, not the cover page.
The trade-off worth naming
This isn't free. Storing one row per page instead of one row per file means more rows, more storage, and slower writes during indexing for large documents. For a 300-page archive, that's a real cost, not a rounding error. Worth it for what it enables, but not something to gloss over if you're considering the same change.
Has anyone else restructured an index like this after the fact instead of designing for it upfront? Curious how much of a migration headache it was for existing data.
This is the actual mechanism behind page-level search results in WebEquipe PDF Search, if you want to see it running on a real plugin rather than the simplified version above.

Top comments (0)