# PDF に白黒絵文字をテキストとして出力する実装ガイド

# 目的

Android の PdfDocument を使い、通常文字と絵文字が混在する文字列を PDF に出力するための実装方法をまとめます。

この実装は次の要件を満たします。

  • 単純絵文字を黒一色で表示する
  • 😶‍🌫️ のような ZWJ 絵文字を分離させず、1 個の絵文字として表示する
  • 通常文字と絵文字が混在しても、描画位置を維持する
  • PDF 上で文字列を選択できる
  • PDF からコピーして、元の絵文字としてペーストできる
  • Bitmap 化しない

# 結論

絵文字は EmojiCompat.process() で処理し、付与された EmojiSpan を PDF の Canvas に直接描画します。

EmojiSpan.draw() に渡す PaintPorterDuffColorFilter を設定すると、EmojiCompat のカラー glyph を黒一色にできます。

EmojiCompat.process()
  -> EmojiSpan
  -> EmojiSpan.draw()
  -> PorterDuffColorFilter(Color.BLACK, SRC_IN)
  -> PdfDocument の Canvas

EmojiSpan が取得できない絵文字だけ、白黒絵文字 font の Canvas.drawText() へフォールバックします。

# 使用しない方法

次の方法は使用しません。

  • Bitmap.createBitmap()
  • Canvas.drawBitmap()
  • Paint.getTextPath()
  • Canvas.drawPath()
  • 絵文字ごとの専用画像

Bitmap や path に変換すると、見た目を白黒にできても PDF 上で選択可能なテキストとして扱えません。

また、白黒絵文字 font の Canvas.drawText() だけで描画すると、環境や font によって ZWJ 絵文字が複数 glyph に分離する場合があります。そのため、第一候補には EmojiSpan.draw() を使います。

# 依存関係

emoji2 と bundled font を追加します。

dependencies {
    implementation("androidx.emoji2:emoji2:1.6.0")
    implementation("androidx.emoji2:emoji2-bundled:1.6.0")
}

フォールバック用の白黒絵文字 font もアプリへ配置します。

app/src/main/res/font/notoemoji_regular.ttf

# 必要な import

import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.Typeface;
import android.graphics.fonts.Font;
import android.graphics.fonts.FontFamily;
import android.graphics.fonts.FontStyle;
import android.os.Build;
import android.text.Spanned;
import android.text.TextUtils;

import androidx.core.content.res.ResourcesCompat;
import androidx.emoji2.bundled.BundledEmojiCompatConfig;
import androidx.emoji2.text.EmojiCompat;
import androidx.emoji2.text.EmojiSpan;

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

# EmojiCompat の初期化

画面生成時など、PDF 出力より前に一度だけ初期化します。

private void initializeEmojiCompat() {
    if (!EmojiCompat.isConfigured()) {
        EmojiCompat.Config config = new BundledEmojiCompatConfig(this)
                .setReplaceAll(true);
        EmojiCompat.init(config);
    }
}

private boolean isEmojiReady() {
    return EmojiCompat.isConfigured()
            && EmojiCompat.get().getLoadState() == EmojiCompat.LOAD_STATE_SUCCEEDED;
}

setReplaceAll(true) により、OS 標準 font で表示可能な絵文字も含めて EmojiCompat の EmojiSpan に置換します。単純絵文字と ZWJ 絵文字を同じ経路で処理できます。

PDF 出力は isEmojiReady()true になってから開始します。

# 白黒絵文字 font の読み込み

EmojiSpan を取得できない場合に備え、白黒絵文字 font を読み込みます。

Typeface monochromeEmojiTypeface = loadExactTypeface(R.font.notoemoji_regular);

API 29 以上では、指定した font を明示的な FontFamily として読み込みます。

private Typeface loadExactTypeface(int fontResId) throws Exception {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        Typeface typeface = ResourcesCompat.getFont(this, fontResId);
        if (typeface == null) {
            throw new IllegalStateException("絵文字 font の読み込みに失敗しました");
        }
        return typeface;
    }

    Font font = new Font.Builder(getResources(), fontResId).build();
    FontFamily family = new FontFamily.Builder(font).build();
    FontStyle style = font.getStyle();

    return new Typeface.CustomFallbackBuilder(family)
            .setStyle(style)
            .build();
}

# PDF Canvas への接続

通常の canvas.drawText() の代わりに drawTextWithEmojiSupport() を呼びます。

PdfDocument.Page page = document.startPage(pageInfo);
Canvas canvas = page.getCanvas();

drawTextWithEmojiSupport(
        canvas,
        paint,
        text,
        x,
        baselineY,
        regularTypeface,
        monochromeEmojiTypeface
);

document.finishPage(page);

