お断り: 本記事は C2PA Technical Specification v2.4 §A.8(2026 年 4 月公開時点)と Encypher 社が公開しているリファレンス実装 encypherai/c2pa-text を、筆者が手元の macOS 環境で動かして整理したものです。仕様自体が「review 中、運用フィードバック次第で変更される可能性がある」と明記されている領域なので、本番投入の判断には公式仕様書と最新の実装をご確認ください。誤りを見つけられた場合は記事末尾のフィードバック枠よりお知らせいただけると助かります。

はじめに

C2PA Technical Specification v2.4 で個人的に一番面白いと思っているのが §A.8 の「非構造化テキストへの埋め込み」です。これまでマニフェストを乗せる場所がなかったプレーンテキストに、Unicode の Variation Selectors という不可視文字を使ってマニフェストをバイト単位で忍び込ませる、という割と攻めた追加仕様です。

LLM が出力したテキストをコピペで他システムに貼り付けるような場面で「文字列そのものに来歴情報を持たせたい」という用途を想定しています。仕様の概要は前回の v2.4 解説記事で軽く触れましたが、本記事は実機で動かす側に振り切って、

  • c2patool が現時点でテキスト署名に対応しているか
  • リファレンス実装 c2pa-text で「埋め込んで → 取り出す」と元のバイト列と元のテキストにぴったり戻るか
  • 仕様書 §A.8.3.1 の byteToVariationSelector を自前で書き直すと参照実装とバイト一致するか

を順番に確認していきます。

§A.8 の仕組み

ここからの手順を理解するために必要な §A.8 の仕組みを、前提なしで読めるように整理します。詳しい仕様の全体像は前回の v2.4 解説記事にもまとめているので、合わせて読むとより輪郭が掴みやすいはずです。

Variation Selector とは何か

Unicode には「Variation Selector」(異体字セレクタ) と呼ばれる文字グループがあります。元々は次のような用途のために用意された、それ自体は画面に何も描かない不可視の制御文字です。

  • 漢字の異体字を選び分ける(同じ「辻」でも一点しんにょうか二点しんにょうか、など)
  • 絵文字を「文字寄せ」表示にするか「絵文字寄せ」表示にするかを選ぶ

直前にある文字(漢字・絵文字など)を「どう描くか」を後ろから指定するための補助記号、というのが本来の役割です。直前の文字の見た目を切り替えるのが仕事なので、Variation Selector 自身は字形を持たず、横幅もゼロです。直前に対応する文字がなければ、ただの不可視文字として何もせずそこに居続けます。

具体例として、先ほど挙げた「辻」が異体字セレクタでどう扱われるかを実際に Unicode のデータベースで引いてみます。

まず「辻」のコードポイントを調べる

Unicode のデータベースを引くには、「辻」が Unicode 上のどの番号(コードポイント)に割り当てられているかを最初に知る必要があります。Python の ord() でその場で確認できます。下のスニペットはターミナルに丸ごと貼り付けて Enter を押せば、そのまま実行できる形にしてあります。

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

ord(ch) で 10 進数の 36795 が得られ、これを 16 進数で表記すると 0x8FBB、Unicode の表記ルールに合わせると U+8FBB です。unicodedata.name() でも CJK UNIFIED IDEOGRAPH-8FBB という末尾 4 桁の 16 進数として同じ番号が確認できます。

「辻」のコードポイントから IVD を引く

「辻」が U+8FBB だと分かったので、Unicode コンソーシアム公式の異体字データベース Ideographic Variation Database で実際に引いてみます。公式ファイル IVD_Sequences.txt (2022-09-13 版)8FBB で始まる行に絞って grep すると、Adobe-Japan1 への登録として次の 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+3056CID+3056 辻 一点しんにょう JIS X 0208:1990 (90JIS) の「辻」 = 一点しんにょう
+ U+E0101(VS18)CID+8267CID+8267 辻 二点しんにょう JIS X 0213:2004 (2004JIS) の「辻」 = 二点しんにょう(印刷標準字体)

CID 番号のリンク先は Adobe 公式リポジトリ adobe-type-tools/Adobe-Japan1aj17-kanji.txt の該当行で、各 CID に対応する Unicode 基底文字と IVS シーケンス(<U+8FBB,U+E0100> など)が直接書かれています。字形そのもの(一点しんにょう/二点しんにょうの絵)は同リポジトリの Adobe-Japan1-7.pdf を開いて PDF 内検索で 3056 / 8267 を引くと、CID 順に並んだグリフチャートのページに飛んで確認できます。

