Skip to content

clickable PDF links; added webbrowser, query link handling, and click…#135

Open
ShemShadrack wants to merge 2 commits intoitsjunetime:mainfrom
ShemShadrack:main
Open

clickable PDF links; added webbrowser, query link handling, and click…#135
ShemShadrack wants to merge 2 commits intoitsjunetime:mainfrom
ShemShadrack:main

Conversation

@ShemShadrack
Copy link

Previously worked on "Improved internal PDF link handling and navigation" closed because my branch was behind ended up losing my changes but with this. Remade the same commit improved link handing, no matter the screen size whether book view or phone view external link handling works perfectly.

Copy link
Owner

@itsjunetime itsjunetime left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like this code, and I really appreciate your work on it, but I don't think it's functioning fully right now - I downloaded this example PDF, and when I open it with this PR in tdf, I can only get the links to work if I click them at exactly the right spots - clicking on most of any given link doesn't seem to do anything. Maybe there's some slight adjustments of the location checking on the tui and renderer side that needs to be adjusted?

I'll take a look at this a bit later with this same PDF to see if I can figure out why the links aren't registering; for now, if you could resolve these small issues I've attached, that would be appreciated :)

src/main.rs Outdated
})?;
// await the renderer reply; the renderer now returns
// Result<Option<LinkTarget>, String> so we can display errors
match resp_rx.recv_async().await {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd prefer this recv_async call to be moved into the tokio::select so that the ui doesn't freeze while waiting for the response (just in case it takes a second, for whatever reason).

src/main.rs Outdated
// Result<Option<LinkTarget>, String> so we can display errors
match resp_rx.recv_async().await {
Ok(Ok(Some(LinkTarget::Uri(uri)))) => {
if let Err(e) = webbrowser::open(&uri) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I'm convinced on using webbrowser, since it always only opens in a browser (as opposed to just whatever the default handler for the mimetype is). For example, if you received a pdf and an attached spreadsheet, and you clicked on a link to the spreadsheet in the pdf, it would definitely be weird for your browser to open instead of your spreadsheet app.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we know for certain all other URIs are going to be websites, I'd be chill with it. But I don't think we can know that for certain? I don't know.

/// Query which link (if any) is under the provided mupdf device coordinates for the given
/// page. Returns Ok(Some(LinkTarget)) if a link is found, Ok(None) if no link, or
/// Err(mupdf::error::Error) on mupdf failures.
fn query_link_at(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

really appreciate the doc comments :)

src/renderer.rs Outdated
Comment on lines +114 to +119
if !link.uri.is_empty() {
return Ok(Some(LinkTarget::Uri(link.uri)));
}
if let Some(dest) = link.dest {
return Ok(Some(LinkTarget::GoTo { page_index: dest.loc.page_number as usize }));
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems that links can have both a destination and a uri? Very weird, didn't expect that. I'm fine with us preferring URIs over destination, but we may just want to keep this in mind in case other pdf viewers honor both at the same time.

src/renderer.rs Outdated
Comment on lines +307 to +309
.unwrap_or_else(|e| {
panic!("Renderer failed to send link-query response: {e}")
});
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.unwrap_or_else(|e| {
panic!("Renderer failed to send link-query response: {e}")
});
.expect("Renderer failed to send link-query response");

.expect will print out the error along with your message, so there's no need for unwrap_or_else

src/renderer.rs Outdated
Comment on lines +300 to +306
match query_link_at(&doc, qpage, mupdf_x_px, mupdf_y_px, area_w, area_h, fit_or_fill) {
Ok(t) => resp.send(Ok(t)),
Err(e) => {
let err_str = format!("Failed to query links: {e}");
resp.send(Err(err_str))
}
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
match query_link_at(&doc, qpage, mupdf_x_px, mupdf_y_px, area_w, area_h, fit_or_fill) {
Ok(t) => resp.send(Ok(t)),
Err(e) => {
let err_str = format!("Failed to query links: {e}");
resp.send(Err(err_str))
}
}
let msg = query_link_at(&doc, qpage, mupdf_x_px, mupdf_y_px, area_w, area_h, fit_or_fill)
.map_err(|e| format!("Failed to query links: {e}"));
resp.send(msg)

src/tui.rs Outdated
Comment on lines +629 to +639
let mut page_infos: Vec<(usize, u16)> = self
.rendered[self.page..]
.iter()
.take(pages_shown)
.enumerate()
.filter_map(|(idx, p)| p.img.as_ref().map(|img| (self.page + idx, img.w_h().0)))
.collect();

if self.page_constraints.r_to_l {
page_infos.reverse();
}
Copy link
Owner

@itsjunetime itsjunetime Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can avoid this collect (and thus should probably remove it in the layout code as well) with something like the following:

		let end_idx = (self.page + pages_shown).min(self.rendered.len());
		let mut page_infos = self
			.rendered[self.page..end_idx]
			.iter()
			.enumerate()
			.filter_map(|(idx, p)| p.img.as_ref().map(|img| (self.page + idx, img.w_h().0)));

		if self.page_constraints.r_to_l {
			run_checks_with_iter(page_infos.rev());
		} else {
			run_checks_With_iter(page_infos);
		}

where run_checks_with_iter does all the stuff you have below this, checking the location and bounds to see where we clicked.

I'm having a somewhat difficult time wrapping my head around all the reversing and calculations and stuff here, though, so maybe I'm wrong.

…ng, zoom parameter parsing, and upstream rotation integration
@ShemShadrack
Copy link
Author

Thanks for the feedback and the example PDF! It was extremely helpful for tracking down the behavior. I've updated the PR to address all of those comments and fix the link detection.

Quick summary:

  • Accurate Hit-Testing: The root cause of the "dead zones" when clicking links was that the TUI was only testing a single top-left pixel against the link's bounding box . I updated map_click_to_page
    to pass the full cell's width and height to the renderer.

  • Swapped webbrowser for open I removed the webbrowser crate and implemented open::that() instead so that external files automatically open with the system's default handler according to their MIME types instead of forcing the browser. I also added a sanitizer to strip #page= anchor fragments from file: paths so the open crate doesn't fail when finding local attachments.

  • Non-Blocking UI: To prevent recv_async() from accidentally freezing the UI, the QueryLinkAt await now runs inside a separate tokio::spawn task.

  • Code Cleanup: I applied your suggestions in renderer.rs to clean up the query_link_at matching logic by using .map_err() and replacing unwrap_or_else with a clean .expect().

  • Internal Link Types & #page fragments: The system now properly handles #nameddest IDs and raw #page=X fragments natively hidden in the URI by converting them smoothly into LinkTarget::GoTo jumps.

  • On Automatic Zooming: For URLs specifying zoom (like 2#zoom=200), the URI parser now cleanly strips the &zoom= parameter and successfully completes an immediate JumpToPage.

Currently, tdf-viewer treats Zoom specifically as an interactive state entirely sandboxed within the TUI frontend. To trigger automatic zooming from the renderer backend would require somewhat extensive decoupling.

Screen.Recording.2026-03-12.at.02.33.44.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants