From 17184841af0e1c5916005ff59ddd11d201e4b2f9 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 13 May 2026 11:44:24 +0200 Subject: [PATCH] feat: added module for displaying images --- README.md | 4 +- examples/ShowImage.java | 192 ++++ examples/duke.jpg | Bin 0 -> 14262 bytes image/README.md | 102 ++ image/pom.xml | 119 +++ .../codejive/miniterm/image/ImageEncoder.java | 106 ++ .../miniterm/image/ImageEncoders.java | 158 +++ .../miniterm/image/impl/BlockEncoder.java | 945 ++++++++++++++++++ .../miniterm/image/impl/ITermEncoder.java | 188 ++++ .../miniterm/image/impl/KittyEncoder.java | 217 ++++ .../miniterm/image/impl/SixelEncoder.java | 287 ++++++ .../miniterm/image/util/AnsiUtils.java | 16 + .../miniterm/image/util/ColorQuantizer.java | 324 ++++++ .../miniterm/image/util/FontSize.java | 19 + .../miniterm/image/util/ImageUtils.java | 88 ++ .../miniterm/image/util/Resolution.java | 16 + pom.xml | 1 + 17 files changed, 2781 insertions(+), 1 deletion(-) create mode 100644 examples/ShowImage.java create mode 100644 examples/duke.jpg create mode 100644 image/README.md create mode 100644 image/pom.xml create mode 100644 image/src/main/java/org/codejive/miniterm/image/ImageEncoder.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/ImageEncoders.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/impl/BlockEncoder.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/impl/ITermEncoder.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/impl/KittyEncoder.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/impl/SixelEncoder.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/util/AnsiUtils.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/util/ColorQuantizer.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/util/FontSize.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/util/ImageUtils.java create mode 100644 image/src/main/java/org/codejive/miniterm/image/util/Resolution.java diff --git a/README.md b/README.md index 8d6a1cb..936fdf7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Two variants are available: And then we have utility modules (the "built on top"): - **[`ansiparser`](ansiparser/README.md)** — compact ANSI escape sequence parser - **[`colors`](colors/README.md)** — terminal colour palette querying and setting +- **[`image`](image/README.md)** — terminal image rendering and protocol detection - **[`mousetrack`](mousetrack/README.md)** — terminal mouse-tracking helpers and event parser - **[`termcap`](termcap/README.md)** — terminal capability detection @@ -108,13 +109,14 @@ if (bg != null) { ## Modules -Three artifacts are published independently: +Several artifacts are published independently: | Artifact | Description | |----------|-------------| | [`miniterm`](miniterm/README.md) | Legacy terminal implementation, Java 8+ | | [`miniterm-ffm`](miniterm-ffm/README.md) | Modern FFM-based terminal implementation, Java 22+ | | [`ansiparser`](ansiparser/README.md) | Compact ANSI escape sequence parser, Java 8+ | +| [`image`](image/README.md) | Terminal image rendering and protocol detection, Java 8+ | | [`mousetrack`](mousetrack/README.md) | Terminal mouse-tracking helpers and event parser, Java 8+ | | [`termcap`](termcap/README.md) | Terminal capability detection, Java 8+ | | [`colors`](colors/README.md) | Terminal colour palette querying and setting via OSC sequences, Java 8+ | diff --git a/examples/ShowImage.java b/examples/ShowImage.java new file mode 100644 index 0000000..1dcd978 --- /dev/null +++ b/examples/ShowImage.java @@ -0,0 +1,192 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS org.codejive.miniterm:miniterm${miniterm.ffm:}:${miniterm.version:0.1.5} +//DEPS org.codejive.miniterm:image:${miniterm.version:0.1.5} + +package examples; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import javax.imageio.ImageIO; +import org.codejive.miniterm.Terminal; +import org.codejive.miniterm.image.ImageEncoder; +import org.codejive.miniterm.image.ImageEncoders; + +/** + * 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 ShowImage [--image=] [--encoder=] [--all]} + * + *

Supported encoder names: sixel, kitty, iterm2, block-full, block-half, block-quadrant, + * block-sextant, block-octant. + */ +public class ShowImage { + + public static void main(String[] args) throws Exception { + try (Terminal terminal = Terminal.create()) { + BufferedImage image = loadImage(args); + + // Define target size in terminal rows/columns + int targetWidth = 20; // 20 columns wide + int targetHeight = 10; // 10 rows tall + + terminal.write("=== Image Encoder Demo ===\n"); + + 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) { + terminal.write("Unknown encoder: " + encoderName + "\n"); + terminal.write( + "Available: sixel, kitty, iterm2, block-full, block-half," + + " block-quadrant, block-sextant, block-octant\n"); + return; + } + terminal.write("Using encoder: " + provider.name() + "\n\n"); + terminal.write("Rendering with " + provider.name() + " encoder:\n"); + renderImage(provider.create(image, targetWidth, targetHeight, fitImage), terminal); + terminal.write("\n\n"); + } else { + // Detect the best encoder for the current terminal + ImageEncoder.Provider bestProvider = ImageEncoders.best(); + ImageEncoder detectedEncoder = + bestProvider.create(image, targetWidth, targetHeight, fitImage); + terminal.write("Detected encoder: " + bestProvider.name() + "\n\n"); + + // Try rendering with the detected encoder + terminal.write("Rendering with " + bestProvider.name() + " encoder:\n"); + renderImage(detectedEncoder, terminal); + terminal.write("\n\n"); + + // Optionally try all available encoders + if (shouldTestAllEncoders(args)) { + terminal.write("\n--- Testing all encoders ---\n\n"); + + for (ImageEncoder.Provider provider : ImageEncoders.providers()) { + testEncoder( + provider.name(), + provider.create(image, targetWidth, targetHeight, fitImage), + terminal); + } + } + } + + terminal.write("\nDemo complete!\n"); + } + } + + private static String getEncoderArg(String[] args) { + for (String arg : args) { + if (arg.startsWith("--encoder=")) { + return arg.substring("--encoder=".length()); + } + } + return null; + } + + private static BufferedImage loadImage(String[] args) throws IOException { + String imagePath = getImageArg(args); + if (imagePath == null) { + return createTestImage(200, 150); + } + + BufferedImage image = ImageIO.read(new File(imagePath)); + if (image == null) { + throw new IOException("Unsupported or unreadable image: " + imagePath); + } + return image; + } + + private static String getImageArg(String[] args) { + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if (arg.startsWith("--image=")) { + return arg.substring("--image=".length()); + } + if ("--image".equals(arg)) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException("Missing value for --image"); + } + return args[i + 1]; + } + } + return null; + } + + private static ImageEncoder.Provider findProvider(String name) { + String normalized = normalizeProviderName(name); + List all = ImageEncoders.providers(); + for (ImageEncoder.Provider provider : all) { + String providerKey = normalizeProviderName(provider.name()); + if (providerKey.equals(normalized)) { + return provider; + } + } + return null; + } + + private static String normalizeProviderName(String value) { + return value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + } + + private static void testEncoder(String name, ImageEncoder encoder, Appendable output) + throws IOException { + output.append(name).append(" encoder:\n"); + renderImage(encoder, output); + output.append("\n\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/examples/duke.jpg b/examples/duke.jpg new file mode 100644 index 0000000000000000000000000000000000000000..70d5fed20aa0386fdd494b74ad253acd0422ae4a GIT binary patch literal 14262 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 " + provider.resolution()); +} +``` + +This returns providers in priority order, with the best option first. `supportedProviders()` checks common terminal environment variables and chooses the most appropriate protocol for the current session. + +## Supported protocols + +| Protocol | Typical terminals | Notes | +|---|---|---| +| `Kitty` | Kitty, Ghostty, Konsole, WezTerm | Modern, efficient PNG graphics protocol | +| `iTerm2` | iTerm2, WezTerm, VS Code, Mintty | Inline images using OSC 1337 | +| `Sixel` | Konsole, Windows Terminal, mlterm, foot, others | Bitmap graphics via DCS/Sixel | +| `Block` | Any terminal with Unicode support | Fallback renderer using block characters | + +The block encoders are exposed as several variants (`FULL`, `HALF`, `QUADRANT`, `SEXTANT`, `OCTANT`) to trade fidelity for compatibility. + +## Encoding model + +Each provider implements the same `ImageEncoder` API: + +- `targetWidth()` / `targetHeight()` — target dimensions in terminal columns and rows +- `targetSize(int width, int height)` — resize the rendered output +- `fitImage()` / `fitImage(boolean)` — preserve aspect ratio or stretch to fill the target box +- `render(Appendable output)` — emit terminal escape sequences + +The rendering work is cached. When you change the target size or fit mode, the encoder invalidates its cached transformation and re-renders lazily on the next call. + +## Adding the dependency + +`image` emits ANSI escape sequences and therefore requires `ansiparser` on the classpath at runtime. The module depends on it as an optional library in Maven so you can keep the dependency explicit in your own build. + +### JBang + +```java +//DEPS org.codejive.miniterm:image:0.1.5 +//DEPS org.codejive.miniterm:ansiparser:0.1.5 +``` + +### Maven + +```xml + + org.codejive.miniterm + image + 0.1.5 + + + org.codejive.miniterm + ansiparser + 0.1.5 + +``` + +### Gradle + +```kotlin +implementation("org.codejive.miniterm:image:0.1.5") +implementation("org.codejive.miniterm:ansiparser:0.1.5") +``` + +## Building + +```bash +./mvnw clean install +``` diff --git a/image/pom.xml b/image/pom.xml new file mode 100644 index 0000000..ddecf30 --- /dev/null +++ b/image/pom.xml @@ -0,0 +1,119 @@ + + + 4.0.0 + + + org.codejive.miniterm + miniterm-parent + 0.1.6-SNAPSHOT + + + image + image + Image support for terminals + https://github.com/codejive/miniterm + + + 8 + org.codejive.miniterm.image + + + + + org.codejive.miniterm + ansiparser + ${project.version} + true + + + + org.jspecify + jspecify + ${version.jspecify} + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + ${version.assertj} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 8 + + + + org.apache.maven.plugins + maven-jar-plugin + + + + ${javaModuleName} + + false + false + + + + + com.diffplug.spotless + spotless-maven-plugin + + + + + **/*.md + **/*.txt + .gitignore + .gitattributes + + + **/target/** + + UNIX + + + + true + 4 + + + + + + src/main/java/**/*.java + src/test/java/**/*.java + + + ${version.google-java-format} + + + + + + + + verify + + check + + + + + + + diff --git a/image/src/main/java/org/codejive/miniterm/image/ImageEncoder.java b/image/src/main/java/org/codejive/miniterm/image/ImageEncoder.java new file mode 100644 index 0000000..b0c7716 --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/ImageEncoder.java @@ -0,0 +1,106 @@ +package org.codejive.miniterm.image; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import org.codejive.miniterm.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/image/src/main/java/org/codejive/miniterm/image/ImageEncoders.java b/image/src/main/java/org/codejive/miniterm/image/ImageEncoders.java new file mode 100644 index 0000000..689bc32 --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/ImageEncoders.java @@ -0,0 +1,158 @@ +package org.codejive.miniterm.image; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import org.codejive.miniterm.image.impl.*; +import org.codejive.miniterm.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/image/src/main/java/org/codejive/miniterm/image/impl/BlockEncoder.java b/image/src/main/java/org/codejive/miniterm/image/impl/BlockEncoder.java new file mode 100644 index 0000000..285c8eb --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/impl/BlockEncoder.java @@ -0,0 +1,945 @@ +package org.codejive.miniterm.image.impl; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import org.codejive.miniterm.image.ImageEncoder; +import org.codejive.miniterm.image.util.AnsiUtils; +import org.codejive.miniterm.image.util.FontSize; +import org.codejive.miniterm.image.util.ImageUtils; +import org.codejive.miniterm.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 - U+00A0 NO-BREAK SPACE (EMPTY) + "█" // 0b1 - U+2588 FULL BLOCK + }; + + // Half-block characters (1x2) + private static final String[] HALF_BLOCKS = { + " ", // 0b00 - U+00A0 NO-BREAK SPACE (EMPTY) + "▀", // 0b01 - U+2580 UPPER HALF BLOCK + "▄", // 0b10 - U+2584 LOWER HALF BLOCK + "█" // 0b11 - U+2588 FULL BLOCK + }; + + // Quadrant characters (2x2) - indexed by bit pattern: top-left, top-right, bottom-left, + // bottom-right + private static final String[] QUADRANT_BLOCKS = { + " ", // 0b0000 - U+00A0 NO-BREAK SPACE (EMPTY) + "▘", // 0b0001 - U+2598 QUADRANT UPPER LEFT + "▝", // 0b0010 - U+259D QUADRANT UPPER RIGHT + "▀", // 0b0011 - U+2580 UPPER HALF BLOCK + "▖", // 0b0100 - U+2596 QUADRANT LOWER LEFT + "▌", // 0b0101 - U+258C LEFT HALF BLOCK + "▞", // 0b0110 - U+259E QUADRANT LOWER LEFT AND UPPER RIGHT + "▛", // 0b0111 - U+259B QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER LEFT + "▗", // 0b1000 - U+2597 QUADRANT LOWER RIGHT + "▚", // 0b1001 - U+259A QUADRANT UPPER LEFT AND LOWER RIGHT + "▐", // 0b1010 - U+2590 RIGHT HALF BLOCK + "▜", // 0b1011 - U+259C QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER RIGHT + "▄", // 0b1100 - U+2584 LOWER HALF BLOCK + "▙", // 0b1101 - U+2599 QUADRANT UPPER LEFT AND LOWER LEFT AND LOWER RIGHT + "▟", // 0b1110 - U+259F QUADRANT UPPER RIGHT AND LOWER LEFT AND LOWER RIGHT + "█" // 0b1111 - U+2588 FULL BLOCK + }; + + // Sextant characters (2x3) - Symbols for Legacy Computing block (U+1FB00-U+1FB3B) + // Lookup table from sextant Unicode range 0x1fb00..=0x1fb3b to sextant pattern: + // `pattern` is a byte whose bits corresponds to elements on a 2 by 3 grid. + // The position of a sextant for a bit position (1-indexed) is as follows: + // ╭───┬───╮ + // │ 1 │ 2 │ + // ├───┼───┤ + // │ 3 │ 4 │ + // ├───┼───┤ + // │ 5 │ 6 │ + // ╰───┴───╯ + private static final String[] SEXTANT_BLOCKS = { + " ", // 0b000000 (0) - U+00A0 NO-BREAK SPACE (EMPTY) + "\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) - U+2588 FULL BLOCK + }; + + // Lookup table from octant Unicode range 0x1cd00..=0x1cde5 to octant pattern: + // `pattern` is a byte whose bits corresponds to elements on a 2 by 4 grid. + // The position of a octant for a bit position (1-indexed) is as follows: + // ╭───┬───╮ + // │ 1 │ 2 │ + // ├───┼───┤ + // │ 3 │ 4 │ + // ├───┼───┤ + // │ 5 │ 6 │ + // ├───┼───┤ + // │ 7 │ 8 │ + // ╰───┴───╯ + // Octant characters (2x4) - Indexed completely from 0b00000000 (0) to 0b11111111 (255) + // Combines Block Elements, Legacy Computing, and the Legacy Computing Supplement blocks. + private static final String[] OCTANT_BLOCKS = { + " ", // 0b000000 (0) - U+00A0 NO-BREAK SPACE (EMPTY) + "\uD833\uDEA8", // 0b00000001 (1) - U+1CEA8 LEFT HALF UPPER ONE QUARTER BLOCK (OCTANT-1) + "\uD833\uDEAB", // 0b00000010 (2) - U+1CEAB RIGHT HALF UPPER ONE QUARTER BLOCK (OCTANT-2) + "\uD83E\uDF82", // 0b00000011 (3) - U+1FB82 UPPER ONE QUARTER BLOCK (OCTANT-12) + "\uD833\uDD00", // 0b00000100 (4) - U+1CD00 BLOCK OCTANT-3 + "▘", // 0b00000101 (5) - U+2598 UPPER LEFT QUADRANT (OCTANT-13) + "\uD833\uDD01", // 0b00000110 (6) - U+1CD01 BLOCK OCTANT-23 + "\uD833\uDD02", // 0b00000111 (7) - U+1CD02 BLOCK OCTANT-123 + "\uD833\uDD03", // 0b00001000 (8) - U+1CD03 BLOCK OCTANT-4 + "\uD833\uDD04", // 0b00001001 (9) - U+1CD04 BLOCK OCTANT-14 + "▝", // 0b00001010 (10) - U+259D UPPER RIGHT QUADRANT (OCTANT-24) + "\uD833\uDD05", // 0b00001011 (11) - U+1CD05 BLOCK OCTANT-124 + "\uD833\uDD06", // 0b00001100 (12) - U+1CD06 BLOCK OCTANT-34 + "\uD833\uDD07", // 0b00001101 (13) - U+1CD07 BLOCK OCTANT-134 + "\uD833\uDD08", // 0b00001110 (14) - U+1CD08 BLOCK OCTANT-234 + "▀", // 0b00001111 (15) - U+2580 UPPER HALF BLOCK (OCTANT-1234) + "\uD833\uDD09", // 0b00010000 (16) - U+1CD09 BLOCK OCTANT-5 + "\uD833\uDD0A", // 0b00010001 (17) - U+1CD0A BLOCK OCTANT-15 + "\uD833\uDD0B", // 0b00010010 (18) - U+1CD0B BLOCK OCTANT-25 + "\uD833\uDD0C", // 0b00010011 (19) - U+1CD0C BLOCK OCTANT-125 + "\uD83E\uDFE6", // 0b00010100 (20) - U+1FBE6 MIDDLE LEFT ONE QUARTER BLOCK (OCTANT-35) + "\uD833\uDD0D", // 0b00010101 (21) - U+1CD0D BLOCK OCTANT-135 + "\uD833\uDD0E", // 0b00010116 (22) - U+1CD0E BLOCK OCTANT-235 + "\uD833\uDD0F", // 0b00010117 (23) - U+1CD0F BLOCK OCTANT-1235 + "\uD833\uDD10", // 0b00011000 (24) - U+1CD10 BLOCK OCTANT-45 + "\uD833\uDD11", // 0b00011001 (25) - U+1CD11 BLOCK OCTANT-145 + "\uD833\uDD12", // 0b00011010 (26) - U+1CD12 BLOCK OCTANT-245 + "\uD833\uDD13", // 0b00011011 (27) - U+1CD13 BLOCK OCTANT-1245 + "\uD833\uDD14", // 0b00011100 (28) - U+1CD14 BLOCK OCTANT-345 + "\uD833\uDD15", // 0b00011101 (29) - U+1CD15 BLOCK OCTANT-1345 + "\uD833\uDD16", // 0b00011110 (30) - U+1CD16 BLOCK OCTANT-2345 + "\uD833\uDD17", // 0b00011111 (31) - U+1CD17 BLOCK OCTANT-12345 + "\uD833\uDD18", // 0b00100000 (32) - U+1CD18 BLOCK OCTANT-6 + "\uD833\uDD19", // 0b00100001 (33) - U+1CD19 BLOCK OCTANT-16 + "\uD833\uDD1A", // 0b00100010 (34) - U+1CD1A BLOCK OCTANT-26 + "\uD833\uDD1B", // 0b00100011 (35) - U+1CD1B BLOCK OCTANT-126 + "\uD833\uDD1C", // 0b00100100 (36) - U+1CD1C BLOCK OCTANT-36 + "\uD833\uDD1D", // 0b00100101 (37) - U+1CD1D BLOCK OCTANT-136 + "\uD833\uDD1E", // 0b00100110 (38) - U+1CD1E BLOCK OCTANT-236 + "\uD833\uDD1F", // 0b00100111 (39) - U+1CD1F BLOCK OCTANT-1236 + "\uD83E\uDFE7", // 0b00101000 (40) - U+1FBE7 MIDDLE RIGHT ONE QUARTER BLOCK (OCTANT-46) + "\uD833\uDD20", // 0b00101001 (41) - U+1CD20 BLOCK OCTANT-146 + "\uD833\uDD21", // 0b00101010 (42) - U+1CD21 BLOCK OCTANT-246 + "\uD833\uDD22", // 0b00101011 (43) - U+1CD22 BLOCK OCTANT-1246 + "\uD833\uDD23", // 0b00101100 (44) - U+1CD23 BLOCK OCTANT-346 + "\uD833\uDD24", // 0b00101101 (45) - U+1CD24 BLOCK OCTANT-1346 + "\uD833\uDD25", // 0b00101110 (46) - U+1CD25 BLOCK OCTANT-2346 + "\uD833\uDD26", // 0b00101111 (47) - U+1CD26 BLOCK OCTANT-12346 + "\uD833\uDD27", // 0b00110000 (48) - U+1CD27 BLOCK OCTANT-56 + "\uD833\uDD28", // 0b00110001 (49) - U+1CD28 BLOCK OCTANT-156 + "\uD833\uDD29", // 0b00110010 (50) - U+1CD29 BLOCK OCTANT-256 + "\uD833\uDD2A", // 0b00110011 (51) - U+1CD2A BLOCK OCTANT-1256 + "\uD833\uDD2B", // 0b00110100 (52) - U+1CD2B BLOCK OCTANT-356 + "\uD833\uDD2C", // 0b00110101 (53) - U+1CD2C BLOCK OCTANT-1356 + "\uD833\uDD2D", // 0b00110110 (54) - U+1CD2D BLOCK OCTANT-2356 + "\uD833\uDD2E", // 0b00110111 (55) - U+1CD2E BLOCK OCTANT-12356 + "\uD833\uDD2F", // 0b00111000 (56) - U+1CD2F BLOCK OCTANT-456 + "\uD833\uDD30", // 0b00111001 (57) - U+1CD30 BLOCK OCTANT-1456 + "\uD833\uDD31", // 0b00111010 (58) - U+1CD31 BLOCK OCTANT-2456 + "\uD833\uDD32", // 0b00111011 (59) - U+1CD32 BLOCK OCTANT-12456 + "\uD833\uDD33", // 0b00111100 (60) - U+1CD33 BLOCK OCTANT-3456 + "\uD833\uDD34", // 0b00111101 (61) - U+1CD34 BLOCK OCTANT-13456 + "\uD833\uDD35", // 0b00111110 (62) - U+1CD35 BLOCK OCTANT-23456 + "\uD83E\uDF85", // 0b00111111 (63) - U+1FB85 UPPER THREE QUARTERS BLOCK (OCTANT-123456) + "\uD833\uDEA3", // 0b01000000 (64) - U+1CEA3 LEFT HALF LOWER ONE QUARTER BLOCK (OCTANT-7) + "\uD833\uDD36", // 0b01000001 (65) - U+1CD36 BLOCK OCTANT-17 + "\uD833\uDD37", // 0b01000010 (66) - U+1CD37 BLOCK OCTANT-27 + "\uD833\uDD38", // 0b01000011 (67) - U+1CD38 BLOCK OCTANT-127 + "\uD833\uDD39", // 0b01000100 (68) - U+1CD39 BLOCK OCTANT-37 + "\uD833\uDD3A", // 0b01000101 (69) - U+1CD3A BLOCK OCTANT-137 + "\uD833\uDD3B", // 0b01000110 (70) - U+1CD3B BLOCK OCTANT-237 + "\uD833\uDD3C", // 0b01000111 (71) - U+1CD3C BLOCK OCTANT-1237 + "\uD833\uDD3D", // 0b01001000 (72) - U+1CD3D BLOCK OCTANT-47 + "\uD833\uDD3E", // 0b01001001 (73) - U+1CD3E BLOCK OCTANT-147 + "\uD833\uDD3F", // 0b01001010 (74) - U+1CD3F BLOCK OCTANT-247 + "\uD833\uDD40", // 0b01001011 (75) - U+1CD40 BLOCK OCTANT-1247 + "\uD833\uDD41", // 0b01001100 (76) - U+1CD41 BLOCK OCTANT-347 + "\uD833\uDD42", // 0b01001101 (77) - U+1CD42 BLOCK OCTANT-1347 + "\uD833\uDD43", // 0b01001110 (78) - U+1CD43 BLOCK OCTANT-2347 + "\uD833\uDD44", // 0b01001111 (79) - U+1CD44 BLOCK OCTANT-12347 + "▖", // 0b01010000 (80) - U+2596 QUADRANT LOWER LEFT (OCTANT-57) + "\uD833\uDD45", // 0b01010001 (81) - U+1CD45 BLOCK OCTANT-157 + "\uD833\uDD46", // 0b01010010 (82) - U+1CD46 BLOCK OCTANT-257 + "\uD833\uDD47", // 0b01010011 (83) - U+1CD47 BLOCK OCTANT-1257 + "\uD833\uDD48", // 0b01010100 (84) - U+1CD48 BLOCK OCTANT-357 + "▌", // 0b01010101 (85) - U+258C LEFT HALF BLOCK (OCTANT-1357) + "\uD833\uDD49", // 0b01010110 (86) - U+1CD49 BLOCK OCTANT-2357 + "\uD833\uDD4A", // 0b01010111 (87) - U+1CD4A BLOCK OCTANT-12357 + "\uD833\uDD4B", // 0b01011000 (88) - U+1CD4B BLOCK OCTANT-457 + "\uD833\uDD4C", // 0b01011001 (89) - U+1CD4C BLOCK OCTANT-1457 + "▞", // 0b01011010 (90) - U+259E QUADRANT UPPER RIGHT AND LOWER LEFT (OCTANT-2457) + "\uD833\uDD4D", // 0b01011011 (91) - U+1CD4D BLOCK OCTANT-12457 + "\uD833\uDD4E", // 0b01011100 (92) - U+1CD4E BLOCK OCTANT-3457 + "\uD833\uDD4F", // 0b01011101 (93) - U+1CD4F BLOCK OCTANT-13457 + "\uD833\uDD50", // 0b01011110 (94) - U+1CD50 BLOCK OCTANT-23457 + "▛", // 0b01011111 (95) - U+259B QUADRANT UL AND UR AND LL (OCTANT-123457) + "\uD833\uDD51", // 0b01100000 (96) - U+1CD51 BLOCK OCTANT-67 + "\uD833\uDD52", // 0b01100001 (97) - U+1CD52 BLOCK OCTANT-167 + "\uD833\uDD53", // 0b01100010 (98) - U+1CD53 BLOCK OCTANT-267 + "\uD833\uDD54", // 0b01100011 (99) - U+1CD54 BLOCK OCTANT-1267 + "\uD833\uDD55", // 0b01100100 (100) - U+1CD55 BLOCK OCTANT-367 + "\uD833\uDD56", // 0b01100101 (101) - U+1CD56 BLOCK OCTANT-1367 + "\uD833\uDD57", // 0b01100110 (102) - U+1CD57 BLOCK OCTANT-2367 + "\uD833\uDD58", // 0b01100111 (103) - U+1CD58 BLOCK OCTANT-12367 + "\uD833\uDD59", // 0b01101000 (104) - U+1CD59 BLOCK OCTANT-467 + "\uD833\uDD5A", // 0b01101001 (105) - U+1CD5A BLOCK OCTANT-1467 + "\uD833\uDD5B", // 0b01101010 (106) - U+1CD5B BLOCK OCTANT-2467 + "\uD833\uDD5C", // 0b01101011 (107) - U+1CD5C BLOCK OCTANT-12467 + "\uD833\uDD5D", // 0b01101100 (108) - U+1CD5D BLOCK OCTANT-3467 + "\uD833\uDD5E", // 0b01101101 (109) - U+1CD5E BLOCK OCTANT-13467 + "\uD833\uDD5F", // 0b01101110 (110) - U+1CD5F BLOCK OCTANT-23467 + "\uD833\uDD60", // 0b01101111 (111) - U+1CD60 BLOCK OCTANT-123467 + "\uD833\uDD61", // 0b01110000 (112) - U+1CD61 BLOCK OCTANT-567 + "\uD833\uDD62", // 0b01110001 (113) - U+1CD62 BLOCK OCTANT-1567 + "\uD833\uDD63", // 0b01110010 (114) - U+1CD63 BLOCK OCTANT-2567 + "\uD833\uDD64", // 0b01110011 (115) - U+1CD64 BLOCK OCTANT-12567 + "\uD833\uDD65", // 0b01110100 (116) - U+1CD65 BLOCK OCTANT-3567 + "\uD833\uDD66", // 0b01110101 (117) - U+1CD66 BLOCK OCTANT-13567 + "\uD833\uDD67", // 0b01110110 (118) - U+1CD67 BLOCK OCTANT-23567 + "\uD833\uDD68", // 0b01110111 (119) - U+1CD68 BLOCK OCTANT-123567 + "\uD833\uDD69", // 0b01111000 (120) - U+1CD69 BLOCK OCTANT-4567 + "\uD833\uDD6A", // 0b01111001 (121) - U+1CD6A BLOCK OCTANT-14567 + "\uD833\uDD6B", // 0b01111010 (122) - U+1CD6B BLOCK OCTANT-24567 + "\uD833\uDD6C", // 0b01111011 (123) - U+1CD6C BLOCK OCTANT-124567 + "\uD833\uDD6D", // 0b01111100 (124) - U+1CD6D BLOCK OCTANT-34567 + "\uD833\uDD6E", // 0b01111101 (125) - U+1CD6E BLOCK OCTANT-134567 + "\uD833\uDD6F", // 0b01111110 (126) - U+1CD6F BLOCK OCTANT-234567 + "\uD833\uDD70", // 0b01111111 (127) - U+1CD70 BLOCK OCTANT-1234567 + "\uD833\uDEA0", // 0b10000000 (128) - U+1CEA0 RIGHT HALF LOWER ONE QUARTER BLOCK (OCTANT-8) + "\uD833\uDD71", // 0b10000001 (129) - U+1CD71 BLOCK OCTANT-18 + "\uD833\uDD72", // 0b10000010 (130) - U+1CD72 BLOCK OCTANT-28 + "\uD833\uDD73", // 0b10000011 (131) - U+1CD73 BLOCK OCTANT-128 + "\uD833\uDD74", // 0b10000100 (132) - U+1CD74 BLOCK OCTANT-38 + "\uD833\uDD75", // 0b10000101 (133) - U+1CD75 BLOCK OCTANT-138 + "\uD833\uDD76", // 0b10000110 (134) - U+1CD76 BLOCK OCTANT-238 + "\uD833\uDD77", // 0b10000111 (135) - U+1CD77 BLOCK OCTANT-1238 + "\uD833\uDD78", // 0b10001000 (136) - U+1CD78 BLOCK OCTANT-48 + "\uD833\uDD79", // 0b10001001 (137) - U+1CD79 BLOCK OCTANT-148 + "\uD833\uDD7A", // 0b10001010 (138) - U+1CD7A BLOCK OCTANT-248 + "\uD833\uDD7B", // 0b10001011 (139) - U+1CD7B BLOCK OCTANT-1248 + "\uD833\uDD7C", // 0b10001100 (140) - U+1CD7C BLOCK OCTANT-348 + "\uD833\uDD7D", // 0b10001101 (141) - U+1CD7D BLOCK OCTANT-1348 + "\uD833\uDD7E", // 0b10001110 (142) - U+1CD7E BLOCK OCTANT-2348 + "\uD833\uDD7F", // 0b10001111 (143) - U+1CD7F BLOCK OCTANT-12348 + "\uD833\uDD80", // 0b10010000 (144) - U+1CD80 BLOCK OCTANT-58 + "\uD833\uDD81", // 0b10010001 (145) - U+1CD81 BLOCK OCTANT-158 + "\uD833\uDD82", // 0b10010010 (146) - U+1CD82 BLOCK OCTANT-258 + "\uD833\uDD83", // 0b10010011 (147) - U+1CD83 BLOCK OCTANT-1258 + "\uD833\uDD84", // 0b10010100 (148) - U+1CD84 BLOCK OCTANT-358 + "\uD833\uDD85", // 0b10010101 (149) - U+1CD85 BLOCK OCTANT-1358 + "\uD833\uDD86", // 0b10010110 (150) - U+1CD86 BLOCK OCTANT-2358 + "\uD833\uDD87", // 0b10010111 (151) - U+1CD87 BLOCK OCTANT-12358 + "\uD833\uDD88", // 0b10011000 (152) - U+1CD88 BLOCK OCTANT-458 + "\uD833\uDD89", // 0b10011001 (153) - U+1CD89 BLOCK OCTANT-1458 + "\uD833\uDD8A", // 0b10011010 (154) - U+1CD8A BLOCK OCTANT-2458 + "\uD833\uDD8B", // 0b10011011 (155) - U+1CD8B BLOCK OCTANT-12458 + "\uD833\uDD8C", // 0b10011100 (156) - U+1CD8C BLOCK OCTANT-3458 + "\uD833\uDD8D", // 0b10011101 (157) - U+1CD8D BLOCK OCTANT-13458 + "\uD833\uDD8E", // 0b10011110 (158) - U+1CD8E BLOCK OCTANT-23458 + "\uD833\uDD8F", // 0b10011111 (159) - U+1CD8F BLOCK OCTANT-123458 + "▗", // 0b10100000 (160) - U+2597 QUADRANT LOWER RIGHT (OCTANT-68) + "\uD833\uDD90", // 0b10100001 (161) - U+1CD90 BLOCK OCTANT-168 + "\uD833\uDD91", // 0b10100010 (162) - U+1CD91 BLOCK OCTANT-268 + "\uD833\uDD92", // 0b10100011 (163) - U+1CD92 BLOCK OCTANT-1268 + "\uD833\uDD93", // 0b10100100 (164) - U+1CD93 BLOCK OCTANT-368 + "▚", // 0b10100101 (165) - U+259A QUADRANT UPPER LEFT AND LOWER RIGHT (OCTANT-1368) + "\uD833\uDD94", // 0b10100110 (166) - U+1CD94 BLOCK OCTANT-2368 + "\uD833\uDD95", // 0b10100111 (167) - U+1CD95 BLOCK OCTANT-12368 + "\uD833\uDD96", // 0b10101000 (168) - U+1CD96 BLOCK OCTANT-468 + "\uD833\uDD97", // 0b10101001 (169) - U+1CD97 BLOCK OCTANT-1468 + "▐", // 0b10101010 (170) - U+2590 RIGHT HALF BLOCK (OCTANT-2468) + "\uD833\uDD98", // 0b10101011 (171) - U+1CD98 BLOCK OCTANT-12468 + "\uD833\uDD99", // 0b10101100 (172) - U+1CD99 BLOCK OCTANT-3468 + "\uD833\uDD9A", // 0b10101101 (173) - U+1CD9A BLOCK OCTANT-13468 + "\uD833\uDD9B", // 0b10101110 (174) - U+1CD9B BLOCK OCTANT-23468 + "▜", // 0b10101111 (175) - U+259C QUADRANT UL AND UR AND LR (OCTANT-123468) + "\uD833\uDD9C", // 0b10110000 (176) - U+1CD9C BLOCK OCTANT-568 + "\uD833\uDD9D", // 0b10110001 (177) - U+1CD9D BLOCK OCTANT-1568 + "\uD833\uDD9E", // 0b10110010 (178) - U+1CD9E BLOCK OCTANT-2568 + "\uD833\uDD9F", // 0b10110011 (179) - U+1CD9F BLOCK OCTANT-12568 + "\uD833\uDDA0", // 0b10110100 (180) - U+1CDA0 BLOCK OCTANT-3568 + "\uD833\uDDA1", // 0b10110101 (181) - U+1CDA1 BLOCK OCTANT-13568 + "\uD833\uDDA2", // 0b10110110 (182) - U+1CDA2 BLOCK OCTANT-23568 + "\uD833\uDDA3", // 0b10110111 (183) - U+1CDA3 BLOCK OCTANT-123568 + "\uD833\uDDA4", // 0b10111000 (184) - U+1CDA4 BLOCK OCTANT-4568 + "\uD833\uDDA5", // 0b10111001 (185) - U+1CDA5 BLOCK OCTANT-14568 + "\uD833\uDDA6", // 0b10111010 (186) - U+1CDA6 BLOCK OCTANT-24568 + "\uD833\uDDA7", // 0b10111011 (187) - U+1CDA7 BLOCK OCTANT-124568 + "\uD833\uDDA8", // 0b10111100 (188) - U+1CDA8 BLOCK OCTANT-34568 + "\uD833\uDDA9", // 0b10111101 (189) - U+1CDA9 BLOCK OCTANT-134568 + "\uD833\uDDAA", // 0b10111110 (190) - U+1CDAA BLOCK OCTANT-234568 + "\uD833\uDDAB", // 0b10111111 (191) - U+1CDAB BLOCK OCTANT-1234568 + "▂", // 0b11000000 (192) - U+2582 LOWER ONE QUARTER BLOCK (OCTANT-78) + "\uD833\uDDAC", // 0b11000001 (193) - U+1CDAC BLOCK OCTANT-178 + "\uD833\uDDAD", // 0b11000010 (194) - U+1CDAD BLOCK OCTANT-278 + "\uD833\uDDAE", // 0b11000011 (195) - U+1CDAE BLOCK OCTANT-1278 + "\uD833\uDDAF", // 0b11000100 (196) - U+1CDAF BLOCK OCTANT-378 + "\uD833\uDDB0", // 0b11000101 (197) - U+1CDB0 BLOCK OCTANT-1378 + "\uD833\uDDB1", // 0b11000110 (198) - U+1CDB1 BLOCK OCTANT-2378 + "\uD833\uDDB2", // 0b11000111 (199) - U+1CDB2 BLOCK OCTANT-12378 + "\uD833\uDDB3", // 0b11001000 (200) - U+1CDB3 BLOCK OCTANT-478 + "\uD833\uDDB4", // 0b11001001 (201) - U+1CDB4 BLOCK OCTANT-1478 + "\uD833\uDDB5", // 0b11001010 (202) - U+1CDB5 BLOCK OCTANT-2478 + "\uD833\uDDB6", // 0b11001011 (203) - U+1CDB6 BLOCK OCTANT-12478 + "\uD833\uDDB7", // 0b11001100 (204) - U+1CDB7 BLOCK OCTANT-3478 + "\uD833\uDDB8", // 0b11001101 (205) - U+1CDB8 BLOCK OCTANT-13478 + "\uD833\uDDB9", // 0b11001110 (206) - U+1CDB9 BLOCK OCTANT-23478 + "\uD833\uDDBA", // 0b11001111 (207) - U+1CDBA BLOCK OCTANT-123478 + "\uD833\uDDBB", // 0b11010000 (208) - U+1CDBB BLOCK OCTANT-578 + "\uD833\uDDBC", // 0b11010001 (209) - U+1CDBC BLOCK OCTANT-1578 + "\uD833\uDDBD", // 0b11010010 (210) - U+1CDBD BLOCK OCTANT-2578 + "\uD833\uDDBE", // 0b11010011 (211) - U+1CDBE BLOCK OCTANT-12578 + "\uD833\uDDBF", // 0b11010100 (212) - U+1CDBF BLOCK OCTANT-3578 + "\uD833\uDDC0", // 0b11010101 (213) - U+1CDC0 BLOCK OCTANT-13578 + "\uD833\uDDC1", // 0b11010110 (214) - U+1CDC1 BLOCK OCTANT-23578 + "\uD833\uDDC2", // 0b11010111 (215) - U+1CDC2 BLOCK OCTANT-123578 + "\uD833\uDDC3", // 0b11011000 (216) - U+1CDC3 BLOCK OCTANT-4578 + "\uD833\uDDC4", // 0b11011001 (217) - U+1CDC4 BLOCK OCTANT-14578 + "\uD833\uDDC5", // 0b11011010 (218) - U+1CDC5 BLOCK OCTANT-24578 + "\uD833\uDDC6", // 0b11011011 (219) - U+1CDC6 BLOCK OCTANT-124578 + "\uD833\uDDC7", // 0b11011100 (220) - U+1CDC7 BLOCK OCTANT-34578 + "\uD833\uDDC8", // 0b11011101 (221) - U+1CDC8 BLOCK OCTANT-134578 + "\uD833\uDDC9", // 0b11011110 (222) - U+1CDC9 BLOCK OCTANT-234578 + "\uD833\uDDCA", // 0b11011111 (223) - U+1CDCA BLOCK OCTANT-1234578 + "\uD833\uDDCB", // 0b11100000 (224) - U+1CDCB BLOCK OCTANT-678 + "\uD833\uDDCC", // 0b11100001 (225) - U+1CDCC BLOCK OCTANT-1678 + "\uD833\uDDCD", // 0b11100010 (226) - U+1CDCD BLOCK OCTANT-2678 + "\uD833\uDDCE", // 0b11100011 (227) - U+1CDCE BLOCK OCTANT-12678 + "\uD833\uDDCF", // 0b11100100 (228) - U+1CDCF BLOCK OCTANT-3678 + "\uD833\uDDD0", // 0b11100101 (229) - U+1CDD0 BLOCK OCTANT-13678 + "\uD833\uDDD1", // 0b11100110 (230) - U+1CDD1 BLOCK OCTANT-23678 + "\uD833\uDDD2", // 0b11100111 (231) - U+1CDD2 BLOCK OCTANT-123678 + "\uD833\uDDD3", // 0b11101000 (232) - U+1CDD3 BLOCK OCTANT-4678 + "\uD833\uDDD4", // 0b11101001 (233) - U+1CDD4 BLOCK OCTANT-14678 + "\uD833\uDDD5", // 0b11101010 (234) - U+1CDD5 BLOCK OCTANT-24678 + "\uD833\uDDD6", // 0b11101011 (235) - U+1CDD6 BLOCK OCTANT-124678 + "\uD833\uDDD7", // 0b11101100 (236) - U+1CDD7 BLOCK OCTANT-34678 + "\uD833\uDDD8", // 0b11101101 (237) - U+1CDD8 BLOCK OCTANT-134678 + "\uD833\uDDD9", // 0b11101110 (238) - U+1CDD9 BLOCK OCTANT-234678 + "\uD833\uDDDA", // 0b11101111 (239) - U+1CDDA BLOCK OCTANT-1234678 + "▄", // 0b11110000 (240) - U+2584 LOWER HALF BLOCK (OCTANT-5678) + "\uD833\uDDDB", // 0b11110001 (241) - U+1CDDB BLOCK OCTANT-15678 + "\uD833\uDDDC", // 0b11110010 (242) - U+1CDDC BLOCK OCTANT-25678 + "\uD833\uDDDD", // 0b11110011 (243) - U+1CDDD BLOCK OCTANT-125678 + "\uD833\uDDDE", // 0b11110100 (244) - U+1CDDE BLOCK OCTANT-35678 + "▙", // 0b11110101 (245) - U+2599 QUADRANT UL AND LL AND LR (OCTANT-135678) + "\uD833\uDDDF", // 0b11110110 (246) - U+1CDDF BLOCK OCTANT-235678 + "\uD833\uDDE0", // 0b11110111 (247) - U+1CDE0 BLOCK OCTANT-1235678 + "\uD833\uDDE1", // 0b11111000 (248) - U+1CDE1 BLOCK OCTANT-45678 + "\uD833\uDDE2", // 0b11111001 (249) - U+1CDE2 BLOCK OCTANT-145678 + "▟", // 0b11111010 (250) - U+259F QUADRANT UR AND LL AND LR (OCTANT-245678) + "\uD833\uDDE3", // 0b11111011 (251) - U+1CDE3 BLOCK OCTANT-1245678 + "▆", // 0b11111100 (252) - U+2586 LOWER THREE QUARTERS BLOCK (OCTANT-345678) + "\uD833\uDDE4", // 0b11111101 (253) - U+1CDE4 BLOCK OCTANT-1345678 + "\uD833\uDDE5", // 0b11111110 (254) - U+1CDE5 BLOCK OCTANT-2345678 + "█" // 0b11111111 (255) - U+2588 FULL BLOCK (OCTANT-12345678) + }; + + /** + * 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: + return OCTANT_BLOCKS[pattern & 0xFF]; + 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/image/src/main/java/org/codejive/miniterm/image/impl/ITermEncoder.java b/image/src/main/java/org/codejive/miniterm/image/impl/ITermEncoder.java new file mode 100644 index 0000000..ef9bf80 --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/impl/ITermEncoder.java @@ -0,0 +1,188 @@ +package org.codejive.miniterm.image.impl; + +import static org.codejive.miniterm.ansiparser.Ansi.OSC; +import static org.codejive.miniterm.ansiparser.Ansi.OSC_BEL; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import javax.imageio.ImageIO; +import org.codejive.miniterm.image.ImageEncoder; +import org.codejive.miniterm.image.util.FontSize; +import org.codejive.miniterm.image.util.ImageUtils; +import org.codejive.miniterm.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(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(OSC_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/image/src/main/java/org/codejive/miniterm/image/impl/KittyEncoder.java b/image/src/main/java/org/codejive/miniterm/image/impl/KittyEncoder.java new file mode 100644 index 0000000..c92fec7 --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/impl/KittyEncoder.java @@ -0,0 +1,217 @@ +package org.codejive.miniterm.image.impl; + +import static org.codejive.miniterm.ansiparser.Ansi.ESC; +import static org.codejive.miniterm.ansiparser.Ansi.OSC_ST; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import javax.imageio.ImageIO; +import org.codejive.miniterm.image.ImageEncoder; +import org.codejive.miniterm.image.util.FontSize; +import org.codejive.miniterm.image.util.ImageUtils; +import org.codejive.miniterm.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[control-data];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 = 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(OSC_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/image/src/main/java/org/codejive/miniterm/image/impl/SixelEncoder.java b/image/src/main/java/org/codejive/miniterm/image/impl/SixelEncoder.java new file mode 100644 index 0000000..2e3188b --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/impl/SixelEncoder.java @@ -0,0 +1,287 @@ +package org.codejive.miniterm.image.impl; + +import static org.codejive.miniterm.ansiparser.Ansi.ESC; +import static org.codejive.miniterm.ansiparser.Ansi.OSC_ST; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.List; +import org.codejive.miniterm.image.ImageEncoder; +import org.codejive.miniterm.image.util.ColorQuantizer; +import org.codejive.miniterm.image.util.FontSize; +import org.codejive.miniterm.image.util.ImageUtils; +import org.codejive.miniterm.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 = 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(OSC_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/image/src/main/java/org/codejive/miniterm/image/util/AnsiUtils.java b/image/src/main/java/org/codejive/miniterm/image/util/AnsiUtils.java new file mode 100644 index 0000000..2a32f55 --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/util/AnsiUtils.java @@ -0,0 +1,16 @@ +package org.codejive.miniterm.image.util; + +import static org.codejive.miniterm.ansiparser.Ansi.CSI; + +public class AnsiUtils { + + 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/image/src/main/java/org/codejive/miniterm/image/util/ColorQuantizer.java b/image/src/main/java/org/codejive/miniterm/image/util/ColorQuantizer.java new file mode 100644 index 0000000..0f4f5f6 --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/util/ColorQuantizer.java @@ -0,0 +1,324 @@ +package org.codejive.miniterm.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/image/src/main/java/org/codejive/miniterm/image/util/FontSize.java b/image/src/main/java/org/codejive/miniterm/image/util/FontSize.java new file mode 100644 index 0000000..f8b9732 --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/util/FontSize.java @@ -0,0 +1,19 @@ +package org.codejive.miniterm.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/image/src/main/java/org/codejive/miniterm/image/util/ImageUtils.java b/image/src/main/java/org/codejive/miniterm/image/util/ImageUtils.java new file mode 100644 index 0000000..18f66c3 --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/util/ImageUtils.java @@ -0,0 +1,88 @@ +package org.codejive.miniterm.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/image/src/main/java/org/codejive/miniterm/image/util/Resolution.java b/image/src/main/java/org/codejive/miniterm/image/util/Resolution.java new file mode 100644 index 0000000..0d8805a --- /dev/null +++ b/image/src/main/java/org/codejive/miniterm/image/util/Resolution.java @@ -0,0 +1,16 @@ +package org.codejive.miniterm.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); + } +} diff --git a/pom.xml b/pom.xml index 0f082ab..7cebea4 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,7 @@ mousetrack termcap colors + image