つまり、文字列としては「辻」のあとに U+E0100 が付いているか U+E0101 が付いているかの違いしかありませんが、Adobe-Japan1 対応フォント(ヒラギノ・小塚明朝・IPAex 明朝など)でかつ IVS をサポートする環境(Adobe Illustrator / InDesign、最新の Web ブラウザなど)で描画させると、前者は一点しんにょう、後者は二点しんにょうの字形に切り替わって表示されます。これが Variation Selector 本来の使い方です。

ここで重要なのは、フォントやレンダラ側が IVS に対応していなくても、U+E0100U+E0101 かというコードポイント列自体は確実に文字列の中に残り、コピペでもそのまま運ばれていく、ということです。C2PA §A.8 はこの性質を逆手に取り、字形選択の意味を完全に無視して「不可視で、コピペで一緒についていく、コードポイントが 256 種類確保されている文字」としてだけ Variation Selector を使います。 Variation Selector の文字番号(コードポイント)は、Unicode 上で次の 2 つの範囲に固まっています。

範囲コードポイント個数
低位 Variation Selector (VS1〜VS16)U+FE00U+FE0F16 個
高位 Variation Selector (VS17〜VS256)U+E0100U+E01EF240 個

合計 256 個。ここがこの仕組みの肝で、「1 バイトで表せる値の個数(0 〜 255 で 256 通り)」とちょうど同じ数だけ揃っています。

バイトと Variation Selector の 1 対 1 対応

C2PA §A.8 はこの「不可視で、ちょうど 256 個ある文字」を、絵文字選択ではなくデータ運搬のラベルとして使います。やっていることをひとことで言うと、

任意のバイナリデータの 1 バイトを、対応する Variation Selector 1 文字に置き換える

これだけです。

そもそも「バイト」というのはコンピュータがデータを 8 ビット (= 0 と 1 が 8 個並んだもの) 単位で扱う際の最小のかたまりです。8 ビットの組み合わせは 2 の 8 乗 = 256 通りあり、数値で表現すると 0 から 255 までの整数になります。たとえばマニフェストの先頭に出てくる「jumb」という 4 文字は、コンピュータ的には次の 4 バイトです。

文字値 (10進)値 (16進)
j1060x6A
u1170x75
m1090x6D
b980x62

C2PA §A.8 はこの「0〜255 の数」を、先ほどの 256 個の Variation Selector に次のルールで割り当てます(§A.8.3.1)。

  • 値が 0〜15 のバイトは、低位 VS の U+FE00 から順番に対応させる(値 0 → U+FE00、値 15 → U+FE0F
  • 値が 16〜255 のバイトは、高位 VS の U+E0100 から順番に対応させる(値 16 → U+E0100、値 255 → U+E01EF

つまり 256 個の値と 256 個の文字番号を「数を 16 でグループ分けして、低い 16 個は近場の VS、残り 240 個は遠くの VS」に並べ替えただけ、という素朴な対応表です。逆向きも同じ表をひっくり返すだけなので、デコード(取り出し)も同じくらいシンプルに書けます。

マニフェスト本体を包む 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];
}

unsigned int(64) というのは「64 ビット (= 8 バイト) の符号なし整数」という意味で、以下それぞれ次の長さです。

フィールド長さ役割
magic8 バイト「これは C2PA テキストマニフェストですよ」と宣言する目印("C2PATXT\0" の ASCII バイト列)
version1 バイトフォーマットのバージョン番号(v2.4 時点では常に 1
manifestLength4 バイトこのあとに続くマニフェスト本体の長さ(バイト数)
jumbfContainermanifestLength バイトマニフェスト本体(JUMBF バイト列)

「目印」「バージョン」「これから何バイト続くか」「本体」というシンプルな並びです。エンコード時には合計で 13 + マニフェスト長 バイトのバイト列ができあがり、それを 1 バイトずつ Variation Selector に置き換えていく、という流れになります。

配置ルール 末尾に貼って先頭に小さな目印を置く

エンコードしたバイト列を可視テキストにくっつける際のルールは §A.8.4.1 に書かれています。押さえておくべきポイントは 3 つです。

  • 可視テキストの末尾に、連続した 1 ブロックとして配置する(途中に挟まない・分割しない)
  • ラッパー全体の直前に、目印として U+FEFF(Zero-Width No-Break Space, 略して ZWNBSP)を 1 つだけ置く
  • ZWNBSP もそれ自体は何も描画しない、横幅ゼロの不可視文字

U+FEFF はバイト順マーク (BOM) としてよく見かける文字ですが、ここでは「この直後から C2PA テキストマニフェストが始まりますよ」という検出マーカー兼、前のテキストとマニフェストを混ぜないための区切りとして使われています。検出側は「テキストを順に読んで U+FEFF を見つけたら、その直後の 8 文字を Variation Selector としてデコードして、"C2PATXT\0" のマジックと一致したら本物」というシンプルな判定で済みます(§A.8.4.2)。

ここまで分かれば、§A.8 の仕組みは「マニフェスト本体をラッパーで包んで、1 バイト → 1 不可視文字に置き換えて、テキストの末尾に目印つきで貼る」というだけのものだと整理できます。ここからは実機でこの動きを順に確認していきます。

やってみた① c2patool でテキスト署名を試す

まず手元で動く c2patool.txt を渡してみます。今回試したのは c2patool 0.26.50(前回 v2.4 解説記事を書いたときに cargo install c2patool した版)です。

$ c2patool --version
c2patool 0.26.50

$ echo "これは LLM が生成したテキストです。" > sample.txt
$ c2patool sample.txt
Error: Unsupported file type

予想どおり Unsupported file type で蹴られました。c2pa-rs 側のコードを text_io / variation_selector などで grep しても該当ファイルはなく、テキストアセットハンドラはまだ本体に入っていない、という状態です。

ただし、§A.8 のリファレンス実装を公開している Encypher 社が 2026-05-05 付けで PR #2117 “Add text/plain asset handler via c2pa-text crate” を上げており、本体マージへの動きは始まっています。

つまり 2026-05 時点での実装ステータスは次のような棲み分けになっています。

実装テキスト署名対応備考
c2pa-rs / c2patool 本体未対応PR #2117 で text/plain asset handler を追加中
c2pa-text crate(Encypher 公式リファレンス)対応Python / TypeScript / Rust / Go の 4 言語で公開、エンコード層のみ担当

リファレンス実装に役割が完全に切れているので、いったん本体を待たずに c2pa-text crate 側で §A.8 の挙動を確認できます。

やってみた② リファレンス実装 c2pa-text で埋め込みと取り出しの往復を確認

encypherai/c2pa-text は Encypher 社が公開している実装です。Python 版を uv で動かします。作業ディレクトリを /tmp 配下に切って uv init --barepyproject.toml だけ作り、uv add c2pa-text で依存を追加します。

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) -> strextract_manifest(watermarked_text) -> (bytes, str) の 2 本がメインです。動作確認用の短いスクリプトを main.py に書きます。

from collections import Counter
from c2pa_text import embed_manifest, extract_manifest

# 本来は c2patool が吐く 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))

# round-trip
extracted, clean = extract_manifest(watermarked)
print("round-trip ok :", extracted == fake_jumbf and clean == text)
uv run main.py

手元での実行結果は次のとおりです。

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 に増えていて、不可視文字の分だけ確実に膨らんでいます。suffix breakdown は ZWNBSP × 1 + Variation Selector × 37 で、24 バイトのダミー JUMBF にラッパーヘッダ 13 バイト(マジック 8 + バージョン 1 + 長さ 4)を足した 37 バイトと正確に一致します。仕様 §A.8.3.1 の「1 バイト → 1 Variation Selector」が素直に効いていることが確認できます。

round-trip ok : True のとおり、embed_manifest で埋め込んだ後に extract_manifest で取り出すと、元のバイト列と元のテキストの両方がぴったり元に戻りました。

やってみた③ §A.8.3.1 を 30 行で自前実装してバイト一致を確認

エンコード層の安心感を上げる意味で、仕様 §A.8.3.1 のアルゴリズムを Python で自前実装し、リファレンス実装と出力がバイト単位で一致するかを見ます。先ほどの main.py を次の内容で置き換えます。

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 + "" + encoded

def my_extract(text: str) -> bytes:
    idx = text.find("")
    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)
uv run main.py

実行結果は以下です。

byte equal: True
c2pa-text decodes mine: True
mine decodes c2pa-text: True

30 行ちょっとの自前実装で、リファレンス実装と完全に同じバイト列が出る、相互にデコードできる、まで取れました。仕様 §A.8.3.1 が本当にこのまま読んで実装できる粒度で書かれていることの証拠でもあります。

おわりに

C2PA v2.4 §A.8 の不可視テキスト署名を、c2patool 未対応 → リファレンス実装で動作確認 → 仕様アルゴリズムを自前実装してバイト一致、という順番で触ってみました。

仕様自体は §A.8.1 が「review 中で運用フィードバック次第で変更されうる」と明記している段階なので、本番ワークロードに乗せる前には最新の仕様改訂と c2pa-rs の対応状況をもう一度確認してください。

C2PA を使ったテキスト・コード・LLM 出力の来歴証明の設計や PoC のご相談がありましたら、お問い合わせからお気軽にどうぞ。

参考資料