diff -ru a/src/pdf/mod.rs b/src/pdf/mod.rs --- a/src/pdf/mod.rs 2026-10-09 20:49:08.880695400 -0400 +++ b/src/pdf/mod.rs 2026-10-09 21:08:42.957867600 -0400 @@ -251,9 +251,8 @@ let catalog_id = w.alloc_id(); // 1 let pages_id = w.alloc_id(); // 2 - // Reserve a font object ID - let font_id = w.alloc_id(); // 3 - w.add_obj(font_id, font_dict())?; + // The text layer's font (johnmcm.com patch: Unicode text layer). + let font_id = add_text_font(&mut w)?; let page_count = doc.page_count(); @@ -296,9 +295,18 @@ Some(oid) => format!(" /Outlines {oid} 0 R /PageMode /UseOutlines"), None => String::new(), }; + // johnmcm.com patch: a named destination for every page, which links to + // other pages use (zero-padded, so the names sort as the spec requires). + let dests = page_obj_ids + .iter() + .enumerate() + .map(|(i, id)| format!("({}) [{id} 0 R /Fit]", page_dest_name(i))) + .collect::>() + .join(" "); + let names_id = w.add(format!("<< /Names [{dests}] >>").into_bytes())?; w.add_obj( catalog_id, - format!("<< /Type /Catalog /Pages {pages_id} 0 R{outline_ref} >>").into_bytes(), + format!("<< /Type /Catalog /Pages {pages_id} 0 R{outline_ref} /Names << /Dests {names_id} 0 R >> >>").into_bytes(), )?; w.finish() diff -ru a/src/pdf/outline.rs b/src/pdf/outline.rs --- a/src/pdf/outline.rs 2026-10-09 20:49:08.882241500 -0400 +++ b/src/pdf/outline.rs 2026-10-09 21:08:42.965875400 -0400 @@ -76,11 +76,11 @@ String::new() }; - let title = pdf_escape_string(&bm.title); + let title = pdf_text_string(&bm.title); w.add_obj( item_id, format!( - "<< /Title ({title}) /Parent {parent_id} 0 R{prev}{next}{dest}{children_str} >>" + "<< /Title {title} /Parent {parent_id} 0 R{prev}{next}{dest}{children_str} >>" ) .into_bytes(), )?; diff -ru a/src/pdf/page.rs b/src/pdf/page.rs --- a/src/pdf/page.rs 2026-10-09 20:49:08.881207900 -0400 +++ b/src/pdf/page.rs 2026-10-09 21:08:42.951016000 -0400 @@ -192,13 +192,17 @@ .iter() .filter_map(|link| { let rect = shape_to_pdf_rect(&link.shape, dpi, pt_h)?; - let url_escaped = pdf_escape_string(&link.url); + // 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)), + }; Some( format!( "<< /Type /Annot /Subtype /Link\n\ /Rect [{:.4} {:.4} {:.4} {:.4}]\n\ /Border [0 0 0]\n\ - /A << /S /URI /URI ({url_escaped}) >> >>", + /A << {action} >> >>", rect.0, rect.1, rect.2, rect.3 ) .into_bytes(), diff -ru a/src/pdf/text.rs b/src/pdf/text.rs --- a/src/pdf/text.rs 2026-10-09 20:49:08.879667800 -0400 +++ b/src/pdf/text.rs 2026-10-09 21:08:42.966981600 -0400 @@ -54,14 +54,10 @@ return; } - // Horizontal scale to fit text width - let text_escaped = pdf_escape_string(text); - // Sum per-character advance widths using Helvetica metrics. - let natural_width: f32 = text - .chars() - .map(|c| helvetica_advance(c) * font_size) - .sum::() - .max(0.01); + // Horizontal scale to fit text width: every code in the text-layer font + // is the same width (johnmcm.com patch: Unicode text layer). + let (codes, count) = text_layer_codes(text); + let natural_width: f32 = (count as f32 * TEXT_FONT_ADVANCE * font_size).max(0.01); let h_scale = if natural_width > 0.01 { (w / natural_width) * 100.0 } else { @@ -74,7 +70,7 @@ if (h_scale - 100.0).abs() > 1.0 { ops.push_str(&format!("{h_scale:.2} Tz\n")); } - ops.push_str(&format!("({text_escaped}) Tj\n")); + ops.push_str(&format!("{codes} Tj\n")); } /// Return the normalized advance width (fraction of em) for `c` in Helvetica. diff -ru a/src/pdf/writer.rs b/src/pdf/writer.rs --- a/src/pdf/writer.rs 2026-10-09 20:49:08.880183100 -0400 +++ b/src/pdf/writer.rs 2026-10-09 21:08:42.965875400 -0400 @@ -154,10 +154,114 @@ make_stream(&extra, bitstream) } -/// Build a Type1 font dictionary for Helvetica (standard 14 font, no embedding needed). -/// Returns object body bytes. -pub(super) fn font_dict() -> Vec { - b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec() +// johnmcm.com patch (pdf-text-and-links.patch): the text layer was set in +// Helvetica with WinAnsiEncoding, and every character outside ASCII became +// "?", so a French, Russian or Chinese book's PDF could not be searched or +// copied from. It is now set in an embedded, glyphless Type0 font (the +// approach Tesseract and OCRmyPDF use): Identity-H, each character written as +// its UTF-16 code unit, every code drawn with the one empty glyph (the text is +// invisible anyway, render mode 3), half an em wide, and a ToUnicode map that +// gives each code back as itself. + +/// Advance width of every character in the text-layer font, in 1/1000 em. +pub(super) const TEXT_FONT_ADVANCE: f32 = 0.5; + +/// The glyphless font (wasm-src/djvu/font/make_glyphless.py): two empty +/// glyphs, each 500 units wide. +const GLYPHLESS_TTF: &[u8] = include_bytes!("glyphless.ttf"); + +/// ToUnicode CMap: each 2-byte code is its own UTF-16 code unit. Ranges may +/// only vary in their last byte, so there is one per high byte (surrogates +/// left out), at most 100 to a block. +fn to_unicode_cmap() -> String { + let highs: Vec = (0u32..=0xFF).filter(|h| !(0xD8..=0xDF).contains(h)).collect(); + let mut s = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\ + /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\ + /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\ + 1 begincodespacerange\n<0000> \nendcodespacerange\n", + ); + for block in highs.chunks(100) { + s.push_str(&format!("{} beginbfrange\n", block.len())); + for h in block { + s.push_str(&format!("<{h:02X}00> <{h:02X}FF> <{h:02X}00>\n")); + } + s.push_str("endbfrange\n"); + } + s.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + s +} + +/// Writes the text-layer font and the objects it needs; returns the id of the +/// font dictionary that pages refer to as /F1. +pub(super) fn add_text_font(w: &mut PdfWriter) -> Result { + let file_id = w.add(make_deflate_stream( + &format!(" /Length1 {}", GLYPHLESS_TTF.len()), + GLYPHLESS_TTF, + ))?; + // Every code maps to glyph 1. + let gid_map: Vec = (0..65536).flat_map(|_| [0u8, 1u8]).collect(); + let gid_id = w.add(make_deflate_stream("", &gid_map))?; + let descriptor_id = w.add( + format!( + "<< /Type /FontDescriptor /FontName /GlyphLessFont /Flags 5 \ + /FontBBox [0 0 500 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 \ + /CapHeight 1000 /StemV 80 /FontFile2 {file_id} 0 R >>" + ) + .into_bytes(), + )?; + let cid_id = w.add( + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /GlyphLessFont \ + /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \ + /FontDescriptor {descriptor_id} 0 R /DW 500 /CIDToGIDMap {gid_id} 0 R >>" + ) + .into_bytes(), + )?; + let to_unicode_id = w.add(make_deflate_stream("", to_unicode_cmap().as_bytes()))?; + w.add( + format!( + "<< /Type /Font /Subtype /Type0 /BaseFont /GlyphLessFont /Encoding /Identity-H \ + /DescendantFonts [{cid_id} 0 R] /ToUnicode {to_unicode_id} 0 R >>" + ) + .into_bytes(), + ) +} + +/// `text` as text-layer codes, a hex string of UTF-16 code units; a character +/// beyond the Basic Multilingual Plane becomes U+FFFD, as a pair of surrogate +/// codes would map back to nothing. Returns the string and its length in codes. +pub(super) fn text_layer_codes(text: &str) -> (String, usize) { + let mut out = String::with_capacity(text.len() * 4 + 2); + out.push('<'); + let mut n = 0; + for c in text.chars() { + let unit = u32::from(c); + let unit = if unit > 0xFFFF { 0xFFFD } else { unit }; + out.push_str(&format!("{unit:04X}")); + n += 1; + } + out.push('>'); + (out, n) +} + +/// The named destination of page `index` (0-based), for links between pages. +pub(super) fn page_dest_name(index: usize) -> String { + format!("p{:06}", index + 1) +} + +/// A PDF text string (for a bookmark title): a literal string when the text +/// is ASCII, otherwise UTF-16BE with a byte-order mark, as hex. +pub(super) fn pdf_text_string(s: &str) -> String { + if s.is_ascii() { + return format!("({})", pdf_escape_string(s)); + } + let mut out = String::from("'); + out } /// Format one colour component for a PDF `rg` operator (0..255 → 0..1).