Contents

ivantokar/hokusai-vapor

**Seamless image processing integration for Vapor applications**

Features

  • Drop-in Integration - Extends Vapor's Application and Request with image processing capabilities
  • Request Helpers - Load images directly from multipart uploads or request body
  • Response Conversion - Automatic conversion to Vapor Response with proper content types
  • Pre-built Routes - Optional ready-to-use endpoints for common operations
  • Lifecycle Management - Automatic initialization and cleanup of libvips
  • Error Handling - Vapor-native error responses with proper HTTP status codes

Quick Example

import Vapor
import HokusaiVapor

func configure(_ app: Application) throws {
    // Initialize Hokusai
    try app.hokusai.configure()
}

func routes(_ app: Application) throws {
    // Load image from request, resize, return as response
    app.post("resize") { req async throws -> Response in
        let image = try await req.hokusaiImage()
        let resized = try image.resize(width: 800, height: 600)
        return try resized.response(format: .jpeg, quality: 85)
    }
}

Perfect For

  • RESTful image processing APIs
  • User avatar/profile picture handling
  • Dynamic social media card generation
  • File upload preprocessing pipelines

Installation

Requirements

macOS:

brew install vips pkg-config

Ubuntu/Debian:

sudo apt update
sudo apt install libvips-dev pkg-config

Swift Package Manager

Add to your Package.swift:

dependencies: [
    .package(url: "https://github.com/vapor/vapor.git", from: "4.89.0"),
    .package(url: "https://github.com/ivantokar/hokusai-vapor.git", from: "0.2.0")
]

targets: [
    .target(
        name: "App",
        dependencies: [
            .product(name: "Vapor", package: "vapor"),
            .product(name: "HokusaiVapor", package: "hokusai-vapor")
        ]
    )
]

Quick Start

1. Configure Hokusai in your app

import Vapor
import HokusaiVapor

public func configure(_ app: Application) throws {
    // Initialize Hokusai
    try app.hokusai.configure()

    // Your other configuration...
    try routes(app)
}

2. Use in your routes

import Vapor
import HokusaiVapor

func routes(_ app: Application) throws {
    // Simple text overlay endpoint
    app.post("watermark") { req async throws -> Response in
        let image = try await req.hokusaiImage()

        let watermarked = try image.drawText(
            "© 2024 MyCompany",
            x: 10,
            y: 10,
            options: TextOptions(
                font: "Arial",
                fontSize: 24,
                color: [255, 255, 255, 200]
            )
        )

        return try watermarked.response(format: "jpeg", quality: 85)
    }
}

3. Use pre-built routes

import HokusaiVapor

func routes(_ app: Application) throws {
    let api = app.grouped("api", "images")

    // Registers /api/images/text and /api/images/convert
    try ImageProcessingRoutes.register(to: api)
}

API Documentation

Application Configuration

import HokusaiVapor

// Configure in configure.swift
try app.hokusai.configure()

// Access version info
print(app.hokusai.vipsVersion)    // "8.15.1"

Request Extensions

Load from Request Body
app.post("process") { req async throws -> Response in
    // Load image from raw request body
    let image = try await req.hokusaiImage()

    // Process the image
    let resized = try image.resize(width: 800)

    return try resized.response(format: "jpeg", quality: 85)
}

Test with curl:

curl -X POST http://localhost:8080/process \
  --data-binary "@photo.jpg" \
  -o output.jpg
Load from Multipart Form Data
app.post("upload") { req async throws -> Response in
    // Load from multipart field named "image"
    let image = try await req.hokusaiImage(field: "image")

    let thumbnail = try image.resize(width: 200, height: 200)

    return try thumbnail.response(format: "png")
}

Test with curl:

curl -X POST http://localhost:8080/upload \
  -F "image=@photo.jpg" \
  -o thumbnail.png

Response Conversion

extension HokusaiImage {
    func response(
        format: String = "jpeg",
        quality: Int? = nil,
        compression: Int? = nil,
        status: HTTPStatus = .ok
    ) throws -> Response
}

Supported formats:

  • jpeg / jpg - JPEG with quality 1-100 (default: 85)
  • png - PNG with compression 0-9 (default: 6)
  • webp - WebP with quality 1-100 (default: 80)
  • avif - AVIF with quality 1-100 (default: 75)
  • gif - GIF
  • tiff / tif - TIFF

PNG uses compression (0-9). If you pass both quality and compression, PNG will use compression. AVIF/HEIF output requires libvips built with libheif support.

Examples:

// JPEG with custom quality
return try image.response(format: "jpeg", quality: 90)

// PNG with maximum compression
return try image.response(format: "png", compression: 9)

// WebP
return try image.response(format: "webp", quality: 80)

// Custom status code
return try image.response(format: "jpeg", status: .created)

Pre-built Routes

Text Overlay Route

Endpoint: POST /text?text=Hello&fontSize=48&x=100&y=200

