# 絵文字処理の実装メモ
# 方針
今回の実装では、絵文字を次の 3 系統で描き分けています。
- 単純絵文字
- 複雑絵文字
- 複雑絵文字のフォールバック
結論から言うと、現在の分岐は次の通りです。
- 単純絵文字:
Noto EmojiをgetTextPath()で白黒 path 描画する
- 複雑絵文字:
- まず
emoji2のEmojiSpanをオフスクリーンBitmapに描く - その結果を白黒マスク化して PDF に貼る
- まず
emoji2が使えない、または span 化できない場合:Noto Emojiを使って shaping 済み glyph をBitmapに描く
- それでも無理な場合:
- system emoji にフォールバックする
この方針にした理由は次の通りです。
PdfDocumentのCanvas.drawText()では😶🌫️のような ZWJ 絵文字が分離しやすいgetTextPath()は単純絵文字には有効だが、複雑絵文字には弱いemoji2は複雑絵文字を 1 つの見た目として描ける- ただしカラーのままでは困るので、
Bitmap化してから白黒化している
# 全体の流れ
drawTextWithEmojiSupport(...) が入口です。
- テキストを
buildTextRuns(...)で分割する - 各 run が通常文字か絵文字かを判定する
- 絵文字なら:
- 複雑絵文字は
Bitmap経路を試す - 単純絵文字は
Path経路で描く
- 複雑絵文字は
Bitmap経路が失敗した複雑絵文字だけ system emoji にフォールバックする
# 主要メソッド
# 1. テキスト描画の入口
このメソッドが、文字列全体を run 単位に分解して描画します。
private void drawTextWithEmojiSupport(
Canvas canvas,
Paint paint,
String text,
float x,
float y,
Typeface baseTypeface,
Typeface emojiTypeface
) {
if (TextUtils.isEmpty(text)) {
return;
}
float currentX = x;
for (TextRun run : buildTextRuns(text)) {
currentX += drawTextRun(
canvas,
paint,
run,
currentX,
y,
baseTypeface,
emojiTypeface
);
}
}
ポイント:
buildTextRuns(...)が「通常文字」と「絵文字」を分ける- 実際の描画戦略は
drawTextRun(...)に寄せている
# 2. 1 run ごとの描画
描画の分岐を 1 箇所に集約したメソッドです。
private float drawTextRun(
Canvas canvas,
Paint paint,
TextRun run,
float x,
float y,
Typeface baseTypeface,
Typeface emojiTypeface
) {
if (!run.isEmoji) {
paint.setTypeface(baseTypeface);
canvas.drawText(run.text, x, y, paint);
return paint.measureText(run.text);
}
EmojiBitmapAsset bitmapAsset = resolveComplexEmojiBitmapAsset(
paint,
run,
emojiTypeface
);
if (bitmapAsset != null) {
return drawEmojiBitmap(canvas, paint, bitmapAsset, x, y);
}
if (run.isComplexEmoji) {
drawSystemEmoji(canvas, paint, run.text, x, y);
return measureSystemEmojiWidth(paint, run.text);
}
drawEmojiPath(canvas, paint, run.text, x, y, emojiTypeface);
return measureEmojiPathWidth(paint, run.text, emojiTypeface);
}
ポイント:
- 通常文字は通常の
drawText() - 複雑絵文字はまず
resolveComplexEmojiBitmapAsset(...) Bitmap化に失敗した複雑絵文字だけ system emoji- 単純絵文字は
drawEmojiPath(...)
# 3. 1 run ごとの幅計測
描画と同じ分岐を計測側にも合わせています。
private float measureTextRunWidth(
Paint paint,
TextRun run,
Typeface baseTypeface,
Typeface emojiTypeface
) {
if (!run.isEmoji) {
paint.setTypeface(baseTypeface);
return paint.measureText(run.text);
}
EmojiBitmapAsset bitmapAsset = resolveComplexEmojiBitmapAsset(
paint,
run,
emojiTypeface
);
if (bitmapAsset != null) {
return measureEmojiBitmapWidth(paint, bitmapAsset);
}
return run.isComplexEmoji
? measureSystemEmojiWidth(paint, run.text)
: measureEmojiPathWidth(paint, run.text, emojiTypeface);
}
ポイント:
- レイアウト崩れを防ぐため、描画と同じ戦略で幅を取っている
# 4. 複雑絵文字の Bitmap 解決
複雑絵文字のときだけ Bitmap 化を試し、結果をキャッシュします。
private EmojiBitmapAsset resolveComplexEmojiBitmapAsset(
Paint paint,
TextRun run,
Typeface emojiTypeface
) {
if (!run.isComplexEmoji) {
return null;
}
String cacheKey = buildEmojiBitmapCacheKey(run.text, paint.getTextSize());
EmojiBitmapAsset cachedAsset = complexEmojiBitmapCache.get(cacheKey);
if (cachedAsset != null) {
return cachedAsset;
}
EmojiBitmapAsset renderedAsset;
try {
renderedAsset = renderComplexEmojiBitmap(
paint,
run.text,
emojiTypeface
);
} catch (RuntimeException e) {
e.printStackTrace();
return null;
}
if (renderedAsset != null) {
complexEmojiBitmapCache.put(cacheKey, renderedAsset);
}
return renderedAsset;
}
ポイント:
- 複雑絵文字だけ
Bitmap経路に入れる text + textSize単位でキャッシュする- 例外時は落とさず
nullを返し、後段でフォールバックする
# 5. 複雑絵文字の描画戦略
複雑絵文字をどう Bitmap 化するかの中核です。
private EmojiBitmapAsset renderComplexEmojiBitmap(
Paint paint,
String emojiText,
Typeface emojiTypeface
) {
// Prefer emoji2 for complex sequence composition, and only fall back to Noto Emoji
// shaping when emoji2 does not produce a drawable span.
EmojiBitmapAsset emojiSpanAsset = renderEmojiSpanBitmap(paint, emojiText);
if (emojiSpanAsset != null) {
return emojiSpanAsset;
}
Paint emojiPaint = new Paint(paint);
emojiPaint.setTypeface(emojiTypeface);
emojiPaint.setStyle(Paint.Style.FILL);
emojiPaint.setColor(Color.BLACK);
emojiPaint.setSubpixelText(true);
emojiPaint.setLinearText(true);
Paint.FontMetrics metrics = emojiPaint.getFontMetrics();
float logicalWidth = emojiPaint.measureText(emojiText);
float logicalHeight = metrics.descent - metrics.ascent;
if (logicalWidth <= 0.0f || logicalHeight <= 0.0f) {
return null;
}
int bitmapWidth = Math.max(
1,
(int) Math.ceil((logicalWidth + EMOJI_BITMAP_PADDING * 2.0f) * EMOJI_BITMAP_RENDER_SCALE)
);
int bitmapHeight = Math.max(
1,
(int) Math.ceil((logicalHeight + EMOJI_BITMAP_PADDING * 2.0f) * EMOJI_BITMAP_RENDER_SCALE)
);
Bitmap bitmap = Bitmap.createBitmap(
bitmapWidth,
bitmapHeight,
Bitmap.Config.ARGB_8888
);
Canvas bitmapCanvas = new Canvas(bitmap);
bitmapCanvas.scale(EMOJI_BITMAP_RENDER_SCALE, EMOJI_BITMAP_RENDER_SCALE);
float drawX = EMOJI_BITMAP_PADDING;
float drawY = EMOJI_BITMAP_PADDING - metrics.ascent;
boolean rendered = false;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
rendered = drawShapedEmojiToBitmap(
bitmapCanvas,
emojiText,
drawX,
drawY,
emojiPaint
);
}
if (!rendered) {
bitmapCanvas.drawText(emojiText, drawX, drawY, emojiPaint);
}
return cropBitmapToEmojiAsset(bitmap, COMPLEX_EMOJI_BITMAP_SCALE);
}
ポイント:
- 第一候補は
emoji2 - 失敗時だけ
Noto Emojishaping 経路 - それもだめならさらに後段で system emoji に落ちる
# 6. emoji2 の EmojiSpan を Bitmap 化
複雑絵文字を 1 つの見た目として描くための第一候補です。
private EmojiBitmapAsset renderEmojiSpanBitmap(Paint paint, String emojiText) {
CharSequence processedText = processEmojiText(emojiText);
if (!(processedText instanceof Spanned)) {
return null;
}
Spanned spanned = (Spanned) processedText;
EmojiSpan[] spans = spanned.getSpans(0, spanned.length(), EmojiSpan.class);
if (spans.length == 0) {
return null;
}
EmojiSpan emojiSpan = spans[0];
int start = spanned.getSpanStart(emojiSpan);
int end = spanned.getSpanEnd(emojiSpan);
if (start < 0 || end <= start) {
return null;
}
Paint spanPaint = new Paint(paint);
spanPaint.setAntiAlias(true);
spanPaint.setFilterBitmap(true);
spanPaint.setDither(true);
Paint.FontMetricsInt fm = spanPaint.getFontMetricsInt();
int emojiWidth = emojiSpan.getSize(spanPaint, spanned, start, end, fm);
int logicalWidth = Math.max(1, emojiWidth);
int logicalHeight = Math.max(1, fm.descent - fm.ascent);
Bitmap bitmap = Bitmap.createBitmap(
Math.max(1, logicalWidth * EMOJI_BITMAP_RENDER_SCALE),
Math.max(1, logicalHeight * EMOJI_BITMAP_RENDER_SCALE),
Bitmap.Config.ARGB_8888
);
Canvas bitmapCanvas = new Canvas(bitmap);
bitmapCanvas.scale(EMOJI_BITMAP_RENDER_SCALE, EMOJI_BITMAP_RENDER_SCALE);
Paint scaledSpanPaint = new Paint(spanPaint);
scaledSpanPaint.setTextSize(spanPaint.getTextSize());
int localTop = 0;
int localBaseline = -fm.ascent;
int localBottom = logicalHeight;
emojiSpan.draw(
bitmapCanvas,
spanned,
start,
end,
0.0f,
localTop,
localBaseline,
localBottom,
scaledSpanPaint
);
convertBitmapToBlackStrokeMask(bitmap);
return cropBitmapToEmojiAsset(bitmap, COMPLEX_EMOJI_BITMAP_SCALE);
}
ポイント:
emoji2が生成したEmojiSpanをそのまま利用する- 一度
Bitmapに描いてから白黒変換する - これが現在の
😶🌫️対応の主経路
# 7. shaping 済み glyph を Bitmap に描くフォールバック
emoji2 で span 化できなかった場合の第二候補です。
private boolean drawShapedEmojiToBitmap(
Canvas canvas,
String emojiText,
float drawX,
float drawY,
Paint paint
) {
PositionedGlyphs glyphs = TextRunShaper.shapeTextRun(
emojiText,
0,
emojiText.length(),
0,
emojiText.length(),
drawX,
drawY,
false,
paint
);
if (glyphs.glyphCount() == 0) {
return false;
}
int[] glyphId = new int[1];
float[] position = new float[2];
for (int i = 0; i < glyphs.glyphCount(); i++) {
Font font = glyphs.getFont(i);
if (font == null) {
return false;
}
glyphId[0] = glyphs.getGlyphId(i);
position[0] = glyphs.getGlyphX(i);
position[1] = glyphs.getGlyphY(i);
canvas.drawGlyphs(glyphId, 0, position, 0, 1, font, paint);
}
return true;
}
ポイント:
- API 29 以上で
TextRunShaperを使う - 複雑絵文字の shaping 結果を glyph 単位で
Bitmapに描く - 今回の検証では
😶🌫️に対して十分ではなかったが、汎用フォールバックとして残している
# 8. 白黒化
emoji2 が出すカラー絵文字を、そのままではなく「黒線寄りのマスク」に変換しています。
private void convertBitmapToBlackStrokeMask(Bitmap bitmap) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int[] pixels = new int[width * height];
bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
for (int i = 0; i < pixels.length; i++) {
int color = pixels[i];
int alpha = color >>> 24;
if (alpha == 0) {
continue;
}
int red = (color >> 16) & 0xFF;
int green = (color >> 8) & 0xFF;
int blue = color & 0xFF;
int luminance = (299 * red + 587 * green + 114 * blue) / 1000;
int darkness = 255 - luminance;
if (darkness < 20) {
pixels[i] = 0;
continue;
}
int maskedAlpha = alpha * darkness / 255;
if (maskedAlpha < 20) {
pixels[i] = 0;
continue;
}
pixels[i] = (maskedAlpha << 24);
}
bitmap.setPixels(pixels, 0, width, 0, 0, width, height);
}
ポイント:
- 単純な「全部黒」ではなく、元の暗さを使って alpha を作っている
- 明るい面は消し、暗い輪郭だけを残す
# 9. Bitmap の余白トリミング
Bitmap 化した後は、透明部分を落としてから PDF に貼っています。
private EmojiBitmapAsset cropBitmapToEmojiAsset(Bitmap bitmap, float scale) {
Rect alphaBounds = findAlphaBounds(bitmap);
if (alphaBounds == null || alphaBounds.width() <= 0 || alphaBounds.height() <= 0) {
bitmap.recycle();
return null;
}
Bitmap croppedBitmap = Bitmap.createBitmap(
bitmap,
alphaBounds.left,
alphaBounds.top,
alphaBounds.width(),
alphaBounds.height()
);
bitmap.recycle();
return new EmojiBitmapAsset(croppedBitmap, scale);
}
ポイント:
- 余白込みの
Bitmapをそのまま使わない - 実際のインク領域だけ切り出す
- その結果を
EmojiBitmapAssetとして持つ
# 10. 単純絵文字判定 / 複雑絵文字判定
この判定で描画戦略が変わります。
private boolean isComplexEmojiSequence(String emojiText) {
if (TextUtils.isEmpty(emojiText)) {
return false;
}
boolean hasVariationSelector = false;
int emojiCodePointCount = 0;
int index = 0;
while (index < emojiText.length()) {
int codePoint = emojiText.codePointAt(index);
if (codePoint == 0x200D
|| codePoint == 0x20E3
|| isRegionalIndicator(codePoint)
|| isEmojiModifier(codePoint)) {
return true;
}
if (codePoint == 0xFE0E || codePoint == 0xFE0F) {
hasVariationSelector = true;
} else if (isEmojiCodePoint(codePoint)) {
emojiCodePointCount++;
}
index += Character.charCount(codePoint);
}
return hasVariationSelector && emojiCodePointCount > 1;
}
ポイント:
ZWJ- keycap
- 旗
- skin tone
- variation selector を含む複数要素絵文字
を複雑絵文字として扱っている
# いまの到達点
今回の制約は次の通りです。
PdfDocumentを使う- 外部ライブラリは増やさない
- 特殊絵文字ごとのハードコード資産は持たない
- コード処理だけで可能な限り対応する
この条件下での現在の到達点は次です。
- 単純絵文字は
Noto Emojiの白黒 path で比較的安定 😶🌫️のような複雑絵文字は、emoji2をBitmap化して白黒マスクにすることで「1つの絵文字としては出せる」- ただし、完全に自然な白黒 glyph にはならない
つまり、現在のコードは「この制約の中でコード処理だけで到達できる現実的な上限」に近い実装になっています。