From 55284cd1753049b6b2e1fa8ea89ce0fe0e1551b5 Mon Sep 17 00:00:00 2001 From: Karsten Schmidt Date: Thu, 29 Feb 2024 10:31:11 +0100 Subject: [PATCH] feat(imago): update/improve/fix fluid position handling - update computeSize(), computMargins(), refSize(), positionOrGravity() - update CompLayerBase - add `ref`-side support for crop, resize, and all comp layer types - update imageLayer(), use "fill" mode for resizing - add tests - add docs --- packages/imago/src/api.ts | 43 +++++++++-- packages/imago/src/layers/image.ts | 7 +- packages/imago/src/layers/svg.ts | 13 +++- packages/imago/src/layers/text.ts | 8 +- packages/imago/src/ops/crop.ts | 4 +- packages/imago/src/ops/resize.ts | 4 +- packages/imago/src/units.ts | 88 ++++++++++++++------- packages/imago/test/units.test.ts | 119 +++++++++++++++++++++++++++++ 8 files changed, 243 insertions(+), 43 deletions(-) create mode 100644 packages/imago/test/units.test.ts diff --git a/packages/imago/src/api.ts b/packages/imago/src/api.ts index 2c0e8deab6..8d5cff32e7 100644 --- a/packages/imago/src/api.ts +++ b/packages/imago/src/api.ts @@ -40,7 +40,7 @@ export type Size = number | Dim; export type Sides = [number, number, number, number]; -export type SizeRef = "min" | "max" | "w" | "h"; +export type SizeRef = "min" | "max" | "w" | "h" | "both"; export type SizeUnit = "px" | "%"; @@ -89,16 +89,47 @@ export type CompLayer = ImgLayer | SVGLayer; export interface CompLayerBase { type: string; blend?: Blend; + /** + * Abstracted layer position. If given, takes precedence over + * {@link CompLayerBase.position}. If neither gravity or position are + * configured, the layer will be centered. + */ gravity?: Gravity; + /** + * Partial layer position given in units of {@link CompLayerBase.unit}. At + * most 2 coordinate can be given here (e.g. left & top). The right & bottom + * values are overriding left/top (in case of conflict). + * + * @remarks + * Note: This option is only used if no {@link CompLayerBase.gravity} is + * specified. If neither gravity or position are configured, the layer will + * be centered. + */ pos?: Position; + /** + * Only used if {@link CompLayerBase.unit} is percent (`%`). Reference side + * ID for computing positions and sizes. See {@link SizeRef} for details. + * + * @defaultValue "min" + */ + ref?: SizeRef; tile?: boolean; + /** + * Unit to use for {@link CompLayerBase.position} and sizes (where + * supported). If `%`, the given values are interpreted as percentages, + * relative to configured {@link CompLayerBase.ref} side. + * + * @defaultValue "px" + */ unit?: SizeUnit; + // allow custom extensions + [id: string]: any; } export interface ImgLayer extends CompLayerBase { + type: "img"; path: string; size?: Size; - unit?: SizeUnit; } export interface SVGLayer extends CompLayerBase { @@ -109,7 +140,6 @@ export interface SVGLayer extends CompLayerBase { export interface TextLayer extends CompLayerBase { type: "text"; - textGravity: Gravity; bg: string; body: string | Fn; color: string; @@ -117,7 +147,8 @@ export interface TextLayer extends CompLayerBase { fontSize: number | string; padding: number; path: string; - size: [number, number]; + size: Dim; + textGravity: Gravity; } export interface CropSpec extends ProcSpec { @@ -270,6 +301,7 @@ export interface ResizeSpec extends ProcSpec { filter?: Keys; fit?: Keys; gravity?: Gravity; + ref: SizeRef; size: Size; unit?: SizeUnit; } @@ -333,7 +365,8 @@ export interface ImgProcCtx { logger: ILogger; opts: Partial; /** - * Paths of all exported images. + * Paths of all exported images, keyed by IDs given via {@link OutputSpec} / + * {@link output}. */ outputs: Record; } diff --git a/packages/imago/src/layers/image.ts b/packages/imago/src/layers/image.ts index 6da4544664..482623c703 100644 --- a/packages/imago/src/layers/image.ts +++ b/packages/imago/src/layers/image.ts @@ -9,6 +9,7 @@ export const imageLayer: CompLayerFn = async (layer, _, ctx) => { gravity, path, pos, + ref, size, unit, ...opts @@ -16,13 +17,13 @@ export const imageLayer: CompLayerFn = async (layer, _, ctx) => { const input = sharp(path); const meta = await input.metadata(); let imgSize: Dim = [meta.width!, meta.height!]; - const $pos = positionOrGravity(pos, gravity, imgSize, ctx.size, unit); + if (size) imgSize = computeSize(size, imgSize, ref, unit); + const $pos = positionOrGravity(pos, gravity, imgSize, ctx.size, ref, unit); if (!size) return { input: path, ...$pos, ...opts }; ensureSize(meta); - imgSize = computeSize(size, imgSize, unit); return { input: await input - .resize(imgSize[0], imgSize[1]) + .resize(imgSize[0], imgSize[1], { fit: "fill" }) .png({ compressionLevel: 0 }) .toBuffer(), ...$pos, diff --git a/packages/imago/src/layers/svg.ts b/packages/imago/src/layers/svg.ts index f54f114e3a..e25343acd3 100644 --- a/packages/imago/src/layers/svg.ts +++ b/packages/imago/src/layers/svg.ts @@ -4,13 +4,22 @@ import type { CompLayerFn, SVGLayer } from "../api.js"; import { positionOrGravity } from "../units.js"; export const svgLayer: CompLayerFn = async (layer, _, ctx) => { - let { type: __, body, gravity, path, pos, unit, ...opts } = layer; + let { + type: __, + body, + gravity, + path, + pos, + ref, + unit, + ...opts + } = layer; if (path) body = readText(path, ctx.logger); const w = +(/width="(\d+)"/.exec(body)?.[1] || 0); const h = +(/height="(\d+)"/.exec(body)?.[1] || 0); return { input: Buffer.from(body), - ...positionOrGravity(pos, gravity, [w, h], ctx.size, unit), + ...positionOrGravity(pos, gravity, [w, h], ctx.size, ref, unit), ...opts, }; }; diff --git a/packages/imago/src/layers/text.ts b/packages/imago/src/layers/text.ts index b25a1a5bf0..a359ec357f 100644 --- a/packages/imago/src/layers/text.ts +++ b/packages/imago/src/layers/text.ts @@ -2,7 +2,7 @@ import { isFunction } from "@thi.ng/checks"; import { writeText } from "@thi.ng/file-io"; import { XML_SVG } from "@thi.ng/prefixes"; -import type { CompLayerFn, TextLayer } from "../api.js"; +import type { CompLayerFn, Dim, TextLayer } from "../api.js"; import { computeSize, positionOrGravity } from "../units.js"; export const textLayer: CompLayerFn = async (layer, _, ctx) => { @@ -18,11 +18,13 @@ export const textLayer: CompLayerFn = async (layer, _, ctx) => { gravity, path, pos, + ref, size, unit, ...opts } = layer; - const [w, h] = computeSize(size, ctx.size, unit); + let bounds: Dim; + const [w, h] = (bounds = computeSize(size, ctx.size, ref, unit)); const [isE, isW, isN, isS] = ["e", "w", "n", "s"].map((x) => textGravity.includes(x) ); @@ -40,7 +42,7 @@ export const textLayer: CompLayerFn = async (layer, _, ctx) => { writeText("text-debug.svg", svg); return { input: Buffer.from(svg), - ...positionOrGravity(pos, gravity, [w, h], ctx.size, unit), + ...positionOrGravity(pos, gravity, bounds, ctx.size, ref, unit), ...opts, }; }; diff --git a/packages/imago/src/ops/crop.ts b/packages/imago/src/ops/crop.ts index 90e66d6be5..808877934c 100644 --- a/packages/imago/src/ops/crop.ts +++ b/packages/imago/src/ops/crop.ts @@ -25,12 +25,12 @@ export const cropProc: Processor = async (spec, input, ctx) => { true, ]; } - const $size = computeSize(size!, ctx.size, unit); + const $size = computeSize(size!, ctx.size, ref, unit); let left = 0, top = 0; if (pos) { ({ left = 0, top = 0 } = - positionOrGravity(pos, gravity, $size, ctx.size, unit) || {}); + positionOrGravity(pos, gravity, $size, ctx.size, ref, unit) || {}); } else { [left, top] = gravityPosition(gravity || "c", $size, ctx.size); } diff --git a/packages/imago/src/ops/resize.ts b/packages/imago/src/ops/resize.ts index 696f3b7b26..2800e49fa8 100644 --- a/packages/imago/src/ops/resize.ts +++ b/packages/imago/src/ops/resize.ts @@ -3,8 +3,8 @@ import { GRAVITY_POSITION, type Processor, type ResizeSpec } from "../api.js"; import { coerceColor, computeSize } from "../units.js"; export const resizeProc: Processor = async (spec, input, ctx) => { - const { bg, filter, fit, gravity, size, unit } = spec; - const [width, height] = computeSize(size, ctx.size, unit); + const { bg, filter, fit, gravity, ref, size, unit } = spec; + const [width, height] = computeSize(size, ctx.size, ref, unit); return [ input.resize({ width, diff --git a/packages/imago/src/units.ts b/packages/imago/src/units.ts index 30c71dabdb..b0233f087e 100644 --- a/packages/imago/src/units.ts +++ b/packages/imago/src/units.ts @@ -5,7 +5,7 @@ import type { Metadata } from "sharp"; import { GRAVITY_MAP, type Color, - type CompLayer, + type CompLayerBase, type Dim, type Gravity, type Sides, @@ -27,21 +27,42 @@ export const coerceColor = (col: Color) => ? { r: col[0], g: col[1], b: col[2], alpha: col[3] ?? 1 } : col; +/** + * + * @remarks + * The given `size` MUST already be resolved (in pixels), e.g. via an earlier + * call to {@link computeSize}. `parentSize` is also in pixels. + * + * @param pos + * @param gravity + * @param size + * @param parentSize + * @param ref + * @param unit + */ export const positionOrGravity = ( - pos: CompLayer["pos"], + pos: CompLayerBase["pos"], gravity: Nullable, [w, h]: Dim, - [parentW, parentH]: Dim, + parentSize: Dim, + ref?: SizeRef, unit: SizeUnit = "px" ) => { if (!pos) return gravity ? { gravity: GRAVITY_MAP[gravity] } : undefined; - const isPC = unit === "%"; + const [parentW, parentH] = parentSize; let { l, r, t, b } = pos; - if (l != null) l = round(isPC ? (l * parentW) / 100 : l); - if (r != null) l = round(parentW - (isPC ? (r * parentW) / 100 : r) - w); - if (t != null) t = round(isPC ? (t * parentH) / 100 : t); - if (b != null) t = round(parentH - (isPC ? (b * parentH) / 100 : b) - h); - return { left: l, top: t }; + [l, r, t, b] = computeMargins( + [l || 0, r || 0, t || 0, b || 0], + parentSize, + ref, + unit + ); + let left: number | undefined, top: number | undefined; + if (pos.l != null) left = round(l); + if (pos.r != null) left = round(parentW - r - w); + if (pos.t != null) top = round(t); + if (pos.b != null) top = round(parentH - b - h); + return { left, top }; }; export const gravityPosition = ( @@ -61,48 +82,61 @@ export const gravityPosition = ( : (parentH - h) >> 1, ]; -export const refSize = ([w, h]: Dim, ref?: SizeRef) => { +export const refSize = ([w, h]: Dim, ref?: SizeRef): Dim => { + let v: number; switch (ref) { case "w": - return w; + return [w, w]; case "h": - return h; + return [h, h]; + case "both": + return [w, h]; case "max": - return Math.max(w, h); + v = Math.max(w, h); + return [v, v]; case "min": default: - return Math.min(w, h); + v = Math.min(w, h); + return [v, v]; } }; export const computeSize = ( size: Size, curr: Dim, + ref: SizeRef = "min", unit: SizeUnit = "px" ): Dim => { const aspect = curr[0] / curr[1]; let res: Dim; if (isNumber(size)) { - res = aspect > 1 ? [size, size / aspect] : [size * aspect, size]; + if (unit === "%") { + res = refSize(curr, ref); + res = [(res[0] * size) / 100, (res[1] * size) / 100]; + } else { + res = [size, size]; + } } else { - const [w, h] = size; + let [w, h] = size; + if (unit === "%") { + const [rw, rh] = refSize(curr, ref); + w *= rw / 100; + h *= rh / 100; + size = [w, h]; + } res = w >= 0 ? h >= 0 ? size : [w, w / aspect] - : h > 0 - ? [w * aspect, h] + : h >= 0 + ? [h * aspect, h] : illegalArgs( `require at least width or height, but got: ${JSON.stringify( size )}` ); } - if (unit === "%") { - res[0] *= curr[0] / 100; - res[1] *= curr[1] / 100; - } res[0] = round(res[0]); res[1] = round(res[1]); return res; @@ -120,14 +154,16 @@ export const computeMargins = ( if (isArray(size) && size.length === 4) { res = ( (isPC - ? size.map((x) => round((x * refSide) / 100)) + ? size.map((x, i) => round((x * refSide[i >> 1]) / 100)) : size.map(round)) ); } else if (isNumber(size)) { - const w = round(isPC ? (refSide * size) / 100 : size); - res = [w, w, w, w]; + const w = round(isPC ? (refSide[0] * size) / 100 : size); + const h = round(isPC ? (refSide[1] * size) / 100 : size); + res = [w, w, h, h]; } else { - const [w, h] = computeSize(size, curr, unit); + const w = round(isPC ? (refSide[0] * size[0]) / 100 : size[0]); + const h = round(isPC ? (refSide[1] * size[1]) / 100 : size[1]); res = [w, w, h, h]; } return res; diff --git a/packages/imago/test/units.test.ts b/packages/imago/test/units.test.ts new file mode 100644 index 0000000000..cd4bc6c7b7 --- /dev/null +++ b/packages/imago/test/units.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from "bun:test"; +import { + computeMargins, + computeSize, + positionOrGravity, + type Dim, + type Position, + type Sides, + type Size, + type SizeRef, +} from "../src/index.js"; + +const W = 1000; +const H = 2000; +const A = W / H; +const W2 = W / 2; +const H2 = H / 2; +const SIZE: Dim = [W, H]; + +test("computeSize (%)", () => { + for (let [size, ref, result] of <[Size, SizeRef, Dim][]>[ + [5, "min", [50, 50]], + [5, "max", [100, 100]], + [5, "both", [50, 100]], + [[5, -1], "min", [50, 100]], + [[-1, 5], "min", [25, 50]], + [[5, 5], "min", [50, 50]], + [[5, -1], "max", [100, 200]], + [[-1, 5], "max", [50, 100]], + [[5, 5], "max", [100, 100]], + [[5, -1], "both", [50, 100]], + [[-1, 10], "both", [100, 200]], + [[10, 20], "both", [100, 400]], + ]) { + expect(computeSize(size, SIZE, ref, "%")).toEqual(result); + } +}); + +test("computeMargins (%)", () => { + for (let [size, ref, result] of <[Size | Sides, SizeRef, Sides][]>[ + [5, "min", [50, 50, 50, 50]], + [5, "w", [50, 50, 50, 50]], + [5, "max", [100, 100, 100, 100]], + [5, "h", [100, 100, 100, 100]], + [5, "both", [50, 50, 100, 100]], + // + [[5, 10], "min", [50, 50, 100, 100]], + [[5, 10], "w", [50, 50, 100, 100]], + [[5, 10], "max", [100, 100, 200, 200]], + [[5, 10], "h", [100, 100, 200, 200]], + [[5, 10], "both", [50, 50, 200, 200]], + // + [[5, 10, 20, 50], "min", [50, 100, 200, 500]], + [[5, 10, 20, 50], "w", [50, 100, 200, 500]], + [[5, 10, 20, 50], "max", [100, 200, 400, 1000]], + [[5, 10, 20, 50], "h", [100, 200, 400, 1000]], + [[5, 10, 20, 50], "both", [50, 100, 400, 1000]], + ]) { + expect(computeMargins(size, SIZE, ref, "%")).toEqual(result); + } +}); + +test("positionOrGravity (%)", () => { + for (let [pos, size, ref, result] of < + [Position, Dim, SizeRef, ReturnType][] + >[ + [{}, [50, 50], "min", {}], + [ + { r: 5, b: 5 }, + [50, -1], + "min", + { left: W - 500 - 50, top: H - 500 / A - 50 }, + ], + [ + { r: 5, b: 5 }, + [50, -1], + "both", + { left: W - W2 - 50, top: H - H2 - 100 }, + ], + [ + { r: 5, b: 5 }, + [-1, 50], + "both", + { left: W - W2 - 50, top: H - H2 - 100 }, + ], + [{ l: 5, t: 10 }, [50, 50], "min", { left: 50, top: 100 }], + [{ l: 5, t: 10 }, [50, 50], "max", { left: 100, top: 200 }], + [ + { r: 5, b: 10 }, + [50, 50], + "min", + { left: W - W2 - 50, top: H - W2 - 100 }, + ], + [ + { r: 5, b: 10 }, + [50, 50], + "max", + { left: W - H2 - 100, top: H - H2 - 200 }, + ], + [{ l: 5, t: 10 }, [50, 50], "both", { left: 50, top: 200 }], + [ + { r: 5, b: 10 }, + [50, 50], + "both", + { left: W - W2 - 50, top: H - H2 - 200 }, + ], + ]) { + expect( + positionOrGravity( + pos, + null, + computeSize(size, SIZE, ref, "%"), + SIZE, + ref, + "%" + ) + ).toEqual(result!); + } +});