Skip to content

Cropped exports were rasterised at screen resolution, discarding the source pixels #1

Description

@kane007

Fixed on main, released as 1.1.0. Filed as a public record because the defect silently destroyed image data for every consumer that cropped, and because the fix changes export behaviour.

What was wrong

EditorEngine.artboardDataUrl() pinned multiplier: 1 whenever a crop region was committed:

const multiplier = !this.cropRegion && this.artboard ? this.artboard.width / rect.width : 1;

The crop region lives in scene coordinates, and fitBaseImage() scales the photo down to fit the on-screen canvas — so scene units are display-sized, and a cropped export came out at whatever size the host's dialog happened to be.

A 2400x2400 photo cropped 1:1 in a ~640px modal exported at ~230x230. Nothing in the UI or the API indicated the resolution had been discarded, and once the blob was stored the pixels were gone for good. The full-size pixels were on the FabricImage the whole time; the engine never asked Fabric for more than 1x.

Found in the Angular sibling of this engine (reported by the Hiero CMS team, 2026-07-29, chasing "fuzzy avatar" complaints) and present here identically.

Two related defects found in the same function

  1. Exports were wrong while the canvas was zoomed or panned. Fabric treats the left/top/width/height given to toDataURL() as viewport-space; the engine passed scene-space values without neutralising the viewport transform, so a zoomed canvas sampled the wrong area at the wrong scale.
  2. aspectOption(w, h) discarded its dimensions, collapsing to { label, ratio }. Hosts had no way to request a pixel size — one consumer resorted to parsing the dimensions back out of the chip's label text.

What changed in 1.1.0

Situation Exported size
No target declared region at source-limited resolution (never below its on-screen size)
exportTarget set min(target, source-limited)
Selected aspect option carries width/height those dimensions, while it is selected
Source has fewer real pixels than the target capped at the source — never upscaled
Result would exceed 4096x4096 clamped to that area
<ImageEditor exportTarget={{ width: 1000, height: 1000 }} />

// or per option — aspectOption() now keeps its dimensions
<ImageEditor aspectRatios={[aspectOption(1200, 630), aspectOption(1000, 1000)]} />

// headless
engine.setExportTarget({ width: 1000, height: 1000 });

Also: AspExportTarget is exported; basic mode now renders custom aspectRatios chips (it silently ignored them before, despite the docs); outputSize() reports the delivered export size so the PDF page matches its image.

Release status

The npm publish for 1.1.0 is pending: this package's npm Trusted Publisher is not yet
pointed at this repo's Release workflow, so the OIDC publish returns 404. The code,
tests and docs are on main; the release will be re-run once that is configured.

Upgrade note

Cropped exports are larger now, by default. That is the fix, but it will show up in storage and upload sizes immediately. Declare exportTarget where a flow genuinely wants a small image. Images stored while the bug was live cannot be recovered — the pixels were never uploaded.

Why it shipped, and what now catches it

Export resolution cannot be asserted in jsdom (Fabric has no 2D context), so no test could have caught it. CI now runs a Playwright suite against a real canvas that decodes emitted blobs and asserts pixel size plus a sharpness score — an upscaled blur fails the assertion. The zoom case fails without the viewport fix.

Same defect, same fix, in the Angular sibling this engine was ported from: @ascentsparksoftware/angular-image-editor@22.1.0 (ascentspark/angular-image-editor#23).

Still open

svg export serialises the whole canvas and ignores the crop region. Pre-existing and unrelated to resolution; tracked separately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions