Skip to content

Punycode Converter — Java source

Convert internationalized domain names (IDN) between Unicode and Punycode (xn--) ACE form. RFC 3492 compliant, runs entirely in your browser, with a shareable link to your exact input.

This is the Java implementation — the same logic the interactive tool runs, in a shareable, citable form.

// punycode - RFC 3492 Punycode encode/decode + IDNA2003 toASCII/toUnicode.
//
// Language:   Java (11+, standard library only)
// Source:     CosmoDev polyglot showcase port of the Punycode tool, ported from
//             src/lib/punycode.ts (the canonical TypeScript implementation) and
//             cli/punycode/punycode.go (the live Go CLI twin).
// License:    display source - part of CosmoDev's polyglot tool pages.
//
// Design goals:
//   - Pure + deterministic; encode never fails, decode returns null (or
//     Optional.empty) on malformed input.
//   - Functionally equivalent to the TS/Go reference: same inputs -> same
//     outputs.
//   - Self-contained: stdlib only. String.codePoints() yields the code
//     points, so astral characters (emoji, CJK extensions) are single
//     elements, matching Go runes and the TS code-point iteration; long
//     carries the RFC 3492 arithmetic with the 2^53-1
//     (Number.MAX_SAFE_INTEGER) overflow guard; code points beyond U+10FFFF
//     reject the label.

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

public final class Punycode {

    private static final int BASE = 36;
    private static final int TMIN = 1;
    private static final int TMAX = 26;
    private static final int SKEW = 38;
    private static final int DAMP = 700;
    private static final int INITIAL_BIAS = 72;
    private static final int INITIAL_N = 128;
    private static final String ACE_PREFIX = "xn--";
    private static final long MAX_INT = 9007199254740991L; // 2^53-1 overflow guard

    private Punycode() {}

    /** Bias adaptation (RFC 3492 section 6.1). */
    private static long adapt(long delta, long numpoints, boolean firsttime) {
        long d = firsttime ? delta / DAMP : delta / 2;
        d += d / numpoints;
        long k = 0;
        while (d > (BASE - TMIN) * TMAX / 2) {
            d /= BASE - TMIN;
            k += BASE;
        }
        return k + (BASE - TMIN + 1) * d / (d + SKEW);
    }

    /** Map a digit value (0-35) to its base-36 character (lowercase). */
    private static char digitToChar(long d) {
        return d < 26 ? (char) ('a' + d) : (char) ('0' + (d - 26));
    }

    /** Map a character to its digit value (0-35), case-insensitive, or -1 when invalid. */
    private static long charToDigit(char c) {
        if (c >= 'a' && c <= 'z') return c - 'a';
        if (c >= 'A' && c <= 'Z') return c - 'A';
        if (c >= '0' && c <= '9') return c - '0' + 26;
        return -1;
    }

    /** True if the string contains any non-ASCII code point (>= 128). */
    private static boolean hasNonAscii(String s) {
        for (int i = 0; i < s.length(); i++)
            if (s.charAt(i) >= 128) return true; // surrogates (astral chars) are >= 128
        return false;
    }

    /**
     * Punycode-encode a single label (RFC 3492), no ACE prefix. Basic code
     * points are emitted first, then a '-' delimiter (only if there was at
     * least one), then the generalized-base-36 deltas.
     */
    public static String encodeLabel(String input) {
        int[] cps = input.codePoints().toArray(); // code points: astral chars are one element
        long length = cps.length;

        StringBuilder output = new StringBuilder();
        long b = 0;
        for (int cp : cps)
            if (cp < 128) {
                output.append((char) cp);
                b++;
            }
        if (b > 0) output.append('-');

        long n = INITIAL_N, delta = 0, bias = INITIAL_BIAS, h = b;
        while (h < length) {
            long m = Long.MAX_VALUE; // smallest code point in the input that is >= n
            for (int cp : cps)
                if (cp >= n && cp < m) m = cp;
            delta += (m - n) * (h + 1);
            n = m;
            for (int cp : cps) {
                if (cp < n) {
                    delta += 1;
                } else if (cp == n) {
                    long q = delta;
                    for (long k = BASE; ; k += BASE) {
                        long t = Math.max(TMIN, Math.min(TMAX, k - bias));
                        if (q < t) break;
                        output.append(digitToChar(t + (q - t) % (BASE - t)));
                        q = (q - t) / (BASE - t);
                    }
                    output.append(digitToChar(q));
                    bias = adapt(delta, h + 1, h == b);
                    delta = 0;
                    h += 1;
                }
            }
            delta += 1;
            n += 1;
        }

        return output.toString();
    }