Query Parameters:

  • text (required) - Text to render
  • fontSize (optional) - Font size in pixels (default: 48)
  • font (optional) - Font path or name (default: "DejaVu-Sans")
  • x (optional) - X position (default: center)
  • y (optional) - Y position (default: center)
  • strokeWidth (optional) - Text outline width
  • quality (optional) - Output quality 1-100 (default: 90)

Example:

curl -X POST "http://localhost:8080/api/images/text?text=Hello&fontSize=64&strokeWidth=2" \
  --data-binary "@photo.jpg" \
  -o with_text.jpg

Format Conversion Route

Endpoint: POST /convert?format=webp&quality=80

Query Parameters:

  • format (required) - Target format: jpeg, png, webp, avif, gif, tiff
  • quality (optional) - Quality 1-100
  • compression (optional) - PNG compression 0-9

Example:

curl -X POST "http://localhost:8080/api/images/convert?format=webp&quality=80" \
  --data-binary "@photo.jpg" \
  -o photo.webp

Advanced Usage

Custom Route with Multiple Operations

app.post("thumbnail") { req async throws -> Response in
    struct Query: Content {
        let width: Int
        let height: Int
        let text: String?
    }

    let params = try req.query.decode(Query.self)
    let image = try await req.hokusaiImage()

    // Create thumbnail
    var processed = try image.resizeToCover(
        width: params.width,
        height: params.height
    )

    // Optionally add watermark
    if let text = params.text {
        processed = try processed.drawText(
            text,
            x: 10,
            y: params.height - 30,
            options: TextOptions(
                font: "Arial",
                fontSize: 20,
                color: [255, 255, 255, 200],
                strokeColor: [0, 0, 0, 200],
                strokeWidth: 1.0
            )
        )
    }

    return try processed.response(format: "jpeg", quality: 85)
}

Composite / Watermark Route

app.post("watermark") { req async throws -> Response in
    let base = try await req.hokusaiImage()
    let overlay = try await Hokusai.image(from: "Assets/logo.png")

    let options = CompositeOptions(mode: .over, opacity: 0.7)
    let composited = try base.composite(
        overlay: overlay,
        x: 20,
        y: 20,
        options: options
    )

    return try composited.response(format: "png", compression: 9)
}

Store Output in Amazon S3

Add AWS SDK for Swift to your Package.swift:

.package(url: "https://github.com/awslabs/aws-sdk-swift.git", from: "1.0.0")
.product(name: "AWSS3", package: "aws-sdk-swift")

Route example:

import AWSClientRuntime
import AWSS3

app.post("upload") { req async throws -> Response in
    let image = try await req.hokusaiImage()
    let data = try image.toBuffer(format: "jpeg", quality: 85)

    let config = try await S3Client.S3ClientConfiguration(region: "us-east-1")
    let client = S3Client(config: config)
    defer {
        Task { try? await client.shutdown() }
    }

    let key = "uploads/\(UUID().uuidString).jpg"
    let request = PutObjectInput(
        body: .data(data),
        bucket: "my-bucket",
        key: key,
        contentType: "image/jpeg"
    )

    _ = try await client.putObject(input: request)
    return Response(status: .ok, body: .init(string: key))
}

Template Text Example

struct TemplateTextController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        routes.post("template-text", use: generate)
    }

    func generate(req: Request) async throws -> Response {
        struct Query: Content {
            let text: String
        }

        let params = try req.query.decode(Query.self)

        // Load template image
        let templateImage = try await Hokusai.image(from: "/path/to/template.png")

        // Configure custom font
        var textOptions = TextOptions()
        textOptions.font = "/path/to/CustomFont.ttf"
        textOptions.fontSize = 96
        textOptions.color = [0, 0, 128, 255]
        textOptions.strokeColor = [255, 255, 255, 255]
        textOptions.strokeWidth = 2.0

        // Add text to template image
        let width = try templateImage.width
        let height = try templateImage.height

        let withText = try templateImage.drawText(
            params.text,
            x: width / 2,
            y: Int(Double(height) * 0.6),
            options: textOptions
        )

        return try withText.response(format: "png", compression: 9)
    }
}

// Register in routes
try app.register(collection: TemplateTextController())

Test:

curl -X POST "http://localhost:8080/template-text?text=Hello%20World" \
  -o template-text.png

Metadata Endpoint

app.post("metadata") { req async throws -> Response in
    struct MetadataResponse: Content {
        let width: Int
        let height: Int
        let format: String?
        let hasAlpha: Bool
    }

    let image = try await req.hokusaiImage()
    let metadata = try image.metadata()

    let response = MetadataResponse(
        width: metadata.width,
        height: metadata.height,
        format: metadata.format?.rawValue,
        hasAlpha: metadata.hasAlpha
    )

    return try await response.encodeResponse(for: req)
}

Docker Deployment

Dockerfile Example

# Build stage
FROM swift:6.1-noble AS build

# Install dependencies
RUN apt-get update && apt-get install -y \
    libvips-dev \
    pkg-config

# Copy source code
WORKDIR /build
COPY . .

# Build application
RUN swift build -c release

# Runtime stage
FROM swift:6.1-noble-slim

