📷

Photoshopで文字毎に異なるフォントを自動的に設定する方法

2021/12/20に公開

はじめに

メルクストーリアでは年に2回、5月と11月にギルドバトルトーナメントを開催しています。このトーナメントで一定の成績を残したギルドには、報酬としてホーム画面に設定できる背景画像を配布しています。

2021年1月31日のメルクストーリア7周年の施策の一環として、この背景画像にギルド名を入れる対応を行いました。

これが

こうなりました!

本稿では、この対応の実現に使用した技術について解説したいと思います。

何が難しいのか

この対応を行う上で問題となったのは、ギルド名に使われている様々な「文字」でした。

ギルド名はユーザーが直接決定でき、NGワードと絵文字以外には大きく制限を設けていないため、日本語でない文字もよく使われています。

例えば、「༅ゴリランド༅」の「༅」や、「フロレスタ໒꒱*」の「໒」や「꒱」です。
(それぞれ、チベット文字の一種、ラオス文字の一種、彝(イ)文字の一種)

このような特殊な文字は、OSに対応するフォントが含まれていれば表示することはできますが、Photoshopで文字として出力することは単純ではありませんでしたし、出力できたとしてもライセンスが不明瞭で許可を得ずに使用することは躊躇われました。

解決のきっかけ

この問題を解決する足がかりとなったのは、GoogleとAdobeが共同で開発しているNotoというフォントでした。
https://www.google.com/get/noto/

Notoフォントは世界中の言語をサポートすることを目的としており、上述したような特殊な文字もサポートしています。ライセンスもフリーで今回の対応にはうってつけの存在でした。

次の問題

Notoフォントによって出力できない文字の問題は解決しました。
次に問題となったのは、その設定手段です。

Notoフォントは、単一のフォントファイルで構成されているわけではなく、1000以上のフォントファイルに分割されています。

今回使用したNoto sans系のレギュラーフォントだけでも119個のフォントファイルに分割されており、出力したい文字がどのフォントファイルに含まれるかを調べて設定する必要がありました。

ギルド名に使われている特殊な文字の数は限られていたため、手作業で設定することもできなくはありません。しかし、初回の対応で出力する必要がある背景画像は468枚、さらに半年毎に52枚ずつ追加されるため、何とか自動化したいという要望がありました。

Photoshopのスクリプトを使用した自動化の方法

最終的には、Photoshopのスクリプト機能を使用して自動化を行いました。
この処理は大きく2つの部分に分かれます。

  1. ある文字がどのフォントファイルに含まれているかを探す処理
  2. その文字に見つかったフォントを設定する処理

1. ある文字がどのフォントファイルに含まれているかを探す処理

この処理の実装には以下の記事が参考になりました。
https://qiita.com/sl2/items/dacaf73c0bd9a4660880

この記事に書かれているようにttxコマンドを使用すると、そのフォントファイルで表示できる文字をリストアップすることができます。

ttxコマンドの出力をまとめて、2.の処理で扱いやすいように加工します。

# -*- coding: utf-8 -*-
require 'rexml/document'
require 'pathname'

# コードポイントファイルを生成します。
# 対象のフォント全てを1つのフォルダに入れ、フォルダのパスを第1引数に入れて実行してください。
# ruby generate_cmap.rb [folder_name]
path = File.join(Dir.pwd, ARGV[0])


font_file_names = []
Dir.foreach(path) do |file_name|
  font_file_names << file_name
end

# Noto Sans CJK JP > Noto Sans > 他 の順に優先度をつける
font_file_names.delete("Noto Sans.ttf")
font_file_names.insert(0, "Noto Sans.ttf")
font_file_names.delete("Noto Sans CJK JP.otf")
font_file_names.insert(0, "Noto Sans CJK JP.otf")

# コードポイントからフォントファミリー名へのマップを作成する
code_to_file = {}
font_file_names.each do |file_name|
  next unless File.extname(file_name) == ".ttf" || File.extname(file_name) == ".otf"
  file_path = File.join(path, file_name)

  # フォントの.ttxファイルを出力する
  # https://qiita.com/sl2/items/dacaf73c0bd9a4660880
  ttx_file_path = "#{File.dirname(file_path)}/#{File.basename(file_path, ".*")}.ttx"
  if File.exists?(ttx_file_path)
    File.delete(ttx_file_path)
  end
  system("ttx -t cmap \"#{file_path}\"")

  # フォントのファミリー名を取得する
  if File.exists?("generate_cmap_temp.txt")
    File.delete("generate_cmap_temp.txt")
  end
  system("fc-scan --format \"%{family}\" \"#{file_path}\" >> generate_cmap_temp.txt")
  family_name = File.read("generate_cmap_temp.txt")

  xml = REXML::Document.new(File.new(ttx_file_path))
  xml.elements.each('ttFont/cmap/cmap_format_4') do |element|
    element.elements.each do |map|
      code = map.attributes['code']
      file_for_code = code_to_file[code]
      if file_for_code.nil?
        code_to_file[code] = family_name
      end
    end
  end
end

# XML形式で保存する
all_file_name = 'NotoSansAll.xml'

doc = REXML::Document.new
doc << REXML::XMLDecl.new('1.0', 'UTF-8')
root = REXML::Element.new('codes')
doc.add_element(root)

code_to_file.each do |code, file|
  child = REXML::Element.new('map')
  child.add_attribute('code', code)
  child.add_attribute('file', file)
  root.add_element(child)
end

File.open(all_file_name, 'w') do |file|
  doc.write(file, indent = 2)
end

処理の結果、以下のXMLファイルが出力されます。

