diff -ru a/src/export_common.rs b/src/export_common.rs --- a/src/export_common.rs 2026-10-10 16:40:12.677934900 -0400 +++ b/src/export_common.rs 2026-10-10 16:46:12.277526700 -0400 @@ -209,6 +209,177 @@ None } +// johnmcm.com patch: where a link between pages goes in the exported PDF. +// +// A DjVu link names its page by number (`#12`, `#page=12`), by the page's +// component name (`#p0012.djvu`, how most books link) or relatively (`#+1`, +// `#-2`). A page-range export runs on a split copy of the book, whose pages +// are numbered from 0 again while its links still use the whole book's +// numbers and names. The caller says, before exporting, which book this is +// and which of its pages go out; links are resolved against that, and a link +// to a page left out is dropped rather than sent to the wrong page. + +#[cfg(feature = "std")] +struct LinkScope { + /// First page exported, as an index in the whole book. + start: usize, + /// One past the last page exported. + end: usize, + /// The whole book's page component names, in page order. + ids: Vec, +} + +#[cfg(feature = "std")] +std::thread_local! { + static LINK_SCOPE: core::cell::RefCell> = const { core::cell::RefCell::new(None) }; +} + +/// Before a PDF export: `book` is the whole document, and pages +/// `start..end` of it (0-based) are the ones exported. +#[cfg(feature = "std")] +pub fn set_link_scope(book: &[u8], start: usize, end: usize) { + let ids = book_page_ids(book); + LINK_SCOPE.with(|s| *s.borrow_mut() = Some(LinkScope { start, end, ids })); +} + +/// After the export, so a later one does not inherit this book's scope. +#[cfg(feature = "std")] +pub fn clear_link_scope() { + LINK_SCOPE.with(|s| *s.borrow_mut() = None); +} + +/// The page component names of a bundled or indirect `FORM:DJVM`, in order; +/// empty for a single-page document or one whose directory cannot be read. +#[cfg(feature = "std")] +fn book_page_ids(book: &[u8]) -> Vec { + use crate::dirm::{DirmComponentKind, DirmPayload}; + let Ok(form) = crate::iff::parse_form(book) else { return Vec::new() }; + if form.form_type != *b"DJVM" { + return Vec::new(); + } + let Some(dirm) = form.chunks.iter().find(|c| c.id == *b"DIRM") else { return Vec::new() }; + let Ok(dirm) = DirmPayload::decode(dirm.data) else { return Vec::new() }; + dirm.components() + .into_iter() + .filter(|c| c.kind == DirmComponentKind::Page) + .map(|c| c.id) + .collect() +} + +/// Where a link goes in the exported PDF. +#[allow(dead_code)] +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum LinkTarget { + /// A page of the PDF (0-based). + Page(usize), + /// Somewhere outside the book: keep the address. + Uri, + /// A page of the book that is not in this PDF, or an address inside the + /// book that names no page: no link at all. + Drop, +} + +/// Resolves `url` for the PDF being exported. `here` is the PDF page (0-based) +/// the link sits on, for the relative `#+N` / `#-N` forms; bookmarks have no +/// page of their own and pass `None`, which drops a relative one. +#[allow(dead_code)] +pub(crate) fn resolve_link(url: &str, here: Option) -> LinkTarget { + #[cfg(feature = "std")] + { + LINK_SCOPE.with(|s| match &*s.borrow() { + Some(scope) => resolve_in(url, here, scope.start, scope.end, &scope.ids), + None => resolve_in(url, here, 0, usize::MAX, &[]), + }) + } + #[cfg(not(feature = "std"))] + { + resolve_in(url, here, 0, usize::MAX, &[]) + } +} + +fn resolve_in(url: &str, here: Option, start: usize, end: usize, ids: &[String]) -> LinkTarget { + let Some(frag) = url.strip_prefix('#') else { + return if url.trim().is_empty() { LinkTarget::Drop } else { LinkTarget::Uri }; + }; + let frag = frag.trim(); + let book = if let Some(step) = relative_step(frag) { + // Relative to the page the link is on, counted in the whole book. + here.and_then(|h| usize::try_from(start as i64 + h as i64 + step).ok()) + } else if let Some(i) = ids.iter().position(|id| same_page_name(id, frag)) { + Some(i) + } else { + bookmark_page_index(url) + }; + match book { + Some(b) if b >= start && b < end => LinkTarget::Page(b - start), + _ => LinkTarget::Drop, + } +} + +/// `+N` or `-N`: a step from the current page. +fn relative_step(frag: &str) -> Option { + let sign = match frag.as_bytes().first()? { + b'+' => 1, + b'-' => -1, + _ => return None, + }; + let n: i64 = frag[1..].parse().ok()?; + Some(sign * n) +} + +/// A component name as a link writes it: exactly, or without its extension +/// (`p0012` for `p0012.djvu`), ignoring case. +fn same_page_name(id: &str, frag: &str) -> bool { + if frag.is_empty() { + return false; + } + let stem = |s: &str| s.rsplit_once('.').map_or(s, |(a, _)| a).to_ascii_lowercase(); + id.eq_ignore_ascii_case(frag) || stem(id) == stem(frag) +} + +#[cfg(test)] +mod link_scope_tests { + use super::*; + + + fn ids() -> Vec { + ["p0001.djvu", "p0002.djvu", "p0003.djvu", "p0004.djvu", "p0005.djvu"].iter().map(|s| s.to_string()).collect() + } + + #[test] + fn whole_book_numbers_names_and_steps() { + let ids = ids(); + assert_eq!(resolve_in("#3", None, 0, 5, &ids), LinkTarget::Page(2)); + assert_eq!(resolve_in("#page=4", None, 0, 5, &ids), LinkTarget::Page(3)); + assert_eq!(resolve_in("#p0005.djvu", None, 0, 5, &ids), LinkTarget::Page(4)); + assert_eq!(resolve_in("#P0002", None, 0, 5, &ids), LinkTarget::Page(1)); + assert_eq!(resolve_in("#+1", Some(1), 0, 5, &ids), LinkTarget::Page(2)); + assert_eq!(resolve_in("#-1", Some(1), 0, 5, &ids), LinkTarget::Page(0)); + assert_eq!(resolve_in("https://example.org/", Some(0), 0, 5, &ids), LinkTarget::Uri); + } + + #[test] + fn a_range_shifts_inside_and_drops_outside() { + let ids = ids(); + // Pages 2..4 of the book (0-based 1..4) are PDF pages 0..3. + assert_eq!(resolve_in("#3", None, 1, 4, &ids), LinkTarget::Page(1)); + assert_eq!(resolve_in("#p0004.djvu", None, 1, 4, &ids), LinkTarget::Page(2)); + assert_eq!(resolve_in("#1", None, 1, 4, &ids), LinkTarget::Drop); + assert_eq!(resolve_in("#p0005.djvu", None, 1, 4, &ids), LinkTarget::Drop); + // On PDF page 0 (book page 2), one step back is book page 1: left out. + assert_eq!(resolve_in("#-1", Some(0), 1, 4, &ids), LinkTarget::Drop); + assert_eq!(resolve_in("#+2", Some(0), 1, 4, &ids), LinkTarget::Page(2)); + } + + #[test] + fn unknown_names_and_relative_bookmarks_are_dropped() { + let ids = ids(); + assert_eq!(resolve_in("#chapter-two", None, 0, 5, &ids), LinkTarget::Drop); + assert_eq!(resolve_in("#+1", None, 0, 5, &ids), LinkTarget::Drop); + assert_eq!(resolve_in("#", None, 0, 5, &ids), LinkTarget::Drop); + } +} + /// A leaf text span: a `Word` or `Character` zone with non-empty text. /// /// Borrows from the source [`TextLayer`]; `rect` is in top-left-origin pixels diff -ru a/src/lib.rs b/src/lib.rs --- a/src/lib.rs 2026-10-10 16:40:13.132302700 -0400 +++ b/src/lib.rs 2026-10-10 16:44:14.749982000 -0400 @@ -356,6 +356,9 @@ /// the leaf word/character zone-walk, and the vertical coordinate flip. #[cfg(feature = "std")] mod export_common; +// johnmcm.com patch: which book and pages a PDF export covers, for its links. +#[cfg(feature = "std")] +pub use export_common::{clear_link_scope, set_link_scope}; /// Shared progress reporting and cooperative cancellation for export writers. #[cfg(feature = "std")] diff -ru a/src/pdf/outline.rs b/src/pdf/outline.rs --- a/src/pdf/outline.rs 2026-10-10 16:40:14.271959600 -0400 +++ b/src/pdf/outline.rs 2026-10-10 16:43:47.912777800 -0400 @@ -101,17 +101,19 @@ /// Resolve a DjVu bookmark URL to a PDF destination string. /// DjVu internal URLs look like `#page_N` or `#+N` or `#-N`. pub(super) fn resolve_bookmark_dest(url: &str, page_ids: &[usize]) -> String { - if let Some(idx) = crate::export_common::bookmark_page_index(url) - && let Some(&pid) = page_ids.get(idx) - { - return format!(" /Dest [{pid} 0 R /Fit]"); + // johnmcm.com patch: page names (#p0012.djvu) resolve too, through the + // same rules as links; an address inside the book that names no page + // gives a bookmark with no destination rather than a broken URI. + use crate::export_common::{LinkTarget, resolve_link}; + match resolve_link(url, None) { + LinkTarget::Page(idx) => match page_ids.get(idx) { + Some(&pid) => format!(" /Dest [{pid} 0 R /Fit]"), + None => String::new(), + }, + LinkTarget::Uri => { + let escaped = pdf_escape_string(url); + format!(" /A << /S /URI /URI ({escaped}) >>") + } + LinkTarget::Drop => String::new(), } - - // External URL or unparseable — use URI action - if !url.is_empty() { - let escaped = pdf_escape_string(url); - return format!(" /A << /S /URI /URI ({escaped}) >>"); - } - - String::new() } diff -ru a/src/pdf/page.rs b/src/pdf/page.rs --- a/src/pdf/page.rs 2026-10-10 16:40:14.279467200 -0400 +++ b/src/pdf/page.rs 2026-10-10 16:43:42.658215900 -0400 @@ -192,10 +192,13 @@ .iter() .filter_map(|link| { let rect = shape_to_pdf_rect(&link.shape, dpi, pt_h)?; - // johnmcm.com patch: a link to a page of the book goes to that page. - let action = match crate::export_common::bookmark_page_index(&link.url) { - Some(i) => format!("/S /GoTo /D ({})", page_dest_name(i)), - None => format!("/S /URI /URI ({})", pdf_escape_string(&link.url)), + // johnmcm.com patch: a link to a page of the book goes to that page + // of the PDF, and one to a page the PDF leaves out is dropped. + use crate::export_common::{LinkTarget, resolve_link}; + let action = match resolve_link(&link.url, Some(page.index())) { + LinkTarget::Page(i) => format!("/S /GoTo /D ({})", page_dest_name(i)), + LinkTarget::Uri => format!("/S /URI /URI ({})", pdf_escape_string(&link.url)), + LinkTarget::Drop => return None, }; Some( format!(