mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Bookmarks and links go to their destination's position and zoom (#556)
Every destination was reduced to a page index (`dest_page` in the inspector), so a bookmark written as [p1 /XYZ 0 420 0] went to the top of page 1 instead of putting y 420 at the top of the window, and /FitH, /FitV, /FitR and an /XYZ zoom were ignored. Destinations now keep their view (ISO 32000-2 §12.3.2.2): - render: `DestView` (Top, Xyz, Fit, FitH, FitV, FitR) parsed from explicit arrays, /D dictionaries and named destinations (/Names /Dests and /Dests). `OutlineItem` gains `view`; `LinkTarget::Page` carries it. Missing, wrong-typed or non-finite operands are null, a zoom that isn't positive keeps the zoom, and an unknown type or a /FitR without four numbers falls back to the page top. `PageInfo::dest_fraction` maps a user-space point to the displayed page through the crop box origin, /Rotate and the view rotation, clamped to the page. - ui: `DocView::go_to_dest` applies it for bookmark and link clicks (and so for `ui.click` on the control channel): /XYZ puts (left, top) at the window's top-left (beside the quick-action gutter) at its zoom; /Fit, /FitH and /FitV switch to fit page, width and height with top/left at the edge; /FitR zooms so the rectangle fits, centred. The /FitB* content box is read as the whole page. Tests: operand parsing incl. hostile values, a fixture with every form (named, GoTo, wrong types, huge numbers, no page), point mapping under crop offsets and rotations, go_to_dest state, and kittest bookmark and link clicks checking the point lands at the window's top. Co-authored-by: lloyd <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
8 files changed
+445
-22
No files matched your search
@@ -139,6 +139,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
Newest first. One line per session: the date, what moved, and the new overall percentage.
|
||||
|
||||
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-09 (community, M14, German):** Deutsch (`de`) is a complete interface catalog (2,035 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `de`, `de_DE`, `de_AT`, `de_CH` and other German locales, and from Preferences. Informal "du", infinitive for commands. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover umlauts, ß and German quotes. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (community, M7, Arabic added text):** Add text writes Arabic into the PDF instead of `?`. The text is shaped with the craft-fonts `Arab` face through harfrust (already in the tree via egui): joining forms, ligatures and mark positions. Each line is laid out in display order by the Unicode bidirectional algorithm, so Arabic mixed with Latin words, European or Arabic-Indic digits and brackets reads correctly; justified right-to-left paragraphs end at the right edge. Each cluster (a letter with its dots or marks) becomes one glyph of a Type 3 font built from the face's outlines, with a ToUnicode entry, so `text_extract`, copy and search get every letter once and no font file is embedded. Wrapping measures shaped words. Each item has its own fonts under names free on the page (a full save renumbers objects but not resource names); updating or deleting the item removes them. Without the face, adding Arabic text returns a clear error and changes nothing, while an existing item holding Arabic still moves and retypes, drawn as `?` as before. Tests cover shaping, display order, ToUnicode, font names and clean-up on one page, and odd input (empty lines, direction controls, lone marks, 18,000 characters). Not yet: bold/italic Arabic, Hebrew, right-to-left typing in the box (misc.rtl-ui), editing existing Arabic text (edit.rtl-cjk-editing). The craft-fonts revision pinned for releases predates its Arabic face, so release builds report the missing font until the pin moves. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (M2, predictor hardening):** Empty PNG predictor frames return without row buffers. Packed TIFF Predictor 2 rows reject overflowing dimensions and size per-component scratch to the samples present in the input, aligned with upstream lopdf#589. Synthetic regressions cover malformed dimensions, partial rows, 1/2/4-bit decoding and padding. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
@@ -1690,7 +1690,7 @@ fn info(d: &Document) -> Value {
|
||||
"links": i.links.iter().map(|l| json!({
|
||||
"page": page1(l.page), "rect": view_rect(i, l.page, l.rect),
|
||||
"target": match &l.target {
|
||||
pdfcraft_render::LinkTarget::Page(p) => json!({ "page": page1(*p) }),
|
||||
pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }),
|
||||
pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }),
|
||||
pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({
|
||||
"layers": changes.iter().map(|(op, ocg)| json!({
|
||||
|
||||
+204
-11
@@ -112,6 +112,27 @@ impl PageInfo {
|
||||
[a * self.width, b * self.height]
|
||||
}
|
||||
|
||||
/// A destination's user-space point (each coordinate possibly unspecified) → the same point
|
||||
/// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down),
|
||||
/// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page
|
||||
/// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output
|
||||
/// is `None` when the input it comes from is (or is not finite). Points off the page are
|
||||
/// clamped to its edge.
|
||||
pub fn dest_fraction(&self, x: Option<f32>, y: Option<f32>, view_rotation: u16) -> [Option<f32>; 2] {
|
||||
let [cx0, cy0, cx1, cy1] = self.crop;
|
||||
let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None };
|
||||
// Unrotated fractions across (u, from the crop's left) and down (v, from its top).
|
||||
let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3)));
|
||||
let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3)));
|
||||
let flip = |f: Option<f32>| f.map(|f| 1.0 - f);
|
||||
match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 {
|
||||
90 => [flip(v), u],
|
||||
180 => [flip(u), flip(v)],
|
||||
270 => [v, flip(u)],
|
||||
_ => [u, v],
|
||||
}
|
||||
}
|
||||
|
||||
/// A view-space rectangle → the normalized user-space rectangle covering the same area of the
|
||||
/// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles,
|
||||
/// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`.
|
||||
@@ -143,7 +164,8 @@ pub struct Link {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LinkTarget {
|
||||
Page(usize),
|
||||
/// A destination in this document: the 0-based page and where on it the view goes.
|
||||
Page(usize, DestView),
|
||||
Uri(String),
|
||||
/// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in
|
||||
/// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned
|
||||
@@ -163,10 +185,55 @@ pub enum LayerOp {
|
||||
Toggle,
|
||||
}
|
||||
|
||||
/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the
|
||||
/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps
|
||||
/// that part of the view as it is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum DestView {
|
||||
/// The top of the page at the current zoom: a destination with no position, or one whose
|
||||
/// type is missing or unknown.
|
||||
#[default]
|
||||
Top,
|
||||
/// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom`
|
||||
/// (1.0 = 100%; 0 or null keeps the current zoom).
|
||||
Xyz { left: Option<f32>, top: Option<f32>, zoom: Option<f32> },
|
||||
/// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page).
|
||||
Fit,
|
||||
/// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge.
|
||||
FitH { top: Option<f32> },
|
||||
/// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge.
|
||||
FitV { left: Option<f32> },
|
||||
/// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized).
|
||||
FitR { rect: [f32; 4] },
|
||||
}
|
||||
|
||||
impl DestView {
|
||||
/// The view an explicit destination array asks for, from its elements after the page
|
||||
/// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown
|
||||
/// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric,
|
||||
/// non-finite or missing operand is `None`; a zoom that is not positive is `None`.
|
||||
pub fn from_operands(kind: Option<&[u8]>, operands: &[Option<f32>]) -> Self {
|
||||
let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite());
|
||||
match kind {
|
||||
Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) },
|
||||
Some(b"Fit" | b"FitB") => Self::Fit,
|
||||
Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) },
|
||||
Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) },
|
||||
Some(b"FitR") => match (n(0), n(1), n(2), n(3)) {
|
||||
(Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] },
|
||||
_ => Self::Top,
|
||||
},
|
||||
_ => Self::Top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OutlineItem {
|
||||
pub title: String,
|
||||
pub page: Option<usize>,
|
||||
/// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page).
|
||||
pub view: DestView,
|
||||
pub children: Vec<OutlineItem>,
|
||||
pub open: bool,
|
||||
}
|
||||
@@ -449,14 +516,21 @@ impl<'a> Inspector<'a> {
|
||||
|
||||
// ── destinations ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn dest_page(&self, dest: &Object, depth: u32) -> Option<usize> {
|
||||
/// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …),
|
||||
/// a dictionary with `/D`, or a named destination that resolves to one.
|
||||
fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
match self.resolve(dest) {
|
||||
Object::Array(a) => a.first().and_then(|p| self.page_of(p)),
|
||||
Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest_page(d, depth + 1)),
|
||||
Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest_page(d, depth + 1)),
|
||||
Object::Array(a) => {
|
||||
let page = a.first().and_then(|p| self.page_of(p))?;
|
||||
let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok());
|
||||
let operands: Vec<Option<f32>> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect();
|
||||
Some((page, DestView::from_operands(kind, &operands)))
|
||||
}
|
||||
Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)),
|
||||
Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -531,17 +605,18 @@ impl<'a> Inspector<'a> {
|
||||
break; // cycle
|
||||
}
|
||||
let Some(d) = self.dict(o) else { break };
|
||||
let page = d
|
||||
let dest = d
|
||||
.get(b"Dest")
|
||||
.ok()
|
||||
.and_then(|dest| self.dest_page(dest, 0))
|
||||
.or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest_page(dest, 0)));
|
||||
.and_then(|dest| self.dest(dest, 0))
|
||||
.or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0)));
|
||||
let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v));
|
||||
let children = match (d.get(b"First").ok(), depth < 32) {
|
||||
(Some(f), true) => self.outline_siblings(f, seen, depth + 1),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0);
|
||||
items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, children, open });
|
||||
items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, view, children, open });
|
||||
cur = d.get(b"Next").ok();
|
||||
if items.len() > 100_000 {
|
||||
break;
|
||||
@@ -672,11 +747,15 @@ impl<'a> Inspector<'a> {
|
||||
fn link(&self, page: usize, d: &Dictionary) -> Option<Link> {
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
|
||||
let target = if let Ok(dest) = d.get(b"Dest") {
|
||||
LinkTarget::Page(self.dest_page(dest, 0)?)
|
||||
let (page, view) = self.dest(dest, 0)?;
|
||||
LinkTarget::Page(page, view)
|
||||
} else {
|
||||
let a = d.get(b"A").ok().and_then(|a| self.dict(a))?;
|
||||
match self.name(a, b"S").as_deref() {
|
||||
Some("GoTo") => LinkTarget::Page(self.dest_page(a.get(b"D").ok()?, 0)?),
|
||||
Some("GoTo") => {
|
||||
let (page, view) = self.dest(a.get(b"D").ok()?, 0)?;
|
||||
LinkTarget::Page(page, view)
|
||||
}
|
||||
Some("URI") => {
|
||||
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
|
||||
}
|
||||
@@ -1171,6 +1250,120 @@ trailer << /Root 1 0 R >>
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_operands_become_views() {
|
||||
use DestView::*;
|
||||
let v = |kind: &[u8], ops: &[Option<f32>]| DestView::from_operands(Some(kind), ops);
|
||||
// ISO 32000-2 Table 149, each type.
|
||||
assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None });
|
||||
assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) });
|
||||
assert_eq!(v(b"Fit", &[]), Fit);
|
||||
assert_eq!(v(b"FitB", &[]), Fit);
|
||||
assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) });
|
||||
assert_eq!(v(b"FitBH", &[None]), FitH { top: None });
|
||||
assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) });
|
||||
assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) });
|
||||
// FitR's corners are normalized.
|
||||
assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] });
|
||||
// Missing operands are null; so are non-finite ones, and a zoom that isn't positive.
|
||||
assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None });
|
||||
assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None });
|
||||
assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None });
|
||||
assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None });
|
||||
assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None });
|
||||
// A FitR without four usable numbers, an unknown type or no type: the top of the page.
|
||||
assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top);
|
||||
assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top);
|
||||
assert_eq!(v(b"Zoom", &[Some(3.0)]), Top);
|
||||
assert_eq!(v(b"xyz", &[Some(3.0)]), Top);
|
||||
assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top);
|
||||
}
|
||||
|
||||
/// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form,
|
||||
/// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed.
|
||||
const DESTS: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj
|
||||
11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj
|
||||
12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj
|
||||
13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj
|
||||
14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj
|
||||
15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj
|
||||
16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj
|
||||
17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj
|
||||
18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj
|
||||
19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj
|
||||
20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj
|
||||
30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj
|
||||
31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj
|
||||
32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj
|
||||
33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj
|
||||
40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn bookmarks_and_links_keep_their_destination_view() {
|
||||
use DestView::*;
|
||||
let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens");
|
||||
let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect();
|
||||
assert_eq!(
|
||||
outline,
|
||||
[
|
||||
("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }),
|
||||
("fith", Some(1), FitH { top: Some(250.0) }),
|
||||
("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }),
|
||||
("old named", Some(1), FitV { left: Some(120.0) }),
|
||||
("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }),
|
||||
// Hostile operands fall back without failing the bookmark.
|
||||
("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }),
|
||||
("short", Some(1), Top),
|
||||
("no type", Some(1), Top),
|
||||
("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }),
|
||||
("no page", None, Top),
|
||||
]
|
||||
);
|
||||
let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect();
|
||||
assert_eq!(
|
||||
links,
|
||||
[
|
||||
LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }),
|
||||
LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }),
|
||||
LinkTarget::Page(1, FitH { top: Some(100.0) }),
|
||||
LinkTarget::Page(1, Fit),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_points_map_to_the_displayed_page() {
|
||||
// A crop box away from the origin: x 100..400, y 50..450.
|
||||
let mut p = PageInfo { width: 300.0, height: 400.0, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation: 0 };
|
||||
assert_eq!(p.dest_fraction(Some(100.0), Some(450.0), 0), [Some(0.0), Some(0.0)]);
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.25)]);
|
||||
// Unspecified stays unspecified; off-page and huge values are clamped to the page.
|
||||
assert_eq!(p.dest_fraction(None, Some(350.0), 0), [None, Some(0.25)]);
|
||||
assert_eq!(p.dest_fraction(Some(-1e30), Some(1e30), 0), [Some(0.0), Some(0.0)]);
|
||||
assert_eq!(p.dest_fraction(Some(f32::INFINITY), Some(f32::NAN), 0), [None, None]);
|
||||
// /Rotate 90 (clockwise): user y runs left to right across the displayed page, user x
|
||||
// top to bottom. A view rotation adds to it.
|
||||
p.rotation = 90;
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.75), Some(0.5)]);
|
||||
assert_eq!(p.dest_fraction(None, Some(350.0), 0), [Some(0.75), None]);
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.75)]);
|
||||
p.rotation = 180;
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.75)]);
|
||||
p.rotation = 270;
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.25), Some(0.5)]);
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.25)]);
|
||||
// A degenerate crop box never divides by zero.
|
||||
let flat = PageInfo { crop: [0.0, 0.0, 0.0, 0.0], rotation: 0, ..p };
|
||||
assert!(flat.dest_fraction(Some(5.0), Some(-5.0), 0).iter().all(|f| f.is_some_and(|f| (0.0..=1.0).contains(&f))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structure_failure_preserves_renderer_pages() {
|
||||
for crash in [true, false] {
|
||||
|
||||
@@ -13,8 +13,8 @@ mod raster;
|
||||
pub mod text;
|
||||
|
||||
pub use inspect::{
|
||||
Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, Xfa,
|
||||
attachment_data, inspect, pretty_date,
|
||||
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
|
||||
Xfa, attachment_data, inspect, pretty_date,
|
||||
};
|
||||
pub use raster::{
|
||||
MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, device_pixels, effective_scale,
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2};
|
||||
use pdfcraft_engine::{DocId, Edit};
|
||||
use pdfcraft_render::{DocInfo, LayerOp, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
|
||||
use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
|
||||
@@ -200,6 +200,10 @@ pub struct DocView {
|
||||
pub forward: Vec<usize>,
|
||||
/// Pending navigation: page and fraction down the page to align with the viewport top.
|
||||
pub goto: Option<(usize, f32)>,
|
||||
/// Pending position within that page from a destination ([`Self::go_to_dest`]): the point
|
||||
/// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points
|
||||
/// from the viewport's top-left corner.
|
||||
goto_point: Option<(usize, Option<f32>, Option<f32>, Vec2)>,
|
||||
/// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down /
|
||||
/// Page Up where the pages scroll.
|
||||
pub key_scroll: f32,
|
||||
@@ -357,6 +361,7 @@ impl DocView {
|
||||
back: Vec::new(),
|
||||
forward: Vec::new(),
|
||||
goto: None,
|
||||
goto_point: None,
|
||||
key_scroll: 0.0,
|
||||
single_edges: None,
|
||||
flash: None,
|
||||
@@ -428,6 +433,7 @@ impl DocView {
|
||||
self.select_anchor = None;
|
||||
}
|
||||
self.goto = None;
|
||||
self.goto_point = None;
|
||||
self.zoom_anchor = None;
|
||||
self.flash = None;
|
||||
}
|
||||
@@ -675,6 +681,67 @@ impl DocView {
|
||||
self.page_input = (page + 1).to_string();
|
||||
}
|
||||
|
||||
/// Go to a destination, a bookmark's or a link's (ISO 32000-2 §12.3.2.2): its page, scrolled
|
||||
/// and zoomed as `dest` asks, as Acrobat does. `/XYZ` puts (left, top) at the window's
|
||||
/// top-left at its zoom (null or 0 keeps the zoom); `/Fit`, `/FitH` and `/FitV` switch to fit
|
||||
/// page, width and height with `top` or `left` at the window's edge; `/FitR` zooms so the
|
||||
/// rectangle fits, centred. The `B` (content box) forms are read as the page. A null
|
||||
/// coordinate keeps that scroll position (null `top` on a new page: its top), and a point
|
||||
/// past the end of the document scrolls as far as it can.
|
||||
pub fn go_to_dest(&mut self, page: usize, dest: DestView, info: &DocInfo) {
|
||||
self.go_to_page(page);
|
||||
let page = self.current;
|
||||
self.goto_point = None;
|
||||
let Some(p) = info.pages.get(page) else { return };
|
||||
let point = |x: Option<f32>, y: Option<f32>| p.dest_fraction(x, y, self.rotation);
|
||||
// The window's top-left as far as pages go: its left edge is the gutter that keeps them
|
||||
// clear of the floating quick-action bar, so `left` 0 shows the page's edge beside it.
|
||||
let corner = vec2(SIDE, 0.0);
|
||||
let (fx, fy, rel) = match dest {
|
||||
DestView::Top => return,
|
||||
DestView::Xyz { left, top, zoom } => {
|
||||
if let Some(z) = zoom {
|
||||
self.zoom = z.clamp(0.08, 64.0);
|
||||
self.fit = Fit::None;
|
||||
self.zoom_anchor = None;
|
||||
}
|
||||
let [fx, fy] = point(left, top);
|
||||
(fx, fy, corner)
|
||||
}
|
||||
DestView::Fit => {
|
||||
self.fit = Fit::Page;
|
||||
return;
|
||||
}
|
||||
DestView::FitH { top } => {
|
||||
self.fit = Fit::Width;
|
||||
let [fx, fy] = point(None, top);
|
||||
(fx, fy, corner)
|
||||
}
|
||||
DestView::FitV { left } => {
|
||||
self.fit = Fit::Height;
|
||||
let [fx, fy] = point(left, None);
|
||||
(fx, fy, corner)
|
||||
}
|
||||
DestView::FitR { rect } => {
|
||||
let ([Some(ax), Some(ay)], [Some(bx), Some(by)]) = (point(Some(rect[0]), Some(rect[3])), point(Some(rect[2]), Some(rect[1]))) else {
|
||||
return;
|
||||
};
|
||||
// The rectangle as displayed, in points (it can be turned a quarter).
|
||||
let (dw, dh) = self.display_size(p);
|
||||
let (w, h) = ((ax - bx).abs() * dw, (ay - by).abs() * dh);
|
||||
if w >= 1.0 && h >= 1.0 && self.viewport_w >= 1.0 && self.viewport_h >= 1.0 {
|
||||
self.zoom = (self.viewport_w / (w * PT)).min(self.viewport_h / (h * PT)).clamp(0.08, 64.0);
|
||||
self.fit = Fit::None;
|
||||
self.zoom_anchor = None;
|
||||
}
|
||||
(Some((ax + bx) / 2.0), Some((ay + by) / 2.0), vec2(self.viewport_w, self.viewport_h) / 2.0)
|
||||
}
|
||||
};
|
||||
if fx.is_some() || fy.is_some() {
|
||||
self.goto_point = Some((page, fx, fy, rel));
|
||||
}
|
||||
}
|
||||
|
||||
/// Next page (`true`) or previous page. In two-page view this moves a whole spread: the other
|
||||
/// page of the spread is already on screen, so stepping to it wouldn't move the view (#70).
|
||||
pub fn step_page(&mut self, forward: bool) {
|
||||
@@ -1409,6 +1476,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
view.content.selected = None;
|
||||
}
|
||||
|
||||
// A destination's position (bookmarks, links) refines `goto`: its point at the window's
|
||||
// top-left, or the centre of a /FitR rectangle at the window's centre.
|
||||
if let Some((page, fx, fy, rel)) = view.goto_point.take() {
|
||||
let r = rects[page.min(rects.len() - 1)];
|
||||
// Within the content, so an unreachable point never shows a frame scrolled past the end.
|
||||
let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0));
|
||||
if let Some(fx) = fx {
|
||||
scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x));
|
||||
}
|
||||
if let Some(fy) = fy {
|
||||
scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y));
|
||||
}
|
||||
}
|
||||
let out = scroll.show_viewport(ui, |ui, viewport| {
|
||||
// egui's drag responses accept every pointer button. Keep a wheel gesture from also
|
||||
// selecting text, drawing a mark, or panning with the Hand tool.
|
||||
@@ -1831,7 +1911,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
if sr.contains(p) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
let label = match &l.target {
|
||||
LinkTarget::Page(n) => {
|
||||
LinkTarget::Page(n, _) => {
|
||||
crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))])
|
||||
}
|
||||
LinkTarget::Uri(u) => u.clone(),
|
||||
@@ -2065,7 +2145,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
None => {}
|
||||
}
|
||||
match clicked_link {
|
||||
Some(LinkTarget::Page(p)) => view.go_to_page(p),
|
||||
Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info),
|
||||
Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link),
|
||||
Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb),
|
||||
Some(LinkTarget::Other(s)) => app.notify_fmt("{s} actions run in the JavaScript engine (M6)", &[("s", &s)]),
|
||||
@@ -3043,4 +3123,47 @@ mod tests {
|
||||
assert_eq!(PageLayout::try_parse(" Two-Up "), Some(PageLayout::TwoUp));
|
||||
assert_eq!(PageLayout::try_parse("facing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destinations_set_the_zoom_mode_and_the_point_to_show() {
|
||||
let info = pdfcraft_render::DocInfo {
|
||||
pages: (0..3)
|
||||
.map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 })
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut v = view(3, PageLayout::Continuous);
|
||||
v.fit = Fit::Width;
|
||||
// No position (or a malformed one): the top of the page, zoom as it was.
|
||||
v.go_to_dest(1, DestView::Top, &info);
|
||||
assert_eq!((v.current, v.goto, v.goto_point, v.fit), (1, Some((1, 0.0)), None, Fit::Width));
|
||||
// /XYZ with a null zoom keeps the zoom mode; its point is a quarter down page 3.
|
||||
v.go_to_dest(2, DestView::Xyz { left: None, top: Some(300.0), zoom: None }, &info);
|
||||
assert_eq!((v.current, v.fit, v.goto_point), (2, Fit::Width, Some((2, None, Some(0.25), vec2(SIDE, 0.0)))));
|
||||
v.go_to_dest(0, DestView::Xyz { left: Some(150.0), top: None, zoom: Some(2.5) }, &info);
|
||||
assert_eq!((v.fit, v.zoom, v.goto_point), (Fit::None, 2.5, Some((0, Some(0.5), None, vec2(SIDE, 0.0)))));
|
||||
// A zoom beyond the view's range is clamped to it.
|
||||
v.go_to_dest(0, DestView::Xyz { left: None, top: None, zoom: Some(1e30) }, &info);
|
||||
assert_eq!((v.zoom, v.goto_point), (64.0, None));
|
||||
v.go_to_dest(0, DestView::Fit, &info);
|
||||
assert_eq!((v.fit, v.goto_point), (Fit::Page, None));
|
||||
v.go_to_dest(0, DestView::FitH { top: Some(100.0) }, &info);
|
||||
assert_eq!((v.fit, v.goto_point), (Fit::Width, Some((0, None, Some(0.75), vec2(SIDE, 0.0)))));
|
||||
v.go_to_dest(0, DestView::FitV { left: Some(75.0) }, &info);
|
||||
assert_eq!((v.fit, v.goto_point), (Fit::Height, Some((0, Some(0.25), None, vec2(SIDE, 0.0)))));
|
||||
// /FitR: the zoom that fits a 100×50 pt rectangle in an 800×600 window, centred.
|
||||
(v.viewport_w, v.viewport_h) = (800.0, 600.0);
|
||||
v.go_to_dest(0, DestView::FitR { rect: [0.0, 300.0, 100.0, 350.0] }, &info);
|
||||
assert_eq!(v.fit, Fit::None);
|
||||
assert!((v.zoom - 8.0 / PT).abs() < 1e-4, "{}", v.zoom);
|
||||
let (page, fx, fy, rel) = v.goto_point.expect("a point to show");
|
||||
assert_eq!((page, rel), (0, vec2(400.0, 300.0)));
|
||||
assert!((fx.unwrap_or(-1.0) - 50.0 / 300.0).abs() < 1e-4 && (fy.unwrap_or(-1.0) - 75.0 / 400.0).abs() < 1e-4, "{fx:?} {fy:?}");
|
||||
// A page past the end goes to the last page; a page the document info lacks, to its top.
|
||||
v.go_to_dest(usize::MAX, DestView::FitH { top: Some(100.0) }, &info);
|
||||
assert_eq!((v.current, v.goto_point.map(|g| g.0)), (2, Some(2)));
|
||||
let empty = pdfcraft_render::DocInfo::default();
|
||||
v.go_to_dest(1, DestView::FitH { top: Some(100.0) }, &empty);
|
||||
assert_eq!((v.current, v.goto_point), (1, None));
|
||||
}
|
||||
}
|
||||
@@ -378,6 +378,8 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
|
||||
pub(crate) enum Nav {
|
||||
Page(usize),
|
||||
/// A bookmark's destination: its page and where on it.
|
||||
Dest(usize, pdfcraft_render::DestView),
|
||||
Flash(usize, [f32; 4]),
|
||||
}
|
||||
|
||||
@@ -673,6 +675,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
match nav {
|
||||
Some(Nav::Page(p)) => app.views[index].go_to_page(p),
|
||||
Some(Nav::Dest(p, dest)) => match app.session.get(id) {
|
||||
Some(doc) => app.views[index].go_to_dest(p, dest, &doc.info),
|
||||
None => app.views[index].go_to_page(p),
|
||||
},
|
||||
Some(Nav::Flash(p, r)) => {
|
||||
let v = &mut app.views[index];
|
||||
v.go_to_page(p);
|
||||
@@ -850,7 +856,7 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
|
||||
if resp.clicked()
|
||||
&& let Some(p) = item.page
|
||||
{
|
||||
*cx.nav = Some(Nav::Page(p));
|
||||
*cx.nav = Some(Nav::Dest(p, item.view));
|
||||
}
|
||||
if resp.double_clicked() && cx.editable {
|
||||
*cx.action = Some(BmAction::StartRename(path.to_vec()));
|
||||
|
||||
@@ -451,6 +451,105 @@ fn clicking_a_link_goes_to_its_destination() {
|
||||
assert!((target.min.y - vp.min.y).abs() < 40.0, "page 3 is scrolled to the top: {target:?} in {vp:?}");
|
||||
}
|
||||
|
||||
/// Bookmarks and a link with positioned destinations (ISO 32000-2 §12.3.2.2). Page 2's crop box
|
||||
/// is away from the origin (x 50..350, y 100..500), so its user-space y 420 is 20% down the page.
|
||||
const DESTS: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [20 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 600] /CropBox [50 100 350 500] >> endobj
|
||||
5 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
10 0 obj << /Type /Outlines /First 11 0 R /Last 13 0 R /Count 3 >> endobj
|
||||
11 0 obj << /Title (Section 1.1 Shapes) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj
|
||||
12 0 obj << /Title (Zoomed in) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /XYZ 125 300 3] >> endobj
|
||||
13 0 obj << /Title (Broken) /Parent 10 0 R /Dest [5 0 R /XYZ (left) /top true] >> endobj
|
||||
20 0 obj << /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /Dest [4 0 R /FitH 420] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
fn dests_harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("dests.pdf", None, DESTS.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "bookmarks").unwrap();
|
||||
app
|
||||
});
|
||||
// The Bookmarks panel widens to fit its titles over many frames; fit-width zoom follows it.
|
||||
// Positions are checked once the window's layout holds still.
|
||||
let mut width = 0.0;
|
||||
for _ in 0..400 {
|
||||
h.run_steps(1);
|
||||
let w = h.state().views[0].viewport_rect().width();
|
||||
if w == width {
|
||||
break;
|
||||
}
|
||||
width = w;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Where the point a fraction (`fx`, `fy`) across and down `page` is on screen, relative to the
|
||||
/// viewport's top-left corner.
|
||||
fn from_viewport_corner(h: &Harness<'static, PdfCraftApp>, page: usize, fx: f32, fy: f32) -> egui::Vec2 {
|
||||
let (r, vp) = (rect(h, page).expect("page on screen"), h.state().views[0].viewport_rect());
|
||||
egui::pos2(r.min.x + fx * r.width(), r.min.y + fy * r.height()) - vp.min
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_destinations_scroll_to_their_position() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::canvas::Fit;
|
||||
let mut h = dests_harness();
|
||||
// Fit width: the pages are taller than the window, so any point of them can reach its top.
|
||||
h.get_by_label("Section 1.1 Shapes").click();
|
||||
h.run_steps(6);
|
||||
let v = &h.state().views[0];
|
||||
assert_eq!(v.current, 1, "the bookmark targets page 2");
|
||||
assert_eq!(v.fit, Fit::Width, "/XYZ with zoom 0 keeps the zoom");
|
||||
let d = from_viewport_corner(&h, 1, 0.0, 0.2);
|
||||
assert!(d.y.abs() < 2.0, "y 420 on page 2 is at the top of the window, not the page top: {d:?}");
|
||||
|
||||
// /XYZ left top zoom: 300%, with (125, 300), a quarter across and half way down, at the
|
||||
// window's top-left (the page is now wider than the window): its left is the edge of the
|
||||
// gutter that keeps pages clear of the quick-action bar.
|
||||
h.get_by_label("Zoomed in").click();
|
||||
h.run_steps(6);
|
||||
let v = &h.state().views[0];
|
||||
assert_eq!((v.current, v.fit, v.zoom), (1, Fit::None, 3.0));
|
||||
let d = from_viewport_corner(&h, 1, 0.25, 0.5);
|
||||
assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "the destination point is at the window's top-left: {d:?}");
|
||||
|
||||
// Malformed operands fall back to the top of the page at the current zoom.
|
||||
h.get_by_label("Broken").click();
|
||||
h.run_steps(6);
|
||||
let v = &h.state().views[0];
|
||||
assert_eq!((v.current, v.zoom), (2, 3.0));
|
||||
let d = from_viewport_corner(&h, 2, 0.0, 0.0);
|
||||
assert!(d.y > 0.0 && d.y < 40.0, "page 3 is scrolled to its top: {d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_destinations_scroll_to_their_position() {
|
||||
use pdfcraft_ui_egui::canvas::Fit;
|
||||
let mut h = dests_harness();
|
||||
h.state_mut().set_option("zoom", "100%").unwrap();
|
||||
h.run_steps(4);
|
||||
let r = rect(&h, 0).expect("page 1");
|
||||
// The link spans x 50..250, y 300..350 in PDF space on the 300×400 page 1.
|
||||
let at = egui::pos2(r.min.x + r.width() * (150.0 / 300.0), r.min.y + r.height() * (1.0 - 325.0 / 400.0));
|
||||
h.hover_at(at);
|
||||
h.run_steps(2);
|
||||
h.drag_at(at); // press
|
||||
h.drop_at(at); // release
|
||||
h.run_steps(8);
|
||||
let v = &h.state().views[0];
|
||||
assert_eq!((v.current, v.fit), (1, Fit::Width), "/FitH goes to page 2 and fits its width");
|
||||
let d = from_viewport_corner(&h, 1, 0.0, 0.2);
|
||||
assert!(d.y.abs() < 2.0, "y 420 on page 2 is at the top of the window: {d:?}");
|
||||
}
|
||||
|
||||
/// Two pages with a text field on each and a non-embedded Helvetica.
|
||||
const FORM: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] >> >> endobj
|
||||
|
||||
@@ -1154,8 +1154,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination"]
|
||||
notes = "GoTo and URI links; other action types (JavaScript, Launch, named actions) arrive with M6"
|
||||
evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view"]
|
||||
notes = "GoTo and URI links; other action types (JavaScript, Launch, named actions) arrive with M6. Destinations keep their position and zoom (/XYZ, /Fit, /FitH, /FitV, /FitR, named); the /FitB* content box is read as the whole page"
|
||||
|
||||
[[feature]]
|
||||
id = "view.annotation-hover"
|
||||
@@ -1250,8 +1250,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["doc_info"]
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/control.rs::state_and_view_options"]
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/view.rs::bookmark_destinations_scroll_to_their_position"]
|
||||
notes = "A bookmark goes to its destination's position and zoom, not just its page"
|
||||
|
||||
[[feature]]
|
||||
id = "view.highlight-current-bookmark"
|
||||
|
||||
Reference in new issue
Block a user