diff --git a/README.md b/README.md index 75f2332..da608fe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Twinkle -Twinkle is a Java framework for creating text-based user interfaces (TUIs) in terminal emulators. It provides a layered architecture with components for text manipulation, screen rendering, shapes/borders, and terminal abstraction. +Twinkle is a Java framework for creating text-based user interfaces (TUIs) in terminal emulators. It provides a layered architecture with components for text manipulation, screen rendering, shapes/borders, image encoding, and terminal abstraction. ## Architecture @@ -10,6 +10,7 @@ The project follows a layered architecture: - **Foundation**: Text utilities and ANSI escape code support - **Rendering**: Screen buffers and double-buffering for flicker-free rendering - **UI Components**: Drawing utilities for borders and shapes +- **Images**: [twinkle-image](/twinkle-image/) is a completely stand-alone library for rendering images in terminals - **Terminal Access**: Abstraction layer with pluggable implementations ## Modules @@ -34,6 +35,13 @@ The project follows a layered architecture: - Line and corner styles - Drawing utilities +- **`twinkle-image`** - Terminal image encoding framework (no dependencies) + - **Sixel** - Legacy DEC format (xterm, mlterm) + - **Kitty** - Modern format (Kitty, WezTerm) + - **iTerm2** - Inline format (iTerm2, WezTerm) + - **Block** - Unicode block-based fallback rendering + - Pluggable encoder implementations + ### Terminal Implementations - **`twinkle-terminal`** - Terminal access and management abstraction API @@ -48,6 +56,7 @@ The project follows a layered architecture: - **`examples`** - Example programs demonstrating Twinkle capabilities - `BouncingTwinkleDemo` - Animated demo with bouncing text and ASCII borders + - `ImageEncoderDemo` - Image rendering demonstration with automatic encoder detection ## Building @@ -64,6 +73,9 @@ After building, you can run the example programs to see Twinkle in action. This ```bash # Bouncing animation demo jbang bounce + +# Image encoder demo +jbang image ``` Or if you want to use regular Java commands: @@ -71,9 +83,13 @@ Or if you want to use regular Java commands: ```bash # Bouncing animation demo java -jar examples/target/examples-1.0-SNAPSHOT.jar org.codejive.twinkle.examples.BouncingTwinkleDemo + +# Image encoder demo +java -jar examples/target/examples-1.0-SNAPSHOT.jar org.codejive.twinkle.examples.ImageEncoderDemo ``` ## Requirements - Java 8 or higher (tests require Java 21) - A terminal emulator with ANSI support +- For image rendering: Terminal with Sixel, Kitty, iTerm2, or Unicode block support diff --git a/examples/pom.xml b/examples/pom.xml index d7f0cb3..c9d8ee4 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -29,6 +29,12 @@ 1.0-SNAPSHOT compile + + org.codejive.twinkle + twinkle-image + 1.0-SNAPSHOT + compile + com.github.lalyos jfiglet diff --git a/examples/src/main/java/org/codejive/twinkle/demos/ImageEncoderDemo.java b/examples/src/main/java/org/codejive/twinkle/demos/ImageEncoderDemo.java new file mode 100644 index 0000000..c1323e6 --- /dev/null +++ b/examples/src/main/java/org/codejive/twinkle/demos/ImageEncoderDemo.java @@ -0,0 +1,169 @@ +package org.codejive.twinkle.demos; + +// spotless:off +//DEPS org.codejive.twinkle:twinkle-terminal-aesh:1.0-SNAPSHOT +//DEPS org.codejive.twinkle:twinkle-image:1.0-SNAPSHOT +// spotless:on + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.List; +import org.codejive.twinkle.image.ImageEncoder; +import org.codejive.twinkle.image.ImageEncoders; +import org.codejive.twinkle.terminal.Terminal; + +/** + * Demo application showing how to use the terminal image encoding framework. + * + *

This example demonstrates rendering images to the terminal using different encoders (Sixel, + * Kitty, iTerm2, and block-based Unicode rendering). + * + *

Usage: {@code ImageEncoderDemo [--encoder=] [--all]} + * + *

