fix(render,input): guard the zoom gain, clip annotation captions, no-throw dump

Three findings reachable by ordinary use:

* `Controller::axis_zoom()` divided by `Plot::ui_scale()` directly, though
  `Style::device()` documents a non-positive scale as host state that must be
  tolerated. With `ui_scale == 0` and a negative drag delta, `logical_dy` was
  `-inf`, the factor `+inf`, and the guard `!(factor > 0.0)` did not reject it:
  `zoom_x_axis`/`zoom_y_axis` computed `anchor + (bound - anchor) * inf`, and
  `set_y_range()` stored it verbatim and cleared `auto_scale` -- an axis left
  non-finite until an auto-fit. A negative scale silently inverted the zoom.
  A scale that is not positive and finite now means no zoom at all.

* `draw_annotations()` stages its caption into the frame's single text batch,
  which `flush_text()` draws *after* `clear_clip()`, so a caption staged while
  the axes clip was engaged was rasterised with the viewport clip. An
  annotation anchored outside the visible range kept a caption with no cross,
  and in a `Figure` -- whose rows tile the figure -- that caption painted into
  the neighbouring row. A caption whose anchor is outside `axes` is now skipped
  with its cross, rather than splitting the frame's one text draw call (which
  would cost a draw call per row and mis-clip the labels staged outside `axes`).

* `dump_frame_ppm()` was the one public entry point not `noexcept`, so a
  `std::bad_alloc` crossed the API boundary. Its buffers are now sized before
  anything is on disk and a failure is reported as `ErrorCode::OutOfMemory`,
  exactly as `TrueTypeFont::from_file()` reports it.

Tests: `ui_scale` 0 and -1 leave the axis range finite and unchanged with
`auto_scale` intact; an annotation anchored outside a bottom figure row's y
range leaves no caption ink in the row above, while an in-range one still
draws; a `static_assert` pins the dump's `noexcept`.
This commit is contained in:
Martino Ferrari committed 2026-09-28 22:36:34 +02:00
1 parent 9aff06acee
commit 2fbd1de7d4
5 files changed
+188 -19

No files matched your search

+12 -4
View File
@@ -608,10 +608,18 @@ void Controller::axis_zoom(const PointerEvent& e) {
// A pointer drag reports pixels, not wheel notches, so it uses its own
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
// moves, so the device-pixel delta is converted to logical pixels first -- the
// same drag must zoom the same amount on a 2x display. A scale no host should
// ever set (0, or negative) makes the factor non-positive, and the guard below
// turns that into no zoom at all rather than an axis re-centred on infinity.
const f64 logical_dy = static_cast<f64>(e.dy) / static_cast<f64>(plot_->ui_scale());
// same drag must zoom the same amount on a 2x display.
//
// A scale that is not a positive number is not a scale -- a host that has not
// measured its display, a field nobody filled in -- the same state
// `Style::device()` tolerates. Here it means *no zoom*: dividing by it would make
// `logical_dy` infinite and the factor infinite (or NaN), and that factor reaches
// `set_x_range()`/`set_y_range()`, which store it verbatim and clear `auto_scale`
// -- an axis left non-finite until an auto-fit. A negative scale would silently
// invert the zoom direction too, so the whole class is refused here.
const f64 scale = static_cast<f64>(plot_->ui_scale());
if (!(scale > 0.0) || !std::isfinite(scale)) return;
const f64 logical_dy = static_cast<f64>(e.dy) / scale;
const f64 factor = std::exp(-logical_dy * kZoomPerPixel);
if (!(factor > 0.0)) return;
if (mode_ == DragMode::AxisZoomX) {