# 絵文字処理の実装メモ

# 方針

今回の実装では、絵文字を次の 3 系統で描き分けています。

  1. 単純絵文字
  2. 複雑絵文字
  3. 複雑絵文字のフォールバック

結論から言うと、現在の分岐は次の通りです。

  • 単純絵文字:
    • Noto EmojigetTextPath() で白黒 path 描画する
  • 複雑絵文字:
    • まず emoji2EmojiSpan をオフスクリーン Bitmap に描く
    • その結果を白黒マスク化して PDF に貼る
  • emoji2 が使えない、または span 化できない場合:
    • Noto Emoji を使って shaping 済み glyph を Bitmap に描く
  • それでも無理な場合:
    • system emoji にフォールバックする

この方針にした理由は次の通りです。

  • PdfDocumentCanvas.drawText() では 😶‍🌫️ のような ZWJ 絵文字が分離しやすい
  • getTextPath() は単純絵文字には有効だが、複雑絵文字には弱い
  • emoji2 は複雑絵文字を 1 つの見た目として描ける
  • ただしカラーのままでは困るので、Bitmap 化してから白黒化している

# 全体の流れ

drawTextWithEmojiSupport(...) が入口です。

  1. テキストを buildTextRuns(...) で分割する
  2. 各 run が通常文字か絵文字かを判定する
  3. 絵文字なら:
    • 複雑絵文字は Bitmap 経路を試す
    • 単純絵文字は Path 経路で描く
  4. 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 Emoji shaping 経路
  • それもだめならさらに後段で system emoji に落ちる

# 6. emoji2EmojiSpanBitmap

複雑絵文字を 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 で比較的安定
  • 😶‍🌫️ のような複雑絵文字は、emoji2Bitmap 化して白黒マスクにすることで「1つの絵文字としては出せる」
  • ただし、完全に自然な白黒 glyph にはならない

つまり、現在のコードは「この制約の中でコード処理だけで到達できる現実的な上限」に近い実装になっています。