<?xml version='1.0' encoding='UTF-8'?>
<codes>
  <map code='0x0' file='Noto Sans Adlam Unjoined'/>
...
  <map code='0x2c' file='Noto Sans Arabic UI'/>
  <map code='0x2d' file='Noto Sans Arabic UI'/>
  <map code='0x2e' file='Noto Sans Arabic UI'/>
...
  <map code='0x1' file='Noto Sans CJK JP'/>
  <map code='0x2' file='Noto Sans CJK JP'/>
  <map code='0x3' file='Noto Sans CJK JP'/>
  <map code='0x4' file='Noto Sans CJK JP'/>
...
</codes>

2. その文字に見つかったフォントを設定する処理

2つ目の処理もなかなかに厄介でした。

「特定のテキストレイヤーのフォントをスクリプトから一括で変更すること」は簡単にできます。
また、「特定のテキストレイヤーのフォントをUIから文字毎に変更すること」も簡単にできます。

しかし、「特定のテキストレイヤーのフォントをスクリプトから文字毎に変更すること」は簡単ではありませんでした。
ドキュメントを探しても、それらしい方法が載っていないのです。

解決のきっかけとなった投稿はこちらです。
https://community.adobe.com/t5/photoshop/any-way-to-check-change-font-in-a-portion-of-a-textitem/td-p/10510168?page=1

曰く、以下のコードを実行すれば、文字毎にフォントを設定することが可能ということでした。

function setFormatting(start, end, fontName, fontStyle, fontSize) {
  var idsetd = app.charIDToTypeID("setd");
  var action = new ActionDescriptor();
  var idnull = app.charIDToTypeID("null");
  var reference = new ActionReference();
  var idTxLr = app.charIDToTypeID("TxLr");
  var idOrdn = app.charIDToTypeID("Ordn");
  var idTrgt = app.charIDToTypeID("Trgt");
  reference.putEnumerated(idTxLr, idOrdn, idTrgt);
  action.putReference(idnull, reference);
  var idT = app.charIDToTypeID("T   ");
  var textAction = new ActionDescriptor();
  var idTxtt = app.charIDToTypeID("Txtt");
  var actionList = new ActionList();
  var textRange = new ActionDescriptor();
  var idFrom = app.charIDToTypeID("From");
  textRange.putInteger(idFrom, start);
  textRange.putInteger(idT, end);
  var idTxtS = app.charIDToTypeID("TxtS");
  var formatting = new ActionDescriptor();
  var idFntN = app.charIDToTypeID("FntN");
  formatting.putString(idFntN, fontName);
  var idFntS = app.charIDToTypeID("FntS");
  formatting.putString(idFntS, fontStyle);
  var idSz = app.charIDToTypeID("Sz  ");
  var idPnt = app.charIDToTypeID("#Pnt");
  formatting.putUnitDouble(idSz, idPnt, fontSize);
  textRange.putObject(idTxtS, idTxtS, formatting);
  actionList.putObject(idTxtt, textRange);
  textAction.putList(idTxtt, actionList);
  action.putObject(idT, idTxLr, textAction);
  app.executeAction(idsetd, action, DialogModes.NO);
}

おそらく、Photoshopのスクリプトには人が書く用のAPIと操作ベースのAPIの2種類が用意されており、

  • 人が書く用のAPIは読み易い代わりに機能が制限されている。
  • 操作ベースのAPIは可読性を犠牲にしているが、Photoshopの全ての機能を実行することができる。(基本的に人が書くものではなく、Scripting Listenerから出力されたものを加工することが多い。)

みたいな感じになっているのだと推測しています。

上記のスクリプトは後者で、文字毎にフォントを設定することは前者の方法では実現できないのだと思います。

何にせよこのスクリプトを使用することで文字毎にフォントを設定することができました。

文字毎にフォントを選択して設定するコードは以下の通りです。[1]

// テキストの文字毎に適切なフォントを設定する
function setFont(textLayer, codes) {
  if (textLayer == null || textLayer.kind != LayerKind.TEXT) {
    throw new Error();
  }

  app.activeDocument.activeLayer = textLayer;
  for (var i = 0; i < textLayer.textItem.contents.length; ++i) {
    var codePoint = textLayer.textItem.contents.charCodeAt(i);
    var fontName = codes[codePoint];
    setFormatting(i, i + 1, fontName, "Regular", 39.46);
  }
}

その他細々とした問題

出力した画像を目視で確認したところ、一部のギルド名で文字の位置が上下にずれる現象が起きていました。
文字毎にずれる場合と、テキストレイヤー全体がずれる場合があったのですが、根本解決が難しかったため、個別に調整できるようにしています。

ズレたギルド名の例
・の位置が下にずれている

全体的に下にずれている

おわりに

本記事では、ギルドバトルトーナメントの報酬背景画像の生成のために、Photoshopで文字毎に異なるフォントファイルをスクリプトから自動的に設定する方法についてご紹介しました。

かなりニッチな内容で、実際にこのような手法が活用できる場面は限られているかと思いますが、何かの参考になれば幸いです。

謝辞

ギルド名をお借りした、路地裏猫のおでん様、༅ゴリランド༅様、フロレスタ໒꒱*様、ナタデココ˙³˙様にお礼を申し上げます。

脚注
  1. charCodeAtはUTF16でエンコードされた値を返すため、Unicodeのコードポイントと一致しない場合があります。本来Unicodeのコードポイントを返すcodePointAtを使うべきですが、Javascriptのバージョンが古く使用できないためcharCodeAtで代替しています。文字によっては正確なマッピングができない可能性がありますが、今のところ問題は起きていません。 ↩︎

GitHubで編集を提案
Happy Elements

Discussion