Skip to main content

eui_core/
zoom.rs

1//! EXP-1586: the ZoomableImage native's maths (catalog/style.json
2//! `zoomableImage`, whose $comment is the spec). Pure: every painter feeds
3//! its pointer, wheel, pinch and key input through these functions, so a
4//! zoom, a pan or a double tap lands on the same view everywhere. Mirrors
5//! `src/zoom.ts`; `fixtures/zoom.json` locks it.
6
7use std::sync::LazyLock;
8
9use serde::{Deserialize, Serialize};
10
11use crate::generated::catalog as g;
12
13/// The pill's geometry (style.json `zoomableImage.pill`).
14#[derive(Debug, Clone, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct ZoomPillSpec {
17    pub height: f64,
18    pub inset: f64,
19    pub padding: f64,
20    pub gap: f64,
21    pub percent_min_width: f64,
22    pub divider_height: f64,
23}
24
25/// style.json `zoomableImage`.
26#[derive(Debug, Clone, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ZoomSpec {
29    pub tall_aspect: f64,
30    pub max_scale: f64,
31    pub presets: Vec<f64>,
32    pub menu_presets: Vec<f64>,
33    pub double_tap_factor: f64,
34    pub wheel_rate: f64,
35    pub wheel_line_px: f64,
36    pub rubber: f64,
37    pub momentum_tau_ms: f64,
38    pub auto_hide_ms: f64,
39    pub pill: ZoomPillSpec,
40}
41
42pub static ZOOM: LazyLock<ZoomSpec> = LazyLock::new(|| {
43    let style: serde_json::Value = serde_json::from_str(g::STYLE_JSON).expect("style.json");
44    serde_json::from_value(style["zoomableImage"].clone()).expect("style.json zoomableImage")
45});
46
47/// The `initial` prop (catalog enum `zoomInitial`); anything else = `Auto`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase", from = "String")]
50pub enum ZoomInitial {
51    #[default]
52    Auto,
53    Fit,
54    FitWidth,
55    Actual,
56}
57
58impl From<String> for ZoomInitial {
59    fn from(s: String) -> Self {
60        Self::parse(&s)
61    }
62}
63
64impl ZoomInitial {
65    pub fn parse(s: &str) -> Self {
66        match s {
67            "fit" => Self::Fit,
68            "fitWidth" => Self::FitWidth,
69            "actual" => Self::Actual,
70            _ => Self::Auto,
71        }
72    }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
76pub struct ZoomSize {
77    pub width: f64,
78    pub height: f64,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
82pub struct ZoomPoint {
83    pub x: f64,
84    pub y: f64,
85}
86
87/// scale = displayed px per image px (1 = 100%); x/y = the picture's
88/// top-left in the viewport.
89#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
90pub struct ZoomView {
91    pub scale: f64,
92    pub x: f64,
93    pub y: f64,
94}
95
96/// What every function needs to know about the picture and its box.
97#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct ZoomFrame {
100    pub natural: ZoomSize,
101    pub viewport: ZoomSize,
102    #[serde(default)]
103    pub initial: ZoomInitial,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub max_scale: Option<f64>,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
109pub struct ZoomLimits {
110    pub min: f64,
111    pub max: f64,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ZoomPanRoom {
116    pub x: bool,
117    pub y: bool,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct ZoomBounds {
123    pub at_min: bool,
124    pub at_max: bool,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct ZoomMenuItem {
129    /// None = Fit (the rest view).
130    pub scale: Option<f64>,
131    pub label: String,
132    pub checked: bool,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub enum WheelDeltaMode {
138    Pixel,
139    Line,
140}
141
142const EPS: f64 = 1e-6;
143
144/// Rounded to 1e-6 so every platform prints the same numbers.
145fn tidy(v: f64) -> f64 {
146    let r = (v * 1e6).round() / 1e6;
147    if r == 0.0 {
148        0.0
149    } else {
150        r
151    }
152}
153
154fn tidy_view(v: ZoomView) -> ZoomView {
155    ZoomView { scale: tidy(v.scale), x: tidy(v.x), y: tidy(v.y) }
156}
157
158fn clamp_n(v: f64, lo: f64, hi: f64) -> f64 {
159    lo.max(hi.min(v))
160}
161
162fn positive(v: f64) -> f64 {
163    if v.is_finite() && v > 0.0 {
164        v
165    } else {
166        0.0
167    }
168}
169
170struct Sizes {
171    iw: f64,
172    ih: f64,
173    vw: f64,
174    vh: f64,
175}
176
177fn sizes(f: &ZoomFrame) -> Sizes {
178    Sizes {
179        iw: positive(f.natural.width),
180        ih: positive(f.natural.height),
181        vw: positive(f.viewport.width),
182        vh: positive(f.viewport.height),
183    }
184}
185
186/// A picture taller than 1 / tallAspect times its width.
187pub fn zoom_is_tall(natural: ZoomSize) -> bool {
188    let w = positive(natural.width);
189    let h = positive(natural.height);
190    w > 0.0 && h > 0.0 && w / h < ZOOM.tall_aspect
191}
192
193fn fit_raw(f: &ZoomFrame) -> f64 {
194    let s = sizes(f);
195    if s.iw == 0.0 || s.ih == 0.0 || s.vw == 0.0 || s.vh == 0.0 {
196        return 1.0;
197    }
198    1f64.min(s.vw / s.iw).min(s.vh / s.ih)
199}
200
201fn limits_raw(f: &ZoomFrame) -> (f64, f64) {
202    let min = fit_raw(f);
203    let wanted = match f.max_scale.map(positive) {
204        Some(v) if v > 0.0 => v,
205        _ => ZOOM.max_scale,
206    };
207    (min, min.max(wanted))
208}
209
210fn rest_raw(f: &ZoomFrame) -> f64 {
211    let s = sizes(f);
212    let (min, max) = limits_raw(f);
213    let mode = match f.initial {
214        ZoomInitial::Auto if zoom_is_tall(f.natural) => ZoomInitial::FitWidth,
215        ZoomInitial::Auto => ZoomInitial::Fit,
216        other => other,
217    };
218    let scale = match mode {
219        ZoomInitial::FitWidth => {
220            if s.iw > 0.0 && s.vw > 0.0 {
221                1f64.min(s.vw / s.iw)
222            } else {
223                1.0
224            }
225        }
226        ZoomInitial::Actual => 1.0,
227        _ => min,
228    };
229    clamp_n(scale, min, max)
230}
231
232/// The whole picture inside the viewport, never upscaled: the minimum.
233pub fn zoom_fit_scale(f: &ZoomFrame) -> f64 {
234    tidy(fit_raw(f))
235}
236
237/// The scale range: [fit, max(fit, maxScale)].
238pub fn zoom_limits(f: &ZoomFrame) -> ZoomLimits {
239    let (min, max) = limits_raw(f);
240    ZoomLimits { min: tidy(min), max: tidy(max) }
241}
242
243/// The scale the picture opens at (and a double tap / `0` / the fit button
244/// returns to), inside the limits.
245pub fn zoom_rest_scale(f: &ZoomFrame) -> f64 {
246    tidy(rest_raw(f))
247}
248
249/// Clamp one axis: centred when the scaled picture fits, else no gap.
250fn clamp_axis(offset: f64, scaled: f64, extent: f64) -> f64 {
251    if scaled <= extent + EPS {
252        (extent - scaled) / 2.0
253    } else {
254        clamp_n(offset, extent - scaled, 0.0)
255    }
256}
257
258/// The view inside its bounds (scale within the limits, no letterbox).
259pub fn zoom_clamp(view: ZoomView, f: &ZoomFrame) -> ZoomView {
260    let s = sizes(f);
261    let (min, max) = limits_raw(f);
262    let scale = clamp_n(view.scale, min, max);
263    tidy_view(ZoomView {
264        scale,
265        x: clamp_axis(view.x, s.iw * scale, s.vw),
266        y: clamp_axis(view.y, s.ih * scale, s.vh),
267    })
268}
269
270/// The opening view: the rest scale, centred; a taller-than-the-viewport
271/// picture starts at its top.
272pub fn zoom_rest_view(f: &ZoomFrame) -> ZoomView {
273    let s = sizes(f);
274    let scale = rest_raw(f);
275    zoom_clamp(ZoomView { scale, x: (s.vw - s.iw * scale) / 2.0, y: 0.0 }, f)
276}
277
278/// Whether the view sits at its rest scale.
279pub fn zoom_at_rest(view: ZoomView, f: &ZoomFrame) -> bool {
280    (view.scale - rest_raw(f)).abs() < 1e-4
281}
282
283/// Zoom to `scale` (clamped) keeping the image point under `anchor` fixed.
284pub fn zoom_to(view: ZoomView, scale: f64, anchor: ZoomPoint, f: &ZoomFrame) -> ZoomView {
285    let (min, max) = limits_raw(f);
286    let next = clamp_n(scale, min, max);
287    let ratio = if view.scale > 0.0 { next / view.scale } else { 1.0 };
288    zoom_clamp(
289        ZoomView {
290            scale: next,
291            x: anchor.x - (anchor.x - view.x) * ratio,
292            y: anchor.y - (anchor.y - view.y) * ratio,
293        },
294        f,
295    )
296}
297
298/// The viewport's centre, the anchor of keys and the pill.
299pub fn zoom_centre(f: &ZoomFrame) -> ZoomPoint {
300    let s = sizes(f);
301    ZoomPoint { x: s.vw / 2.0, y: s.vh / 2.0 }
302}
303
304/// ⌘/ctrl + wheel (or a trackpad pinch delivered as a wheel): the scale
305/// factor of one wheel event.
306pub fn zoom_wheel_factor(delta_y: f64, mode: WheelDeltaMode) -> f64 {
307    let px = match mode {
308        WheelDeltaMode::Line => delta_y * ZOOM.wheel_line_px,
309        WheelDeltaMode::Pixel => delta_y,
310    };
311    (-px * ZOOM.wheel_rate).exp()
312}
313
314/// One ⌘/ctrl-wheel event about the cursor.
315pub fn zoom_wheel(view: ZoomView, delta_y: f64, mode: WheelDeltaMode, anchor: ZoomPoint, f: &ZoomFrame) -> ZoomView {
316    zoom_to(view, view.scale * zoom_wheel_factor(delta_y, mode), anchor, f)
317}
318
319/// A plain wheel / two-finger scroll: pans by the deltas, clamped.
320pub fn zoom_scroll(view: ZoomView, delta_x: f64, delta_y: f64, f: &ZoomFrame) -> ZoomView {
321    zoom_clamp(ZoomView { scale: view.scale, x: view.x - delta_x, y: view.y - delta_y }, f)
322}
323
324/// The ladder steps walk: the presets plus the rest scale, inside the
325/// limits, ascending.
326pub fn zoom_ladder(f: &ZoomFrame) -> Vec<f64> {
327    let (min, max) = limits_raw(f);
328    let rest = rest_raw(f);
329    let mut all: Vec<f64> = ZOOM.presets.iter().copied().chain([min, rest, max]).filter(|s| *s >= min - EPS && *s <= max + EPS).collect();
330    all.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
331    let mut out: Vec<f64> = Vec::new();
332    for s in all {
333        if out.last().is_none_or(|last| s - last > 1e-4) {
334            out.push(tidy(s));
335        }
336    }
337    out
338}
339
340/// The next ladder scale strictly above (`1`) or below (`-1`) the current
341/// one, or the current one at the end.
342pub fn zoom_step_scale(scale: f64, direction: i32, f: &ZoomFrame) -> f64 {
343    let ladder = zoom_ladder(f);
344    if direction > 0 {
345        return ladder.iter().copied().find(|s| *s > scale + 1e-4).unwrap_or(*ladder.last().unwrap_or(&scale));
346    }
347    ladder.iter().rev().copied().find(|s| *s < scale - 1e-4).unwrap_or(*ladder.first().unwrap_or(&scale))
348}
349
350/// + / − (keys and the pill) about the viewport centre.
351pub fn zoom_step(view: ZoomView, direction: i32, f: &ZoomFrame) -> ZoomView {
352    zoom_to(view, zoom_step_scale(view.scale, direction, f), zoom_centre(f), f)
353}
354
355/// The scale a double tap at rest jumps to.
356pub fn zoom_double_tap_scale(f: &ZoomFrame) -> f64 {
357    let (_, max) = limits_raw(f);
358    tidy(max.min(1f64.max(ZOOM.double_tap_factor * rest_raw(f))))
359}
360
361/// Double click / tap: at rest → zoom in about the point; else → rest.
362pub fn zoom_double_tap(view: ZoomView, point: ZoomPoint, f: &ZoomFrame) -> ZoomView {
363    if zoom_at_rest(view, f) {
364        zoom_to(view, zoom_double_tap_scale(f), point, f)
365    } else {
366        zoom_rest_view(f)
367    }
368}
369
370/// A key while the picture has focus; None = not a zoom key.
371pub fn zoom_key(view: ZoomView, key: &str, f: &ZoomFrame) -> Option<ZoomView> {
372    match key {
373        "+" | "=" => Some(zoom_step(view, 1, f)),
374        "-" | "−" | "_" => Some(zoom_step(view, -1, f)),
375        "0" => Some(zoom_rest_view(f)),
376        "1" => Some(zoom_to(view, 1.0, zoom_centre(f), f)),
377        _ => None,
378    }
379}
380
381/// The overshoot past an edge, damped: (1 − 1 / (o·c/d + 1))·d.
382pub fn zoom_rubber_band(overshoot: f64, extent: f64) -> f64 {
383    if extent <= 0.0 || overshoot == 0.0 {
384        return 0.0;
385    }
386    let sign = if overshoot < 0.0 { -1.0 } else { 1.0 };
387    let o = overshoot.abs();
388    tidy(sign * (1.0 - 1.0 / ((o * ZOOM.rubber) / extent + 1.0)) * extent)
389}
390
391fn rubber_axis(offset: f64, scaled: f64, extent: f64) -> f64 {
392    let fits = scaled <= extent + EPS;
393    let lo = if fits { (extent - scaled) / 2.0 } else { extent - scaled };
394    let hi = if fits { (extent - scaled) / 2.0 } else { 0.0 };
395    if offset > hi {
396        hi + zoom_rubber_band(offset - hi, extent)
397    } else if offset < lo {
398        lo + zoom_rubber_band(offset - lo, extent)
399    } else {
400        offset
401    }
402}
403
404/// A drag from `start` by (dx, dy) while held: past an edge the overshoot
405/// rubber-bands. The release is [`zoom_clamp`] of the last view.
406pub fn zoom_drag(start: ZoomView, dx: f64, dy: f64, f: &ZoomFrame) -> ZoomView {
407    let s = sizes(f);
408    tidy_view(ZoomView {
409        scale: start.scale,
410        x: rubber_axis(start.x + dx, s.iw * start.scale, s.vw),
411        y: rubber_axis(start.y + dy, s.ih * start.scale, s.vh),
412    })
413}
414
415/// A pinch from `start` by the raw factor about `anchor` while held: past
416/// the min/max the scale rubber-bands (d = the limit). The release is
417/// [`zoom_clamp`] of the last view.
418pub fn zoom_pinch(start: ZoomView, factor: f64, anchor: ZoomPoint, f: &ZoomFrame) -> ZoomView {
419    let s = sizes(f);
420    let (min, max) = limits_raw(f);
421    let raw = start.scale * factor;
422    let scale = if raw > max {
423        max + zoom_rubber_band(raw - max, max)
424    } else if raw < min {
425        min + zoom_rubber_band(raw - min, min)
426    } else {
427        raw
428    };
429    let ratio = if start.scale > 0.0 { scale / start.scale } else { 1.0 };
430    tidy_view(ZoomView {
431        scale,
432        x: rubber_axis(anchor.x - (anchor.x - start.x) * ratio, s.iw * scale, s.vw),
433        y: rubber_axis(anchor.y - (anchor.y - start.y) * ratio, s.ih * scale, s.vh),
434    })
435}
436
437/// Mouse-drag momentum `t` ms after a release at velocity (px/ms),
438/// exponential decay, stopping at the edges.
439pub fn zoom_momentum(view: ZoomView, vx: f64, vy: f64, t: f64, f: &ZoomFrame) -> ZoomView {
440    let tau = ZOOM.momentum_tau_ms;
441    let travel = tau * (1.0 - (-t.max(0.0) / tau).exp());
442    zoom_clamp(ZoomView { scale: view.scale, x: view.x + vx * travel, y: view.y + vy * travel }, f)
443}
444
445/// Whether momentum `t` ms after a release is still moving (above 0.01 px/ms).
446pub fn zoom_momentum_active(vx: f64, vy: f64, t: f64) -> bool {
447    let decay = (-t.max(0.0) / ZOOM.momentum_tau_ms).exp();
448    vx.hypot(vy) * decay > 0.01
449}
450
451/// The axes the picture can pan on: an axis without room hands a swipe to
452/// the host (paging).
453pub fn zoom_pan_room(view: ZoomView, f: &ZoomFrame) -> ZoomPanRoom {
454    let s = sizes(f);
455    ZoomPanRoom { x: s.iw * view.scale > s.vw + EPS, y: s.ih * view.scale > s.vh + EPS }
456}
457
458/// A new viewport: at rest = the new rest view; zoomed = the same scale
459/// about the viewport centre, clamped.
460pub fn zoom_resize(view: ZoomView, from: ZoomSize, f: &ZoomFrame) -> ZoomView {
461    let before = ZoomFrame { viewport: from, ..*f };
462    if zoom_at_rest(view, &before) {
463        return zoom_rest_view(f);
464    }
465    let s = sizes(f);
466    let cx = positive(from.width) / 2.0;
467    let cy = positive(from.height) / 2.0;
468    zoom_clamp(ZoomView { scale: view.scale, x: s.vw / 2.0 - (cx - view.x), y: s.vh / 2.0 - (cy - view.y) }, f)
469}
470
471/// The pill's label: round(scale × 100) + `%` (JS `Math.round`: halves up).
472pub fn zoom_percent(scale: f64) -> String {
473    format!("{}%", (scale * 100.0 + 0.5).floor() as i64)
474}
475
476/// The % button's Menu: Fit, then the menu presets inside the limits.
477pub fn zoom_menu(view: ZoomView, f: &ZoomFrame, fit_label: &str) -> Vec<ZoomMenuItem> {
478    let (min, max) = limits_raw(f);
479    let at_rest = zoom_at_rest(view, f);
480    let mut items = Vec::new();
481    for &s in &ZOOM.menu_presets {
482        if s == 0.0 {
483            items.push(ZoomMenuItem { scale: None, label: fit_label.to_string(), checked: at_rest });
484        } else if s >= min - EPS && s <= max + EPS {
485            items.push(ZoomMenuItem { scale: Some(s), label: zoom_percent(s), checked: !at_rest && (view.scale - s).abs() < 1e-4 });
486        }
487    }
488    items
489}
490
491/// The pill's disabled ends.
492pub fn zoom_bounds(view: ZoomView, f: &ZoomFrame) -> ZoomBounds {
493    let (min, max) = limits_raw(f);
494    ZoomBounds { at_min: view.scale <= min + 1e-4, at_max: view.scale >= max - 1e-4 }
495}
496
497/// THE JSON entry (the FFI's `zoom_json`, the fixture replays ×4): `{op,
498/// frame: {natural, viewport, initial?, maxScale?}, from?: view | "rest",
499/// …}` with the op named like the fixtures/zoom.json group it answers:
500/// `rest`, `clamp {view}`, `zoomTo {scale, anchor}`, `wheel {deltaY, mode,
501/// anchor}`, `scroll {deltaX, deltaY}`, `ladder`, `steps {scale,
502/// direction}`, `step {direction}` (a view), `doubleTap {point}`, `keys
503/// {key}`, `rubberBand {overshoot, extent}`, `drag {dx, dy}`, `pinch
504/// {factor, anchor}`, `momentum {vx, vy, t}`, `momentumActive {vx, vy, t}`,
505/// `panRoom`, `resize {fromViewport}` (`from: "rest"` = the rest view at
506/// the OLD viewport), `percent {scale}`, `menu {fitLabel?}`, `bounds`.
507pub fn zoom_request(req: &serde_json::Value) -> Result<serde_json::Value, String> {
508    use serde_json::{json, Value};
509    let op = req.get("op").and_then(Value::as_str).ok_or("op is required")?;
510    let num = |k: &str| req.get(k).and_then(Value::as_f64).unwrap_or(0.0);
511    let point = |k: &str| -> ZoomPoint { req.get(k).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() };
512    let frame: ZoomFrame = match req.get("frame") {
513        Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("frame: {e}"))?,
514        None => ZoomFrame::default(),
515    };
516    let from = |f: &ZoomFrame| -> Result<ZoomView, String> {
517        match req.get("from") {
518            None => Ok(zoom_rest_view(f)),
519            Some(Value::String(s)) if s == "rest" => Ok(zoom_rest_view(f)),
520            Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("from: {e}")),
521        }
522    };
523    let view = |v: ZoomView| json!(v);
524    Ok(match op {
525        "rest" => json!({
526            "fit": zoom_fit_scale(&frame),
527            "limits": zoom_limits(&frame),
528            "restScale": zoom_rest_scale(&frame),
529            "view": zoom_rest_view(&frame),
530            "tall": zoom_is_tall(frame.natural),
531        }),
532        "clamp" => {
533            let v: ZoomView = serde_json::from_value(req.get("view").cloned().unwrap_or(Value::Null)).map_err(|e| format!("view: {e}"))?;
534            view(zoom_clamp(v, &frame))
535        }
536        "zoomTo" => view(zoom_to(from(&frame)?, num("scale"), point("anchor"), &frame)),
537        "wheel" => {
538            let mode = if req.get("mode").and_then(Value::as_str) == Some("line") { WheelDeltaMode::Line } else { WheelDeltaMode::Pixel };
539            view(zoom_wheel(from(&frame)?, num("deltaY"), mode, point("anchor"), &frame))
540        }
541        "scroll" => view(zoom_scroll(from(&frame)?, num("deltaX"), num("deltaY"), &frame)),
542        "ladder" => json!(zoom_ladder(&frame)),
543        "steps" => json!(zoom_step_scale(num("scale"), if num("direction") < 0.0 { -1 } else { 1 }, &frame)),
544        "step" => view(zoom_step(from(&frame)?, if num("direction") < 0.0 { -1 } else { 1 }, &frame)),
545        "doubleTap" => view(zoom_double_tap(from(&frame)?, point("point"), &frame)),
546        "keys" => json!(zoom_key(from(&frame)?, req.get("key").and_then(Value::as_str).unwrap_or(""), &frame)),
547        "rubberBand" => json!(zoom_rubber_band(num("overshoot"), num("extent"))),
548        "drag" => view(zoom_drag(from(&frame)?, num("dx"), num("dy"), &frame)),
549        "pinch" => view(zoom_pinch(from(&frame)?, num("factor"), point("anchor"), &frame)),
550        "momentum" => view(zoom_momentum(from(&frame)?, num("vx"), num("vy"), num("t"), &frame)),
551        "momentumActive" => json!(zoom_momentum_active(num("vx"), num("vy"), num("t"))),
552        "panRoom" => json!(zoom_pan_room(from(&frame)?, &frame)),
553        "resize" => {
554            let old: ZoomSize = serde_json::from_value(req.get("fromViewport").cloned().unwrap_or(Value::Null)).map_err(|e| format!("fromViewport: {e}"))?;
555            let start = match req.get("from") {
556                Some(Value::String(s)) if s == "rest" => zoom_rest_view(&ZoomFrame { viewport: old, ..frame }),
557                _ => from(&frame)?,
558            };
559            view(zoom_resize(start, old, &frame))
560        }
561        "percent" => json!(zoom_percent(num("scale"))),
562        "menu" => json!(zoom_menu(from(&frame)?, &frame, req.get("fitLabel").and_then(Value::as_str).unwrap_or("Fit"))),
563        "bounds" => json!(zoom_bounds(from(&frame)?, &frame)),
564        other => return Err(format!("unknown zoom op {other:?}")),
565    })
566}