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 0000000..70d5fed Binary files /dev/null and b/examples/duke.jpg differ diff --git a/image/README.md b/image/README.md new file mode 100644 index 0000000..98de779 --- /dev/null +++ b/image/README.md @@ -0,0 +1,102 @@ +# image + +`image` is a Java 8+ terminal image rendering utility, part of the [java-miniterm](../README.md) project. + +It can render `BufferedImage` objects to terminal graphics protocols such as Kitty, iTerm2, and Sixel, with a Unicode block fallback for terminals that do not support a native graphics protocol. The module automatically detects the best protocol for the current terminal and exposes a small, configurable encoder API. + +## Usage + +The simplest entry point is `ImageEncoders.best()`, which picks the highest-priority provider currently supported by the environment: + +```java +import java.awt.image.BufferedImage; +import javax.imageio.ImageIO; +import org.codejive.miniterm.Terminal; +import org.codejive.miniterm.image.ImageEncoder; +import org.codejive.miniterm.image.ImageEncoders; + +BufferedImage image = ImageIO.read(new java.io.File("photo.png")); +ImageEncoder encoder = ImageEncoders.best().create(image, 40, 12, false); + +try (Terminal terminal = Terminal.create()) { + encoder.render(terminal); +} +``` + +`ImageEncoder` is stateful: the image and initial target size are fixed when the encoder is created, while the output size and fit mode can be adjusted afterwards. + +```java +encoder.targetSize(60, 20).fitImage(true); +encoder.render(terminal); +``` + +### Detect supported providers + +```java +for (ImageEncoder.Provider provider : ImageEncoders.supportedProviders()) { + System.out.println(provider.name() + " -> " + 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