Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ earlier development is recorded in the [commit history](https://github.com/omaco

### Fixed

- Keep a region whose edges land between pixels from being resampled in the
editor. Wayland reports pointer coordinates as fixed point, so a dragged
area is normally fractional while Copy/Save round it outward to whole source
pixels; drawing that fractional frame resampled the whole preview through a
sub-pixel scale, which read as a slightly soft, low-resolution annotator
image next to the sharp PNG. At one device pixel per source pixel the frame
now covers exactly the pixels the export keeps.
- Keep the annotation editor from enlarging a capture past the pixels it
carries. A screen whose device ratio is higher than the document's own
density — a fractional-scale monitor whose surface ratio Qt still reports
rounded up, or a capture taken on a coarser monitor and edited on a finer
one — used to stretch the selection into a soft, low-resolution-looking
preview while Copy/Save kept every native pixel. The frame now stops at one
device pixel per document pixel and exports are unchanged.
- Retain logical display dimensions inside exported PNGs, so captures from
scaled monitors reopen at the correct size from Omaroll, files, or the
clipboard without downsampling or slowing the fast PNG encoder.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,15 @@ preserving every native pixel. This also covers cropped, framed, and scrolling
captures. Images without this metadata use their pixel dimensions; print DPI
alone is not treated as a screenshot's display scale.

The annotator never enlarges a capture past the pixels it carries: on a screen
denser than the document — a fractional monitor whose surface ratio Qt still
reports rounded up, or a capture taken on a coarser monitor and edited on a
finer one — the image stays at its own size instead of being stretched into a
soft preview, and Copy/Save still export every captured pixel. At that density
the frame also covers exactly the pixels Copy/Save keep: an area whose edges
land between pixels (the usual case — Wayland pointer coordinates are fixed
point) is not resampled into a slightly soft preview.

To open the image currently on the Wayland clipboard:

```bash
Expand Down
12 changes: 12 additions & 0 deletions docs/editing-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ Flattened PNG exports carry their complete logical dimensions in an
File and clipboard imports read it without needing the private sidecar;
an editable document's operation log takes precedence. Untagged images retain
their pixel dimensions: print DPI does not establish screenshot display scale.
The annotator shows a document at one device pixel per pixel it carries, never
denser. A screen whose device ratio exceeds the document's own density — a
fractional-scale monitor whose surface ratio Qt still reports rounded up, or a
capture taken on a coarser monitor and edited on a finer one — leaves the
frame at the document's native size instead of stretching the selection, so
what is on screen keeps the pixels the export will carry. Where the screen and
the document are at that density, the frame covers exactly those pixels: a
selection edge that lands between source pixels, which is the normal case
because Wayland reports pointer coordinates as fixed point, is rounded outward
to the whole pixels the export keeps, and the frame follows that same grid. A
fractional frame would draw the entire capture through a sub-pixel resample —
soft on screen while Copy/Save still produced the native image.

## The two exceptions, and why they're still safe

Expand Down
9 changes: 9 additions & 0 deletions src/capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,15 @@ QRect pixelSelection(const CaptureData &capture, const QRectF &selection) {

} // namespace

QSizeF sourcePixelDensity(const CaptureData &capture) {
if (capture.source.isNull() || capture.previewSize.isEmpty())
return {1.0, 1.0};
return {capture.source.width() /
static_cast<qreal>(capture.previewSize.width()),
capture.source.height() /
static_cast<qreal>(capture.previewSize.height())};
}

QRectF arrowVisualBounds(const Annotation &annotation, qreal displayScale) {
return arrowVisualBoundsInternal(annotation, displayScale);
}
Expand Down
4 changes: 4 additions & 0 deletions src/capture.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ void describeFileCapture(CaptureData &capture, QImage image,
* using the same pixel scale as renderCapture. */
[[nodiscard]] QSize renderedCaptureLogicalSize(const CaptureData &capture,
const QSize &renderedSize);
/** Native source pixels the document carries per logical preview pixel, per
* axis: its own density, independent of any display scale. `1x1` for a
* document with no separate logical size (or an empty source). */
[[nodiscard]] QSizeF sourcePixelDensity(const CaptureData &capture);
/** Lowercase serialization name ("aurora", "custom", ...) for a backdrop
* style, used in the operation log and the `[background] default` config
* key. */
Expand Down
43 changes: 40 additions & 3 deletions src/editor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2210,9 +2210,24 @@ QRectF CaptureEditor::baseImageRect() const {
? kBackdropMargin
: 0.0;
const QSizeF framedSize = canvasRect_.size() + QSizeF(2 * margin, 2 * margin);
// Never enlarge the capture past the pixels it actually carries. The screen
// is denser than the document whenever its device ratio exceeds the
// document's own density: a fractional-scale monitor whose surface ratio Qt
// still reports rounded up (1.5x reported as 2x until the compositor's
// scale arrives), or a capture taken on a coarser monitor and edited on a
// finer one. Fitting to the screen then stretches the source into invented
// pixels and reads as soft, while the exported PNG keeps every native one.
// One document pixel per device pixel is the floor: the frame stops
// growing and the chrome keeps its own size.
const QSizeF density = sourcePixelDensity(capture_);
const qreal deviceRatio = devicePixelRatioF();
const qreal nativeLimit =
deviceRatio > 0.0
? std::min(density.width(), density.height()) / deviceRatio
: 1.0;
const qreal scale =
std::min<qreal>({1.0, available.width() / framedSize.width(),
available.height() / framedSize.height()});
available.height() / framedSize.height(), nativeLimit});
const QSizeF shown = canvasRect_.size() * scale;
// Snapped to the pixel grid: centering can land the origin on a half
// pixel, which is needless blur at scale 1 (the common case, an
Expand Down Expand Up @@ -2408,9 +2423,31 @@ QRectF CaptureEditor::sourceFrameWidgetRect() const {
if (canvas.isEmpty() || canvasRect_.isEmpty())
return {};
const qreal scale = std::max<qreal>(editScale(), 0.001);
// At one device pixel per source pixel the frame shows exactly the pixels the
// export carries. A selection that lands between source pixels rounds
// outward to whole ones in pixelSelection, so the native crop is up to a
// pixel wider than the selection itself — and a fractional selection is the
// normal case, not an edge one: Wayland pointer coordinates are fixed point.
// Drawing that fractional frame resamples the whole capture through a
// sub-pixel scale, which reads soft on screen while the export keeps every
// native pixel. Snap the frame to the grid the export crops on; the document
// keeps its own coordinates, and layers still map through editScale.
const QSizeF density = sourcePixelDensity(capture_);
const qreal deviceRatio = devicePixelRatioF();
const auto nativeAxis = [&](qreal origin, qreal length, qreal axisDensity) {
if (axisDensity <= 0.0 || deviceRatio <= 0.0 ||
!qFuzzyCompare(scale * deviceRatio, axisDensity))
return length * scale;
const qreal pixels = std::ceil((origin + length) * axisDensity) -
std::floor(origin * axisDensity);
return pixels / axisDensity * scale;
};
const QSizeF shown(
nativeAxis(selection_.left(), selection_.width(), density.width()),
nativeAxis(selection_.top(), selection_.height(), density.height()));
return {canvas.left() - canvasRect_.left() * scale,
canvas.top() - canvasRect_.top() * scale,
selection_.width() * scale, selection_.height() * scale};
canvas.top() - canvasRect_.top() * scale, shown.width(),
shown.height()};
}

QPointF CaptureEditor::toAnnotationPoint(const QPointF &position) const {
Expand Down
Loading