幅の計測が必要な場合は、同じ分岐を使う measureTextWithEmojiSupport() を呼びます。

float width = measureTextWithEmojiSupport(
        paint,
        text,
        regularTypeface,
        monochromeEmojiTypeface
);

描画と計測で同じ run 分割と幅計測を使うことが重要です。分岐が異なると、絵文字以降の文字位置がずれます。

# 描画ヘルパー

# 文字列全体の描画と計測

private float measureTextWithEmojiSupport(
        Paint paint,
        String text,
        Typeface baseTypeface,
        Typeface emojiTypeface
) {
    if (TextUtils.isEmpty(text)) {
        return 0.0f;
    }

    float width = 0.0f;
    for (TextRun run : buildTextRuns(text)) {
        width += measureTextRunWidth(paint, run, baseTypeface, emojiTypeface);
    }
    return width;
}

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
        );
    }
}

# run 単位の分岐

private float measureTextRunWidth(
        Paint paint,
        TextRun run,
        Typeface baseTypeface,
        Typeface emojiTypeface
) {
    if (!run.isEmoji) {
        paint.setTypeface(baseTypeface);
        return paint.measureText(run.text);
    }

    EmojiSpanAsset emojiSpanAsset = resolveEmojiSpanAsset(paint, run);
    if (emojiSpanAsset != null) {
        return emojiSpanAsset.width;
    }

    return measureMonochromeEmojiTextWidth(paint, run.text, emojiTypeface);
}

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);
    }

    EmojiSpanAsset emojiSpanAsset = resolveEmojiSpanAsset(paint, run);
    if (emojiSpanAsset != null) {
        return drawEmojiSpan(canvas, paint, emojiSpanAsset, x, y);
    }

    return drawMonochromeEmojiText(canvas, paint, run.text, x, y, emojiTypeface);
}

# EmojiSpan の解決

private EmojiSpanAsset resolveEmojiSpanAsset(Paint paint, TextRun run) {
    if (!run.isEmoji) {
        return null;
    }

    CharSequence processedText = processEmojiText(run.text);
    if (!(processedText instanceof Spanned)) {
        return null;
    }

    Spanned spanned = (Spanned) processedText;
    EmojiSpan[] spans = spanned.getSpans(0, spanned.length(), EmojiSpan.class);
    if (spans.length != 1) {
        return null;
    }

    EmojiSpan emojiSpan = spans[0];
    int start = spanned.getSpanStart(emojiSpan);
    int end = spanned.getSpanEnd(emojiSpan);
    if (start != 0 || end != spanned.length()) {
        return null;
    }

    Paint.FontMetricsInt fm = new Paint.FontMetricsInt();
    int width = emojiSpan.getSize(paint, spanned, start, end, fm);
    if (width <= 0) {
        return null;
    }

    return new EmojiSpanAsset(spanned, emojiSpan, start, end, width, fm);
}

# 黒一色での EmojiSpan 描画

private float drawEmojiSpan(
        Canvas canvas,
        Paint paint,
        EmojiSpanAsset emojiSpanAsset,
        float x,
        float baselineY
) {
    int baseline = Math.round(baselineY);
    int top = baseline + emojiSpanAsset.fontMetrics.ascent;
    int bottom = baseline + emojiSpanAsset.fontMetrics.descent;

    Paint spanPaint = new Paint(paint);
    spanPaint.setAntiAlias(true);
    spanPaint.setColorFilter(new PorterDuffColorFilter(
            Color.BLACK,
            PorterDuff.Mode.SRC_IN
    ));

    emojiSpanAsset.emojiSpan.draw(
            canvas,
            emojiSpanAsset.spanned,
            emojiSpanAsset.start,
            emojiSpanAsset.end,
            x,
            top,
            baseline,
            bottom,
            spanPaint
    );
    return emojiSpanAsset.width;
}

重要なのは、EmojiSpan.draw() を PDF の Canvas に直接実行することです。Bitmap を経由しないため、PDF 上の文字選択とコピーを維持できます。

PorterDuff.Mode.SRC_IN を使うと、glyph の alpha を維持しながら表示色を黒へ統一できます。

# EmojiSpan がない場合のフォールバック

private float measureMonochromeEmojiTextWidth(
        Paint paint,
        String emojiText,
        Typeface emojiTypeface
) {
    Paint emojiPaint = new Paint(paint);
    emojiPaint.setTypeface(emojiTypeface);
    return emojiPaint.measureText(emojiText);
}

