Skip to content

Commit

Permalink
feat(imago): update/improve/fix fluid position handling
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
postspectacular committed Feb 29, 2024
1 parent 190d68e commit 55284cd
Show file tree
Hide file tree
Showing 8 changed files with 243 additions and 43 deletions.
43 changes: 38 additions & 5 deletions packages/imago/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" | "%";

Expand Down Expand Up @@ -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 {
Expand All @@ -109,15 +140,15 @@ export interface SVGLayer extends CompLayerBase {

export interface TextLayer extends CompLayerBase {
type: "text";
textGravity: Gravity;
bg: string;
body: string | Fn<ImgProcCtx, string>;
color: string;
font: string;
fontSize: number | string;
padding: number;
path: string;
size: [number, number];
size: Dim;
textGravity: Gravity;
}

export interface CropSpec extends ProcSpec {
Expand Down Expand Up @@ -270,6 +301,7 @@ export interface ResizeSpec extends ProcSpec {
filter?: Keys<KernelEnum>;
fit?: Keys<FitEnum>;
gravity?: Gravity;
ref: SizeRef;
size: Size;
unit?: SizeUnit;
}
Expand Down Expand Up @@ -333,7 +365,8 @@ export interface ImgProcCtx {
logger: ILogger;
opts: Partial<ImgProcOpts>;
/**
* Paths of all exported images.
* Paths of all exported images, keyed by IDs given via {@link OutputSpec} /
* {@link output}.
*/
outputs: Record<string, string>;
}
Expand Down
7 changes: 4 additions & 3 deletions packages/imago/src/layers/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,21 @@ export const imageLayer: CompLayerFn = async (layer, _, ctx) => {
gravity,
path,
pos,
ref,
size,
unit,
...opts
} = <ImgLayer>layer;
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,
Expand Down
13 changes: 11 additions & 2 deletions packages/imago/src/layers/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = <SVGLayer>layer;
let {
type: __,
body,
gravity,
path,
pos,
ref,
unit,
...opts
} = <SVGLayer>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,
};
};
8 changes: 5 additions & 3 deletions packages/imago/src/layers/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -18,11 +18,13 @@ export const textLayer: CompLayerFn = async (layer, _, ctx) => {
gravity,
path,
pos,
ref,
size,
unit,
...opts
} = <TextLayer>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)
);
Expand All @@ -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,
};
};
4 changes: 2 additions & 2 deletions packages/imago/src/ops/crop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/imago/src/ops/resize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = <ResizeSpec>spec;
const [width, height] = computeSize(size, ctx.size, unit);
const { bg, filter, fit, gravity, ref, size, unit } = <ResizeSpec>spec;
const [width, height] = computeSize(size, ctx.size, ref, unit);
return [
input.resize({
width,
Expand Down
88 changes: 62 additions & 26 deletions packages/imago/src/units.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Metadata } from "sharp";
import {
GRAVITY_MAP,
type Color,
type CompLayer,
type CompLayerBase,
type Dim,
type Gravity,
type Sides,
Expand All @@ -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<Gravity>,
[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 = (
Expand All @@ -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;
Expand All @@ -120,14 +154,16 @@ export const computeMargins = (
if (isArray(size) && size.length === 4) {
res = <Sides>(
(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;
Expand Down

0 comments on commit 55284cd

Please sign in to comment.