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 →