読み上げを有効にしたまま、自分の壁紙アプリの一覧画面を上から辿ってみた夜のことです。カテゴリの見出しまでは、思っていたとおりに読まれました。
ところが右上にアイコンが並ぶ帯へ入ったところで、端末は「ボタン」「ボタン」「画像」とだけ言って先へ進みました。どれを押せば保存できるのか、音だけでは分かりません。
その画面の翻訳は揃っておりました。多言語で配信しているので、未翻訳のキーが残っていないかは公開前に必ず確かめております。それでも、読み上げの側にはこれだけの穴が開いていたのです。
翻訳の網には、ラベルの穴が映りません
理由は単純でした。翻訳の検査は、キーの集合どうしを突き合わせる仕組みです。日本語にあって英語にないキーは見つかります。——けれども、ラベルそのものを書き忘れた箇所には、キーが存在しません。存在しないものは、未翻訳としても数えられないのです。
SwiftUI にはもう一つ、見落としやすい経路があります。.accessibilityLabel("ボタン") のように文字列リテラルを渡すと、それは LocalizedStringKey として扱われます。Localizable.strings に「ボタン」というキーが無ければ、警告もエラーも出ないまま、その文字列がそのまま読み上げられます。翻訳ファイルの側からは、この行は最初から見えていません。
Android の @string/ 参照も似た性質を持ちます。参照先がどこにも無ければビルドで気づけますが、ある言語の values にだけ無い場合は、既定の言語の文字列が黙って読まれます。
ラベルが要るかどうかは静的に決まり、何と読ませるかは画面を見ないと決まりません。 この二つを分けてから、ようやく手が動くようになりました。前半は自分の環境で確定できますし、後半はモデルに相談する価値があります。
まず、アイコン要素とラベルを1枚に集めます
判定の前に材料を集めます。iOS は SwiftUI の Image(...) を起点に直後のモディファイアを見て、Android はレイアウト XML の ImageButton と ImageView を拾います。この段階では良し悪しを決めません。決めないことが、あとで効いてきます。
# collect_labels.py — アイコン要素と読み上げラベルをコードから集めます
import json
import re
import sys
from pathlib import Path
from xml.etree import ElementTree
ANDROID_NS = "{http://schemas.android.com/apk/res/android}"
ICON_VIEWS = {"ImageButton", "ImageView"}
# SwiftUI では文字列リテラルも LocalizedStringKey として扱われます。
# Text(...) 包みの有無にかかわらず、まず strings への登録を探します
LABEL_RE = re.compile(r'\.accessibilityLabel\(\s*(?:Text\(\s*)?"([^"]*)"')
IMAGE_RE = re.compile(r'\bImage\(\s*(?:systemName:\s*)?"([^"]+)"')
STRINGS_RE = re.compile(r'"([^"]+)"\s*=\s*"([^"]*)"\s*;')
def load_lproj(root: Path):
table = {}
for path in sorted(root.glob("*.lproj/Localizable.strings")):
locale = path.parent.name.replace(".lproj", "")
locale = "default" if locale == "en" else locale
for key, value in STRINGS_RE.findall(path.read_text(encoding="utf-8")):
table.setdefault(key, {})[locale] = value
return table
def collect_ios(root: Path):
strings = load_lproj(root)
records = []
for path in sorted(root.rglob("*.swift")):
lines = path.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
icon = IMAGE_RE.search(line)
if not icon:
continue
# モディファイアは直後に並ぶので、後ろ4行だけを見ます
window = "\n".join(lines[i : i + 5])
label = LABEL_RE.search(window)
# strings に登録があればキー、無ければその文字列がそのまま読まれます
resolved = strings.get(label.group(1)) if label else None
is_button = any("Button(" in lines[j] for j in range(max(0, i - 3), i))
records.append(
{
"platform": "ios",
"element": "Button+Image" if is_button else "Image",
"icon": icon.group(1),
"interactive": is_button,
"label_raw": label.group(1) if label else None,
"label_kind": ("key" if resolved else "literal") if label else None,
"values": resolved or {},
}
)
return records
def load_android_strings(res_dir: Path):
table = {}
for xml in sorted(res_dir.glob("values*/strings.xml")):
locale = xml.parent.name.replace("values-", "")
locale = "default" if locale == "values" else locale
for node in ElementTree.parse(xml).getroot().findall("string"):
table.setdefault(node.get("name"), {})[locale] = (node.text or "").strip()
return table
def collect_android(res_dir: Path):
strings = load_android_strings(res_dir)
records = []
for xml in sorted((res_dir / "layout").glob("*.xml")):
for node in ElementTree.parse(xml).iter():
if node.tag not in ICON_VIEWS:
continue
desc = node.get(ANDROID_NS + "contentDescription")
key = desc[8:] if desc and desc.startswith("@string/") else None
values = strings.get(key, {}) if key else {}
records.append(
{
"platform": "android",
"element": node.tag,
"icon": (node.get(ANDROID_NS + "src") or "").replace("@drawable/", ""),
"interactive": node.tag == "ImageButton",
"label_raw": desc,
"label_kind": ("key" if values else "literal") if desc else None,
"values": values,
}
)
return records
if __name__ == "__main__":
root = Path(sys.argv[1])
records = collect_ios(root / "ios") + collect_android(root / "android" / "res")
json.dump(records, sys.stdout, ensure_ascii=False, indent=2)手元の検証用プロジェクトでは、アイコン要素が 11 件集まりました。ボタンが 9 件、画像が 2 件です。
解決する前に役割語を探して、自分の命名で落としました
最初に書いた検査は、集めた文字列に「ボタン」「button」「画像」などの役割語が含まれていないかを、そのまま調べるものでした。読み上げは要素の役割を自分で言いますので、ラベル側で重ねると二重に読まれてしまうからです。
走らせた結果の冒頭が、これでした。
ROLE_WORD | ios/Button+Image | arrow.down.circle | 役割語が含まれます: save_button
MISSING | ios/Button+Image | heart | 操作できる要素にラベルがありません
ROLE_WORD | ios/Button+Image | square.and.arrow.up | 役割語が含まれます: ボタン
ROLE_WORD | ios/Button+Image | shuffle | 役割語が含まれます: shuffle_button1行目と4行目で手が止まりました。save_button も shuffle_button も、私が付けたキーの名前です。読み上げが実際に読むのは「保存」と「ランダム」であって、キーの名前ではありません。自分の命名規則で自分の検査を落としていたわけです。
直し方は、順序を一つ入れ替えるだけでした。役割語の検査は、キーを解決したあとの表示文字列に対してだけ行います。@string/ 参照そのものや、キー名を検査の入力にしてはいけません。
いま思えば、これは「集めるところでは判定しない」と決めておきながら、判定の側で生の文字列を受け取ってしまった、という設計の綻びでした。集める側と判定する側の受け渡しに、何を渡すかを書いておくべきだったのです。
機械で決まることだけを、ローカルで確定させます
順序を直した検査が次のものです。ここで見るのは、ラベルが在るか、参照が解決するか、必要な言語ぶん揃っているか、役割語を重ねていないかの四つだけです。
# prescreen.py — モデルに渡す前に、機械で決まる不備をここで落とします
import json
import sys
ROLE_WORDS = ("ボタン", "button", "画像", "image", "アイコン", "icon")
REQUIRED_LOCALES = ("default", "ja")
def display_texts(record):
"""読み上げが実際に読む文字列だけを返します。キーや参照は含めません"""
if record.get("label_kind") == "key":
return list((record.get("values") or {}).values())
if record.get("label_kind") == "literal":
return [record["label_raw"]]
return []
def verdicts(record):
out = []
kind = record.get("label_kind")
values = record.get("values") or {}
if not kind:
if record["interactive"]:
return [("MISSING", "操作できる要素にラベルがありません")]
return [("DECORATIVE?", "装飾なら読み上げから明示的に外します")]
if kind == "literal":
out.append(("HARDCODED", f"どの言語でも同じ文字列が読まれます: {record['label_raw']}"))
elif not values:
out.append(("UNRESOLVED", f"{record['label_raw']} が解決できません"))
else:
gaps = [loc for loc in REQUIRED_LOCALES if not values.get(loc)]
if gaps:
out.append(("LOCALE_GAP", f"未定義の言語: {', '.join(gaps)}"))
# 役割語は「解決したあとの表示文字列」だけを見ます
for text in display_texts(record):
if any(word in (text or "").lower() for word in ROLE_WORDS):
out.append(("ROLE_WORD", f"役割語が含まれます: {text}"))
break
return out
if __name__ == "__main__":
records = json.load(open(sys.argv[1], encoding="utf-8"))
passed = []
for rec in records:
found = verdicts(rec)
head = f"{rec['platform']}/{rec['element']}"
if not found:
passed.append(rec)
print(f"{'PASS':<11}| {head:<22}| {rec['icon']:<20}| 文言の判定へ回します")
continue
for code, detail in found:
print(f"{code:<11}| {head:<22}| {rec['icon']:<20}| {detail}")
stopped = len(records) - len(passed)
print(f"合計 {len(records)} 件 / ローカルで確定 {stopped} 件 / モデルへ回す {len(passed)} 件")
json.dump(passed, open("to_judge.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)同じ 11 件に対して、出力はこうなりました。
PASS | ios/Button+Image | arrow.down.circle | 文言の判定へ回します
MISSING | ios/Button+Image | heart | 操作できる要素にラベルがありません
HARDCODED | ios/Button+Image | square.and.arrow.up| どの言語でも同じ文字列が読まれます: ボタン
ROLE_WORD | ios/Button+Image | square.and.arrow.up| 役割語が含まれます: ボタン
HARDCODED | ios/Button+Image | square.grid.2x2 | どの言語でも同じ文字列が読まれます: カテゴリ一覧を開くボタン
ROLE_WORD | ios/Button+Image | square.grid.2x2 | 役割語が含まれます: カテゴリ一覧を開くボタン
LOCALE_GAP | ios/Button+Image | shuffle | 未定義の言語: default
HARDCODED | ios/Image | hero_banner | どの言語でも同じ文字列が読まれます: 画像
ROLE_WORD | ios/Image | hero_banner | 役割語が含まれます: 画像
PASS | android/ImageButton | ic_save | 文言の判定へ回します
MISSING | android/ImageButton | ic_heart | 操作できる要素にラベルがありません
PASS | android/ImageButton | ic_share | 文言の判定へ回します
UNRESOLVED | android/ImageButton | ic_shuffle | @string/shuffle_button が解決できません
ROLE_WORD | android/ImageView | hero_banner | 役割語が含まれます: Image
合計 11 件 / ローカルで確定 8 件 / モデルへ回す 3 件11 件のうち 8 件が、モデルを呼ばずに確定しました。残る 3 件を言語ごとに開いても 6 行です。判定の名前と意味を、そのまま表にしておきます。
| 判定 | 意味 | 直し方 |
|---|---|---|
MISSING | 操作できる要素にラベルが無い | ラベルを足す。ここは議論の余地がありません |
DECORATIVE? | 装飾の画像にラベルが無い | 装飾なら読み上げから明示的に外す |
HARDCODED | キーとして解決できず、その文字列がそのまま読まれる | キーを定義して各言語へ移す |
UNRESOLVED | 参照先がどの言語にも無い | キーの綴りか定義漏れを確認する |
LOCALE_GAP | 一部の言語にだけ定義が無い | 不足している言語を埋める |
ROLE_WORD | 読み上げが自分で言う役割語が入っている | 役割語を外し、動作だけを書く |
前半の二つは公開前に毎回走らせ、三つ目はときどきにしております。終了コードを非ゼロにするのは MISSING と UNRESOLVED の二つだけです。確実に間違いだと言い切れるのがこの二つだからで、残りは表示するにとどめて自分で読みます。判断の分かれる項目でビルドを止める検査は、ひと月ほどで無効化されてしまいます。無効化された検査は、何も見つけてくれません。
もう一つ、時間を節約してくれた習慣があります。集めた結果の JSON を、小さなファイルとしてそのまま履歴に残しております。あとからラベルが消えたときに、どの変更で落ちたのかが差分で分かるので、読み上げを有効にして画面を辿り直さずに済みます。
DECORATIVE? に疑問符を付けてあるのは、装飾かどうかを機械が決められないからです。ここだけは人が答えます。
Gemini に渡すのは、残った文言だけです
残った 3 件は、静的には何も悪いところがありません。「保存」も「共有」も、キーとして解決でき、日本語にも英語にも定義があります。それでも画面の中で紛らわしいことはありますし、言語によっては長すぎることもあります。この判断だけをモデルに渡します。
# judge_labels.py — 静的検問を通ったラベルだけを Gemini に渡します
import json
import os
import sys
from google import genai
from google.genai import types
MODEL = "gemini-3.8-flash"
SCHEMA = {
"type": "object",
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"locale": {"type": "string"},
"verdict": {
"type": "string",
"enum": ["ok", "ambiguous", "duplicate", "too_long", "wrong_register"],
},
"reason": {"type": "string"},
"suggestion": {"type": "string"},
},
"required": ["id", "locale", "verdict", "reason"],
},
}
},
"required": ["results"],
}
INSTRUCTION = """あなたは画面読み上げのレビュー担当です。
与えられるのは、ある一画面に並ぶ要素のラベルです。次の観点だけで判定してください。
- ok: その言語の読み上げとして、何が起きるかが分かる
- ambiguous: 同じ画面の他のラベルと区別がつかない、または対象が分からない
- duplicate: 同じ画面の別ラベルと実質同じ意味になっている
- too_long: 読み上げに3秒以上かかり、先頭だけでは用が足りない
- wrong_register: その言語として不自然、または画面内で文体が揃っていない
一般論でラベルの良し悪しを語らず、渡した画面の中だけで比べてください。
suggestion は verdict が ok 以外のときだけ、その言語で書いてください。"""
def build_payload(records):
screen = []
for rec in records:
for locale, text in (rec.get("values") or {}).items():
screen.append(
{
"id": f"{rec['platform']}:{rec['icon']}",
"locale": locale,
"role": "button" if rec["interactive"] else "image",
"label": text,
}
)
return screen
if __name__ == "__main__":
records = json.load(open(sys.argv[1] if len(sys.argv) > 1 else "to_judge.json", encoding="utf-8"))
payload = build_payload(records)
if not payload:
print("判定対象がありません。ローカルの検問で全件が確定しました")
raise SystemExit(0)
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
response = client.models.generate_content(
model=MODEL,
contents=json.dumps({"screen": payload}, ensure_ascii=False),
config=types.GenerateContentConfig(
system_instruction=INSTRUCTION,
temperature=0,
response_mime_type="application/json",
response_schema=SCHEMA,
),
)
for item in sorted(json.loads(response.text)["results"], key=lambda r: (r["id"], r["locale"])):
mark = " " if item["verdict"] == "ok" else "→ "
print(f"{mark}{item['id']:<26}{item['locale']:<8}{item['verdict']:<14}{item['reason']}")
if item.get("suggestion"):
print(f"{'':<34}案: {item['suggestion']}")渡す情報を決めるところに、少し時間を使いました。アイコン名は渡していません。ic_share のような名前を見せると、ラベルではなくアイコン名の説明が返ってきてしまうためです。逆に、同じ画面に並ぶ他のラベルは必ず一緒に渡します。紛らわしいかどうかは、その画面の中でしか決まらないからです。
temperature は 0 にして、response_schema で形を固定しています。判定の名前が揺れると、翌週の出力と並べられなくなります。
読み上げの言葉は、画面を見ないと決まりません
返ってきた指摘のうち、そのまま採用したものは半分ほどでした。残りは自分で書き直しております。
たとえば「保存」は、一覧画面では通じますが、詳細画面に同じラベルの別ボタンが並ぶと紛らわしくなります。モデルからは長い言い換えが返ってきましたが、私は「この壁紙を保存」に落ち着けました。読み上げの先頭で用件が分かることを優先したかったのです。
長さの問題も、日本語だけを見ていると気づきません。「ランダム」は二拍ほどで読み終わりますが、同じ意味の語は言語によってかなり長くなります。並べて聞いてみると、短い語を先に置く語順のほうが速く用が足りることが分かりました。
——なお、画面全体を対象にした監査の設計や誤検出の畳み方は、Gemini API でアクセシビリティ監査を自動化する — 誤検出に振り回されない設計 に別途書いております。今回のようにラベルだけへ範囲を絞ると、検査は驚くほど短く書けます。
今日できる、いちばん小さな一手
読み上げを有効にして、自分のアプリの1画面だけを上から下まで辿ってみてください。スクリプトを書くのはそのあとで十分です。私も、辿ってみるまでは穴が開いていることに気づいておりませんでした。
スクリプトは、ラベルが無い場所を教えてくれます。モデルは、文言が濁っている場所を教えてくれます。——けれども、隅に並ぶアイコンの帯こそ人がいちばん手を伸ばすところだ、ということは、どちらも知りません。そこは自分で聞くほかありませんでした。
お読みいただきありがとうございました。同じ課題に向き合っている方の、最初の1画面の役に立てば幸いです。