# PDF に白黒絵文字をテキストとして出力する実装ガイド
# 目的
Android の PdfDocument を使い、通常文字と絵文字が混在する文字列を PDF に出力するための実装方法をまとめます。
この実装は次の要件を満たします。
- 単純絵文字を黒一色で表示する
😶🌫️のような ZWJ 絵文字を分離させず、1 個の絵文字として表示する- 通常文字と絵文字が混在しても、描画位置を維持する
- PDF 上で文字列を選択できる
- PDF からコピーして、元の絵文字としてペーストできる
- Bitmap 化しない
# 結論
絵文字は EmojiCompat.process() で処理し、付与された EmojiSpan を PDF の Canvas に直接描画します。
EmojiSpan.draw() に渡す Paint へ PorterDuffColorFilter を設定すると、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() には top、baseline、bottom が必要です。元の描画位置を baseline として扱い、EmojiSpan.getSize() で取得した FontMetricsInt から上下位置を計算します。
# PDF ビューアで確認する
表示だけでなく、PDF ビューア上で選択とコピーを確認します。描画方法を Bitmap や path に変更すると、見た目が同じでもテキスト情報が失われます。
# 動作確認項目
通常文字と複数種類の絵文字を混在させた文字列で確認します。
前🙂中😶🌫️後😀終
確認項目:
- 通常文字と絵文字の間隔が自然である
- 単純絵文字と ZWJ 絵文字が黒一色である
- ZWJ 絵文字が分離しない
- PDF 上ですべての文字を選択できる
- コピー後に元の文字列としてペーストできる
# 確認済みの結果
この実装では、通常文字、単純絵文字、ZWJ 絵文字を混在させた場合でも、黒一色表示、位置の維持、選択、コピー、ペーストが成立することを確認済みです。