build: compile release APK and update dependencies (expo-image-manipulator/loader)

This commit is contained in:
2026-05-31 14:24:26 +02:00
parent 69637c4203
commit 2b5a259fe5
1260 changed files with 3804 additions and 166620 deletions
+60
View File
@@ -0,0 +1,60 @@
import { useReleasingSharedObject } from 'expo-modules-core';
import { SharedRef } from 'expo-modules-core/types';
import { Action, ImageResult, SaveFormat, SaveOptions } from './ImageManipulator.types';
import { ImageManipulatorContext } from './ImageManipulatorContext';
import ExpoImageManipulator from './NativeImageManipulatorModule';
import { validateArguments } from './validators';
// @needsAudit
/**
* Manipulate the image provided via `uri`. Available modifications are rotating, flipping (mirroring),
* resizing and cropping. Each invocation results in a new file. With one invocation you can provide
* a set of actions to perform over the image. Overwriting the source file would not have an effect
* in displaying the result as images are cached.
* @param uri URI of the file to manipulate. Should be on the local file system or a base64 data URI.
* @param actions An array of objects representing manipulation options. Each object should have
* __only one__ of the keys that corresponds to specific transformation.
* @param saveOptions A map defining how modified image should be saved.
* @return Promise which fulfils with [`ImageResult`](#imageresult) object.
* @deprecated It has been replaced by the new, contextual and object-oriented API.
* Use [`ImageManipulator.manipulate`](#manipulatesource) or [`useImageManipulator`](#useimagemanipulatorsource) instead.
*/
export async function manipulateAsync(
uri: string,
actions: Action[] = [],
saveOptions: SaveOptions = {}
): Promise<ImageResult> {
validateArguments(uri, actions, saveOptions);
const { format = SaveFormat.JPEG, ...rest } = saveOptions;
const context = ExpoImageManipulator.manipulate(uri);
for (const action of actions) {
if ('resize' in action) {
context.resize(action.resize);
} else if ('rotate' in action) {
context.rotate(action.rotate);
} else if ('flip' in action) {
context.flip(action.flip);
} else if ('crop' in action) {
context.crop(action.crop);
} else if ('extent' in action && context.extent) {
context.extent(action.extent);
}
}
const image = await context.renderAsync();
const result = await image.saveAsync({ format, ...rest });
// These shared objects will not be used anymore, so free up some memory.
context.release();
image.release();
return result;
}
export function useImageManipulator(source: string | SharedRef<'image'>): ImageManipulatorContext {
return useReleasingSharedObject(() => ExpoImageManipulator.manipulate(source), [source]);
}
export { ExpoImageManipulator as ImageManipulator };
+143
View File
@@ -0,0 +1,143 @@
import type { NativeModule } from 'expo';
import { SharedRef } from 'expo-modules-core/types';
import type { ImageManipulatorContext } from './ImageManipulatorContext';
import ImageRef from './ImageRef';
// @needsAudit
export type ImageResult = {
/**
* An URI to the modified image (usable as the source for an `Image` or `Video` element).
*/
uri: string;
/**
* Width of the image or video.
*/
width: number;
/**
* Height of the image or video.
*/
height: number;
/**
* It is included if the `base64` save option was truthy, and is a string containing the
* JPEG/PNG (depending on `format`) data of the image in Base64. Prepend that with `'data:image/xxx;base64,'`
* to get a data URI, which you can use as the source for an `Image` element for example
* (where `xxx` is `jpeg` or `png`).
*/
base64?: string;
};
// @needsAudit
export type ActionResize = {
/**
* Values correspond to the result image dimensions. If you specify only one value, the other will
* be calculated automatically to preserve image ratio.
*/
resize: {
width?: number;
height?: number;
};
};
// @needsAudit
export type ActionRotate = {
/**
* Degrees to rotate the image. Rotation is clockwise when the value is positive and
* counter-clockwise when negative.
*/
rotate: number;
};
// @docsMissing
export enum FlipType {
Vertical = 'vertical',
Horizontal = 'horizontal',
}
// @needsAudit
export type ActionFlip = {
/**
* An axis on which image will be flipped. Only one flip per transformation is available. If you
* want to flip according to both axes then provide two separate transformations.
*/
flip: FlipType;
};
// @needsAudit
export type ActionCrop = {
/**
* Fields specify top-left corner and dimensions of a crop rectangle.
*/
crop: {
originX: number;
originY: number;
width: number;
height: number;
};
};
// @needsAudit
export type ActionExtent = {
/**
* Set the image size and offset. If the image is enlarged, unfilled areas are set to the `backgroundColor`.
* To position the image, use `originX` and `originY`.
*
* @platform web
*/
extent: {
backgroundColor?: string | null;
originX?: number;
originY?: number;
width: number;
height: number;
};
};
// @docsMissing
export type Action = ActionResize | ActionRotate | ActionFlip | ActionCrop | ActionExtent;
// @docsMissing
export enum SaveFormat {
JPEG = 'jpeg',
PNG = 'png',
WEBP = 'webp',
}
// @needsAudit
/**
* A map defining how modified image should be saved.
*/
export type SaveOptions = {
/**
* Whether to also include the image data in Base64 format.
*/
base64?: boolean;
/**
* A value in range `0.0` - `1.0` specifying compression level of the result image. `1` means
* no compression (highest quality) and `0` the highest compression (lowest quality).
*/
compress?: number;
/**
* Specifies what type of compression should be used and what is the result file extension.
* `SaveFormat.PNG` compression is lossless but slower, `SaveFormat.JPEG` is faster but the image
* has visible artifacts. Defaults to `SaveFormat.JPEG`
*/
format?: SaveFormat;
};
export declare class ImageManipulator extends NativeModule {
/**
* @hidden
*/
Context: typeof ImageManipulatorContext;
/**
* @hidden
*/
Image: typeof ImageRef;
/**
* Loads an image from the given URI and creates a new image manipulation context.
*/
manipulate(source: string | SharedRef<'image'>): ImageManipulatorContext;
}
+58
View File
@@ -0,0 +1,58 @@
import { SharedObject } from 'expo';
import type { ActionCrop, ActionExtent } from './ImageManipulator.types';
import type { ImageRef } from './ImageRef';
import ExpoImageManipulator from './NativeImageManipulatorModule';
/**
* A context for an image manipulation. It provides synchronous, chainable functions that schedule transformations on the original image to the background thread.
* Use an asynchronous [`renderAsync`](#renderasync) to await for all transformations to finish and access the final image.
*/
export declare class ImageManipulatorContext extends SharedObject {
/**
* Resizes the image to the given size.
* @param size Values correspond to the result image dimensions. If you specify only one value, the other will
* be calculated automatically to preserve image ratio.
*/
resize(size: { width?: number | null; height?: number | null }): ImageManipulatorContext;
/**
* Rotates the image by the given number of degrees.
* @param degrees Degrees to rotate the image. Rotation is clockwise when the value is positive and
* counter-clockwise when negative.
*/
rotate(degrees: number): ImageManipulatorContext;
/**
* Flips the image vertically or horizontally.
* @param flipType An axis on which image will be flipped. Only one flip per transformation is available. If you
* want to flip according to both axes then provide two separate transformations.
*/
flip(flipType: 'vertical' | 'horizontal'): ImageManipulatorContext;
/**
* Crops the image to the given rectangle's origin and size.
* @param rect Fields specify top-left corner and dimensions of a crop rectangle.
*/
crop(rect: ActionCrop['crop']): ImageManipulatorContext;
/**
* Set the image size and offset. If the image is enlarged, unfilled areas are set to the `backgroundColor`.
* To position the image, use `originX` and `originY`.
*
* @platform web
*/
extent(options: ActionExtent['extent']): ImageManipulatorContext;
/**
* Resets the manipulator context to the originally loaded image.
*/
reset(): ImageManipulatorContext;
/**
* Awaits for all manipulation tasks to finish and resolves with a reference to the resulted native image.
*/
renderAsync(): Promise<ImageRef>;
}
export default ExpoImageManipulator.Context as typeof ImageManipulatorContext;
+27
View File
@@ -0,0 +1,27 @@
import { SharedRef } from 'expo';
import type { ImageResult, SaveOptions } from './ImageManipulator.types';
import ExpoImageManipulator from './NativeImageManipulatorModule';
/**
* A reference to a native instance of the image.
*/
export declare class ImageRef extends SharedRef<'image'> {
/**
* Width of the image.
*/
width: number;
/**
* Height of the image.
*/
height: number;
/**
* Saves the image to the file system in the cache directory.
* @param options A map defining how modified image should be saved.
*/
saveAsync(options?: SaveOptions): Promise<ImageResult>;
}
export default ExpoImageManipulator.ImageRef as typeof ImageRef;
@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo';
import { ImageManipulator } from './ImageManipulator.types';
export default requireNativeModule<ImageManipulator>('ExpoImageManipulator');
@@ -0,0 +1,27 @@
import { NativeModule } from 'expo';
import { registerWebModule } from 'expo-modules-core';
import { SharedRef } from 'expo-modules-core/types';
import ImageManipulatorContext from './web/ImageManipulatorContext.web';
import ImageManipulatorImageRef from './web/ImageManipulatorImageRef.web';
import { loadImageAsync } from './web/utils.web';
class ImageManipulator extends NativeModule {
Context = ImageManipulatorContext;
Image = ImageManipulatorImageRef;
manipulate(source: string | SharedRef<'image'>): ImageManipulatorContext {
return new ImageManipulatorContext(() => {
if (typeof source === 'string') {
return loadImageAsync(source);
}
// Image refs should provide the `uri` property on Web. It could be either remote url, blob or data url.
if (typeof source === 'object' && 'uri' in source && typeof source.uri === 'string') {
return loadImageAsync(source.uri);
}
throw new Error(`Source not supported: ${source}`);
});
}
}
export default registerWebModule(ImageManipulator, 'ImageManipulator');
+19
View File
@@ -0,0 +1,19 @@
export { ImageManipulator, manipulateAsync, useImageManipulator } from './ImageManipulator';
// SaveFormat and FlipType are enums
export { SaveFormat, FlipType } from './ImageManipulator.types';
export type { SaveOptions, ImageResult } from './ImageManipulator.types';
// Export types that are deprecated as of SDK 52
export type {
ActionResize,
ActionRotate,
ActionFlip,
ActionCrop,
ActionExtent,
Action,
} from './ImageManipulator.types';
export type { ImageRef } from './ImageRef';
export type { ImageManipulatorContext } from './ImageManipulatorContext';
+134
View File
@@ -0,0 +1,134 @@
import {
Action,
ActionCrop,
ActionExtent,
ActionFlip,
ActionResize,
ActionRotate,
FlipType,
SaveFormat,
SaveOptions,
} from './ImageManipulator.types';
export function validateArguments(uri: string, actions: Action[], saveOptions: SaveOptions) {
validateUri(uri);
validateActions(actions);
validateSaveOptions(saveOptions);
}
export function validateUri(uri: string): void {
if (!(typeof uri === 'string')) {
throw new TypeError('The "uri" argument must be a string');
}
}
export function validateActions(actions: Action[]): void {
if (!Array.isArray(actions)) {
throw new TypeError('The "actions" argument must be an array');
}
for (const action of actions) {
if (typeof action !== 'object' || action === null) {
throw new TypeError('Action must be an object');
}
const supportedActionTypes = ['crop', 'extent', 'flip', 'rotate', 'resize'];
const actionKeys = Object.keys(action);
if (actionKeys.length !== 1) {
throw new TypeError(
`Single action must contain exactly one transformation: ${supportedActionTypes.join(', ')}`
);
}
const actionType = actionKeys[0];
if (!supportedActionTypes.includes(actionType)) {
throw new TypeError(`Unsupported action type: ${actionType}`);
}
if (actionType === 'crop') {
validateCropAction(action as ActionCrop);
} else if (actionType === 'extent') {
validateExtentAction(action as ActionExtent);
} else if (actionType === 'flip') {
validateFlipAction(action as ActionFlip);
} else if (actionType === 'rotate') {
validateRotateAction(action as ActionRotate);
} else if (actionType === 'resize') {
validateResizeAction(action as ActionResize);
}
}
}
function validateCropAction(action: ActionCrop): void {
const isValid =
typeof action.crop === 'object' &&
action.crop !== null &&
typeof action.crop.originX === 'number' &&
typeof action.crop.originY === 'number' &&
typeof action.crop.width === 'number' &&
typeof action.crop.height === 'number';
if (!isValid) {
throw new TypeError(
'Crop action must be an object of shape { originX: number; originY: number; width: number; height: number }'
);
}
}
function validateExtentAction(action: ActionExtent): void {
const isValid =
typeof action.extent === 'object' &&
action.extent !== null &&
(action.extent.backgroundColor == null || typeof action.extent.backgroundColor === 'string') &&
(action.extent.originX == null || typeof action.extent.originX === 'number') &&
(action.extent.originY == null || typeof action.extent.originY === 'number') &&
typeof action.extent.width === 'number' &&
typeof action.extent.height === 'number';
if (!isValid) {
throw new TypeError(
'Extent action must be an object of shape { backgroundColor?: string; originX?: number; originY?: number; width: number; height: number }'
);
}
}
function validateFlipAction(action: ActionFlip): void {
if (
typeof action.flip !== 'string' ||
![FlipType.Horizontal, FlipType.Vertical].includes(action.flip)
) {
throw new TypeError(`Unsupported flip type: ${action.flip}`);
}
}
function validateRotateAction(action: ActionRotate): void {
if (typeof action.rotate !== 'number') {
throw new TypeError('Rotation must be a number');
}
}
function validateResizeAction(action: ActionResize): void {
const isValid =
typeof action.resize === 'object' &&
action.resize !== null &&
(typeof action.resize.width === 'number' || typeof action.resize.width === 'undefined') &&
(typeof action.resize.height === 'number' || typeof action.resize.height === 'undefined');
if (!isValid) {
throw new TypeError(
'Resize action must be an object of shape { width?: number; height?: number }'
);
}
}
export function validateSaveOptions({ base64, compress, format }: SaveOptions): void {
if (base64 !== undefined && typeof base64 !== 'boolean') {
throw new TypeError('The "base64" argument must be a boolean');
}
if (compress !== undefined) {
if (typeof compress !== 'number') {
throw new TypeError('The "compress" argument must be a number');
}
if (compress < 0 || compress > 1) {
throw new TypeError('The "compress" argument must be a number between 0 and 1');
}
}
const allowedFormats = [SaveFormat.JPEG, SaveFormat.PNG, SaveFormat.WEBP];
if (format !== undefined && !allowedFormats.includes(format)) {
throw new TypeError(`The "format" argument must be one of: ${allowedFormats.join(', ')}`);
}
}
@@ -0,0 +1,90 @@
import { SharedObject } from 'expo';
import { ActionCrop, ActionExtent, FlipType } from '../ImageManipulator.types';
import ImageManipulatorImageRef from './ImageManipulatorImageRef.web';
import { crop, extent, flip, resize, rotate } from './actions/index.web';
type ContextLoader = () => HTMLCanvasElement | Promise<HTMLCanvasElement>;
export default class ImageManipulatorContext extends SharedObject {
private loader: ContextLoader;
private _currentTask: Promise<HTMLCanvasElement> | undefined;
get currentTask() {
if (this._currentTask) {
return this._currentTask;
}
this._currentTask = new Promise((resolve) => resolve(this.loader()));
return this._currentTask;
}
set currentTask(task) {
this._currentTask = task;
}
constructor(loader?: ContextLoader) {
super();
this.loader = loader ?? (() => document.createElement('canvas'));
}
resize(size: { width: number; height: number }): ImageManipulatorContext {
return this.addTask((canvas) => resize(canvas, size));
}
rotate(degrees: number): ImageManipulatorContext {
return this.addTask((canvas) => rotate(canvas, degrees));
}
flip(flipType: FlipType): ImageManipulatorContext {
return this.addTask((canvas) => flip(canvas, flipType));
}
crop(rect: ActionCrop['crop']): ImageManipulatorContext {
return this.addTask((canvas) => crop(canvas, rect));
}
extent(options: ActionExtent['extent']): ImageManipulatorContext {
return this.addTask((canvas) => extent(canvas, options));
}
reset(): ImageManipulatorContext {
this.currentTask = new Promise((resolve) => resolve(this.loader()));
return this;
}
async renderAsync(): Promise<ImageManipulatorImageRef> {
const canvas = await this.currentTask;
// We're copying the canvas so ref's `saveAsync` can safely use `toBlob` again with the desired format and quality.
// The original canvas cannot be reused as the manipulator context may still draw on it.
const clonedCanvas = document.createElement('canvas');
const clonedCanvasCtx = clonedCanvas.getContext('2d');
clonedCanvas.width = canvas.width;
clonedCanvas.height = canvas.height;
clonedCanvasCtx?.drawImage(canvas, 0, 0);
return new Promise((resolve) => {
// Create a full-sized, full-quality blob from the original canvas.
canvas.toBlob(
(blob) => {
const url = blob ? URL.createObjectURL(blob) : canvas.toDataURL();
const image = new ImageManipulatorImageRef(url, clonedCanvas);
resolve(image);
},
// Use PNG format so the result is of the best quality.
// If you need another format, see `saveAsync` function on the image ref.
'image/png'
);
});
}
private addTask(
task: (canvas: HTMLCanvasElement) => HTMLCanvasElement | Promise<HTMLCanvasElement>
): ImageManipulatorContext {
this.currentTask = this.currentTask.then((canvas) => {
return task(canvas);
});
return this;
}
}
@@ -0,0 +1,48 @@
import { SharedRef } from 'expo';
import { ImageResult, SaveFormat, SaveOptions } from '../ImageManipulator.types';
import { blobToBase64String } from './utils.web';
export default class ImageManipulatorImageRef extends SharedRef<'image'> {
readonly nativeRefType: string = 'image';
readonly uri: string;
readonly canvas: HTMLCanvasElement;
constructor(uri: string, canvas: HTMLCanvasElement) {
super();
this.uri = uri;
this.canvas = canvas;
}
get width() {
return this.canvas.width;
}
get height() {
return this.canvas.height;
}
async saveAsync(options: SaveOptions = { base64: false }): Promise<ImageResult> {
return new Promise((resolve, reject) => {
this.canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error(`Unable to save image: ${this.uri}`));
}
const base64 = options.base64 ? await blobToBase64String(blob) : undefined;
const uri = URL.createObjectURL(blob);
resolve({
uri,
width: this.width,
height: this.height,
base64,
});
},
`image/${options.format ?? SaveFormat.JPEG}`,
options.compress
);
});
}
}
+36
View File
@@ -0,0 +1,36 @@
import { CodedError } from 'expo-modules-core';
import { ActionCrop } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value));
export default (canvas: HTMLCanvasElement, options: ActionCrop['crop']) => {
// ensure values are defined.
let { originX = 0, originY = 0, width = 0, height = 0 } = options;
// lock within bounds.
width = clamp(width, canvas.width);
height = clamp(height, canvas.height);
originX = clamp(originX, canvas.width);
originY = clamp(originY, canvas.height);
// lock sum of crop.
width = Math.min(originX + width, canvas.width) - originX;
height = Math.min(originY + height, canvas.height) - originY;
if (width === 0 || height === 0) {
throw new CodedError(
'ERR_IMAGE_MANIPULATOR_CROP',
'Crop size must be greater than 0: ' + JSON.stringify(options, null, 2)
);
}
const result = document.createElement('canvas');
result.width = width;
result.height = height;
const context = getContext(result);
context.drawImage(canvas, originX, originY, width, height, 0, 0, width, height);
return result;
};
@@ -0,0 +1,43 @@
import { CodedError } from 'expo-modules-core';
import { ActionExtent } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
export default (canvas: HTMLCanvasElement, options: ActionExtent['extent']) => {
// ensure values are defined.
const { backgroundColor = null, originX = 0, originY = 0, width = 0, height = 0 } = options;
if (width === 0 || height === 0) {
throw new CodedError(
'ERR_IMAGE_MANIPULATOR_EXTENT',
'Extent size must be greater than 0: ' + JSON.stringify(options, null, 2)
);
}
const result = document.createElement('canvas');
result.width = width;
result.height = height;
const sx = originX < 0 ? 0 : originX;
const sy = originY < 0 ? 0 : originY;
const sw =
originX < 0 ? Math.min(canvas.width, width + originX) : Math.min(canvas.width - originX, width);
const sh =
originY < 0
? Math.min(canvas.height, height + originY)
: Math.min(canvas.height - originY, height);
const dx = originX < 0 ? -originX : 0;
const dy = originY < 0 ? -originY : 0;
const context = getContext(result);
if (backgroundColor != null) {
context.fillStyle = backgroundColor;
context.fillRect(0, 0, width, height);
}
context.drawImage(canvas, sx, sy, sw, sh, dx, dy, sw, sh);
return result;
};
+26
View File
@@ -0,0 +1,26 @@
import { ActionFlip, FlipType } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
export default (canvas: HTMLCanvasElement, flip: ActionFlip['flip']) => {
const xFlip = flip === FlipType.Horizontal;
const yFlip = flip === FlipType.Vertical;
const result = document.createElement('canvas');
result.width = canvas.width;
result.height = canvas.height;
const context = getContext(result);
// Set the origin to the center of the image
context.translate(canvas.width / 2, canvas.height / 2);
// Flip/flop the canvas
const xScale = xFlip ? -1 : 1;
const yScale = yFlip ? -1 : 1;
context.scale(xScale, yScale);
// Draw the image
context.drawImage(canvas, -canvas.width / 2, -canvas.height / 2, canvas.width, canvas.height);
return result;
};
+119
View File
@@ -0,0 +1,119 @@
import { ActionResize } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
/**
* Hermite resize - fast image resize/resample using Hermite filter. 1 cpu version!
* https://stackoverflow.com/a/18320662/4047926
*
* @param {HTMLCanvasElement} canvas
* @param {int} width
* @param {int} height
* @param {boolean} resizeCanvas if true, canvas will be resized. Optional.
*/
function resampleSingle(
canvas: HTMLCanvasElement,
width: number,
height: number,
resizeCanvas: boolean = false
): HTMLCanvasElement {
const result = document.createElement('canvas');
result.width = canvas.width;
result.height = canvas.height;
const widthSource = canvas.width;
const heightSource = canvas.height;
width = Math.round(width);
height = Math.round(height);
const wRatio = widthSource / width;
const hRatio = heightSource / height;
const wRatioHalf = Math.ceil(wRatio / 2);
const hRatioHalf = Math.ceil(hRatio / 2);
const ctx = getContext(canvas);
const img = ctx.getImageData(0, 0, widthSource, heightSource);
const img2 = ctx.createImageData(width, height);
const data = img.data;
const data2 = img2.data;
for (let j = 0; j < height; j++) {
for (let i = 0; i < width; i++) {
const x2 = (i + j * width) * 4;
let weight = 0;
let weights = 0;
let weightsAlpha = 0;
let gx_r = 0;
let gx_g = 0;
let gx_b = 0;
let gx_a = 0;
const yCenter = (j + 0.5) * hRatio;
const yy_start = Math.floor(j * hRatio);
const yy_stop = Math.ceil((j + 1) * hRatio);
for (let yy = yy_start; yy < yy_stop; yy++) {
const dy = Math.abs(yCenter - (yy + 0.5)) / hRatioHalf;
const center_x = (i + 0.5) * wRatio;
const w0 = dy * dy; //pre-calc part of w
const xx_start = Math.floor(i * wRatio);
const xx_stop = Math.ceil((i + 1) * wRatio);
for (let xx = xx_start; xx < xx_stop; xx++) {
const dx = Math.abs(center_x - (xx + 0.5)) / wRatioHalf;
const w = Math.sqrt(w0 + dx * dx);
if (w >= 1) {
//pixel too far
continue;
}
//hermite filter
weight = 2 * w * w * w - 3 * w * w + 1;
const xPosition = 4 * (xx + yy * widthSource);
//alpha
gx_a += weight * data[xPosition + 3];
weightsAlpha += weight;
//colors
if (data[xPosition + 3] < 255) {
weight = (weight * data[xPosition + 3]) / 250;
}
gx_r += weight * data[xPosition];
gx_g += weight * data[xPosition + 1];
gx_b += weight * data[xPosition + 2];
weights += weight;
}
}
data2[x2] = gx_r / weights;
data2[x2 + 1] = gx_g / weights;
data2[x2 + 2] = gx_b / weights;
data2[x2 + 3] = gx_a / weightsAlpha;
}
}
//resize canvas
if (resizeCanvas) {
result.width = width;
result.height = height;
}
//draw
const context = getContext(result);
context.putImageData(img2, 0, 0);
return result;
}
export default (canvas: HTMLCanvasElement, { width, height }: ActionResize['resize']) => {
const imageRatio = canvas.width / canvas.height;
let requestedWidth: number = 0;
let requestedHeight: number = 0;
if (width !== undefined) {
requestedWidth = width;
requestedHeight = requestedWidth / imageRatio;
}
if (height !== undefined) {
requestedHeight = height;
if (requestedWidth === 0) {
requestedWidth = requestedHeight * imageRatio;
}
}
return resampleSingle(canvas, requestedWidth, requestedHeight, true);
};
@@ -0,0 +1,41 @@
import { ActionRotate } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
function sizeFromAngle(
width: number,
height: number,
angle: number
): { width: number; height: number } {
const radians = (angle * Math.PI) / 180;
let c = Math.cos(radians);
let s = Math.sin(radians);
if (s < 0) {
s = -s;
}
if (c < 0) {
c = -c;
}
return { width: height * s + width * c, height: height * c + width * s };
}
export default (canvas: HTMLCanvasElement, degrees: ActionRotate['rotate']) => {
const { width, height } = sizeFromAngle(canvas.width, canvas.height, degrees);
const result = document.createElement('canvas');
result.width = width;
result.height = height;
const context = getContext(result);
// Set the origin to the center of the image
context.translate(result.width / 2, result.height / 2);
// Rotate the canvas around the origin
const radians = (degrees * Math.PI) / 180;
context.rotate(radians);
// Draw the image
context.drawImage(canvas, -canvas.width / 2, -canvas.height / 2, canvas.width, canvas.height);
return result;
};
+5
View File
@@ -0,0 +1,5 @@
export { default as crop } from './CropAction.web';
export { default as extent } from './ExtentAction.web';
export { default as flip } from './FlipAction.web';
export { default as resize } from './ResizeAction.web';
export { default as rotate } from './RotateAction.web';
+39
View File
@@ -0,0 +1,39 @@
import { CodedError } from 'expo-modules-core';
export function getContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new CodedError('ERR_IMAGE_MANIPULATOR', 'Failed to create canvas context');
}
return ctx;
}
export async function blobToBase64String(blob: Blob): Promise<string> {
const dataURL = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () =>
reject(new Error(`Unable to convert blob to base64 string: ${reader.error}`));
reader.readAsDataURL(blob);
});
return dataURL.replace(/^data:image\/\w+;base64,/, '');
}
export function loadImageAsync(uri: string): Promise<HTMLCanvasElement> {
return new Promise((resolve, reject) => {
const imageSource = new Image();
imageSource.crossOrigin = 'anonymous';
const canvas = document.createElement('canvas');
imageSource.onload = () => {
canvas.width = imageSource.naturalWidth;
canvas.height = imageSource.naturalHeight;
const context = getContext(canvas);
context.drawImage(imageSource, 0, 0, imageSource.naturalWidth, imageSource.naturalHeight);
resolve(canvas);
};
imageSource.onerror = () => reject(canvas);
imageSource.src = uri;
});
}