Supported encoder names: sixel, kitty, iterm2, block-full, block-half, block-quadrant, + * block-sextant, block-octant. + */ +public class ImageEncoderDemo { + + public static void main(String[] args) throws Exception { + try (Terminal terminal = Terminal.getDefault()) { + PrintWriter writer = terminal.writer(); + + // Create a simple test image + BufferedImage testImage = createTestImage(200, 150); + + // Define target size in terminal rows/columns + int targetWidth = 20; // 20 columns wide + int targetHeight = 10; // 10 rows tall + + writer.println("=== Image Encoder Demo ==="); + + boolean fitImage = true; + + String encoderName = getEncoderArg(args); + + if (encoderName != null) { + // Use a specific encoder requested via --encoder= + ImageEncoder.Provider provider = findProvider(encoderName); + if (provider == null) { + writer.println("Unknown encoder: " + encoderName); + writer.println( + "Available: sixel, kitty, iterm2, block-full, block-half," + + " block-quadrant, block-sextant, block-octant"); + writer.flush(); + return; + } + writer.println("Using encoder: " + provider.name()); + writer.println(); + writer.println("Rendering with " + provider.name() + " encoder:"); + writer.flush(); + renderImage( + provider.create(testImage, targetWidth, targetHeight, fitImage), writer); + writer.println("\n"); + } else { + // Detect the best encoder for the current terminal + ImageEncoder.Provider bestProvider = ImageEncoders.best(); + ImageEncoder detectedEncoder = + bestProvider.create(testImage, targetWidth, targetHeight, fitImage); + writer.println("Detected encoder: " + bestProvider.name()); + writer.println(); + + // Try rendering with the detected encoder + writer.println("Rendering with " + bestProvider.name() + " encoder:"); + writer.flush(); + renderImage(detectedEncoder, writer); + writer.println("\n"); + + // Optionally try all available encoders + if (shouldTestAllEncoders(args)) { + writer.println("\n--- Testing all encoders ---\n"); + + for (ImageEncoder.Provider provider : ImageEncoders.providers()) { + testEncoder( + provider.name(), + provider.create(testImage, targetWidth, targetHeight, fitImage), + writer); + } + } + } + + writer.println("\nDemo complete!"); + writer.flush(); + } + } + + private static String getEncoderArg(String[] args) { + for (String arg : args) { + if (arg.startsWith("--encoder=")) { + return arg.substring("--encoder=".length()); + } + } + return null; + } + + private static ImageEncoder.Provider findProvider(String name) { + String normalized = name.toLowerCase(); + List all = ImageEncoders.providers(); + for (ImageEncoder.Provider provider : all) { + String providerKey = provider.name().toLowerCase(); + if (providerKey.equals(normalized)) { + return provider; + } + } + return null; + } + + private static void testEncoder(String name, ImageEncoder encoder, PrintWriter writer) + throws IOException { + writer.println(name + " encoder:"); + writer.flush(); + renderImage(encoder, writer); + writer.println("\n"); + } + + private static void renderImage(ImageEncoder encoder, Appendable output) throws IOException { + encoder.render(output); + } + + /** + * Creates a simple test image with a gradient and some shapes. + * + * @param width the image width + * @param height the image height + * @return the created test image + */ + private static BufferedImage createTestImage(int width, int height) { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + + // Draw gradient background + for (int y = 0; y < height; y++) { + float hue = (float) y / height; + Color color = Color.getHSBColor(hue, 0.8f, 0.9f); + g.setColor(color); + g.fillRect(0, y, width, 1); + } + + // Draw some shapes + g.setColor(Color.WHITE); + g.fillOval(width / 4, height / 4, width / 2, height / 2); + + g.setColor(Color.BLACK); + g.drawString("Test Image", width / 3, height / 2); + + g.dispose(); + return image; + } + + private static boolean shouldTestAllEncoders(String[] args) { + for (String arg : args) { + if ("--all".equals(arg) || "-a".equals(arg)) { + return true; + } + } + return false; + } +} diff --git a/jbang-catalog.json b/jbang-catalog.json index d47886f..0841392 100644 --- a/jbang-catalog.json +++ b/jbang-catalog.json @@ -3,6 +3,10 @@ "bounce": { "description": "A simple TUI that demonstrates a bouncing Twinkle.", "script-ref": "examples/src/main/java/org/codejive/twinkle/demos/BouncingTwinkleDemo.java" + }, + "image": { + "description": "A simple TUI that demonstrates image rendering.", + "script-ref": "examples/src/main/java/org/codejive/twinkle/demos/ImageEncoderDemo.java" } } -} \ No newline at end of file +} diff --git a/pom.xml b/pom.xml index d1c229a..86da8c9 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,7 @@ twinkle-text twinkle-screen twinkle-shapes + twinkle-image twinkle-terminal twinkle-terminal-aesh twinkle-terminal-jline diff --git a/twinkle-image/README.md b/twinkle-image/README.md new file mode 100644 index 0000000..e0a038f --- /dev/null +++ b/twinkle-image/README.md @@ -0,0 +1,217 @@ +# Twinkle Image Encoding Framework + +A library for rendering images in terminal emulators using various image encoding formats. + +**This library has _no_ dependencies!** It can therefore be easily used outside of the Twinkle framework. + +## Supported Encoders + +This module provides implementations for four major terminal image encoding formats: + +### 1. **Sixel** + +- Legacy encoding format developed by Digital Equipment Corporation (DEC) +- Widely supported by various terminal emulators (xterm, mlterm, etc.) +- Encodes images as six-pixel-high strips +- Good compatibility but less efficient than modern formats + +### 2. **Kitty** + +- Modern encoding format developed for the Kitty terminal emulator +- Supports direct PNG transmission via base64 encoding +- Highly efficient with support for advanced features +- Supported by: Kitty, WezTerm, and others + +### 3. **iTerm2** + +- Inline image encoding format for iTerm2 terminal +- Uses OSC escape sequences with base64-encoded images +- Supports various image formats +- Supported by: iTerm2, WezTerm, and others + +### 4. **Block** (Unicode-based) + +- Uses Unicode block drawing characters for image rendering +- **Most compatible** - works in any terminal with Unicode support +- Five rendering modes with different resolution/compatibility tradeoffs: + - **Full-block** (1x1 sub-pixels): Solid blocks, lowest resolution + - **Half-block** (1x2 sub-pixels): Maximum compatibility, basic resolution + - **Quadrant** (2x2 sub-pixels): Good balance of resolution and compatibility + - **Sextant** (2x3 sub-pixels): Higher resolution, requires Unicode 13.0 support + - **Octant** (2x4 sub-pixels): Highest resolution, experimental +- Uses 2-color clustering per cell (foreground + background) for sub-pixel modes +- No special terminal capabilities required beyond ANSI RGB colors + +## Usage + +### Basic Usage + +```java +import java.awt.image.BufferedImage; +import javax.imageio.ImageIO; +import java.io.File; +import org.codejive.twinkle.image.ImageEncoder; +import org.codejive.twinkle.image.ImageEncoders; + +// Load an image +BufferedImage image = ImageIO.read(new File("image.png")); + +// Define target size in terminal columns and rows +int targetWidth = 40; // 40 columns +int targetHeight = 20; // 20 rows +boolean fitImage = false; // Preserve aspect ratio + +// Auto-detect the best encoder for the current terminal and create it +ImageEncoder encoder = ImageEncoders.detectAndCreate(image, targetWidth, targetHeight, fitImage); + +// Render the image +encoder.render(System.out); +``` + +### Using a Specific Encoder + +```java +// Use an encoder explicitly, in this case Kitty +ImageEncoder kitty = ImageEncoders.kitty(image, targetWidth, targetHeight, fitImage); +kitty.render(output); +``` + +## API Reference + +### ImageEncoder Interface + +The core interface for all image encoder implementations: + +```java +public interface ImageEncoder { + String name(); + int targetWidth(); + int targetHeight(); + ImageEncoder targetSize(int targetWidth, int targetHeight); + boolean fitImage(); + ImageEncoder fitImage(boolean fitImage); + void render(Appendable output) throws IOException; +} +``` + +**Methods:** + +- `name()`: Returns the encoder name (e.g., "sixel", "kitty", "iterm", "block-quadrant") +- `targetWidth()` / `targetHeight()`: Get the current target size in terminal columns/rows +- `targetSize(width, height)`: Set the target size in terminal columns and rows (returns this for chaining) +- `fitImage()`: Get whether the image should be fitted exactly or preserve aspect ratio +- `fitImage(boolean)`: Set fit mode (returns this for chaining) +- `render(output)`: Render the image to the given Appendable using the encoder's format + +**Note:** Encoders are stateful objects that encapsulate an image and rendering parameters. The target size and fit mode can be changed via setters. Expensive transformations like image scaling are performed lazily on the first call to `render()` and cached for subsequent calls. + +### ImageEncoders Factory + +Factory methods for creating encoder instances: + +- `ImageEncoders.detectEncoderType()` - Detect the best encoder type for the current terminal (returns `EncoderType` enum) +- `ImageEncoders.detectAndCreate(image, width, height, fit)` - Auto-detect and create the best encoder for the current terminal +- `ImageEncoders.sixel(image, width, height, fit)` - Create a Sixel encoder instance +- `ImageEncoders.kitty(image, width, height, fit)` - Create a Kitty encoder instance +- `ImageEncoders.iterm(image, width, height, fit)` - Create an iTerm2 encoder instance +- `ImageEncoders.block(mode, image, width, height, fit)` - Create a block encoder with specific mode +- `ImageEncoders.blockFull(image, width, height, fit)` - Create a full-block encoder (1x1, lowest resolution) +- `ImageEncoders.blockHalf(image, width, height, fit)` - Create a half-block encoder (1x2, maximum compatibility) +- `ImageEncoders.blockQuadrant(image, width, height, fit)` - Create a quadrant-block encoder (2x2, recommended fallback) +- `ImageEncoders.blockSextant(image, width, height, fit)` - Create a sextant-block encoder (2x3, higher resolution) +- `ImageEncoders.blockOctant(image, width, height, fit)` - Create an octant-block encoder (2x4, highest resolution) + +## Encoder Detection + +The `ImageEncoders.detectEncoderType()` method checks environment variables to determine the best encoder: + +- **iTerm2**: Detected via `TERM_PROGRAM=iTerm.app` environment variable +- **Kitty**: Detected via `KITTY_WINDOW_ID` environment variable +- **WezTerm**: Detected via `TERM_PROGRAM=WezTerm` (uses Kitty encoding format) +- **Sixel**: Detected for terminals with xterm, mlterm, or vt340 in the `TERM` variable +- **Fallback**: Block encoder with quadrant mode (works in virtually all terminals) + +Use `ImageEncoders.detectAndCreate()` to automatically detect and create the appropriate encoder instance, or use `detectEncoderType()` to get the detected type without creating an encoder. + +**EncoderType Enum:** +```java +public enum EncoderType { + SIXEL, + KITTY, + ITERM, + BLOCK_QUADRANT +} +``` + +## Block Encoder Details + +The block encoder uses Unicode block drawing characters to achieve sub-pixel resolution within each terminal cell. Since each cell can only have one foreground and one background color, the encoder: + +1. **Samples pixels**: Collects all pixel colors for each terminal cell +2. **Clusters colors**: Uses k-means clustering to find the 2 best representative colors +3. **Assigns pixels**: Maps each pixel to either foreground or background based on proximity +4. **Selects character**: Chooses the appropriate Unicode character matching the pixel pattern +5. **Outputs with colors**: Writes the character with ANSI RGB color codes + +### Block Modes + +| Mode | Grid | Total Pixels | Compatibility | Use Case | +|----------|------|--------------|---------------|----------------------------------| +| Full | 1×1 | 1 | ★★★★★ | Solid blocks (lowest resolution) | +| Half | 1×2 | 2 | ★★★★★ | Maximum compatibility | +| Quadrant | 2×2 | 4 | ★★★★☆ | Recommended default | +| Sextant | 2×3 | 6 | ★★★☆☆ | Modern terminals (Unicode 13.0+) | +| Octant | 2×4 | 8 | ★★☆☆☆ | Experimental, highest resolution | + +## Image Scaling + +All encoders automatically scale images to fit within the specified target size. The scaling behavior depends on the `fitImage` parameter: + +- **`fitImage = false`** (default): Preserves aspect ratio, scaling the image to fit within the target dimensions without distortion +- **`fitImage = true`**: Scales to exact target dimensions, potentially stretching or squashing the image + +The encoder handles all scaling internally, using the font size information to calculate the appropriate pixel dimensions. + +## Notes + +### Performance Considerations + +- **Kitty**: Most efficient for large images (direct PNG transmission) +- **iTerm2**: Efficient for moderate-sized images +- **Block**: Works everywhere, moderate quality, best for fallback +- **Sixel**: Good compatibility but less efficient for complex images + +## Command-Line Demo + +Run the demo with different options: + +```bash +# Auto-detect encoder and render +java -jar examples/target/examples-1.0-SNAPSHOT.jar + +# Test all encoders +java -jar examples/target/examples-1.0-SNAPSHOT.jar --all +``` + +## Examples + +See [examples/src/main/java/org/codejive/twinkle/demos/ImageEncoderDemo.java](../examples/src/main/java/org/codejive/twinkle/demos/ImageEncoderDemo.java) for a complete working example. + +Build and run the demo: + +```bash +mvn clean install +java -jar examples/target/examples-1.0-SNAPSHOT.jar +``` + +## Future Enhancements + +Potential areas for improvement: + +- Terminal capability detection (query terminal for supported features) +- Automatic font size detection via terminal queries +- Support for animation (animated GIFs) +- Improved caching and performance optimization +- Additional encoding formats (e.g., Jexer bitmap format) +- Dithering options for better quality on limited color terminals +- Color quantization improvements for Sixel encoding diff --git a/twinkle-image/pom.xml b/twinkle-image/pom.xml new file mode 100644 index 0000000..0edd267 --- /dev/null +++ b/twinkle-image/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + + org.codejive.twinkle + twinkle + 1.0-SNAPSHOT + ../pom.xml + + + twinkle-image + jar + + Image encoding support + + diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/ImageEncoder.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/ImageEncoder.java new file mode 100644 index 0000000..ab4a812 --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/ImageEncoder.java @@ -0,0 +1,106 @@ +package org.codejive.twinkle.image; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import org.codejive.twinkle.image.util.Resolution; +import org.jspecify.annotations.NonNull; + +/** + * Base interface for terminal image encoding formats. + * + *

Implementations of this interface handle rendering images to terminals using various image + * encoding formats such as Sixel, Kitty, and iTerm2. + * + *

ImageEncoders are stateful objects that are configured with an image and font size at + * construction time. The target size and fit mode can be adjusted using setters, and expensive + * transformations (like image scaling) are performed lazily on the first call to {@link + * #render(Appendable)} and cached for subsequent calls. + */ +public interface ImageEncoder { + + /** + * Gets the target width in terminal columns that the image should occupy. + * + * @return the target width in terminal columns + */ + int targetWidth(); + + /** + * Gets the target height in terminal rows that the image should occupy. + * + * @return the target height in terminal rows + */ + int targetHeight(); + + /** + * Sets the target size in terminal columns and rows that the image should occupy. + * + *

Changing this value invalidates any cached transformations. + * + * @param targetWidth the target width in terminal columns + * @param targetHeight the target height in terminal rows + * @return this encoder for method chaining + */ + @NonNull ImageEncoder targetSize(int targetWidth, int targetHeight); + + /** + * Gets whether the image should be fitted exactly to the target size. + * + * @return true if the image is fitted exactly, false if aspect ratio is preserved + */ + boolean fitImage(); + + /** + * Sets whether the image should be fitted exactly to the target size (stretching if needed) or + * preserve aspect ratio. + * + *

Changing this value invalidates any cached transformations. + * + * @param fitImage if true, scale the image to fit the targetSize exactly (stretching if + * needed); if false, preserve aspect ratio + * @return this encoder for method chaining + */ + @NonNull ImageEncoder fitImage(boolean fitImage); + + /** + * Renders the image to the terminal using the specific encoding format's escape sequences. + * + *

This method performs expensive transformations (such as image scaling) lazily on the first + * call and caches the results for subsequent calls. If the target size or fit mode is changed + * via setters, the cache is invalidated and transformations are re-performed on the next + * render. + * + * @param output the Appendable to write the escape sequences to + * @throws IOException if an I/O error occurs while writing to the output + */ + void render(@NonNull Appendable output) throws IOException; + + interface Provider { + /** + * Gets the name of the encoder type (e.g., "sixel", "kitty", "iterm2"). + * + * @return the name of the encoder type + */ + @NonNull String name(); + + /** + * Gets the resolution of the encoder. This indicates how many pixels correspond to one + * terminal cell for this encoding format. + * + * @return the resolution of the encoder + */ + @NonNull Resolution resolution(); + + /** + * Creates a new encoder instance for the given image and parameters. + * + * @param image the image to encode + * @param targetWidth the initial target width in terminal columns + * @param targetHeight the initial target height in terminal rows + * @param fitImage the initial fit mode + * @return a new encoder instance + */ + @NonNull ImageEncoder create( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage); + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/ImageEncoders.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/ImageEncoders.java new file mode 100644 index 0000000..c7b68d9 --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/ImageEncoders.java @@ -0,0 +1,158 @@ +package org.codejive.twinkle.image; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import org.codejive.twinkle.image.impl.*; +import org.codejive.twinkle.image.impl.BlockEncoder.*; +import org.jspecify.annotations.NonNull; + +/** + * Factory for creating image encoder instances. + * + *

This factory provides convenient methods to create image encoder implementations. Encoders are + * stateful objects that encapsulate an image and rendering parameters. + */ +public class ImageEncoders { + + public static @NonNull List providers() { + return Arrays.asList( + new SixelEncoder.Provider(), + new KittyEncoder.Provider(), + new ITermEncoder.Provider(), + new BlockEncoder.Provider(BlockMode.FULL), + new BlockEncoder.Provider(BlockMode.HALF), + new BlockEncoder.Provider(BlockMode.QUADRANT), + new BlockEncoder.Provider(BlockMode.SEXTANT), + new BlockEncoder.Provider(BlockMode.OCTANT)); + } + + /** + * Attempts to detect which encoder types are supported by the current terminal. + * + *

This method checks environment variables and terminal capabilities to determine which + * encoder types are supported. Results are ordered by priority (best protocol first). The + * detection logic checks for: + * + *

+ * + * @return the detected encoder types, or block encoder as a fallback (most compatible) + */ + public static @NonNull List supportedProviders() { + // LinkedHashMap keyed by provider name for deduplication and priority ordering + LinkedHashMap supported = new LinkedHashMap<>(); + + String term = getEnv("TERM"); + String termLower = term != null ? term.toLowerCase() : ""; + String termProgram = getEnv("TERM_PROGRAM"); + + // Kitty terminal sets KITTY_WINDOW_ID + if (getEnv("KITTY_WINDOW_ID") != null) { + supported.putIfAbsent("Kitty", new KittyEncoder.Provider()); + } + + // Kitty sets TERM=xterm-kitty + if (termLower.equals("xterm-kitty")) { + supported.putIfAbsent("Kitty", new KittyEncoder.Provider()); + } + + // Ghostty uses Kitty graphics protocol + if (termLower.equals("xterm-ghostty")) { + supported.putIfAbsent("Kitty", new KittyEncoder.Provider()); + } + + // WezTerm supports iTerm2 graphics protocol; detected via WEZTERM_PANE or TERM_PROGRAM + if (getEnv("WEZTERM_PANE") != null || "WezTerm".equalsIgnoreCase(termProgram)) { + supported.putIfAbsent("iTerm2", new ITermEncoder.Provider()); + } + + // iTerm2 sets ITERM_SESSION_ID + if (getEnv("ITERM_SESSION_ID") != null) { + supported.putIfAbsent("iTerm2", new ITermEncoder.Provider()); + } + + // TERM_PROGRAM=iTerm.app + if ("iTerm.app".equals(termProgram)) { + supported.putIfAbsent("iTerm2", new ITermEncoder.Provider()); + } + + // Mintty, VSCode integrated terminal, Tabby, and Hyper support iTerm2 inline images + if (termProgram != null) { + String tp = termProgram.toLowerCase(); + if ("mintty".equals(tp) + || "vscode".equals(tp) + || "tabby".equals(tp) + || "hyper".equals(tp)) { + supported.putIfAbsent("iTerm2", new ITermEncoder.Provider()); + } + } + + // Rio terminal supports both iTerm2 and Sixel + if (termLower.equals("rio")) { + supported.putIfAbsent("iTerm2", new ITermEncoder.Provider()); + supported.putIfAbsent("Sixel", new SixelEncoder.Provider()); + } + + // Konsole supports Kitty, iTerm2, and Sixel protocols + if (getEnv("KONSOLE_VERSION") != null) { + supported.putIfAbsent("Sixel", new SixelEncoder.Provider()); + supported.putIfAbsent("iTerm2", new ITermEncoder.Provider()); + supported.putIfAbsent("Kitty", new KittyEncoder.Provider()); + } + + // Windows Terminal supports Sixel (since v1.22) + if (getEnv("WT_SESSION") != null) { + supported.putIfAbsent("Sixel", new SixelEncoder.Provider()); + } + + // Terminals known to support Sixel via TERM identification + if (termLower.contains("mlterm") + || termLower.contains("foot") + || termLower.contains("contour") + || termLower.contains("yaft") + || termLower.contains("ctx") + || termLower.contains("darktile")) { + supported.putIfAbsent("Sixel", new SixelEncoder.Provider()); + } + + // --- Block encoders as universal fallback --- + // Works in any terminal with Unicode support (virtually all modern terminals) + supported.put("Block (full)", new BlockEncoder.Provider(BlockMode.FULL)); + supported.put("Block (half)", new BlockEncoder.Provider(BlockMode.HALF)); + supported.put("Block (quadrant)", new BlockEncoder.Provider(BlockMode.QUADRANT)); + + return new ArrayList<>(supported.values()); + } + + private static String getEnv(String name) { + try { + String value = System.getenv(name); + return (value != null && !value.isEmpty()) ? value : null; + } catch (SecurityException e) { + return null; + } + } + + /** + * Gets the best available encoder provider for the current terminal. + * + * @return the best available encoder provider + */ + public static ImageEncoder.@NonNull Provider best() { + List providers = supportedProviders(); + return providers.get(0); + } + + private ImageEncoders() { + // Utility class, prevent instantiation + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/BlockEncoder.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/BlockEncoder.java new file mode 100644 index 0000000..6426918 --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/BlockEncoder.java @@ -0,0 +1,670 @@ +package org.codejive.twinkle.image.impl; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import org.codejive.twinkle.image.ImageEncoder; +import org.codejive.twinkle.image.util.AnsiUtils; +import org.codejive.twinkle.image.util.FontSize; +import org.codejive.twinkle.image.util.ImageUtils; +import org.codejive.twinkle.image.util.Resolution; +import org.jspecify.annotations.NonNull; + +/** + * Implementation of a block-based terminal image encoder using Unicode block characters. + * + *

This encoder works in any terminal by using Unicode block drawing characters (half-blocks, + * quadrants, sextants, or octants) to represent sub-pixel resolution within each character cell. + * Since each cell can only have one foreground and one background color, this implementation uses + * color clustering to find the best two representative colors for each cell's pixels. + * + *

This is the most compatible image rendering method as it requires no special terminal support + * beyond Unicode and ANSI color codes. + * + *

This encoder is stateful: the image, font size, and block mode are set at construction time + * and are immutable, while the target size and fit mode can be changed via setters. Expensive + * transformations like image scaling are performed lazily on the first call to {@link + * #render(Appendable)} and cached for subsequent calls. + */ +public class BlockEncoder implements ImageEncoder { + // Immutable state + private final @NonNull BufferedImage image; + private final @NonNull BlockMode mode; + + // Mutable state + private int targetWidth; + private int targetHeight; + private boolean fitImage; + + // Cached transformations + private BufferedImage scaledImage; + + public static BlockEncoder create( + @NonNull BlockMode mode, + @NonNull BufferedImage image, + int targetWidth, + int targetHeight, + boolean fitImage) { + return new BlockEncoder(mode, image, targetWidth, targetHeight, fitImage); + } + + /** + * Defines the different block rendering modes for the block-based image encoder. + * + *

Block modes determine how many sub-pixels are rendered within each terminal character + * cell, trading off between resolution and compatibility. + */ + public enum BlockMode { + /** + * Full block mode using solid block characters + * + *

Each cell represents a single pixel (1x1), with no subdivision. This is the simplest, + * rendering each terminal cell as a solid color. Provides lowest resolution. + */ + FULL(1, 1), + + /** + * Half-block mode using upper and lower half block characters + * + *

Divides each cell into 2 vertical pixels (1x2), providing basic vertical resolution + * improvement. This is the most compatible mode, supported in virtually all terminals. + */ + HALF(1, 2), + + /** + * Quadrant mode using 2x2 block characters + * + *

Divides each cell into 4 pixels (2x2), providing moderate resolution improvement in + * both dimensions. Well supported in modern terminals. + */ + QUADRANT(2, 2), + + /** + * Sextant mode using 2x3 block characters. + * + *

Divides each cell into 6 pixels (2x3), providing higher vertical resolution. Requires + * Unicode support for Symbols for Legacy Computing characters (U+1FB00-U+1FB3B). + */ + SEXTANT(2, 3), + + /** + * Octant mode using 2x4 block characters. + * + *

Divides each cell into 8 pixels (2x4), providing the highest resolution. Requires wide + * Unicode support. + */ + OCTANT(2, 4); + + private final int columns; + private final int rows; + + BlockMode(int columns, int rows) { + this.columns = columns; + this.rows = rows; + } + + /** + * Gets the number of horizontal sub-pixels per cell. + * + * @return the number of horizontal sub-pixels in this block mode (1 or 2) + */ + public int columns() { + return columns; + } + + /** + * Gets the number of vertical sub-pixels per cell. + * + * @return the number of vertical sub-pixels in this block mode (2, 3, or 4) + */ + public int rows() { + return rows; + } + + /** + * Gets the total number of sub-pixels per cell. + * + * @return columns * rows + */ + public int pixelsPerCell() { + return columns * rows; + } + } + + // Full block characters (1x1) + private static final String[] FULL_BLOCKS = { + " ", // 0b0 - empty + "█" // 0b1 - full block + }; + + // Half-block characters (1x2) + private static final String[] HALF_BLOCKS = { + " ", // 0b00 - empty + "▀", // 0b01 - upper half + "▄", // 0b10 - lower half + "█" // 0b11 - full block + }; + + // Quadrant characters (2x2) - indexed by bit pattern: top-left, top-right, bottom-left, + // bottom-right + private static final String[] QUADRANT_BLOCKS = { + " ", // 0b0000 + "▘", // 0b0001 - top-left + "▝", // 0b0010 - top-right + "▀", // 0b0011 - top half + "▖", // 0b0100 - bottom-left + "▌", // 0b0101 - left half + "▞", // 0b0110 - diagonal bottom-left to top-right + "▛", // 0b0111 - top and left + "▗", // 0b1000 - bottom-right + "▚", // 0b1001 - diagonal top-left to bottom-right + "▐", // 0b1010 - right half + "▜", // 0b1011 - top and right + "▄", // 0b1100 - bottom half + "▙", // 0b1101 - bottom and left + "▟", // 0b1110 - bottom and right + "█" // 0b1111 - full block + }; + + // Sextant characters (2x3) - Symbols for Legacy Computing block (U+1FB00-U+1FB3B) + // Bit layout: bit 0 = top-left (pos 1), bit 1 = top-right (pos 2) + // bit 2 = middle-left (pos 3), bit 3 = middle-right (pos 4) + // bit 4 = bottom-left (pos 5), bit 5 = bottom-right (pos 6) + // Two patterns use existing Block Elements characters instead of this range: + // pattern 21 (0b010101, positions 1,3,5) = ▌ LEFT HALF BLOCK (U+258C) + // pattern 42 (0b101010, positions 2,4,6) = ▐ RIGHT HALF BLOCK (U+2590) + private static final String[] SEXTANT_BLOCKS = { + " ", // 0b000000 (0) + "\uD83E\uDF00", // 0b000001 (1) - U+1FB00 SEXTANT-1 + "\uD83E\uDF01", // 0b000010 (2) - U+1FB01 SEXTANT-2 + "\uD83E\uDF02", // 0b000011 (3) - U+1FB02 SEXTANT-12 + "\uD83E\uDF03", // 0b000100 (4) - U+1FB03 SEXTANT-3 + "\uD83E\uDF04", // 0b000101 (5) - U+1FB04 SEXTANT-13 + "\uD83E\uDF05", // 0b000110 (6) - U+1FB05 SEXTANT-23 + "\uD83E\uDF06", // 0b000111 (7) - U+1FB06 SEXTANT-123 + "\uD83E\uDF07", // 0b001000 (8) - U+1FB07 SEXTANT-4 + "\uD83E\uDF08", // 0b001001 (9) - U+1FB08 SEXTANT-14 + "\uD83E\uDF09", // 0b001010 (10) - U+1FB09 SEXTANT-24 + "\uD83E\uDF0A", // 0b001011 (11) - U+1FB0A SEXTANT-124 + "\uD83E\uDF0B", // 0b001100 (12) - U+1FB0B SEXTANT-34 + "\uD83E\uDF0C", // 0b001101 (13) - U+1FB0C SEXTANT-134 + "\uD83E\uDF0D", // 0b001110 (14) - U+1FB0D SEXTANT-234 + "\uD83E\uDF0E", // 0b001111 (15) - U+1FB0E SEXTANT-1234 + "\uD83E\uDF0F", // 0b010000 (16) - U+1FB0F SEXTANT-5 + "\uD83E\uDF10", // 0b010001 (17) - U+1FB10 SEXTANT-15 + "\uD83E\uDF11", // 0b010010 (18) - U+1FB11 SEXTANT-25 + "\uD83E\uDF12", // 0b010011 (19) - U+1FB12 SEXTANT-125 + "\uD83E\uDF13", // 0b010100 (20) - U+1FB13 SEXTANT-35 + "▌", // 0b010101 (21) - U+258C LEFT HALF BLOCK (positions 1,3,5) + "\uD83E\uDF14", // 0b010110 (22) - U+1FB14 SEXTANT-235 + "\uD83E\uDF15", // 0b010111 (23) - U+1FB15 SEXTANT-1235 + "\uD83E\uDF16", // 0b011000 (24) - U+1FB16 SEXTANT-45 + "\uD83E\uDF17", // 0b011001 (25) - U+1FB17 SEXTANT-145 + "\uD83E\uDF18", // 0b011010 (26) - U+1FB18 SEXTANT-245 + "\uD83E\uDF19", // 0b011011 (27) - U+1FB19 SEXTANT-1245 + "\uD83E\uDF1A", // 0b011100 (28) - U+1FB1A SEXTANT-345 + "\uD83E\uDF1B", // 0b011101 (29) - U+1FB1B SEXTANT-1345 + "\uD83E\uDF1C", // 0b011110 (30) - U+1FB1C SEXTANT-2345 + "\uD83E\uDF1D", // 0b011111 (31) - U+1FB1D SEXTANT-12345 + "\uD83E\uDF1E", // 0b100000 (32) - U+1FB1E SEXTANT-6 + "\uD83E\uDF1F", // 0b100001 (33) - U+1FB1F SEXTANT-16 + "\uD83E\uDF20", // 0b100010 (34) - U+1FB20 SEXTANT-26 + "\uD83E\uDF21", // 0b100011 (35) - U+1FB21 SEXTANT-126 + "\uD83E\uDF22", // 0b100100 (36) - U+1FB22 SEXTANT-36 + "\uD83E\uDF23", // 0b100101 (37) - U+1FB23 SEXTANT-136 + "\uD83E\uDF24", // 0b100110 (38) - U+1FB24 SEXTANT-236 + "\uD83E\uDF25", // 0b100111 (39) - U+1FB25 SEXTANT-1236 + "\uD83E\uDF26", // 0b101000 (40) - U+1FB26 SEXTANT-46 + "\uD83E\uDF27", // 0b101001 (41) - U+1FB27 SEXTANT-146 + "▐", // 0b101010 (42) - U+2590 RIGHT HALF BLOCK (positions 2,4,6) + "\uD83E\uDF28", // 0b101011 (43) - U+1FB28 SEXTANT-1246 + "\uD83E\uDF29", // 0b101100 (44) - U+1FB29 SEXTANT-346 + "\uD83E\uDF2A", // 0b101101 (45) - U+1FB2A SEXTANT-1346 + "\uD83E\uDF2B", // 0b101110 (46) - U+1FB2B SEXTANT-2346 + "\uD83E\uDF2C", // 0b101111 (47) - U+1FB2C SEXTANT-12346 + "\uD83E\uDF2D", // 0b110000 (48) - U+1FB2D SEXTANT-56 + "\uD83E\uDF2E", // 0b110001 (49) - U+1FB2E SEXTANT-156 + "\uD83E\uDF2F", // 0b110010 (50) - U+1FB2F SEXTANT-256 + "\uD83E\uDF30", // 0b110011 (51) - U+1FB30 SEXTANT-1256 + "\uD83E\uDF31", // 0b110100 (52) - U+1FB31 SEXTANT-356 + "\uD83E\uDF32", // 0b110101 (53) - U+1FB32 SEXTANT-1356 + "\uD83E\uDF33", // 0b110110 (54) - U+1FB33 SEXTANT-2356 + "\uD83E\uDF34", // 0b110111 (55) - U+1FB34 SEXTANT-12356 + "\uD83E\uDF35", // 0b111000 (56) - U+1FB35 SEXTANT-456 + "\uD83E\uDF36", // 0b111001 (57) - U+1FB36 SEXTANT-1456 + "\uD83E\uDF37", // 0b111010 (58) - U+1FB37 SEXTANT-2456 + "\uD83E\uDF38", // 0b111011 (59) - U+1FB38 SEXTANT-12456 + "\uD83E\uDF39", // 0b111100 (60) - U+1FB39 SEXTANT-3456 + "\uD83E\uDF3A", // 0b111101 (61) - U+1FB3A SEXTANT-13456 + "\uD83E\uDF3B", // 0b111110 (62) - U+1FB3B SEXTANT-23456 + "█" // 0b111111 (63) - full block + }; + + /** + * Creates a block encoder with the specified mode, image, and font size. + * + * @param mode the block rendering mode + * @param image the image to encode + * @param targetWidth the initial target width in terminal columns + * @param targetHeight the initial target height in terminal rows + * @param fitImage the initial fit mode + */ + protected BlockEncoder( + @NonNull BlockMode mode, + @NonNull BufferedImage image, + int targetWidth, + int targetHeight, + boolean fitImage) { + if (mode == null) { + throw new IllegalArgumentException("Mode cannot be null"); + } + if (image == null) { + throw new IllegalArgumentException("Image cannot be null"); + } + if (targetWidth <= 0) { + throw new IllegalArgumentException("Target width must be positive"); + } + if (targetHeight <= 0) { + throw new IllegalArgumentException("Target height must be positive"); + } + this.mode = mode; + this.image = image; + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + this.fitImage = fitImage; + } + + /** + * Creates a block encoder with half-block mode (most compatible). + * + * @param image the image to encode + * @param targetWidth the initial target width in terminal columns + * @param targetHeight the initial target height in terminal rows + * @param fitImage the initial fit mode + */ + public BlockEncoder( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + this(BlockMode.HALF, image, targetWidth, targetHeight, fitImage); + } + + /** + * Gets the block rendering mode used by this encoder. + * + * @return the block mode (FULL, HALF, QUADRANT, SEXTANT, or OCTANT) + */ + public @NonNull BlockMode mode() { + return mode; + } + + @Override + public @NonNull ImageEncoder targetSize(int targetWidth, int targetHeight) { + if (targetWidth <= 0) { + throw new IllegalArgumentException("Target width must be positive"); + } + if (targetHeight <= 0) { + throw new IllegalArgumentException("Target height must be positive"); + } + if (this.targetWidth != targetWidth || this.targetHeight != targetHeight) { + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + this.scaledImage = null; // Invalidate cache + } + return this; + } + + @Override + public int targetWidth() { + return targetWidth; + } + + @Override + public int targetHeight() { + return targetHeight; + } + + @Override + public @NonNull ImageEncoder fitImage(boolean fitImage) { + if (this.fitImage != fitImage) { + this.fitImage = fitImage; + this.scaledImage = null; // Invalidate cache + } + return this; + } + + @Override + public boolean fitImage() { + return fitImage; + } + + @Override + public void render(@NonNull Appendable output) throws IOException { + if (output == null) { + throw new IllegalArgumentException("Output cannot be null"); + } + + // Lazily compute and cache the scaled image + if (scaledImage == null) { + // Calculate the physical pixel dimensions of the terminal area + // This accounts for the actual font size (e.g., 8x16 pixels per cell) + Resolution fontSize = FontSize.defaultFontSize(); + int physicalWidth = targetWidth * fontSize.x; + int physicalHeight = targetHeight * fontSize.y; + + // Scale image to match the physical dimensions (preserving aspect ratio or fitting + // exactly) + scaledImage = ImageUtils.scaleImage(image, physicalWidth, physicalHeight, fitImage); + } + + // Calculate how many cells the scaled image actually fills + // (aspect ratio preservation may leave the image smaller in one dimension) + Resolution fontSize = FontSize.defaultFontSize(); + int actualCols = + Math.min( + (int) Math.ceil((double) scaledImage.getWidth() / fontSize.x), targetWidth); + int actualRows = + Math.min( + (int) Math.ceil((double) scaledImage.getHeight() / fontSize.y), + targetHeight); + + // Render using only the cells covered by the image + renderBlocks(scaledImage, actualCols, actualRows, output); + } + + /** + * Renders the scaled image using block characters. + * + * @param image the scaled image + * @param targetWidth the target width in terminal columns + * @param targetHeight the target height in terminal rows + * @param output the output to write to + * @throws IOException if an I/O error occurs + */ + private void renderBlocks( + @NonNull BufferedImage image, + int targetWidth, + int targetHeight, + @NonNull Appendable output) + throws IOException { + + int cols = mode.columns(); + int rows = mode.rows(); + + // Calculate how many physical pixels each sub-pixel represents + Resolution fontSize = FontSize.defaultFontSize(); + double pixelsPerSubPixelX = (double) fontSize.x / cols; + double pixelsPerSubPixelY = (double) fontSize.y / rows; + + for (int cellRow = 0; cellRow < targetHeight; cellRow++) { + for (int cellCol = 0; cellCol < targetWidth; cellCol++) { + // Sample pixels for this cell + int[] pixels = + sampleCell(image, cellCol, cellRow, pixelsPerSubPixelX, pixelsPerSubPixelY); + + // Find the two best representative colors + ColorPair colors = findBestColorPair(pixels); + + // Determine which pixels belong to foreground vs background + int pattern = determinePattern(pixels, colors); + + // Get the appropriate block character + String blockChar = getBlockCharacter(pattern); + + // Output the character with colors + outputCell(output, blockChar, colors); + } + // Reset colors at the end of each line to prevent bleeding + output.append(AnsiUtils.STYLE_RESET); + if (cellRow < targetHeight - 1) { + output.append('\n'); + } + } + } + + /** + * Samples the pixels for a single cell. + * + * @param image the image to sample from + * @param cellCol the cell column + * @param cellRow the cell row + * @param pixelsPerSubPixelX physical pixels per sub-pixel in X direction + * @param pixelsPerSubPixelY physical pixels per sub-pixel in Y direction + * @return array of RGB pixel values + */ + private int[] sampleCell( + @NonNull BufferedImage image, + int cellCol, + int cellRow, + double pixelsPerSubPixelX, + double pixelsPerSubPixelY) { + int cols = mode.columns(); + int rows = mode.rows(); + int[] pixels = new int[cols * rows]; + + int imgWidth = image.getWidth(); + int imgHeight = image.getHeight(); + + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + // Calculate sub-pixel coordinates + int subPixelX = cellCol * cols + col; + int subPixelY = cellRow * rows + row; + + // Map to physical pixel coordinates + int x = (int) (subPixelX * pixelsPerSubPixelX); + int y = (int) (subPixelY * pixelsPerSubPixelY); + + // Clamp coordinates to image bounds + x = Math.min(x, imgWidth - 1); + y = Math.min(y, imgHeight - 1); + + pixels[row * cols + col] = image.getRGB(x, y); + } + } + + return pixels; + } + + /** + * Finds the best two representative colors for the given pixels using color clustering. + * + * @param pixels array of RGB pixel values + * @return the foreground and background colors + */ + private @NonNull ColorPair findBestColorPair(int[] pixels) { + // Simple k-means clustering with k=2 + // Initialize with darkest and brightest pixels + int darkest = 0xFFFFFF; + int brightest = 0x000000; + + for (int i = 0; i < pixels.length; i++) { + int rgb = pixels[i]; + int brightness = getBrightness(rgb); + + if (brightness < getBrightness(darkest)) { + darkest = rgb; + } + if (brightness > getBrightness(brightest)) { + brightest = rgb; + } + } + + // Perform a few iterations of k-means + int color1 = darkest; + int color2 = brightest; + + for (int iter = 0; iter < 3; iter++) { + long sumR1 = 0, sumG1 = 0, sumB1 = 0, count1 = 0; + long sumR2 = 0, sumG2 = 0, sumB2 = 0, count2 = 0; + + for (int pixel : pixels) { + if (colorDistance(pixel, color1) < colorDistance(pixel, color2)) { + sumR1 += (pixel >> 16) & 0xFF; + sumG1 += (pixel >> 8) & 0xFF; + sumB1 += pixel & 0xFF; + count1++; + } else { + sumR2 += (pixel >> 16) & 0xFF; + sumG2 += (pixel >> 8) & 0xFF; + sumB2 += pixel & 0xFF; + count2++; + } + } + + if (count1 > 0) { + color1 = + ((int) (sumR1 / count1) << 16) + | ((int) (sumG1 / count1) << 8) + | (int) (sumB1 / count1); + } + if (count2 > 0) { + color2 = + ((int) (sumR2 / count2) << 16) + | ((int) (sumG2 / count2) << 8) + | (int) (sumB2 / count2); + } + } + + return new ColorPair(color1, color2); + } + + /** + * Determines the bit pattern for which pixels belong to the foreground color. + * + * @param pixels array of RGB pixel values + * @param colors the foreground and background colors + * @return bit pattern where 1 = foreground, 0 = background + */ + private int determinePattern(int[] pixels, @NonNull ColorPair colors) { + int pattern = 0; + for (int i = 0; i < pixels.length; i++) { + if (colorDistance(pixels[i], colors.foreground) + < colorDistance(pixels[i], colors.background)) { + pattern |= (1 << i); + } + } + return pattern; + } + + /** + * Gets the appropriate block character for the given pattern. + * + * @param pattern the bit pattern + * @return the Unicode block character + */ + private @NonNull String getBlockCharacter(int pattern) { + switch (mode) { + case FULL: + return FULL_BLOCKS[pattern & 0x1]; + case HALF: + return HALF_BLOCKS[pattern & 0x3]; + case QUADRANT: + return QUADRANT_BLOCKS[pattern & 0xF]; + case SEXTANT: + return SEXTANT_BLOCKS[pattern & 0x3F]; + case OCTANT: + // For octant, we'll use quadrants as a fallback for now + // Full octant support would require additional characters + return QUADRANT_BLOCKS[pattern & 0xF]; + default: + return " "; + } + } + + /** + * Outputs a cell with the specified character and colors. + * + * @param output the output to write to + * @param blockChar the block character + * @param colors the foreground and background colors + * @throws IOException if an I/O error occurs + */ + private void outputCell( + @NonNull Appendable output, @NonNull String blockChar, @NonNull ColorPair colors) + throws IOException { + // Set foreground color + int fgR = (colors.foreground >> 16) & 0xFF; + int fgG = (colors.foreground >> 8) & 0xFF; + int fgB = colors.foreground & 0xFF; + + // Set background color + int bgR = (colors.background >> 16) & 0xFF; + int bgG = (colors.background >> 8) & 0xFF; + int bgB = colors.background & 0xFF; + + output.append(AnsiUtils.rgbFg(fgR, fgG, fgB)); + output.append(AnsiUtils.rgbBg(bgR, bgG, bgB)); + output.append(blockChar); + } + + /** + * Calculates the brightness of an RGB color. + * + * @param rgb the RGB value + * @return the brightness (0-255) + */ + private int getBrightness(int rgb) { + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + // Use perceived brightness formula + return (int) (0.299 * r + 0.587 * g + 0.114 * b); + } + + /** + * Calculates the distance between two RGB colors. + * + * @param rgb1 first RGB value + * @param rgb2 second RGB value + * @return the color distance + */ + private int colorDistance(int rgb1, int rgb2) { + int r1 = (rgb1 >> 16) & 0xFF; + int g1 = (rgb1 >> 8) & 0xFF; + int b1 = rgb1 & 0xFF; + + int r2 = (rgb2 >> 16) & 0xFF; + int g2 = (rgb2 >> 8) & 0xFF; + int b2 = rgb2 & 0xFF; + + int dr = r1 - r2; + int dg = g1 - g2; + int db = b1 - b2; + + return dr * dr + dg * dg + db * db; + } + + /** Helper class to hold a pair of colors (foreground and background). */ + private static class ColorPair { + final int foreground; + final int background; + + ColorPair(int foreground, int background) { + this.foreground = foreground; + this.background = background; + } + } + + /** Provider for creating BlockEncoder instances. */ + public static class Provider implements ImageEncoder.Provider { + private final @NonNull BlockMode mode; + + public Provider(@NonNull BlockMode mode) { + this.mode = mode; + } + + @Override + public @NonNull String name() { + return "block-" + mode.name().toLowerCase(); + } + + @Override + public @NonNull Resolution resolution() { + return new Resolution(mode.columns(), mode.rows()); + } + + @Override + public @NonNull ImageEncoder create( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + return new BlockEncoder(mode, image, targetWidth, targetHeight, fitImage); + } + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/ITermEncoder.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/ITermEncoder.java new file mode 100644 index 0000000..a24a7bd --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/ITermEncoder.java @@ -0,0 +1,186 @@ +package org.codejive.twinkle.image.impl; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import javax.imageio.ImageIO; +import org.codejive.twinkle.image.ImageEncoder; +import org.codejive.twinkle.image.util.AnsiUtils; +import org.codejive.twinkle.image.util.FontSize; +import org.codejive.twinkle.image.util.ImageUtils; +import org.codejive.twinkle.image.util.Resolution; +import org.jspecify.annotations.NonNull; + +/** + * Implementation of the iTerm2 inline image encoding format. + * + *

The iTerm2 inline image encoding format allows displaying images directly in the terminal. It + * uses OSC (Operating System Command) escape sequences with base64-encoded image data. + * + *

Format: ESC ]1337;File=[arguments]:base64-data ^G + * + *

This encoder is stateful: the image and font size are set at construction time and are + * immutable, while the target size and fit mode can be changed via setters. Expensive + * transformations like image scaling and PNG encoding are performed lazily on the first call to + * {@link #render(Appendable)} and cached for subsequent calls. + * + * @see iTerm2 Inline Images Protocol + */ +public class ITermEncoder implements ImageEncoder { + // Immutable state + private final @NonNull BufferedImage image; + + // Mutable state + private int targetWidth; + private int targetHeight; + private boolean fitImage; + + // Cached transformations + private BufferedImage scaledImage; + private String base64Data; + private int encodedDataLength; + + private static final String ITERM_FILE_CMD = "1337;File="; + + public static ITermEncoder create( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + return new ITermEncoder(image, targetWidth, targetHeight, fitImage); + } + + /** + * Creates a new iTerm2 encoder for the given image and font size. + * + * @param image the image to encode + * @param targetWidth the initial target width in terminal columns + * @param targetHeight the initial target height in terminal rows + * @param fitImage the initial fit mode + */ + protected ITermEncoder( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + if (image == null) { + throw new IllegalArgumentException("Image cannot be null"); + } + if (targetWidth <= 0 || targetHeight <= 0) { + throw new IllegalArgumentException("Target size must be positive"); + } + this.image = image; + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + this.fitImage = fitImage; + } + + @Override + public int targetWidth() { + return targetWidth; + } + + @Override + public int targetHeight() { + return targetHeight; + } + + @Override + public @NonNull ImageEncoder targetSize(int targetWidth, int targetHeight) { + if (targetWidth <= 0 || targetHeight <= 0) { + throw new IllegalArgumentException("Target size must be positive"); + } + if (this.targetWidth != targetWidth || this.targetHeight != targetHeight) { + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + invalidateCache(); + } + return this; + } + + @Override + public @NonNull ImageEncoder fitImage(boolean fitImage) { + if (this.fitImage != fitImage) { + this.fitImage = fitImage; + invalidateCache(); + } + return this; + } + + @Override + public boolean fitImage() { + return fitImage; + } + + private void invalidateCache() { + this.scaledImage = null; + this.base64Data = null; + } + + @Override + public void render(@NonNull Appendable output) throws IOException { + if (output == null) { + throw new IllegalArgumentException("Output cannot be null"); + } + + // Lazily compute and cache the scaled image and encoded data + if (base64Data == null) { + // Calculate target pixel dimensions based on terminal size and font size + Resolution fontSize = FontSize.defaultFontSize(); + int targetWidthPx = targetWidth * fontSize.x; + int targetHeightPx = targetHeight * fontSize.y; + + // Scale the image to fit the target dimensions + scaledImage = ImageUtils.scaleImage(image, targetWidthPx, targetHeightPx, fitImage); + + // Encode image as PNG + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(scaledImage, "png", baos); + byte[] imageData = baos.toByteArray(); + encodedDataLength = imageData.length; + + // Encode to base64 + base64Data = Base64.getEncoder().encodeToString(imageData); + } + + // Build iTerm2 inline image command + // Format: ESC ]1337;File=[arguments]:base64-data ^G + // Arguments: + // - name: optional filename (base64 encoded) + // - size: size in bytes + // - width: width in columns or pixels + // - height: height in rows or pixels + // - preserveAspectRatio: 0 or 1 + // - inline: 1 to display inline + + output.append(AnsiUtils.OSC); + output.append(ITERM_FILE_CMD); + + // Add arguments + StringBuilder args = new StringBuilder(); + args.append("size=").append(encodedDataLength); + args.append(";width=").append(targetWidth); // Width in columns + args.append(";height=").append(targetHeight); // Height in rows + args.append(";preserveAspectRatio=1"); // Preserve aspect ratio + args.append(";inline=1"); // Display inline + + output.append(args); + output.append(":"); + output.append(base64Data); + output.append(AnsiUtils.BEL); + } + + /** Provider for creating ITermEncoder instances. */ + public static class Provider implements ImageEncoder.Provider { + @Override + public @NonNull String name() { + return "iterm2"; + } + + @Override + public @NonNull Resolution resolution() { + return FontSize.defaultFontSize(); + } + + @Override + public @NonNull ImageEncoder create( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + return new ITermEncoder(image, targetWidth, targetHeight, fitImage); + } + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/KittyEncoder.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/KittyEncoder.java new file mode 100644 index 0000000..2f6daee --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/KittyEncoder.java @@ -0,0 +1,215 @@ +package org.codejive.twinkle.image.impl; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import javax.imageio.ImageIO; +import org.codejive.twinkle.image.ImageEncoder; +import org.codejive.twinkle.image.util.AnsiUtils; +import org.codejive.twinkle.image.util.FontSize; +import org.codejive.twinkle.image.util.ImageUtils; +import org.codejive.twinkle.image.util.Resolution; +import org.jspecify.annotations.NonNull; + +/** + * Implementation of the Kitty terminal graphics encoding format. + * + *

The Kitty graphics encoding format is a modern, efficient format developed for the Kitty + * terminal emulator. It supports direct transmission of PNG images encoded in base64, with various + * sophisticated features like image IDs, placements, and more. + * + *

Format: ESC _G;base64-data ESC \ + * + *

This encoder is stateful: the image and font size are set at construction time and are + * immutable, while the target size and fit mode can be changed via setters. Expensive + * transformations like image scaling and PNG encoding are performed lazily on the first call to + * {@link #render(Appendable)} and cached for subsequent calls. + * + * @see Kitty Graphics Protocol + */ +public class KittyEncoder implements ImageEncoder { + // Immutable state + private final @NonNull BufferedImage image; + + // Mutable state + private int targetWidth; + private int targetHeight; + private boolean fitImage; + + // Cached transformations + private BufferedImage scaledImage; + private String base64Data; + + private static final String APC = AnsiUtils.ESC + "_"; // Application Program Command + private static final char GRAPHICS_CMD = 'G'; + + public static KittyEncoder create( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + return new KittyEncoder(image, targetWidth, targetHeight, fitImage); + } + + /** + * Creates a new Kitty encoder for the given image and font size. + * + * @param image the image to encode + * @param targetWidth the initial target width in terminal columns + * @param targetHeight the initial target height in terminal rows + * @param fitImage the initial fit mode + */ + protected KittyEncoder( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + if (image == null) { + throw new IllegalArgumentException("Image cannot be null"); + } + if (targetWidth <= 0 || targetHeight <= 0) { + throw new IllegalArgumentException("Target size must be positive"); + } + this.image = image; + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + this.fitImage = fitImage; + } + + @Override + public int targetWidth() { + return targetWidth; + } + + @Override + public int targetHeight() { + return targetHeight; + } + + @Override + public @NonNull ImageEncoder targetSize(int targetWidth, int targetHeight) { + if (targetWidth <= 0 || targetHeight <= 0) { + throw new IllegalArgumentException("Target size must be positive"); + } + if (this.targetWidth != targetWidth || this.targetHeight != targetHeight) { + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + invalidateCache(); + } + return this; + } + + @Override + public @NonNull ImageEncoder fitImage(boolean fitImage) { + if (this.fitImage != fitImage) { + this.fitImage = fitImage; + invalidateCache(); + } + return this; + } + + @Override + public boolean fitImage() { + return fitImage; + } + + private void invalidateCache() { + this.scaledImage = null; + this.base64Data = null; + } + + @Override + public void render(@NonNull Appendable output) throws IOException { + if (output == null) { + throw new IllegalArgumentException("Output cannot be null"); + } + + // Lazily compute and cache the scaled image and encoded data + if (base64Data == null) { + // Calculate target pixel dimensions based on terminal size and font size + Resolution fontSize = FontSize.defaultFontSize(); + int targetWidthPx = targetWidth * fontSize.x; + int targetHeightPx = targetHeight * fontSize.y; + + // Scale the image to fit the target dimensions + scaledImage = ImageUtils.scaleImage(image, targetWidthPx, targetHeightPx, fitImage); + + // Encode image as PNG + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(scaledImage, "png", baos); + byte[] imageData = baos.toByteArray(); + + // Encode to base64 + base64Data = Base64.getEncoder().encodeToString(imageData); + } + + // Calculate number of rows and columns the image will occupy + int cols = targetWidth; + int rows = targetHeight; + + // Build Kitty graphics command + // Control data format: a=,f=,t=,c=,r= + // a=T : transmit and display + // f=100 : PNG format + // t=d : direct transmission (inline) + // c,r : columns and rows + StringBuilder controlData = new StringBuilder(); + controlData.append("a=T"); // Transmit and display immediately + controlData.append(",f=100"); // PNG format + controlData.append(",t=d"); // Direct transmission + controlData.append(",c=").append(cols); // Width in columns + controlData.append(",r=").append(rows); // Height in rows + + // Split base64 data into chunks (maximum 4096 bytes per chunk recommended) + int chunkSize = 4096; + int dataLength = base64Data.length(); + + for (int i = 0; i < dataLength; i += chunkSize) { + int end = Math.min(i + chunkSize, dataLength); + String chunk = base64Data.substring(i, end); + boolean isLastChunk = (end >= dataLength); + + // Start graphics command + output.append(APC); + output.append(GRAPHICS_CMD); + + // Add control data only for first chunk + if (i == 0) { + output.append(controlData); + } + + // Add 'm' parameter to indicate chunking + if (!isLastChunk) { + if (i == 0) { + output.append(","); + } + output.append("m=1"); // More chunks coming + } else { + if (i > 0) { + output.append("m=0"); // Last chunk + } + } + + // Add the data + output.append(";"); + output.append(chunk); + + // End graphics command + output.append(AnsiUtils.ST); + } + } + + /** Provider for creating KittyEncoder instances. */ + public static class Provider implements ImageEncoder.Provider { + @Override + public @NonNull String name() { + return "kitty"; + } + + @Override + public @NonNull Resolution resolution() { + return FontSize.defaultFontSize(); + } + + @Override + public @NonNull ImageEncoder create( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + return new KittyEncoder(image, targetWidth, targetHeight, fitImage); + } + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/SixelEncoder.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/SixelEncoder.java new file mode 100644 index 0000000..b2c0513 --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/impl/SixelEncoder.java @@ -0,0 +1,285 @@ +package org.codejive.twinkle.image.impl; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.List; +import org.codejive.twinkle.image.ImageEncoder; +import org.codejive.twinkle.image.util.AnsiUtils; +import org.codejive.twinkle.image.util.ColorQuantizer; +import org.codejive.twinkle.image.util.FontSize; +import org.codejive.twinkle.image.util.ImageUtils; +import org.codejive.twinkle.image.util.Resolution; +import org.jspecify.annotations.NonNull; + +/** + * Implementation of the Sixel image encoding format. + * + *

Sixel is a bitmap graphics format originally developed by Digital Equipment Corporation (DEC). + * It's supported by various terminal emulators including xterm (with -ti vt340 option), mlterm, and + * others. + * + *

The Sixel encoding format encodes images as a series of six-pixel-high strips, which are then + * transmitted as printable ASCII characters. + * + *

This encoder is stateful: the image is set at construction time and is immutable, while the + * target size and fit mode can be changed via setters. The expensive encoding process (scaling, + * color quantization, and Sixel encoding) is performed lazily on the first call to {@link + * #render(Appendable)} and the result is cached for subsequent calls. + */ +public class SixelEncoder implements ImageEncoder { + // Immutable state + private final @NonNull BufferedImage image; + + // Mutable state + private int targetWidth; + private int targetHeight; + private boolean fitImage; + + // Cached encoded result + private String cachedSixelData; + + private static final String DCS = AnsiUtils.ESC + "P"; // Device Control String + private static final String SIXEL_INTRO = "q"; // Sixel introducer + + public static @NonNull SixelEncoder sixel( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + return new SixelEncoder(image, targetWidth, targetHeight, fitImage); + } + + /** + * Creates a new Sixel encoder for the given image and font size. + * + * @param image the image to encode + * @param targetWidth the initial target width in terminal columns + * @param targetHeight the initial target height in terminal rows + * @param fitImage the initial fit mode + */ + protected SixelEncoder( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + if (image == null) { + throw new IllegalArgumentException("Image cannot be null"); + } + if (targetWidth <= 0 || targetHeight <= 0) { + throw new IllegalArgumentException("Target size must be positive"); + } + this.image = image; + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + this.fitImage = fitImage; + } + + @Override + public int targetWidth() { + return targetWidth; + } + + @Override + public int targetHeight() { + return targetHeight; + } + + @Override + public @NonNull ImageEncoder targetSize(int targetWidth, int targetHeight) { + if (targetWidth <= 0 || targetHeight <= 0) { + throw new IllegalArgumentException("Target size must be positive"); + } + if (this.targetWidth != targetWidth || this.targetHeight != targetHeight) { + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + this.cachedSixelData = null; // Invalidate cache + } + return this; + } + + @Override + public @NonNull ImageEncoder fitImage(boolean fitImage) { + if (this.fitImage != fitImage) { + this.fitImage = fitImage; + this.cachedSixelData = null; // Invalidate cache + } + return this; + } + + @Override + public boolean fitImage() { + return fitImage; + } + + @Override + public void render(@NonNull Appendable output) throws IOException { + if (output == null) { + throw new IllegalArgumentException("Output cannot be null"); + } + + // Lazily compute and cache the encoded Sixel data + if (cachedSixelData == null) { + // Scale the image to target dimensions + Resolution fontSize = FontSize.defaultFontSize(); + int targetWidthPx = targetWidth * fontSize.x; + int targetHeightPx = targetHeight * fontSize.y; + BufferedImage scaledImage = + ImageUtils.scaleImage(image, targetWidthPx, targetHeightPx, fitImage); + + // Encode to Sixel format and cache the result + StringBuilder sixelData = new StringBuilder(); + sixelData.append(DCS); + sixelData.append("0;1"); // P1=0 (default aspect), P2=1 (transparent background) + sixelData.append(SIXEL_INTRO); + encodeSixelData(scaledImage, sixelData); + sixelData.append(AnsiUtils.ST); + cachedSixelData = sixelData.toString(); + } + + // Output the cached Sixel data + output.append(cachedSixelData); + } + + /** + * Encodes the image data in Sixel format. + * + * @param image the image to encode + * @param output the output to write to + * @throws IOException if an I/O error occurs + */ + private void encodeSixelData(@NonNull BufferedImage image, @NonNull Appendable output) + throws IOException { + + int width = image.getWidth(); + int height = image.getHeight(); + + // Set raster attributes: aspect ratio (1:1) and explicit image dimensions + output.append("\"1;1;"); + output.append(Integer.toString(width)); + output.append(';'); + output.append(Integer.toString(height)); + + // Quantize image to max 256 colors for Sixel + ColorQuantizer.QuantizedImage quantized = + ColorQuantizer.quantize(image, Math.min(256, width * height)); + + // Define color palette + List palette = quantized.palette(); + for (int i = 0; i < palette.size(); i++) { + int rgb = palette.get(i); + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + + // Define color using RGB percentages (0-100) + output.append('#'); + output.append(Integer.toString(i)); + output.append(";2;"); + output.append(Integer.toString(r * 100 / 255)); + output.append(';'); + output.append(Integer.toString(g * 100 / 255)); + output.append(';'); + output.append(Integer.toString(b * 100 / 255)); + } + + // Encode image data in six-pixel strips + int[][] indexedPixels = quantized.indexedPixels(); + + // Process image in strips of 6 pixels high + for (int stripY = 0; stripY < height; stripY += 6) { + // For each color, encode all pixels of that color in this strip + for (int colorIndex = 0; colorIndex < palette.size(); colorIndex++) { + boolean colorUsedInStrip = false; + StringBuilder stripData = new StringBuilder(); + + // Check each column + for (int x = 0; x < width; x++) { + // Build sixel value for this column (6 pixels) + int sixelValue = 0; + for (int dy = 0; dy < 6 && stripY + dy < height; dy++) { + if (indexedPixels[stripY + dy][x] == colorIndex) { + sixelValue |= (1 << dy); + } + } + + if (sixelValue > 0) { + colorUsedInStrip = true; + } + // Always output a character for every column to preserve + // correct x-positioning (RLE will compress '?' runs) + stripData.append((char) ('?' + sixelValue)); + } + + // Only output if this color was used in this strip + if (colorUsedInStrip) { + // Select color + output.append('#'); + output.append(Integer.toString(colorIndex)); + + // Compress repeated characters + compressAndAppend(stripData.toString(), output); + + // Return to start of line + output.append('$'); + } + } + + // Move to next strip (unless this is the last strip) + if (stripY + 6 < height) { + output.append('-'); + } + } + } + + /** + * Compresses repeated characters using Sixel repeat sequences and appends to output. + * + * @param data the data to compress + * @param output the output to write to + * @throws IOException if an I/O error occurs + */ + private void compressAndAppend(@NonNull String data, @NonNull Appendable output) + throws IOException { + if (data.isEmpty()) { + return; + } + + int i = 0; + while (i < data.length()) { + char ch = data.charAt(i); + int count = 1; + + // Count consecutive identical characters + while (i + count < data.length() && data.charAt(i + count) == ch) { + count++; + } + + // Use repeat sequence if count >= 3 (saves space) + if (count >= 3) { + output.append('!'); + output.append(Integer.toString(count)); + output.append(ch); + } else { + // Output characters directly + for (int j = 0; j < count; j++) { + output.append(ch); + } + } + + i += count; + } + } + + /** Provider for creating SixelEncoder instances. */ + public static class Provider implements ImageEncoder.Provider { + @Override + public @NonNull String name() { + return "sixel"; + } + + @Override + public @NonNull Resolution resolution() { + return FontSize.defaultFontSize(); + } + + @Override + public @NonNull ImageEncoder create( + @NonNull BufferedImage image, int targetWidth, int targetHeight, boolean fitImage) { + return new SixelEncoder(image, targetWidth, targetHeight, fitImage); + } + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/util/AnsiUtils.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/AnsiUtils.java new file mode 100644 index 0000000..e0616e7 --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/AnsiUtils.java @@ -0,0 +1,19 @@ +package org.codejive.twinkle.image.util; + +public class AnsiUtils { + public static final String ESC = "\u001b"; // Control Sequence Introducer + public static final String CSI = ESC + "["; // Control Sequence Introducer + public static final String OSC = ESC + "]"; // Operating System Command + public static final String ST = ESC + "\\"; // String Terminator for OSC + public static final String BEL = "\u0007"; // Bell (Alternative String Terminator for OSC) + + public static final CharSequence STYLE_RESET = CSI + "0m"; // Reset all attributes + + public static String rgbFg(int fgR, int fgG, int fgB) { + return CSI + "38;2;" + fgR + ";" + fgG + ";" + fgB + "m"; + } + + public static String rgbBg(int bgR, int bgG, int bgB) { + return CSI + "48;2;" + bgR + ";" + bgG + ";" + bgB + "m"; + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/util/ColorQuantizer.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/ColorQuantizer.java new file mode 100644 index 0000000..7b0e697 --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/ColorQuantizer.java @@ -0,0 +1,324 @@ +package org.codejive.twinkle.image.util; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.NonNull; + +/** + * Utility class for color quantization of images. + * + *

This class provides methods to reduce the color palette of an image to a specified number of + * colors using median cut algorithm. This is useful for encoders like Sixel that have a limited + * color palette (typically 256 colors). + */ +public class ColorQuantizer { + + private ColorQuantizer() { + // Utility class, prevent instantiation + } + + /** Result of color quantization containing the palette and indexed pixel data. */ + public static class QuantizedImage { + private final @NonNull List palette; + private final int @NonNull [][] indexedPixels; + + /** + * Creates a new quantized image result. + * + * @param palette the color palette (list of RGB colors) + * @param indexedPixels 2D array of palette indices for each pixel + */ + public QuantizedImage(@NonNull List palette, int @NonNull [][] indexedPixels) { + this.palette = palette; + this.indexedPixels = indexedPixels; + } + + /** + * Gets the color palette. + * + * @return the palette + */ + public @NonNull List palette() { + return palette; + } + + /** + * Gets the indexed pixel data. + * + * @return the indexed pixels + */ + public int @NonNull [][] indexedPixels() { + return indexedPixels; + } + } + + /** + * Quantizes an image to a maximum number of colors using median cut algorithm. + * + * @param image the image to quantize + * @param maxColors the maximum number of colors in the palette (typically 256 for Sixel) + * @return the quantized image with palette and indexed pixels + * @throws IllegalArgumentException if image is null or maxColors is invalid + */ + public static @NonNull QuantizedImage quantize(@NonNull BufferedImage image, int maxColors) { + if (image == null) { + throw new IllegalArgumentException("Image cannot be null"); + } + if (maxColors < 2 || maxColors > 256) { + throw new IllegalArgumentException( + "Max colors must be between 2 and 256, got: " + maxColors); + } + + int width = image.getWidth(); + int height = image.getHeight(); + + // Collect all unique colors from the image + Map colorCounts = new HashMap<>(); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int rgb = image.getRGB(x, y) & 0xFFFFFF; // Mask out alpha + colorCounts.merge(rgb, 1, Integer::sum); + } + } + + // If the image already has fewer colors than maxColors, use them directly + List palette; + if (colorCounts.size() <= maxColors) { + palette = new ArrayList<>(colorCounts.keySet()); + } else { + // Use median cut algorithm to reduce colors + palette = medianCut(new ArrayList<>(colorCounts.keySet()), maxColors); + } + + // Build index map for fast lookup + Map colorToIndex = new HashMap<>(); + for (int i = 0; i < palette.size(); i++) { + colorToIndex.put(palette.get(i), i); + } + + // Create indexed pixel array + int[][] indexedPixels = new int[height][width]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int rgb = image.getRGB(x, y) & 0xFFFFFF; + Integer index = colorToIndex.get(rgb); + if (index == null) { + // Find nearest color in palette + index = findNearestColor(rgb, palette); + } + indexedPixels[y][x] = index; + } + } + + return new QuantizedImage(palette, indexedPixels); + } + + /** + * Performs median cut algorithm on a list of colors. + * + * @param colors the list of colors to quantize + * @param maxColors the target number of colors + * @return the quantized palette + */ + private static @NonNull List medianCut(@NonNull List colors, int maxColors) { + // Start with one bucket containing all colors + List buckets = new ArrayList<>(); + buckets.add(new ColorBucket(colors)); + + // Repeatedly split the bucket with the largest range until we have maxColors buckets + while (buckets.size() < maxColors) { + // Find bucket with largest range + ColorBucket largest = null; + int largestRange = -1; + for (ColorBucket bucket : buckets) { + int range = bucket.getRange(); + if (range > largestRange) { + largestRange = range; + largest = bucket; + } + } + + if (largest == null || largestRange == 0) { + break; // Cannot split further + } + + // Split the bucket + buckets.remove(largest); + ColorBucket[] split = largest.split(); + buckets.add(split[0]); + buckets.add(split[1]); + } + + // Get average color from each bucket + List palette = new ArrayList<>(); + for (ColorBucket bucket : buckets) { + palette.add(bucket.getAverageColor()); + } + + return palette; + } + + /** + * Finds the index of the nearest color in the palette. + * + * @param rgb the target color + * @param palette the color palette + * @return the index of the nearest color + */ + private static int findNearestColor(int rgb, @NonNull List palette) { + int r1 = (rgb >> 16) & 0xFF; + int g1 = (rgb >> 8) & 0xFF; + int b1 = rgb & 0xFF; + + int nearestIndex = 0; + int minDistance = Integer.MAX_VALUE; + + for (int i = 0; i < palette.size(); i++) { + int paletteColor = palette.get(i); + int r2 = (paletteColor >> 16) & 0xFF; + int g2 = (paletteColor >> 8) & 0xFF; + int b2 = paletteColor & 0xFF; + + // Euclidean distance in RGB space + int dr = r1 - r2; + int dg = g1 - g2; + int db = b1 - b2; + int distance = dr * dr + dg * dg + db * db; + + if (distance < minDistance) { + minDistance = distance; + nearestIndex = i; + } + } + + return nearestIndex; + } + + /** A bucket of colors for the median cut algorithm. */ + private static class ColorBucket { + private final List colors; + + ColorBucket(List colors) { + this.colors = colors; + } + + /** + * Gets the range of this bucket (max range across R, G, B channels). + * + * @return the range + */ + int getRange() { + if (colors.isEmpty()) { + return 0; + } + + int minR = 255, maxR = 0; + int minG = 255, maxG = 0; + int minB = 255, maxB = 0; + + for (int color : colors) { + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + + minR = Math.min(minR, r); + maxR = Math.max(maxR, r); + minG = Math.min(minG, g); + maxG = Math.max(maxG, g); + minB = Math.min(minB, b); + maxB = Math.max(maxB, b); + } + + int rangeR = maxR - minR; + int rangeG = maxG - minG; + int rangeB = maxB - minB; + + return Math.max(rangeR, Math.max(rangeG, rangeB)); + } + + /** + * Splits this bucket into two buckets by median cut on the channel with largest range. + * + * @return array of two buckets + */ + ColorBucket[] split() { + if (colors.size() < 2) { + return new ColorBucket[] {this, new ColorBucket(new ArrayList<>())}; + } + + // Find channel with largest range + int minR = 255, maxR = 0; + int minG = 255, maxG = 0; + int minB = 255, maxB = 0; + + for (int color : colors) { + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + + minR = Math.min(minR, r); + maxR = Math.max(maxR, r); + minG = Math.min(minG, g); + maxG = Math.max(maxG, g); + minB = Math.min(minB, b); + maxB = Math.max(maxB, b); + } + + int rangeR = maxR - minR; + int rangeG = maxG - minG; + int rangeB = maxB - minB; + + // Determine which channel to split on + final int channel; // 0=R, 1=G, 2=B + if (rangeR >= rangeG && rangeR >= rangeB) { + channel = 0; + } else if (rangeG >= rangeB) { + channel = 1; + } else { + channel = 2; + } + + // Sort colors by the selected channel + colors.sort( + (c1, c2) -> { + int v1 = (c1 >> (16 - channel * 8)) & 0xFF; + int v2 = (c2 >> (16 - channel * 8)) & 0xFF; + return Integer.compare(v1, v2); + }); + + // Split at median + int median = colors.size() / 2; + List left = new ArrayList<>(colors.subList(0, median)); + List right = new ArrayList<>(colors.subList(median, colors.size())); + + return new ColorBucket[] {new ColorBucket(left), new ColorBucket(right)}; + } + + /** + * Gets the average color of all colors in this bucket. + * + * @return the average color as RGB integer + */ + int getAverageColor() { + if (colors.isEmpty()) { + return 0; + } + + long sumR = 0, sumG = 0, sumB = 0; + for (int color : colors) { + sumR += (color >> 16) & 0xFF; + sumG += (color >> 8) & 0xFF; + sumB += color & 0xFF; + } + + int avgR = (int) (sumR / colors.size()); + int avgG = (int) (sumG / colors.size()); + int avgB = (int) (sumB / colors.size()); + + return (avgR << 16) | (avgG << 8) | avgB; + } + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/util/FontSize.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/FontSize.java new file mode 100644 index 0000000..3ddf27e --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/FontSize.java @@ -0,0 +1,19 @@ +package org.codejive.twinkle.image.util; + +public class FontSize { + + // Common monospace font size (width x height in pixels) + private static Resolution defaultFontSize = new Resolution(8, 16); + + public static Resolution defaultFontSize() { + return defaultFontSize; + } + + public static void defaultFontSize(Resolution newFontSize) { + defaultFontSize = newFontSize; + } + + private FontSize() { + // Private constructor to prevent instantiation + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/util/ImageUtils.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/ImageUtils.java new file mode 100644 index 0000000..23f811e --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/ImageUtils.java @@ -0,0 +1,88 @@ +package org.codejive.twinkle.image.util; + +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.image.BufferedImage; +import org.jspecify.annotations.NonNull; + +/** Utility class for common image operations used by terminal image encoders. */ +public class ImageUtils { + + private ImageUtils() { + // Utility class, prevent instantiation + } + + /** + * Scales an image to fit within the specified dimensions while maintaining aspect ratio. + * + *

The image will be scaled to fit completely within the target dimensions. If the aspect + * ratio of the source image differs from the target dimensions, the resulting image will be + * smaller in one dimension to preserve the aspect ratio. + * + * @param source the source image to scale + * @param targetWidth the maximum target width in pixels + * @param targetHeight the maximum target height in pixels + * @return the scaled image with preserved aspect ratio + * @throws IllegalArgumentException if source is null or dimensions are invalid + */ + public static @NonNull BufferedImage scaleImage( + @NonNull BufferedImage source, int targetWidth, int targetHeight) { + return scaleImage(source, targetWidth, targetHeight, false); + } + + /** + * Scales an image to the specified dimensions. + * + * @param source the source image to scale + * @param targetWidth the target width in pixels + * @param targetHeight the target height in pixels + * @param fitImage if true, scale the image to fit the target dimensions exactly (stretching if + * needed); if false, preserve aspect ratio + * @return the scaled image + * @throws IllegalArgumentException if source is null or dimensions are invalid + */ + public static @NonNull BufferedImage scaleImage( + @NonNull BufferedImage source, int targetWidth, int targetHeight, boolean fitImage) { + + if (source == null) { + throw new IllegalArgumentException("Source image cannot be null"); + } + if (targetWidth <= 0 || targetHeight <= 0) { + throw new IllegalArgumentException( + "Target dimensions must be positive: " + targetWidth + "x" + targetHeight); + } + + int scaledWidth; + int scaledHeight; + + if (fitImage) { + // Fit the image to the exact target dimensions (may stretch) + scaledWidth = targetWidth; + scaledHeight = targetHeight; + } else { + // Calculate scaling to fit within target dimensions while preserving aspect ratio + double scaleX = (double) targetWidth / source.getWidth(); + double scaleY = (double) targetHeight / source.getHeight(); + double scale = Math.min(scaleX, scaleY); + + scaledWidth = (int) Math.round(source.getWidth() * scale); + scaledHeight = (int) Math.round(source.getHeight() * scale); + + // Ensure dimensions are at least 1x1 + scaledWidth = Math.max(1, scaledWidth); + scaledHeight = Math.max(1, scaledHeight); + } + + BufferedImage scaled = + new BufferedImage(scaledWidth, scaledHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = scaled.createGraphics(); + g.drawImage( + source.getScaledInstance(scaledWidth, scaledHeight, Image.SCALE_SMOOTH), + 0, + 0, + null); + g.dispose(); + + return scaled; + } +} diff --git a/twinkle-image/src/main/java/org/codejive/twinkle/image/util/Resolution.java b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/Resolution.java new file mode 100644 index 0000000..a80f9e7 --- /dev/null +++ b/twinkle-image/src/main/java/org/codejive/twinkle/image/util/Resolution.java @@ -0,0 +1,16 @@ +package org.codejive.twinkle.image.util; + +public class Resolution implements Comparable { + public final int x; + public final int y; + + public Resolution(int x, int y) { + this.x = x; + this.y = y; + } + + @Override + public int compareTo(Resolution other) { + return Integer.compare(this.x * this.y, other.x * other.y); + } +}