    /**
     * Punycode-decode a single label (RFC 3492). Returns null when the input
     * is malformed (invalid digit, truncated generalized number, non-ASCII in
     * the basic portion, code point beyond U+10FFFF, or overflow).
     */
    public static String decodeLabel(String input) {
        int lastDash = input.lastIndexOf('-');
        List<Integer> output = new ArrayList<>();
        if (lastDash >= 0) {
            for (int i = 0; i < lastDash; i++) {
                if (input.charAt(i) >= 128) return null; // basic portion must be ASCII
                output.add((int) input.charAt(i));
            }
        }
        String ext = lastDash >= 0 ? input.substring(lastDash + 1) : input;

        long n = INITIAL_N, i = 0, bias = INITIAL_BIAS, pos = 0;
        while (pos < ext.length()) {
            long oldi = i, w = 1;
            for (long k = BASE; ; k += BASE) {
                if (pos >= ext.length()) return null; // truncated generalized number
                long digit = charToDigit(ext.charAt((int) pos));
                if (digit < 0) return null; // invalid digit
                pos += 1;
                if (digit >= MAX_INT / w) return null; // overflow guard
                i += digit * w;
                long t = Math.max(TMIN, Math.min(TMAX, k - bias));
                if (digit < t) break;
                w *= BASE - t;
            }
            bias = adapt(i - oldi, output.size() + 1, oldi == 0);
            long outLen = output.size() + 1;
            n += i / outLen;
            i %= outLen;
            if (n > 0x10FFFF) return null;
            output.add((int) i, (int) n);
            i += 1;
        }

        StringBuilder sb = new StringBuilder();
        for (int cp : output) sb.appendCodePoint(cp);
        return sb.toString();
    }

    /** Split on '.', keeping empty labels (including a trailing one). */
    private static String[] splitLabels(String s) {
        List<String> labels = new ArrayList<>();
        StringBuilder cur = new StringBuilder();
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == '.') {
                labels.add(cur.toString());
                cur.setLength(0);
            } else {
                cur.append(c);
            }
        }
        labels.add(cur.toString());
        return labels.toArray(new String[0]);
    }

    /**
     * IDNA toASCII: lowercase the domain, ACE-encode ("xn--" + Punycode) any
     * label containing a non-ASCII code point, leave ASCII-only labels
     * untouched. Empty input returns empty.
     */
    public static String encode(String domain) {
        if (domain.isEmpty()) return "";
        String lower = domain.toLowerCase(Locale.ROOT);
        String[] labels = splitLabels(lower);
        StringBuilder out = new StringBuilder();
        for (int i = 0; i < labels.length; i++) {
            if (i > 0) out.append('.');
            out.append(hasNonAscii(labels[i]) ? ACE_PREFIX + encodeLabel(labels[i]) : labels[i]);
        }
        return out.toString();
    }

    /**
     * IDNA toUnicode: decode any "xn--" label (case-insensitive, prefix
     * detected on the lowercased label), leave every other label untouched.
     * Returns null when any "xn--" label is invalid - the whole domain is
     * rejected, matching IDNA semantics. Empty input returns empty.
     */
    public static String decode(String domain) {
        if (domain.isEmpty()) return "";
        String[] labels = splitLabels(domain);
        StringBuilder out = new StringBuilder();
        for (int i = 0; i < labels.length; i++) {
            String label = labels[i];
            if (i > 0) out.append('.');
            if (label.toLowerCase(Locale.ROOT).startsWith(ACE_PREFIX) && label.length() > ACE_PREFIX.length()) {
                String decoded = decodeLabel(label.substring(ACE_PREFIX.length()));
                if (decoded == null) return null;
                out.append(decoded);
            } else {
                out.append(label);
            }
        }
        return out.toString();
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →