-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(cloudflare): honour fit and position in the image endpoint #2449
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@emdash-cms/cloudflare": patch | ||
| "emdash": patch | ||
| --- | ||
|
|
||
| Fixes cropping for EmDash media on Cloudflare. An image asked to fill a fixed box — a square avatar, a fixed-ratio thumbnail — came back scaled down and letterboxed inside it, because the endpoint never passed the requested fit to the Images binding. `fit` and `position` are now honoured, so a crop crops and its focal side is respected. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -21,6 +21,7 @@ import { | |||||||||||||||||||||||||||||||||||
| originalMediaHeaders, | ||||||||||||||||||||||||||||||||||||
| parseTransformParams, | ||||||||||||||||||||||||||||||||||||
| resolveTransformQuality, | ||||||||||||||||||||||||||||||||||||
| type ImageTransformFit, | ||||||||||||||||||||||||||||||||||||
| type ImageTransformFormat, | ||||||||||||||||||||||||||||||||||||
| } from "emdash/media/image-endpoint"; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -33,6 +34,45 @@ const FORMAT_MIME: Record<ImageTransformFormat, ImageOutputOptions["format"]> = | |||||||||||||||||||||||||||||||||||
| png: "image/png", | ||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||
| * Maps Astro `fit` values to the Cloudflare Images binding's fit vocabulary. | ||||||||||||||||||||||||||||||||||||
| * Unmapped values (e.g. `outside`) become `undefined`, leaving the binding's | ||||||||||||||||||||||||||||||||||||
| * default behaviour unchanged. | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| const FIT_TO_BINDING: Record<ImageTransformFit, ImageTransform["fit"] | undefined> = { | ||||||||||||||||||||||||||||||||||||
| fill: "squeeze", | ||||||||||||||||||||||||||||||||||||
| contain: "contain", | ||||||||||||||||||||||||||||||||||||
| cover: "cover", | ||||||||||||||||||||||||||||||||||||
| "scale-down": "scale-down", | ||||||||||||||||||||||||||||||||||||
| inside: "contain", | ||||||||||||||||||||||||||||||||||||
| outside: undefined, | ||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||
| * Maps Astro `position` values to the Cloudflare Images binding's gravity | ||||||||||||||||||||||||||||||||||||
| * vocabulary. Compound or unknown positions are dropped. | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+51
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [needs fixing] This comment is mostly a vocabulary walk-through and a justification ("better than cropping to a confidently wrong edge"). The map below is self-describing; the reader only needs a short pointer to what it maps and what happens to unknown positions.
Suggested change
|
||||||||||||||||||||||||||||||||||||
| const GRAVITY_BY_POSITION = new Map<string, ImageTransform["gravity"]>([ | ||||||||||||||||||||||||||||||||||||
| ["face", "face"], | ||||||||||||||||||||||||||||||||||||
| ["left", "left"], | ||||||||||||||||||||||||||||||||||||
| ["right", "right"], | ||||||||||||||||||||||||||||||||||||
| ["top", "top"], | ||||||||||||||||||||||||||||||||||||
| ["bottom", "bottom"], | ||||||||||||||||||||||||||||||||||||
| ["center", "center"], | ||||||||||||||||||||||||||||||||||||
| ["centre", "center"], | ||||||||||||||||||||||||||||||||||||
| ["auto", "auto"], | ||||||||||||||||||||||||||||||||||||
| ["entropy", "entropy"], | ||||||||||||||||||||||||||||||||||||
| ["attention", "auto"], | ||||||||||||||||||||||||||||||||||||
| ["north", "top"], | ||||||||||||||||||||||||||||||||||||
| ["south", "bottom"], | ||||||||||||||||||||||||||||||||||||
| ["east", "right"], | ||||||||||||||||||||||||||||||||||||
| ["west", "left"], | ||||||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| function toBindingGravity(position: string): ImageTransform["gravity"] | undefined { | ||||||||||||||||||||||||||||||||||||
| return GRAVITY_BY_POSITION.get(position.trim().toLowerCase()); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** Resolve the Images binding by the name the Cloudflare adapter configured. */ | ||||||||||||||||||||||||||||||||||||
| function resolveImagesBinding(): ImagesBinding | undefined { | ||||||||||||||||||||||||||||||||||||
| const configured = (globalThis as { __ASTRO_IMAGES_BINDING_NAME?: unknown }) | ||||||||||||||||||||||||||||||||||||
|
|
@@ -82,11 +122,19 @@ export const GET: APIRoute = async (ctx) => { | |||||||||||||||||||||||||||||||||||
| return streamOriginal(source.body, source.contentType); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const { width, height, format, quality } = parsed.options; | ||||||||||||||||||||||||||||||||||||
| const { width, height, format, quality, fit, position } = parsed.options; | ||||||||||||||||||||||||||||||||||||
| const outputMime = FORMAT_MIME[format] ?? "image/webp"; | ||||||||||||||||||||||||||||||||||||
| const transform: ImageTransform = {}; | ||||||||||||||||||||||||||||||||||||
| if (width) transform.width = width; | ||||||||||||||||||||||||||||||||||||
| if (height) transform.height = height; | ||||||||||||||||||||||||||||||||||||
| if (fit) { | ||||||||||||||||||||||||||||||||||||
| const bindingFit = FIT_TO_BINDING[fit]; | ||||||||||||||||||||||||||||||||||||
| if (bindingFit) transform.fit = bindingFit; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| if (position) { | ||||||||||||||||||||||||||||||||||||
| const gravity = toBindingGravity(position); | ||||||||||||||||||||||||||||||||||||
| if (gravity) transform.gravity = gravity; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| // Lossy formats get an explicit quality: the Images binding has no | ||||||||||||||||||||||||||||||||||||
| // default of its own and encodes near-losslessly without one, producing | ||||||||||||||||||||||||||||||||||||
| // renditions several times the size of the original. PNG is exempt — | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import type { APIContext } from "astro"; | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
|
|
||
| const adapterGET = vi.fn(() => new Response("adapter", { status: 200 })); | ||
| vi.mock("@astrojs/cloudflare/image-transform-endpoint", () => ({ GET: adapterGET })); | ||
|
|
||
| /** Records the transform the endpoint hands to the Images binding. */ | ||
| const transform = vi.fn(); | ||
| const output = vi.fn(() => ({ | ||
| response: () => new Response("bytes", { headers: { "Content-Type": "image/webp" } }), | ||
| })); | ||
|
|
||
| const images = { | ||
| input: () => ({ | ||
| transform: (options: unknown) => { | ||
| transform(options); | ||
| return { output }; | ||
| }, | ||
| }), | ||
| }; | ||
|
|
||
| vi.mock("cloudflare:workers", () => ({ env: { IMAGES: images } })); | ||
|
Comment on lines
+4
to
+22
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] These const { adapterGET, images, transform, output } = vi.hoisted(() => ({
adapterGET: vi.fn(() => new Response("adapter", { status: 200 })),
transform: vi.fn(),
output: vi.fn(() => ({ response: () => new Response("bytes", { headers: { "Content-Type": "image/webp" } }) })),
images: { /* ... */ },
})); |
||
|
|
||
| const { GET } = await import("../src/image-endpoint.js"); | ||
|
|
||
| const storage = { | ||
| download: async () => ({ | ||
| body: new ReadableStream<Uint8Array>({ | ||
| start(controller) { | ||
| controller.enqueue(new Uint8Array([1, 2, 3])); | ||
| controller.close(); | ||
| }, | ||
| }), | ||
| contentType: "image/jpeg", | ||
| }), | ||
| }; | ||
|
|
||
| /** Request the endpoint the way Astro's image service does. */ | ||
| function request(params: string): Promise<Response> { | ||
| const href = encodeURIComponent("/_emdash/api/media/file/01J5ABC.webp"); | ||
| const ctx = { | ||
| request: new Request(`https://example.com/_image?href=${href}&${params}`), | ||
| locals: { emdash: { storage } }, | ||
| } as unknown as APIContext; | ||
| return GET(ctx) as Promise<Response>; | ||
| } | ||
|
|
||
| /** The options passed to the Images binding for the most recent request. */ | ||
| function lastTransform(): Record<string, unknown> { | ||
| // eslint-disable-next-line typescript/no-unsafe-type-assertion -- vitest mock call args | ||
| return transform.mock.calls.at(-1)?.[0] as Record<string, unknown>; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| transform.mockClear(); | ||
| }); | ||
|
|
||
| describe("Cloudflare image endpoint: fit and position", () => { | ||
| it("forwards fit=cover so a square crop crops instead of letterboxing", async () => { | ||
| // Astro emits `fit`/`position` for constrained images. Dropping `fit` | ||
| // left the binding on its default, so a 32x32 avatar came back | ||
| // scaled-down inside the box rather than cover-cropped to fill it. | ||
| await request("w=32&h=32&f=webp&fit=cover&position=center"); | ||
|
|
||
| expect(lastTransform()).toMatchObject({ width: 32, height: 32, fit: "cover" }); | ||
| }); | ||
|
|
||
| it("maps position to the binding's gravity vocabulary", async () => { | ||
| await request("w=32&h=32&fit=cover&position=top"); | ||
| expect(lastTransform().gravity).toBe("top"); | ||
|
|
||
| // sharp spells it "centre" and calls saliency-based cropping | ||
| // "attention"; the binding uses "center" and "auto". | ||
| await request("w=32&h=32&fit=cover&position=centre"); | ||
| expect(lastTransform().gravity).toBe("center"); | ||
|
|
||
| await request("w=32&h=32&fit=cover&position=attention"); | ||
| expect(lastTransform().gravity).toBe("auto"); | ||
| }); | ||
|
|
||
| it("maps sharp's compass names onto the edges they mean", async () => { | ||
| // Astro forwards sharp's vocabulary verbatim, so `position="north"` | ||
| // reaches the endpoint and means the same edge as `top`. | ||
| for (const [position, gravity] of [ | ||
| ["north", "top"], | ||
| ["south", "bottom"], | ||
| ["east", "right"], | ||
| ["west", "left"], | ||
| ]) { | ||
| await request(`w=32&h=32&fit=cover&position=${position}`); | ||
| expect(lastTransform().gravity).toBe(gravity); | ||
| } | ||
| }); | ||
|
|
||
| it("maps Astro's sharp-only inside fit onto contain", async () => { | ||
| await request("w=64&h=64&fit=inside"); | ||
| expect(lastTransform().fit).toBe("contain"); | ||
| }); | ||
|
|
||
| it("maps Astro's fill to the binding's squeeze", async () => { | ||
| await request("w=64&h=64&fit=fill"); | ||
| expect(lastTransform().fit).toBe("squeeze"); | ||
| }); | ||
|
|
||
| it("omits fit and gravity the binding would reject rather than forwarding them", async () => { | ||
| // `outside` resizes to exceed the box without cropping, which the binding | ||
| // cannot express, and a compound position names a corner it has no keyword | ||
| // for. Both are dropped so the rendition still resolves, and neither is | ||
| // swapped for a fit that would crop. | ||
| await request("w=64&h=64&fit=outside&position=top%20left"); | ||
|
|
||
| const options = lastTransform(); | ||
| expect(options.fit).toBeUndefined(); | ||
| expect(options.gravity).toBeUndefined(); | ||
| expect(options).toMatchObject({ width: 64, height: 64 }); | ||
| }); | ||
|
|
||
| it("leaves fit unset when the request carries none", async () => { | ||
| await request("w=800&f=webp"); | ||
| expect(lastTransform().fit).toBeUndefined(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -37,6 +37,27 @@ export const MAX_TRANSFORM_DIMENSION = 4000; | |||||||||||||||||||||||||||||
| /** A format string accepted by {@link ImageTransformOptions.format}. */ | ||||||||||||||||||||||||||||||
| export type ImageTransformFormat = (typeof ALLOWED_TRANSFORM_FORMATS)[number]; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Fit values Astro's image service may emit. Unknown values are dropped so | ||||||||||||||||||||||||||||||
| * backends keep their default behaviour. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
|
Comment on lines
+40
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [needs fixing] This JSDoc block explains the allowlist, but most of it is review-facing justification: it argues why
Suggested change
|
||||||||||||||||||||||||||||||
| export const ALLOWED_TRANSFORM_FITS = [ | ||||||||||||||||||||||||||||||
| "fill", | ||||||||||||||||||||||||||||||
| "contain", | ||||||||||||||||||||||||||||||
| "cover", | ||||||||||||||||||||||||||||||
| "scale-down", | ||||||||||||||||||||||||||||||
| "inside", | ||||||||||||||||||||||||||||||
| "outside", | ||||||||||||||||||||||||||||||
| ] as const; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** A fit string accepted by {@link ImageTransformOptions.fit}. */ | ||||||||||||||||||||||||||||||
| export type ImageTransformFit = (typeof ALLOWED_TRANSFORM_FITS)[number]; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** Type guard for {@link ImageTransformFit}. */ | ||||||||||||||||||||||||||||||
| export function isTransformFit(value: string): value is ImageTransformFit { | ||||||||||||||||||||||||||||||
| return (ALLOWED_TRANSFORM_FITS as readonly string[]).includes(value); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** Validated options for a single transform. */ | ||||||||||||||||||||||||||||||
| export interface ImageTransformOptions { | ||||||||||||||||||||||||||||||
| width?: number; | ||||||||||||||||||||||||||||||
|
|
@@ -48,6 +69,19 @@ export interface ImageTransformOptions { | |||||||||||||||||||||||||||||
| * {@link DEFAULT_TRANSFORM_QUALITY}); lossless PNG deliberately gets none. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| quality?: number; | ||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * How the rendition fills the requested box, from Astro's `fit`. Left | ||||||||||||||||||||||||||||||
| * `undefined` when the request carried none or carried a value outside | ||||||||||||||||||||||||||||||
| * {@link ALLOWED_TRANSFORM_FITS}, so callers keep their backend's default. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| fit?: ImageTransformFit; | ||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Which part of the image survives a crop, from Astro's `position`. Only | ||||||||||||||||||||||||||||||
| * meaningful for fits that crop. Kept as the raw string: the vocabulary is | ||||||||||||||||||||||||||||||
| * the backend's (a keyword like `center`, or coordinates), so mapping it is | ||||||||||||||||||||||||||||||
| * the adapter's job. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| position?: string; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** Long-lived immutable cache -- transform output is deterministic per key+params. */ | ||||||||||||||||||||||||||||||
|
|
@@ -150,13 +184,18 @@ export function resolveTransformQuality( | |||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Parse and validate `?w=&h=&f=&q=` query params. Width is required (it sizes | ||||||||||||||||||||||||||||||
| * the rendition); dimensions are bounded so a request can't ask for an | ||||||||||||||||||||||||||||||
| * unbounded or nonsensical transform. Format falls back to | ||||||||||||||||||||||||||||||
| * Parse and validate `?w=&h=&f=&q=&fit=&position=` query params. Width is | ||||||||||||||||||||||||||||||
| * required (it sizes the rendition); dimensions are bounded so a request can't | ||||||||||||||||||||||||||||||
| * ask for an unbounded or nonsensical transform. Format falls back to | ||||||||||||||||||||||||||||||
| * {@link DEFAULT_TRANSFORM_FORMAT} when not requested. `q` is validated when | ||||||||||||||||||||||||||||||
| * present but otherwise left `undefined` so the caller can apply a per-format | ||||||||||||||||||||||||||||||
| * default (lossy formats get one, lossless PNG does not — see | ||||||||||||||||||||||||||||||
| * {@link DEFAULT_TRANSFORM_QUALITY}). | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * `fit` and `position` describe how the rendition fills its box. Unlike the | ||||||||||||||||||||||||||||||
| * others they are advisory: an unrecognised value is dropped rather than | ||||||||||||||||||||||||||||||
| * failing the request. The platform endpoint maps them onto its backend's | ||||||||||||||||||||||||||||||
|
Comment on lines
+195
to
+197
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [needs fixing] The
Suggested change
|
||||||||||||||||||||||||||||||
| * vocabulary. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| export function parseTransformParams(params: URLSearchParams): ParsedTransformParams { | ||||||||||||||||||||||||||||||
| const width = parseDimension(params.get("w")); | ||||||||||||||||||||||||||||||
|
|
@@ -185,7 +224,13 @@ export function parseTransformParams(params: URLSearchParams): ParsedTransformPa | |||||||||||||||||||||||||||||
| quality = q; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return { ok: true, options: { width, height, format, quality } }; | ||||||||||||||||||||||||||||||
| const fitRaw = params.get("fit"); | ||||||||||||||||||||||||||||||
| const fit = fitRaw !== null && isTransformFit(fitRaw) ? fitRaw : undefined; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const positionRaw = params.get("position")?.trim(); | ||||||||||||||||||||||||||||||
| const position = positionRaw ? positionRaw : undefined; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return { ok: true, options: { width, height, format, quality, fit, position } }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[needs fixing] The block contains a clear justification/rejected-alternative narrative ("rather than substituting a fit that would crop"). The
Recordtype andoutside: undefinedentry already express that unmapped values fall through to the binding default, so the comment only needs to say what the map does.