お断り: 本記事は C2PA Technical Specification v2.4 §A.8(2026 年 4 月公開時点)と Encypher 社が公開しているリファレンス実装 encypherai/c2pa-text を、筆者が手元の macOS 環境で動かして検証したものです。仕様自体が「レビュー段階であり、運用フィードバックによって変更される可能性がある」と明記されている領域のため、本番導入にあたっては公式仕様書および最新のリポジトリをご確認ください。誤りや更新情報がございましたら、記事末尾のフィードバック枠よりお知らせいただけますと幸いです。
はじめに
C2PA Technical Specification v2.4 において特に注目を集めているのが、§A.8 で規定された「非構造化テキストへの埋め込み」です。従来はメタデータを格納するコンテナ構造を持たなかったプレーンテキストに対し、Unicode の Variation Selector(異体字セレクタ)という不可視文字を活用してマニフェストデータをバイト単位で埋め込むという、極めて意欲的な仕様拡張です。
大規模言語モデル(LLM)の出力テキストをクリップボード経由でコピー&ペーストするような利用シーンにおいて、「文字列データそのものに来歴や真正性情報を帯同させる」用途が想定されています。仕様の概要は前回の v2.4 解説記事でも触れましたが、本記事では実機における検証に焦点を当て、以下の 3 点を検証します。
- 公式 CLI ツール
c2patoolにおけるテキスト署名の現行サポート状況 - リファレンス実装 c2pa-text を用いた「埋め込み → 抽出」の完全な復元性
- 仕様書 §A.8.3.1 の変換アルゴリズム
byteToVariationSelectorの独自実装によるバイナリ一致検証
§A.8 の仕組み
検証手順を理解する前提として、§A.8 の基本アーキテクチャを整理します。仕様の全体像については前回の v2.4 解説記事も併せてご参照ください。
Variation Selector とは何か
Unicode には「Variation Selector」(異体字セレクタ)と呼ばれる制御文字群が存在します。本来は以下のような用途のために設計された、画面上に独立した幅や形状を持たない不可視の制御文字です。
- 漢字の異体字の指定(例:「辻」における一点しんにょうと二点しんにょうの切り替え)
- 絵文字の表示スタイル選択(テキスト風のモノクロ表示か、カラー絵文字表示か)
直前に配置された基底文字(漢字や記号など)のレンダリンググリフを後方から修飾・指定するのが本来の役割です。それ自体は字形を持たず表示幅もゼロであるため、直前に対応する文字が存在しない場合、レンダリング上は幅を持たない不可視文字としてそのまま保持されます。
具体例として、「辻」という漢字が異体字セレクタによってどのように処理されるかを Unicode データベースで確認してみます。
「辻」のコードポイントの特定
まず、「辻」の Unicode コードポイントを調べます。Python の ord() 関数で即座に取得可能です。
python3 << 'PY'
import unicodedata
ch = "辻"
print(f"文字 : {ch}")
print(f"10進数 : {ord(ch)}")
print(f"コードポイント : U+{ord(ch):04X}")
print(f"Unicode 名 : {unicodedata.name(ch)}")
print(f"UTF-8 バイト列 : {ch.encode('utf-8').hex(' ')}")
PY
実行結果は以下のとおりです。
文字 : 辻
10進数 : 36795
コードポイント : U+8FBB
Unicode 名 : CJK UNIFIED IDEOGRAPH-8FBB
UTF-8 バイト列 : e8 be bb
「辻」のコードポイントは U+8FBB であることが確認できます。
IVD(Ideographic Variation Database)の参照
Unicode コンソーシアムが管理する Ideographic Variation Database の公式ファイル IVD_Sequences.txt(2022-09-13 版) から 8FBB を検索すると、以下の 2 つの登録シーケンスが確認できます。
$ curl -sS https://www.unicode.org/ivd/data/2022-09-13/IVD_Sequences.txt | grep "^8FBB"
8FBB E0100; Adobe-Japan1; CID+3056
8FBB E0101; Adobe-Japan1; CID+8267
各エントリの対応関係は以下のとおりです。
| 入力文字列 | 対応する Adobe-Japan1 CID | 描画されるグリフ |
|---|---|---|
辻 + U+E0100(VS17) | CID+3056 | |
辻 + U+E0101(VS18) | CID+8267 |
文字列データとしては、「辻」の後ろに U+E0100 が付与されているか U+E0101 が付与されているかの違いに過ぎません。しかし IVS 対応フォントおよび対応レンダラーを介することで、字形の切り替えが実現されます。
ここで着目すべき点は、フォントや表示系が IVS に未対応であっても、U+E0100 や U+E0101 といったコードポイント列そのものは文字列バッファ内に完全に保全され、コピー&ペーストを行っても脱落せずに伝播するという事実です。C2PA §A.8 はこの特性を利用し、本来の字形指定用途から離れ、「不可視であり、プレーンテキストのやり取りで保全され、コードポイントがちょうど 256 種類存在する制御文字」として Variation Selector をデータ運搬に活用します。
Variation Selector のコードポイントは、Unicode 上で以下の 2 つの領域に定義されています。
| 領域 | コードポイント範囲 | 収録数 |
|---|---|---|
| 低位 Variation Selector(VS1〜VS16) | U+FE00 〜 U+FE0F | 16 文字 |
| 高位 Variation Selector(VS17〜VS256) | U+E0100 〜 U+E01EF | 240 文字 |
合計はちょうど 256 個です。この「1 バイトが表現できる状態数(0〜255 の 256 通り)」と完全に合致している点が、この仕様の中核となっています。
バイトと Variation Selector の 1 対 1 マッピング
C2PA §A.8 は、任意のバイナリデータを 1 バイトごとに対応する Variation Selector 1 文字へとマッピングします。
1 バイト(8 ビット)は 0 から 255 までの数値をとります。例えばマニフェストヘッダに含まれる jumb という文字列は、バイト表現では以下の 4 つの値となります。
| 文字 | 10進数値 | 16進表記 |
|---|---|---|
j | 106 | 0x6A |
u | 117 | 0x75 |
m | 109 | 0x6D |
b | 98 | 0x62 |
仕様 §A.8.3.1 では、この 0〜255 の数値を以下の規則で Variation Selector に割り当てます。
- 0〜15 の値:低位 VS(
U+FE00〜U+FE0F)に線形マッピング(例: 0 →U+FE00、15 →U+FE0F) - 16〜255 の値:高位 VS(
U+E0100〜U+E01EF)に線形マッピング(例: 16 →U+E0100、255 →U+E01EF)
単純な線形変換であるため、逆変換(デコード)も極めて軽量かつ一意に処理できます。
マニフェストを包含する C2PATextManifestWrapper
C2PA マニフェスト(JUMBF 形式のバイナリ)は、直接エンコードされるのではなく、C2PATextManifestWrapper というコンテナ構造に格納されてから Variation Selector 列へと変換されます(仕様 §A.8.2.1)。
aligned(8) class C2PATextManifestWrapper {
unsigned int(64) magic = 0x4332504154585400; // "C2PATXT\0"
unsigned int(8) version = 1;
unsigned int(32) manifestLength;
unsigned int(8) jumbfContainer[manifestLength];
}
各フィールドの定義は以下のとおりです。
| フィールド | サイズ | 役割 |
|---|---|---|
magic | 8 バイト | C2PA テキストマニフェストであることを示す識別子("C2PATXT\0" の ASCII 列) |
version | 1 バイト | フォーマットバージョン(v2.4 仕様では 1 固定) |
manifestLength | 4 バイト | 後続するマニフェスト本体のバイト長(ビッグエンディアン) |
jumbfContainer | manifestLength バイト | マニフェスト本体の JUMBF バイナリデータ |
ヘッダ部(13 バイト)とマニフェスト本体を連結したバイト列が生成され、その各バイトが順次 Variation Selector 文字へと変換されます。
配置規則と検出マーカー
エンコードされた不可視文字列をプレーンテキストに付加する際の規則は §A.8.4.1 に規定されています。
- 可視テキストの末尾に、連続した単一ブロックとして配置する(文中への挿入や分割配置は禁止)
- マニフェストブロックの直前に、検出マーカーとして
U+FEFF(Zero-Width No-Break Space: ZWNBSP)を 1 文字配置する - ZWNBSP 自体も描画幅を持たない不可視文字である
U+FEFF は通常 BOM(バイト順マーク)として知られますが、本仕様では「ここから C2PA テキストマニフェストが開始される」ことを示す境界識別子として機能します。パーサーはテキスト末尾方向を走査し、U+FEFF を検出した直後の 8 文字をデコードしてマジックナンバー "C2PATXT\0" を確認することで、有効なマニフェストの存在を高速に判定できます(§A.8.4.2)。
実証検証① c2patool によるテキスト署名の現状
まずは公式 CLI ツールである c2patool(v0.26.50)でプレーンテキストファイルの署名を試みました。
$ c2patool --version
c2patool 0.26.50
$ echo "これは LLM が生成したテキストです。" > sample.txt
$ c2patool sample.txt
Error: Unsupported file type
想定どおり Unsupported file type エラーが返されました。c2pa-rs 本体のコードベースを確認したところ、現行バージョンではテキスト形式を処理するアセットハンドラはまだマージされていません。
ただし、リファレンス実装元である Encypher 社により、2026 年 5 月 5 日付で PR #2117 “Add text/plain asset handler via c2pa-text crate” が提出されており、本体統合に向けた作業が進められています。
2026 年 5 月時点の実装ステータスは以下のとおりです。
| 実装環境 | テキスト署名対応 | 備考 |
|---|---|---|
| c2pa-rs / c2patool 本体 | 未対応 | PR #2117 にて text/plain ハンドラを開発中 |
| c2pa-text クレート(公式リファレンス) | 対応 | Python / TypeScript / Rust / Go の 4 言語で提供。エンコード・デコード層を担当 |
仕様自体の検証は c2pa-text クレートを用いることで先行して実施可能です。
実証検証② リファレンス実装 c2pa-text による往復検証
Encypher 社が公開しているリファレンス実装 encypherai/c2pa-text の Python 版を使い、データの埋め込みと抽出の可逆性を確認します。uv を用いて検証環境を構築しました。
cd /tmp
mkdir -p c2patext-trial
cd c2patext-trial
uv init --bare
touch main.py
uv add c2pa-text
主要 API は、文字列とバイナリを受け取る embed_manifest(text, manifest_bytes) -> str と、埋め込み済みテキストからバイナリと元テキストを復元する extract_manifest(watermarked_text) -> (bytes, str) の 2 つです。以下の検証スクリプトを作成しました。
from collections import Counter
from c2pa_text import embed_manifest, extract_manifest
# エンコード層の動作確認のため、JUMBF 構造を模したダミーバイト列を使用
fake_jumbf = b"\x00\x00\x00\x18jumb\x00\x00\x00\x10jumdc2pa\x00\x00\x00\x01"
text = "これは LLM が生成したテキストです。"
watermarked = embed_manifest(text, fake_jumbf)
print(f"fake_jumbf bytes : {len(fake_jumbf)}")
print(f"original chars : {len(text)}")
print(f"watermarked chars : {len(watermarked)}")
print(f"original utf8 bytes : {len(text.encode('utf-8'))}")
print(f"watermarked utf8 bytes : {len(watermarked.encode('utf-8'))}")
# 付加された不可視文字のコードポイント分布を確認
suffix = watermarked[len(text):]
buckets = Counter()
for c in suffix:
cp = ord(c)
if cp == 0xFEFF:
buckets["U+FEFF (ZWNBSP)"] += 1
elif 0xFE00 <= cp <= 0xFE0F:
buckets["U+FE00..U+FE0F (low VS)"] += 1
elif 0xE0100 <= cp <= 0xE01EF:
buckets["U+E0100..U+E01EF (high VS)"] += 1
print("suffix breakdown:", dict(buckets))
# 往復の完全性(ラウンドトリップ)確認
extracted, clean = extract_manifest(watermarked)
print("round-trip ok :", extracted == fake_jumbf and clean == text)
実行結果は以下のとおりです。
fake_jumbf bytes : 24
original chars : 20
watermarked chars : 58
original utf8 bytes : 50
watermarked utf8 bytes : 186
suffix breakdown: {'U+FEFF (ZWNBSP)': 1, 'U+E0100..U+E01EF (high VS)': 22, 'U+FE00..U+FE0F (low VS)': 15}
round-trip ok : True
文字数は 20 文字から 58 文字へ、UTF-8 バイト数は 50 バイトから 186 バイトへと増加しています。付加された文字の内訳は「ZWNBSP × 1」+「Variation Selector × 37」となっており、24 バイトの JUMBF データに 13 バイトのラッパーヘッダを加えた計 37 バイトと正確に一致します。
また、round-trip ok : True が示すとおり、抽出処理によって元のバイナリデータおよび元のテキスト文字列が欠落なく復元されることが確認できました。
実証検証③ 仕様アルゴリズムの独自実装によるバイナリ一致確認
仕様の透明性と実装容易性を評価するため、§A.8.3.1 に定義された変換ロジックを約 30 行の Python コードで自前実装し、リファレンス実装と完全に出力が一致するかをテストしました。
import struct
from c2pa_text import embed_manifest, extract_manifest
MAGIC = b"C2PATXT\x00"
VERSION = 1
def byte_to_vs(b: int) -> str:
if 0 <= b <= 15:
return chr(0xFE00 + b)
if 16 <= b <= 255:
return chr(0xE0100 + (b - 16))
raise ValueError(f"out of range: {b}")
def vs_to_byte(c: str) -> int:
cp = ord(c)
if 0xFE00 <= cp <= 0xFE0F:
return cp - 0xFE00
if 0xE0100 <= cp <= 0xE01EF:
return cp - 0xE0100 + 16
raise ValueError(f"not a c2pa text VS: U+{cp:04X}")
def my_embed(text: str, jumbf: bytes) -> str:
wrapper = MAGIC + struct.pack(">BI", VERSION, len(jumbf)) + jumbf
encoded = "".join(byte_to_vs(b) for b in wrapper)
return text + "\uFEFF" + encoded
def my_extract(text: str) -> bytes:
idx = text.find("\uFEFF")
if idx < 0:
raise ValueError("no ZWNBSP marker")
raw = bytes(vs_to_byte(c) for c in text[idx + 1:])
assert raw[:8] == MAGIC, f"bad magic: {raw[:8]!r}"
length = struct.unpack(">I", raw[9:13])[0]
return raw[13:13 + length]
# 相互検証
fake = b"\x00\x00\x00\x18jumb\x00\x00\x00\x10jumdc2pa\x00\x00\x00\x01"
text = "Hello, signed plain text."
ref = embed_manifest(text, fake) # c2pa-text の出力
mine = my_embed(text, fake) # 独自実装の出力
print("byte equal:", ref == mine)
print("c2pa-text decodes mine:", extract_manifest(mine)[0] == fake)
print("mine decodes c2pa-text:", my_extract(ref) == fake)
実行結果は以下のとおりです。
byte equal: True
c2pa-text decodes mine: True
mine decodes c2pa-text: True
簡潔な独自実装でありながら、公式リファレンス実装とバイト単位で完全に一致し、双方向でのデコードにも成功しました。仕様書 §A.8.3.1 の定義が明確かつ曖昧さのない形で標準化されていることが実証されています。
おわりに
C2PA v2.4 §A.8 で提案された不可視テキスト署名について、公式ツールの現状把握、リファレンス実装による往復性の確認、そして独自コードによるバイナリレベルの一致検証を行いました。
本仕様は現在レビュー段階であり、実運用からのフィードバックに基づいて調整される可能性がありますが、コンテナを持たないプレーンテキストに対する来歴保証アプローチとして非常に洗練されたアーキテクチャを有しています。
LLM 出力やソースコード、テキストデータに対する Content Credentials の付与・検証設計については、お問い合わせよりご相談ください。
参考資料
- C2PA Technical Specification v2.4 §A.8 Embedding Manifests into Unstructured Text
- C2PA Technical Specification v2.4 §A.8.3.1 Byte to Variation Selector Conversion
- C2PA Technical Specification v2.4 §A.8.4.1 Placement Rules
- encypherai/c2pa-text リファレンス実装(Python / TS / Rust / Go)
- contentauth/c2pa-rs PR #2117 Add text/plain asset handler via c2pa-text crate
- 前回記事 C2PA Technical Specification v2.4 公開解説