# Install runtime dependencies
RUN apt-get update && apt-get install -y \
    libvips \
    fonts-dejavu-core \
    fontconfig \
    && rm -rf /var/lib/apt/lists/*

# Copy custom fonts
COPY fonts/ /usr/share/fonts/custom/
RUN fc-cache -f -v

# Copy executable
COPY --from=build /build/.build/release/App /app/

EXPOSE 8080
CMD ["/app/App", "serve", "--env", "production", "--hostname", "0.0.0.0"]

docker-compose.yml

version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - LOG_LEVEL=info
    volumes:
      - ./templates:/app/templates

Build and Run

# Build image
docker compose build

# Run container
docker compose up

# Test endpoint
curl -X POST "http://localhost:8080/api/images/text?text=Hello" \
  --data-binary "@photo.jpg" \
  -o output.jpg

Performance Considerations

Memory Usage

Request body size limits can be configured in Vapor:

// In configure.swift
app.routes.defaultMaxBodySize = "10mb"  // Adjust based on your needs

Concurrent Processing

Hokusai is thread-safe and can handle concurrent requests:

// Process multiple images concurrently
app.post("batch") { req async throws -> [String] in
    struct BatchRequest: Content {
        let images: [Data]
    }

    let batch = try req.content.decode(BatchRequest.self)

    return try await withThrowingTaskGroup(of: String.self) { group in
        for (index, imageData) in batch.images.enumerated() {
            group.addTask {
                let image = try await Hokusai.image(from: imageData)
                let resized = try image.resize(width: 800)
                let filename = "output_\(index).jpg"
                try resized.toFile("/tmp/\(filename)")
                return filename
            }
        }

        var results: [String] = []
        for try await result in group {
            results.append(result)
        }
        return results
    }
}

Error Handling

app.post("process") { req async throws -> Response in
    do {
        let image = try await req.hokusaiImage()
        let processed = try image.resize(width: 800)
        return try processed.response(format: "jpeg")
    } catch let error as HokusaiError {
        throw Abort(.badRequest, reason: "Image processing failed: \(error)")
    } catch let error as AbortError {
        throw error
    } catch {
        throw Abort(.internalServerError, reason: "Unexpected error: \(error)")
    }
}

Troubleshooting

"No image data in request body" Error

Make sure you're sending the image data in the request body:

# Correct - binary data in body
curl -X POST http://localhost:8080/process \
  --data-binary "@photo.jpg"

# Incorrect - will fail
curl -X POST http://localhost:8080/process

Font Not Found in Docker

Ensure fonts are copied to the container and font cache is updated:

COPY fonts/ /usr/share/fonts/custom/
RUN fc-cache -f -v

Verify fonts are installed:

docker exec -it container_name fc-list | grep YourFont

iOS Client Example

This example calls a HokusaiVapor server's pre-built /api/images/convert route with a raw image body:

import UIKit

func convertToWebP(_ image: UIImage, baseURL: URL) async throws -> UIImage {
    guard let data = image.jpegData(compressionQuality: 0.9) else {
        throw URLError(.cannotDecodeRawData)
    }

    var components = URLComponents(
        url: baseURL.appendingPathComponent("api/images/convert"),
        resolvingAgainstBaseURL: false
    )
    components?.queryItems = [
        URLQueryItem(name: "format", value: "webp"),
        URLQueryItem(name: "quality", value: "80")
    ]

    guard let url = components?.url else {
        throw URLError(.badURL)
    }

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")
    request.httpBody = data

    let (responseData, _) = try await URLSession.shared.data(for: request)
    guard let processed = UIImage(data: responseData) else {
        throw URLError(.cannotDecodeRawData)
    }

    return processed
}

Examples

See the hokusai-vapor-example demo app for a complete working example with:

  • Interactive web UI for testing features
  • Template text drawing with custom fonts
  • Image metadata extraction
  • Format conversion (JPEG, PNG, WebP, AVIF, GIF)
  • Text overlay with stroke effects
  • Resize and rotate operations
  • Docker deployment

Testing

swift build
swift test

Tests are implemented with XCTest and run with standard SwiftPM tooling. The package keeps a minimal swift-testing dependency to support toolchains where SwiftPM still expects the Testing module at test runtime.

Releases

Hokusai Vapor follows semantic version tags in the format vX.Y.Z.

  • Releases are managed manually via semantic version tags (vX.Y.Z).
  • This repository intentionally does not run GitHub Actions workflows to reduce OSS costs.
  • Human-curated release notes are tracked in CHANGELOG.md.

Swift Package Index

This repository is structured to be compatible with Swift Package Index:

  • semantic version tags (vX.Y.Z)
  • local validation with swift build and swift test
  • clear installation/usage docs in this README

Recommended next step when API docs grow: add a lightweight DocC catalog at Sources/HokusaiVapor/HokusaiVapor.docc and let SPI host the generated documentation.

Contributing

Contributions welcome! Please see the main Hokusai repository.

License

MIT License - see LICENSE file for details.

Package Metadata

Repository: ivantokar/hokusai-vapor

Default branch: main

README: README.md