private float drawMonochromeEmojiText(
        Canvas canvas,
        Paint paint,
        String emojiText,
        float x,
        float y,
        Typeface emojiTypeface
) {
    Paint emojiPaint = new Paint(paint);
    emojiPaint.setTypeface(emojiTypeface);
    emojiPaint.setStyle(Paint.Style.FILL);
    emojiPaint.setColor(Color.BLACK);
    canvas.drawText(emojiText, x, y, emojiPaint);
    return emojiPaint.measureText(emojiText);
}

この経路は補助用途です。ZWJ 絵文字の合成を保証するものではありません。

# 文字列の処理と run 分割

private CharSequence processEmojiText(String text) {
    if (TextUtils.isEmpty(text) || !isEmojiReady()) {
        return text;
    }

    return EmojiCompat.get().process(text);
}

private EmojiSpan findEmojiSpanStartingAt(Spanned spanned, int index) {
    EmojiSpan[] spans = spanned.getSpans(index, spanned.length(), EmojiSpan.class);
    for (EmojiSpan span : spans) {
        if (spanned.getSpanStart(span) == index) {
            return span;
        }
    }
    return null;
}

private List<TextRun> buildTextRuns(String text) {
    List<TextRun> runs = new ArrayList<>();
    CharSequence processedText = processEmojiText(text);

    if (!(processedText instanceof Spanned)) {
        runs.add(new TextRun(text, false));
        return runs;
    }

    Spanned spanned = (Spanned) processedText;
    int index = 0;

    while (index < spanned.length()) {
        EmojiSpan emojiSpan = findEmojiSpanStartingAt(spanned, index);
        if (emojiSpan != null) {
            int spanEnd = spanned.getSpanEnd(emojiSpan);
            String emojiText = spanned.subSequence(index, spanEnd).toString();
            runs.add(new TextRun(emojiText, true));
            index = spanEnd;
            continue;
        }

        int next = spanned.nextSpanTransition(
                index,
                spanned.length(),
                EmojiSpan.class
        );
        String plainText = spanned.subSequence(index, next).toString();
        if (!plainText.isEmpty()) {
            runs.add(new TextRun(plainText, false));
        }
        index = next;
    }

    if (runs.isEmpty()) {
        runs.add(new TextRun(text, false));
    }

    return runs;
}

通常文字と絵文字を run に分割し、描画済みの幅を currentX に加算します。これにより、絵文字の前後に通常文字があっても配置を維持できます。

# 補助クラス

private static class TextRun {
    final String text;
    final boolean isEmoji;

    TextRun(String text, boolean isEmoji) {
        this.text = text;
        this.isEmoji = isEmoji;
    }
}

private static class EmojiSpanAsset {
    final Spanned spanned;
    final EmojiSpan emojiSpan;
    final int start;
    final int end;
    final int width;
    final Paint.FontMetricsInt fontMetrics;

    EmojiSpanAsset(
            Spanned spanned,
            EmojiSpan emojiSpan,
            int start,
            int end,
            int width,
            Paint.FontMetricsInt fontMetrics
    ) {
        this.spanned = spanned;
        this.emojiSpan = emojiSpan;
        this.start = start;
        this.end = end;
        this.width = width;
        this.fontMetrics = fontMetrics;
    }
}

# 実装時の注意点

# PDF 出力前に EmojiCompat の準備完了を確認する

初期化途中で EmojiCompat.process() を呼ぶと、期待する EmojiSpan が付与されない場合があります。PDF 出力開始前に isEmojiReady() を確認します。

# 計測と描画で同じ分岐を使う

中央寄せ、右寄せ、表内配置などで事前に幅を計測する場合は、必ず measureTextWithEmojiSupport() を使います。

通常の paint.measureText() だけで全体幅を取得すると、EmojiSpan.getSize() の結果と一致せず、絵文字以降の位置がずれる可能性があります。

# baseline を基準にする

EmojiSpan.draw() には topbaselinebottom が必要です。元の描画位置を baseline として扱い、EmojiSpan.getSize() で取得した FontMetricsInt から上下位置を計算します。

# PDF ビューアで確認する

表示だけでなく、PDF ビューア上で選択とコピーを確認します。描画方法を Bitmap や path に変更すると、見た目が同じでもテキスト情報が失われます。

# 動作確認項目

通常文字と複数種類の絵文字を混在させた文字列で確認します。

前🙂中😶‍🌫️後😀終

確認項目:

  1. 通常文字と絵文字の間隔が自然である
  2. 単純絵文字と ZWJ 絵文字が黒一色である
  3. ZWJ 絵文字が分離しない
  4. PDF 上ですべての文字を選択できる
  5. コピー後に元の文字列としてペーストできる

# 確認済みの結果

この実装では、通常文字、単純絵文字、ZWJ 絵文字を混在させた場合でも、黒一色表示、位置の維持、選択、コピー、ペーストが成立することを確認済みです。