init
This commit is contained in:
commit
bb18ffc49b
9 changed files with 4880 additions and 0 deletions
45
.forgejo/workflows/ci.yml
Normal file
45
.forgejo/workflows/ci.yml
Normal file
|
@ -0,0 +1,45 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x, 18.x, 20.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Generate coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Archive coverage
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-${{ matrix.node-version }}
|
||||
path: coverage
|
||||
retention-days: 5
|
65
.gitignore
vendored
Normal file
65
.gitignore
vendored
Normal file
|
@ -0,0 +1,65 @@
|
|||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Coverage directory
|
||||
coverage/
|
||||
|
||||
# IDE and editor files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Compiled binary addons
|
||||
build/Release
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# Temporary test outputs
|
||||
__tests__/output/
|
105
README.md
Normal file
105
README.md
Normal file
|
@ -0,0 +1,105 @@
|
|||
# Image Speech Bubble Transformer
|
||||
|
||||
A TypeScript library for applying speech bubble effects to images with various transformation options.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install image-speech-bubble-transformer
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createSpeechBubbleTransformer,
|
||||
Orientation,
|
||||
} from "image-speech-bubble-transformer";
|
||||
import fs from "fs/promises";
|
||||
|
||||
async function example() {
|
||||
// Create transformer instance
|
||||
const transformer = createSpeechBubbleTransformer();
|
||||
|
||||
// Read image buffer
|
||||
const inputBuffer = await fs.readFile("input.png");
|
||||
|
||||
// Process image with options
|
||||
const processedBuffer = await transformer.processSpeechBubble(inputBuffer, {
|
||||
mirror: true,
|
||||
orientation: Orientation.LEFT,
|
||||
});
|
||||
|
||||
// Save processed image
|
||||
await transformer.processAndSave(inputBuffer, "output.png", {
|
||||
mirror: true,
|
||||
orientation: Orientation.LEFT,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `createSpeechBubbleTransformer(assetsDir?: string)`
|
||||
|
||||
- Creates a transformer instance
|
||||
- `assetsDir`: Optional path to custom assets directory
|
||||
|
||||
### `processSpeechBubble(inputBuffer, options)`
|
||||
|
||||
- Applies speech bubble effect to image
|
||||
- Options:
|
||||
- `mirror`: Flip horizontally
|
||||
- `orientation`: Rotate speech bubble
|
||||
- `speechBubblePath`: Custom speech bubble image
|
||||
|
||||
### `processAndSave(inputBuffer, outputPath, options)`
|
||||
|
||||
- Processes and saves image in one step
|
||||
|
||||
## Supported Formats
|
||||
|
||||
- .png, .jpg, .jpeg, .gif, .bmp, .webp, .tiff
|
||||
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
- `npm run build`: Compile TypeScript
|
||||
- `npm run lint`: Run ESLint
|
||||
- `npm test`: Run Jest tests
|
||||
- `npm run test:coverage`: Run tests with coverage report
|
||||
|
||||
### Publishing to NPM
|
||||
|
||||
1. Ensure you're logged into NPM:
|
||||
|
||||
```bash
|
||||
npm login
|
||||
```
|
||||
|
||||
2. Update version (use appropriate semver):
|
||||
|
||||
```bash
|
||||
npm version patch # or minor/major
|
||||
```
|
||||
|
||||
3. Publish:
|
||||
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Ensure `.github/workflows/ci.yml` is set up for automated testing
|
||||
- Configure NPM token in repository secrets
|
29
eslint.config.json
Normal file
29
eslint.config.json
Normal file
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"jest"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:jest/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"jest/no-disabled-tests": "warn",
|
||||
"jest/no-focused-tests": "error",
|
||||
"jest/no-identical-title": "error",
|
||||
"jest/prefer-to-have-length": "warn",
|
||||
"jest/valid-expect": "error"
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true,
|
||||
"jest/globals": true
|
||||
}
|
||||
}
|
10
eslint.config.mjs
Normal file
10
eslint.config.mjs
Normal file
|
@ -0,0 +1,10 @@
|
|||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{files: ["**/*.{js,mjs,cjs,ts}"]},
|
||||
{languageOptions: { globals: globals.browser }},
|
||||
...tseslint.configs.recommended,
|
||||
];
|
66
package.json
Normal file
66
package.json
Normal file
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"name": "image-speech-bubble-transformer",
|
||||
"version": "1.0.0",
|
||||
"description": "TypeScript library for applying speech bubble effects to images",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run clean && tsc",
|
||||
"build:watch": "tsc -w",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"prepare": "npm run build",
|
||||
"prepublishOnly": "npm test && npm run lint",
|
||||
"version": "git add -A src",
|
||||
"postversion": "git push && git push --tags"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://f0rk.systems/reso/packages_speech_bubble.git"
|
||||
},
|
||||
"keywords": [
|
||||
"image",
|
||||
"processing",
|
||||
"speech-bubble",
|
||||
"transformer",
|
||||
"sharp",
|
||||
"image-manipulation"
|
||||
],
|
||||
"author": "proto <proto@throwing.lol>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://f0rk.systems/reso/packages_speech_bubble/issues"
|
||||
},
|
||||
"homepage": "https://f0rk.systems/reso/packages_speech_bubble#readme",
|
||||
"dependencies": {
|
||||
"sharp": "^0.32.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||
"@typescript-eslint/parser": "^6.1.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-jest": "^27.2.3",
|
||||
"globals": "^15.14.0",
|
||||
"prettier": "^3.0.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript-eslint": "^8.21.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
4387
pnpm-lock.yaml
generated
Normal file
4387
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
155
src/index.ts
Normal file
155
src/index.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { PathLike } from "fs";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
|
||||
const SUPPORTED_FORMATS = [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".bmp",
|
||||
".webp",
|
||||
".tiff",
|
||||
];
|
||||
|
||||
export enum Orientation {
|
||||
TOP = 1,
|
||||
LEFT = 2,
|
||||
BOTTOM = 3,
|
||||
RIGHT = 4,
|
||||
}
|
||||
|
||||
export interface SpeechBubbleOptions {
|
||||
mirror?: boolean;
|
||||
orientation?: Orientation;
|
||||
speechBubblePath?: string;
|
||||
}
|
||||
|
||||
export class ImageSpeechBubbleTransformer {
|
||||
private defaultSpeechBubblePath: string;
|
||||
|
||||
constructor(assetsDir: string = path.join(__dirname, "assets")) {
|
||||
this.defaultSpeechBubblePath = path.join(assetsDir, "speech_bubble.png");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file format is supported
|
||||
*/
|
||||
private checkFileFormat(filePath: string): void {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!SUPPORTED_FORMATS.includes(ext)) {
|
||||
throw new Error(
|
||||
`Unsupported file extension ${ext}. Only ${SUPPORTED_FORMATS.join(", ")} are supported.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform image with optional mirroring and rotation
|
||||
*/
|
||||
private async transformImage(
|
||||
inputBuffer: Buffer,
|
||||
options: { mirror?: boolean; orientation?: Orientation } = {}
|
||||
): Promise<Buffer> {
|
||||
let pipeline = sharp(inputBuffer);
|
||||
|
||||
// Mirror (flip horizontally)
|
||||
if (options.mirror) {
|
||||
pipeline = pipeline.flop();
|
||||
}
|
||||
|
||||
// Rotate based on orientation
|
||||
switch (options.orientation) {
|
||||
case Orientation.LEFT:
|
||||
pipeline = pipeline.rotate(270);
|
||||
break;
|
||||
case Orientation.BOTTOM:
|
||||
pipeline = pipeline.rotate(180);
|
||||
break;
|
||||
case Orientation.RIGHT:
|
||||
pipeline = pipeline.rotate(90);
|
||||
break;
|
||||
default:
|
||||
// No rotation for TOP or default
|
||||
break;
|
||||
}
|
||||
|
||||
return pipeline.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process image with speech bubble
|
||||
*/
|
||||
async processSpeechBubble(
|
||||
inputBuffer: Buffer,
|
||||
options: SpeechBubbleOptions = {}
|
||||
): Promise<Buffer> {
|
||||
if (!inputBuffer) {
|
||||
throw new Error("Input buffer is required");
|
||||
}
|
||||
|
||||
const speechBubblePath =
|
||||
options.speechBubblePath || this.defaultSpeechBubblePath;
|
||||
|
||||
this.checkFileFormat(speechBubblePath);
|
||||
|
||||
const speechBubbleBuffer = await sharp(speechBubblePath).toBuffer();
|
||||
|
||||
const processedImage = await sharp(inputBuffer)
|
||||
.ensureAlpha()
|
||||
.metadata()
|
||||
.then(async (metadata) => {
|
||||
// match image size
|
||||
const resizedSpeechBubble = await sharp(speechBubbleBuffer)
|
||||
.resize(metadata.width, metadata.height)
|
||||
.toBuffer();
|
||||
|
||||
const transformedSpeechBubble = await this.transformImage(
|
||||
resizedSpeechBubble,
|
||||
{
|
||||
mirror: options.mirror,
|
||||
orientation: options.orientation,
|
||||
}
|
||||
);
|
||||
|
||||
// subtract speech bubble
|
||||
return sharp(inputBuffer)
|
||||
.composite([
|
||||
{
|
||||
input: transformedSpeechBubble,
|
||||
blend: "difference",
|
||||
},
|
||||
])
|
||||
.toBuffer();
|
||||
});
|
||||
|
||||
return processedImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to process and save image
|
||||
*/
|
||||
async processAndSave(
|
||||
inputBuffer: Buffer,
|
||||
outputPath: PathLike,
|
||||
options: SpeechBubbleOptions = {}
|
||||
): Promise<void> {
|
||||
this.checkFileFormat(outputPath.toString());
|
||||
|
||||
// Process image
|
||||
const processedBuffer = await this.processSpeechBubble(
|
||||
inputBuffer,
|
||||
options
|
||||
);
|
||||
|
||||
const outputDir = path.dirname(outputPath.toString());
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
await sharp(processedBuffer).toFile(outputPath.toString());
|
||||
}
|
||||
}
|
||||
|
||||
export function createSpeechBubbleTransformer(assetsDir?: string) {
|
||||
return new ImageSpeechBubbleTransformer(assetsDir);
|
||||
}
|
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["es2020"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.spec.ts"]
|
||||
}
|
Loading…
Add table
Reference in a new issue