CSV更新→PDF自動生成→印刷会社へ。社員名刺の発注ワークフローをコード化した
3秒まとめ
- Illustratorを立ち上げて1人ずつ名刺データを作る運用をやめた。社員名簿からコピー&ペースト→保存→アウトライン化→書き出しで 1人あたり10分 が固定で溶けていたので、CSV+Pythonで自動生成するパイプラインに置き換えた
- 印刷業者のテンプレ(トンボ・塗り足し3mm・CMYK・フォントアウトライン化)に完全一致するPDFを ReportLab + Ghostscript で出力
-
employees.csvを更新したら GitHub Actions が走って全員分のPDFをZIPで成果物公開。新入社員オンボーディングのワークフローに組み込めば、入社日にあわせて自動発注まで一気通貫にできる - コード生成は Gemini 3.1 Pro / Claude Opus 4.7 / Claude Code の
frontend-designSkill の3レビュアー体制でデザイン指摘を吸い上げた

どんな人向けの記事?
- Illustratorで名刺やDM、はがきを 1人ずつ 作っていて消耗してる総務・コーポレートの人
- 入社のたびにIllustratorを開いて名簿からコピー&ペーストして書き出して、を繰り返している総務・コーポレートの人
- DTP入稿向けのPDF(トンボ・塗り足し・CMYK・フォントアウトライン化)を コードから 生成したい人
- 入社オンボーディングの「貸与品・名刺・備品」発注を自動化したいSREや情シスの人
- AIエージェントにレビューさせるソフトウェア開発の流れを実例で見たい人
なぜ作ったのか
弊社(株式会社マインディア)の名刺はこんな感じで、デザインはかなりシンプルです。表面は日本語、裏面は英語、コーポレートカラーのオレンジ系グラデーションにロゴが入るだけ。
それでも今までは、新入社員が入ってくるたびに 管理部のメンバーが以下の手順を毎回繰り返す 運用でした。
- 起動の重いIllustratorを立ち上げる
- テンプレートとなる名刺データ(.ai)を開く
- 社員名簿のスプレッドシートを横に並べて、 氏名・肩書き・メールアドレスをコピー&ペースト する
- ファイルを名前を付けて保存する
- テキストを全選択してアウトライン化 する
- PDFに書き出す
- 印刷業者の入稿サイトにアップロードして発注
これで 1人あたりおよそ10分 が固定で溶けます。1人だけなら「まあ10分か」で済むんですが、入社が立て込むタイミングだと10人分まとめてやることになるので 半日が消えます。
しかも単純作業に見えて、 どの工程もミスれない タチの悪さがあります。
- Illustratorの起動でまず数十秒待たされる(Apple Siliconでも)
- コピー&ペースト元の名簿セルを1つズラすと、別人のメールアドレスが入った名刺ができあがる
- アウトライン化を忘れると、業者環境でフォントが化けて全部やり直し
- 書き出すPDFのプリセット(CMYK / 塗り足し)を間違えると入稿リジェクト
そして地味に効いてくるのが、共通情報を変更したときの全ファイル書き換え です。たとえば会社が移転して住所が変わると、原則として全員分の .ai ファイルを開いて1つずつ直すことになります。共通要素をレイヤーやシンボルとして共有する作り込みをしておけば一括更新もできますが、そこまで整備されているケースは多くありません。名刺の種類(=社員数)が増えるほど、この種のメンテナンスコストは線形に膨らんでいきます。
しかも、弊社の名刺デザインは シンプル です。テキストとロゴとグラデーションだけ。「人間がIllustratorでファイルを1つずつ書き換える」運用にすべき理由は、客観的に見てひとつもありません。
管理部の 本来のコア業務にもっと時間を使ってもらう ためにも、ここはコードで殴るのが正解だろうと判断しました。CTO(私)が「自動化基盤を作って渡す」役割、管理部は「CSVを更新する」役割、という分業に再設計したのが今回の取り組みです。
いわば Infrastructure as Code の名刺版、「Business Card as a Code」。略してBCaC です。名刺という成果物を、Illustratorの手作業ではなく コードとCSVから宣言的に生成する というアプローチです。
完成したもの
最終的にこういう体験になりました。
# 全社員分を生成 (未入力行はスキップ)
$ make build
# サンプル CSV で 1 名分を確認
$ make sample
$ open output/taro_yamada.pdf
# 1名分だけ生成 (employee_id 指定)
$ python -m minedia_namecard --csv employees.csv --out output --filter-id XX
出力は印刷業者(MHTデザイン を想定)の入稿テンプレに 完全一致 したPDF。トンボ・塗り足し・CMYK・フォントアウトライン化済み。そのまま入稿サイトにアップロードすれば発注完了です。
make build を打つと、employees.csv の全社員分が output/ 配下にPDFで吐かれます。
output/
├── taro_yamada.pdf
├── hanako_sato.pdf
├── ichiro_tanaka.pdf
└── ...
そして後述しますが、employees.csv を main ブランチにpushすると GitHub Actions が全員分を生成してリリースとして公開 します。管理部側からは「CSVに行を1つ足してPRをマージするだけ」で名刺発注用のファイルが手に入る、という体験です。
全体アーキテクチャ
ざっくり書くとこういう流れです。
データ・テンプレ・設定・コードをきっちり分離しているのがポイントです。氏名やメールアドレスのような 可変データ はCSV、会社住所や色のような 半固定値 はTOML、ロジックは Python、デザインの正解は 業者テンプレ に置いてあります。
そしてCI/CD側はこう。
技術スタック
| 用途 | 採用したもの | 理由 |
|---|---|---|
| 言語 | Python 3.11+ |
tomllib 標準搭載、Pydantic v2、データ加工とPDF生成の両方が得意 |
| PDF生成 | ReportLab | DTP向けPDFを細かく制御できる老舗ライブラリ。CMYK・グラデーションも対応 |
| SVG読込 | svglib | アイコンSVGの読み込み用。ただしロゴのlinearGradientは自前パースに切替(後述) |
| データ検証 | Pydantic v2 | CSV/TOMLのバリデーションをスキーマで定義。EmailStr 型を指定するだけで、メールアドレス形式のチェックを自前で書かなくて済む |
| CLI | Typer |
--csv --out --filter-id のような引数定義が型注釈だけで済む |
| アウトライン化 |
Ghostscript (gs -dNoOutputFonts) |
フォント埋め込みではなく パス化。業者のフォント環境に依存しなくなる |
| テンプレ読込 |
PyMuPDF (fitz) |
業者の入稿テンプレPDFからトンボの線分を 実測 して再現 |
| 依存管理 |
pyproject.toml + pip install -e .
|
シンプルさを優先 |
| CI | GitHub Actions | CSV更新→PDF全件再生成→Releases公開 |
入稿仕様の落とし穴と、それをどう超えたか
ここからが本題の技術パートです。
「名刺PDFを作って印刷業者に送るだけ」と聞くと簡単そうに見えますが、商用印刷向けのPDFには 守らないとリジェクトされる仕様 がいくつもあります。
1. ページサイズ・トンボ位置を業者テンプレと完全一致させる
印刷業者のサイトには「入稿テンプレ」が配布されていて、これと 寸分違わぬ位置にトンボ・塗り足し・仕上がり線を置く ことが求められます。1mmでもずれると、断裁線が狂って名刺がズレた状態で仕上がります。
弊社が使っている業者の template_91x55.pdf をPyMuPDFで開いて、トンボ(コーナーのL字線)の座標を 実測 して持ってきました。
@lru_cache(maxsize=1)
def _extract_tombo_lines() -> tuple[tuple[float, float, float, float], ...]:
"""template_91x55.pdf からコーナー近傍の黒い直線のみ抽出する."""
doc = fitz.open(str(TEMPLATE_PATH))
page = doc[0]
page_h = page.rect.height
lines: list[tuple[float, float, float, float]] = []
for d in page.get_drawings():
if d.get("type") != "s":
continue
color = d.get("color")
if color is None or not all(abs(c) < 0.01 for c in color):
continue # 黒以外は除外
for it in d.get("items", []):
if it[0] != "l":
continue
p1, p2 = it[1], it[2]
mx, my = (p1.x + p2.x) / 2, (p1.y + p2.y) / 2
if not _is_near_corner(mx, my):
continue # bleed コーナー近傍のみ
# 軸並行のみ採用 (テキスト罫線等のノイズを除外)
if abs(p1.x - p2.x) > 0.05 and abs(p1.y - p2.y) > 0.05:
continue
x1, y1 = p1.x, page_h - p1.y
x2, y2 = p2.x, page_h - p2.y
lines.append((x1, y1, x2, y2))
doc.close()
return tuple(sorted(set(lines)))
PyMuPDFのY軸は上原点ですが、ReportLabは下原点。page_h - p1.y で 座標系を変換 しています。地味だけど、ここを間違えるとトンボが上下逆さまになって入稿不可になります。
工夫したポイントは2つ。
- 業者テンプレに混ざってる注意書きテキスト(「一般名刺 91×55mm」「裁断位置はここ」みたいな線)を全部除外 したい。コーナー近傍(30pt以内)かつ軸並行(横線か縦線)の 黒線のみ をフィルタしています
-
@lru_cacheで結果を1回だけ計算。社員数分のPDFを生成するとき、毎回PDFを開いて抽出しなおすのは無駄なのでキャッシュ
2. CMYK + フォントアウトライン化
商用印刷の業者は基本RGBを受け付けません。CMYK(シアン・マゼンタ・イエロー・ブラック)でデータを作る必要があります。さらに、業者の環境にフォントが入っていなくても文字化けしないよう、フォントを「文字情報」ではなく「パスの集合」に変換 する必要があります(アウトライン化)。
ReportLabは一応CMYK出力もできるのですが、フォントのアウトライン化はできません。そこでGhostscriptで後処理しています。
def build_gs_command(input_pdf: Path, output_pdf: Path) -> list[str]:
gs = shutil.which("gs") or "gs"
return [
gs,
"-o", str(output_pdf),
"-sDEVICE=pdfwrite",
"-dNoOutputFonts", # ← これがアウトライン化
"-dCompatibilityLevel=1.6",
"-sColorConversionStrategy=CMYK", # ← CMYK変換
"-dProcessColorModel=/DeviceCMYK",
"-dPDFSETTINGS=/prepress", # ← 商用印刷品質プリセット
"-dNOPAUSE",
"-dBATCH",
"-dQUIET",
str(input_pdf),
]
-dNoOutputFonts がGhostscriptの「フォント情報を出力せず、すべてパスに変換する」フラグです。これでフォント未埋め込みでも文字が崩れなくなります。-dPDFSETTINGS=/prepress で商用印刷向けの解像度・圧縮設定にしてくれます。
CLIの呼び出し側ではこの後処理を オプションでスキップ できるようにもしています(デバッグ時はテキスト選択可能なPDFがほしい)。
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
intermediate = Path(tmp.name)
try:
render_card(emp, info, intermediate)
if no_outline:
intermediate.replace(final_path)
else:
outline_pdf(intermediate, final_path)
intermediate.unlink(missing_ok=True)
finally:
if intermediate.exists():
intermediate.unlink(missing_ok=True)
tempfile.NamedTemporaryFile(delete=False) で一旦中間PDFを書いて、Ghostscriptに食わせて最終PDFを生成。例外が起きても finally で必ず中間ファイルを掃除しています。
3. ロゴのSVGは自前パーサで描画する(svglibの限界)
会社ロゴは Adobe Illustrator からSVGエクスポートしたものを assets/logo/text_logo.svg に置いてます。アイコン(メール・電話・URL)も同様。
ところが、svglib には linearGradientがうまく描画されない という地雷があります。弊社のロゴは「左下サーモン#F54040→右上ピーチ#F5AA77」のCIグラデーションが命なので、これがフラットな単色で描画されるのは許容できない。
そこで、SVGの <path d="..."/> だけを正規表現で取り出して、自前パーサでReportLabのPath APIに流し込む という実装にしました。
def _emit_path(p, d: str, sx: float, sy: float, tx: float, ty: float) -> None:
"""SVG d を ReportLab Path に流し込む.
座標変換: ReportLab_x = svg_x * sx + tx, ReportLab_y = svg_y * sy + ty.
SVG は Y軸下向き、ReportLab は上向きなので sy は負を渡す。
対応コマンド: M / L / H / V / C / Z (絶対座標のみ)
"""
cmds = _parse_path(d)
cx, cy = 0.0, 0.0
start_x, start_y = 0.0, 0.0
for cmd, args in cmds:
if cmd == "M":
...
elif cmd == "C":
i = 0
while i + 5 < len(args):
x1 = args[i] * sx + tx
y1 = args[i + 1] * sy + ty
x2 = args[i + 2] * sx + tx
y2 = args[i + 3] * sy + ty
x = args[i + 4] * sx + tx
y = args[i + 5] * sy + ty
p.curveTo(x1, y1, x2, y2, x, y)
cx, cy = x, y
i += 6
...
汎用SVGパーサを作るのは大変ですが、Illustratorから書き出された自社ロゴのSVGは絶対座標 M / L / H / V / C / Z しか使わない と分かっているので、対応コマンドはこれだけで十分です。「ライブラリの欠点を、自前実装で限定的にバイパスする」という割り切り。
これでReportLab native の clipPath + linearGradient を組み合わせて、左下→右上のCIグラデーションを完全に再現できました。
def draw_logo_gradient(c, x_right, y_bottom, target_w_pt):
sx, sy, tx, ty, target_h = _transform(x_right, y_bottom, target_w_pt)
bbox_left = tx
bbox_right = tx + target_w_pt
bbox_bottom = y_bottom
bbox_top = y_bottom + target_h
for d in _get_paths():
p = c.beginPath()
_emit_path(p, d, sx, sy, tx, ty)
c.saveState()
c.clipPath(p, fill=0, stroke=0)
c.linearGradient(
bbox_left, bbox_bottom,
bbox_right, bbox_top,
(colors.CI_GRAD_START, colors.CI_GRAD_END),
)
c.restoreState()
文字のパスでクリップしておいてから、グラデーションで塗りつぶす。SVGのfill=url(#linearGradient...)と等価な処理を、ReportLabの語彙に翻訳した感じです。
4. フォントサイズ下限ガード
これは安価オフセット印刷の 物理的な制約 から生まれたガードです。
# MHTデザインのような低価格オフセット業者では、用紙が安価で
# インクの滲み (dot gain) が大きい。一般的な業界下限 (本文 6pt) では
# かすれ・潰れリスクがあるため、本プロジェクトでは **7pt を絶対下限** と定める。
MIN_FONT_SIZE_PT = 7.0
FONT_SIZE_NAME_PT = 14.0
FONT_SIZE_BODY_PT = 8.0
FONT_SIZE_SUPPLEMENTARY_PT = 7.0
def assert_min_font(size_pt: float) -> float:
"""フォントサイズ下限を強制するガード."""
if size_pt < MIN_FONT_SIZE_PT:
raise ValueError(
f"font size {size_pt}pt < project minimum {MIN_FONT_SIZE_PT}pt "
f"(安い印刷業者では潰れる可能性があります)"
)
return size_pt
将来、誰かが「もうちょっと文字小さくしてもいいんじゃない?」と言い出して 6pt にしたとき、刷り上がってきた名刺を見て「読めねえ……」となるのを防ぐためのガード。ドメイン知識をコードに埋め込む タイプのバリデーションです。
「コードレベルでデザイナーの代わりをする」という発想。実物の印刷物が出来上がるまでフィードバックに5営業日かかる領域なので、こういう静的な制約は手厚めに置いておきました。
5. データはPydanticで型安全に
CSVもTOMLもPydanticでスキーマ化しています。email: EmailStr と型を1行書くだけで、hoge@@example のような壊れたメールアドレスをパース段階で弾けます。正規表現を自前で書く必要なし。
class Employee(BaseModel):
model_config = {"extra": "ignore"} # name_kana など未定義カラムは無視
employee_id: Optional[str] = None
name_ja: str
name_en: str
title_ja: Optional[str] = None
title_en: Optional[str] = None
email: EmailStr
direct_phone: Optional[str] = None
filename: Optional[str] = None
@field_validator("employee_id")
@classmethod
def _validate_id_chars(cls, v: Optional[str]) -> Optional[str]:
if v is not None and not _EMPLOYEE_ID_RE.match(v):
raise ValueError(
f"employee_id must match [A-Za-z0-9]+, got: {v!r}"
)
return v
extra="ignore" で、CSVに name_kana のような「PDFには使わないけど社内管理で持っておきたい」カラムが混ざっていても、シカトしてくれます。社内のスプレッドシートをそのままexportしてもパースできる柔軟性をデフォルトに。
load_csv は 全行を1度パースしてからまとめてエラーを返す 実装にしました。「1行目のエラーで止まる → 直して再実行 → 2行目のエラーで止まる」という地獄を避けるためです。
for line_no, row in enumerate(reader, start=2):
row = {k: (v or "").strip() for k, v in row.items()}
if skip_incomplete and not _is_row_complete(row):
continue
try:
emp = Employee(**row)
except ValidationError as e:
errors.append(f"line {line_no}: {e}")
continue
if emp.employee_id is not None:
if emp.employee_id in seen_ids:
errors.append(
f"line {line_no}: duplicate employee_id={emp.employee_id}"
)
continue
seen_ids.add(emp.employee_id)
employees.append(emp)
if errors:
raise CsvParseError("\n".join(errors))
エラーを蓄積しておいて最後にまとめて投げる。CSVを直すときの認知負荷がぐっと下がります。
設定の分離: config.toml が会社の「定義」
可変データはCSV、半固定値はTOML、ロジックはコード、と書きました。config.toml はこんな感じです。
[colors]
# CMYK [C, M, Y, K] (0.0-1.0)。MHT入稿はCMYKのみ。
black = [0.0, 0.0, 0.0, 1.0]
ci_grad_start = [0.0, 0.83, 0.74, 0.0] # #F54040 サーモン
ci_grad_end = [0.0, 0.40, 0.56, 0.0] # #F5AA77 ピーチ
back_accent_orange = [0.0, 0.50, 0.65, 0.0]
back_subtle_dark = [0.0, 0.10, 0.20, 0.78]
[front]
company = "株式会社マインディア"
address = "〒107-0052 東京都港区赤坂8-5-8 1F"
main_phone = "0X0-XXXX-XXXX"
main_phone_label = "(代表)"
direct_phone_label = "(直通)"
url = "https://corporate.minedia.com/"
[back]
company = "Minedia, Inc."
address_line1 = "1F, Akasaka 8-5-8, Minato, Tokyo"
address_line2 = "107-0052"
main_phone = "0X0-XXXX-XXXX"
direct_phone_label = "(direct)"
url = "https://corporate.minedia.com/"
オフィス移転したとき、CI色を変えたいとき、代表電話を変えたいとき、コードを触らずにTOMLだけ書き換えれば 全社員分のPDFが新しい情報で再生成されます。
色は最初からCMYK値で書いておくのもポイントです。RGB→CMYKは可逆変換じゃないので、デザイナー(と相談したAI)が意図したCMYKを そのまま入稿 できるようにしています。
employees.csv は人事システムのインターフェイス
employee_id,name_ja,name_kana,name_en,title_ja,title_en,email,direct_phone,filename
,山田 太郎,やまだ たろう,Taro Yamada,代表取締役 CEO,Chief Executive Officer,taro.yamada@example.com,,
,佐藤 花子,さとう はなこ,Hanako Sato,取締役 CTO,Chief Technology Officer,hanako.sato@example.com,,
,田中 一郎,たなか いちろう,Ichiro Tanaka,取締役 CFO,Chief Financial Officer,ichiro.tanaka@example.com,0X0-XXXX-XXXX,
employee_id 列は本記事では伏せています(実運用ではここに社員番号が入ります)。employee_id を入れると出力ファイル名が <id>_<name_en>.pdf の形式になり、社員番号順にソートされた状態でZIPに収まります。
このCSVを 人事システム側からexport すれば、そのまま名刺生成のインプットになります。今は手動コミットですが、将来的にはfreee人事労務や SmartHR からAPI経由で取得して自動コミット、までやりたいところ。
AIによるデザインレビュー: 3レビュアー体制
ここからは「シンプルな名刺をAIにデザインさせたら、デザイナーいなくても回せるのか?」という実験パートです。
結論から言うと、1発できれいには作ってくれませんでした。 ただし、複数のLLMにレビューさせると、最終的にプロ並みの指摘が出てきます。
レビュアーは3名を起用しました。
| レビュアー | 役割 |
|---|---|
| Google Gemini 3.1 Pro | 大局的な視点。レイアウトの違和感を素早く検出 |
Claude Code frontend-design Skill |
プロのフロントエンドエンジニア視点。色のコントラスト、タイポグラフィの基本ルール |
| Claude Opus 4.7 | コードリーディングしながら「実装上の根拠」を踏まえた指摘 |
AIから出てきた具体的な指摘
採用した指摘の一部を紹介します。
「アイコンと文字のベースラインが揃ってない」
最初の実装ではメールアイコンと文字の縦位置が微妙にズレていました。アイコンは画像の中央基準で配置していたのに対し、文字はベースライン基準なので、視覚的に揃わない。
# 連絡先 (アイコンは本文 x-height に合わせて 3.0mm)
line_h = mm(4.5)
icon_size = mm(3.5)
icons_dir = ASSET_ROOT / "icons"
contact_x = text_x
text_offset = icon_size + mm(2.0)
contact_y = top_y - mm(20.0)
c.setFillColor(colors.BACK_ACCENT_ORANGE)
c.setFont("NotoSans-Regular", constants.FONT_SIZE_BODY_PT)
_draw_icon(c, icons_dir / "mail_back.svg", contact_x, contact_y, icon_size)
c.drawString(contact_x + text_offset, contact_y + mm(0.8), email)
+ mm(0.8) という微小なオフセットは、アイコンと文字の 見た目のベースライン を合わせるための調整です。x-heightを基準にしているので、CapitalLetterとアイコンの中心が揃って見えるようになっています。
「カラム内でベースラインが揃っていない」
表面の連絡先と裏面の連絡先で、行高(line-height)がバラついているという指摘。
# 行高 4.0mm で裏面と縦リズムを統一
line_h = mm(4.5)
line_h を両面で同じ値にすることで、表裏の縦のリズムを揃えています。両面印刷のあとに重ね合わせたとき、透けて見える連絡先の位置が一致する という地味な配慮。
「文字のコントラストがないから認識しづらい」
裏面の住所が黒文字だと「氏名(オレンジ)→連絡先(オレンジ)→会社名・住所(黒)」と情報優先度の階段が崩れる、という指摘。
# 5) 会社名・住所 (ウォームチャコール: 連絡先より情報優先度を下げる)
bottom_y = oy + mm(constants.CARD_MARGIN_BOTTOM_MM)
c.setFillColor(colors.BACK_SUBTLE_DARK)
c.setFont("NotoSans-Regular", constants.FONT_SIZE_SUPPLEMENTARY_PT)
c.drawString(text_x, bottom_y + mm(6.0), info.company)
c.drawString(text_x, bottom_y + mm(3.0), info.address_line1)
c.drawString(text_x, bottom_y, info.address_line2)
back_subtle_dark = [0.0, 0.10, 0.20, 0.78] のCMYK値、つまり K単色じゃなくてウォームチャコール(CMYに少しずつ色を載せた濃いグレー) を採用しました。視認性は保ちつつ、氏名のオレンジを引き立てる役割。
色名を back_subtle_dark と意図ベースで命名してるところもポイントです。「黒」じゃなくて「補足情報用の控えめな暗色」。コードを読み返したとき、この色が何のために存在するか一目で分かります。
「細字ウェイトは安価オフセットで消える」
Light/Thinウェイトは線が細いので、安い紙+オフセット印刷で 掠れて消える という指摘。これを定数のコメントに恒久的に書き残しました。
# 細字 (Light/Thin ウェイト) は同サイズでも線が消えやすいので
# Regular/Bold のみ使用すること。
ドキュメントに書くと風化するので、 使う直前のコードに警告として置く のがポイント。assert_min_font のような実行時バリデーションと併用しています。
1発でキレイにならなかった話
正直に言うと、最初の出力は 完成度の低いもの でした。
- ロゴが歪んでいた(svglibのlinearGradient問題)
- トンボが業者テンプレと1.5mmズレていた(座標系変換ミス)
- フォントサイズがバラバラだった
- 表裏で位置がズレていた
これを「Gemini 3.1 Proで指摘を出してもらう→Claude Codeで実装する→Opus 4.7で構造レビュー→frontend-design Skillでビジュアルレビュー」のラウンドを 2周 回したら、入稿可能なレベルに到達しました。
正直、今回くらいシンプルなデザインなら 1発で満足のいくアウトプットが出てくるかも と期待していました。実際にはそうではなく、ドメイン知識を持った人間がレビュー観点を回せるかどうか で品質が決まる、という当たり前の結論に着地しました。今回でいうと「印刷物としての名刺の制約(CMYK・トンボ・ドットゲイン)」を私が知っていて、それをAIへの問いかけに翻訳できたのが肝でした。
GitHub Actionsでビルド自動化
CSVを更新したら全員分のPDFが自動生成されるようにしました。
name: Build name cards
on:
push:
branches: [main]
paths:
- 'employees.csv'
- 'config.toml'
- 'src/**'
- 'assets/**'
- 'Makefile'
- 'pyproject.toml'
- '.github/workflows/build-namecards.yml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.11'
cache: 'pip'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcairo2-dev pkg-config python3-dev ghostscript
- name: Install package
run: pip install -e ".[dev]"
- name: Build PDFs
run: make build
- name: Zip output
run: |
cd output
zip -r "../${{ steps.meta.outputs.package_name }}.zip" .
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: ${{ steps.meta.outputs.package_name }}
path: ${{ steps.meta.outputs.package_name }}.zip
if-no-files-found: error
retention-days: 90
- name: Create release
uses: softprops/action-gh-release@v3
with:
tag_name: build-${{ steps.meta.outputs.stamp }}-${{ steps.meta.outputs.short_sha }}
name: Name cards build ${{ steps.meta.outputs.stamp }}
files: ${{ steps.meta.outputs.package_name }}.zip
fail_on_unmatched_files: true
工夫しているのは2点。
-
paths:で CSV・config・コードのいずれかが変わったときだけ ワークフローを走らせる。READMEのtypo修正でPDFを焼き直さない - Workflow Artifacts(90日保持) と GitHub Releases(恒久保存) の両方に出力。新人が入社して名刺発注したいとき、いつでもRelease一覧から最新のZIPを取りに来られる
GitHub Releasesに 恒久保存 しておくのが地味に重要で、過去の名刺バージョン(住所変更前・CI色変更前など)にいつでもアクセスできる デザインの歴史記録 にもなっています。
入社オンボーディングへの応用
ここまで作ると、次は 入社ワークフロー全体の自動化 につなげたくなります。
理想形はこれ。人事システムが「入社1ヶ月前」のタイミングで employees.csv にPRを自動で出して、マージされたら印刷業者APIを叩いて発注、Slackに通知が流れる。これで 人手は完全に消える。
弊社が使ってる業者にAPIはまだ無いので「ZIPをDLしてアップロード」の最後の1ステップは人間が必要ですが、ここを切り出してエスカレーションする だけで運用としては成り立ちます。
ベンダー直の発注APIが整備されれば、これは完全な無人化に到達します。 Vista Print のようなグローバル業者が日本でAPIを提供してくれると話が早いのですが、現状では難しいため気長に待ちたいところです。
規模感: コード行数と「人間が書いたら何日?」の概算
「これってどのくらいの規模感のプロジェクトなの?」というのが気になる方もいると思うので、行数と工数の概算を出しておきます。
行数
| 区分 | 行数 | 中身 |
|---|---|---|
| ソースコード(Python) | 1,122行 |
src/minedia_namecard/ 配下16ファイル(CLI / CSV/TOMLローダ / フロント・バックレンダラ / トンボ抽出 / ロゴSVGパーサ / Ghostscriptラッパ 等) |
| テストコード | 494行 |
tests/ 配下9ファイル(CSVバリデーション / レイアウト定数 / レンダリングsmoke / 統合テスト 等) |
| 設定・CI・Makefile | 174行 |
config.toml / pyproject.toml / Makefile / GitHub Actions workflow |
| 合計 | 約1,790行 |
ソースコードの内訳を見ると、ロゴSVGパーサ(176行)と表面レンダラ(141行)が重め。ロゴのlinearGradient再現と、デザインの微調整が積み重なった結果です。
「人間が書いたら?」の概算
私の体感ベースで「AIアシスト無しで、Python+ReportLabに慣れた中堅エンジニアが1人で書く」と仮定した場合の工数概算は以下です。
| フェーズ | 想定工数 | 内容 |
|---|---|---|
| ReportLab / CMYK / DTP入稿仕様の調査・spike | 0.5〜1人日 | ReportLabのCMYK出力、塗り足し3mm、トンボ仕様、Ghostscriptの -dNoOutputFonts あたりを軽く試行錯誤 |
| CSVローダ・config loader・CLI骨格 | 0.5人日 | Pydantic v2 + Typer の組み合わせなら一気に書ける |
| カードレイアウト(表面・裏面) | 1〜1.5人日 | フォントサイズ・行高・アイコンとの相対位置を実装&微調整 |
| 印刷業者テンプレからのトンボ抽出(PyMuPDF) | 0.5人日 | PyMuPDFのAPIに慣れていれば実測→転記で対応できる |
| ロゴSVGの自前パーサ(linearGradient対応) | 1人日 | svglibの限界に気づいてから、必要最小限のパーサを書き切る |
| Ghostscriptラッパ(アウトライン化+CMYK化) | 0.3人日 | フラグはググればすぐ |
| テスト整備 | 0.5人日 | 統合テストとレンダリングsmokeを中心に |
| GitHub Actions(CI/CD・Release公開) | 0.3人日 | apt-getでghostscript入れてmake buildを回すだけ |
| 合計 | 約5人日 | フォーカスタイム換算で 約40時間、実カレンダーで 1〜2週間 |
実印刷でのフィードバックは別途、 入稿→納品の物理リードタイムで5営業日 が乗りますが、これは人間でもAIでも変わらないので工数には入れていません。
実際にかかった時間
これに対して、今回の取り組みは AIエージェント(Claude Code / Gemini)と並走しながら、実時間 約2時間 で1.0版に到達しました。レビューラウンドを2周回した時間込みです。
人間1人でやった場合の 約5人日(フォーカスタイム換算で約40時間) が、わずか2時間 にまで圧縮された計算になります。 ざっくり20倍の加速。
正直、自分でもこの数字を打ち込むときに「いやそんなはずないだろ……」と二度見しましたが、振り返ってもこのオーダーで合っています。やったことは「Claude Codeに作業ディレクトリで実装させる」「途中で別のLLM(Gemini 3.1 Pro / frontend-design Skill)にレビューさせる」「指摘を当該LLMに戻す」のループだけで、私自身がエディタを開いて手で書いたコードはほぼゼロ。
何が加速したのかを分解するとこんな感じです。
- 既知のAPIに対する初動の速さ: ReportLabやPyMuPDFのAPIを「とりあえずこう書く」の初版がほぼ即時に出る
- 試行錯誤の総量: 1日に10〜20回くらいレンダリング結果を見てフィードバックを返すサイクルが回せる
- デザインレビューの代替: ベースラインやコントラストといったタイポグラフィの基礎を、フロントエンドデザイン系のSkillが指摘してくれる
- ドキュメント代わりの実装: 「アイコンと文字のベースラインを揃えるべき」のような デザイナーの暗黙知 が、コメント付きコードとして書き残される
逆に、AIが弱かったのは以下です。
-
物理出力のフィードバック: 紙で刷ったときの掠れ・滲みはAIには見えない。
MIN_FONT_SIZE_PT = 7.0のような 物理制約ベースのドメイン知識 は人間がコードに埋め込む必要がある - 入稿仕様の正確な解釈: 業者のテンプレを「実測して再現する」発想は、人間が「これが正解」と教えないと辿り着けなかった
設計判断のおさらい
最後に、この実装をする過程で繰り返し意識したことをまとめておきます。
1. データ・設定・コードを完全に分離する
CSV更新だけで運用が回るように設計するなら、コードの中に氏名・メールアドレスがハードコードされていてはいけません。逆に、業者ごとの紙サイズ・トンボ位置はめったに変わらないので、コード(または業者テンプレ)に置きました。「変わる頻度」で配置レイヤーを決める。
2. ドメイン知識をコードに埋め込む
「7pt未満はNG」「Light/Thinはオフセットで消える」「アイコンは本文x-heightに揃える」のような知識は、ドキュメントじゃなくて コードのコメントとバリデーション にしました。半年後の自分が暴走しないために。
3. AIには「複数の役割」をやらせる
ひとつのLLMに「いい名刺を作って」と言っても、いい名刺は出てきません。「全体感を見る役」「タイポグラフィを見る役」「実装根拠を見る役」と 役割を分けてレビュー させると、人間のシニアデザイナー+エンジニア+PMがチームで見たような指摘が返ってきます。
4. 印刷物は実物が返ってくるまで5営業日
ソフトウェアと違って 本物のフィードバックが遅い ドメインです。だからこそ、静的に潰せるバグ(フォントサイズ下限、CMYK値、トンボ位置)はコードのレイヤーで全部潰しておく。Pydantic、assert_min_font、テンプレからの実測再現、これらは全部「印刷してから後悔しないため」の保険です。
【追記】実際に刷ったら赤がくすんだ — RGB→CMYKは「一発変換」じゃなかった
ここまで「CMYKで作ってあるので入稿は問題ない」という前提で書いてきましたが、v1.0を実際に入稿して印刷したところ、色の再現で大きな見落としがありました。
届いた名刺を見て最初に気づいたのは、赤の発色のくすみ です。モニタ上ではビビッドな朱赤(コーポレートカラー)だったはずが、刷り上がりは赤というより 濁ったオレンジ に近い色で、意図した鮮やかさが出ていませんでした。
本編で「印刷物は実物が返ってくるまで5営業日、だから静的に潰せる不具合は事前に潰しておく」と書きましたが、潰しきれていなかった問題が、よりによって最も目立つ色そのものに残っていた という結果です。
何が起きていたのか
原因は、鮮やかな赤がそもそもコート紙のCMYKでは再現しきれない色だった こと、そしてそれを 媒体に合わせて作り込んでいなかった ことでした。
私が config.toml に直書きしていたCMYK値は、こういう「sRGBを素直にCMYKへ変換した」感覚の値です。
ci_grad_start = [0.0, 0.83, 0.74, 0.0] # #F54040 由来。C=0 で組んでいた
ci_grad_end = [0.0, 0.40, 0.56, 0.0] # #F5AA77 由来
back_accent_orange = [0.0, 0.66, 0.70, 0.11] # 文字色
ci_flat = [0.0, 0.77, 0.77, 0.07] # #EC3737 面用ブランドレッド
これは画面上では正しく見えます。ReportLabのプレビューでもAcrobatでも、きちんとビビッドな赤で表示されます。ところが、実際にコート紙(アートポスト等)に刷ると話が変わります。 ポイントは2つありました。
1. 鮮やかな赤は、そもそもCMYKの色域(再現範囲)の外にいる
これが本質です。sRGB(モニタ)の色域はCMYK印刷の色域より広く、#EC3737 のような鮮やかな赤は コート紙のCMYKでは再現しきれない「色域外」の色 です。変換時には、再現できる範囲のいちばん近い色へマッピングされるので、どう変換しても彩度は落ちます。そのうえで私の素変換の値はC=0・M+Y主体だったため、コート紙ではマゼンタが沈みイエローが相対的に勝って オレンジ寄りに転ぶ 方向でした。「シアンが無いからくすむ」のではなく、色域差で彩度が落ち、媒体特性でオレンジに寄った というのが正確なところです。
2. PDFが「どの印刷条件で刷る前提か」を宣言していなかった
私の入稿PDFは 「このCMYK値は、どんな紙・インクで出す前提で作ったのか」という情報(=カラープロファイル / 出力インテント)を持っていませんでした。 後述のとおりこれ自体が刷り色を直接変えるわけではないのですが、データを自己記述させ、画面校正(ソフトプルーフ)の基準を揃える うえで効いてきます。基準が無いまま画面の色だけを信じていたのも、ズレに気づけなかった一因でした。
直し方その1: 印刷条件(カラープロファイル)をデータに宣言する
まず、「どの印刷条件で刷る前提か」をデータ自身に持たせます。弊社の業者(MHTデザイン)は公式ガイドで Japan Color 2001 Coated(ISO 12647-2準拠、日本のコート紙向け標準CMYKプロファイル)を推奨していたので、これをPDFの OutputIntent(出力インテント) として埋め込みました。
それでも宣言しておく価値はあります。ソフトプルーフが業者の出力条件と揃う、データが自己完結する、将来別の条件へ作り変えるときの変換元になる、といった利点があるからです。
実装時に地味な落とし穴がありました。Ghostscriptの -sOutputICCProfile は「色変換に使うプロファイル」を指定するフラグです。これは OutputIntent dictionary をPDFに埋め込むものではありません。 pdfwrite向けには未サポート扱いで、PDF/X互換出力にしない限りOutputIntentは書かれません(独立した検証実験でも確認されています)。そこで、Ghostscriptでアウトライン化+CMYK化したあとに、pikepdf で OutputIntent を書き込む後処理を足しました。
def embed_output_intent(pdf_path, icc_profile, profile_name=None):
"""pikepdf で PDF の Root に OutputIntents を書き込む."""
icc_bytes = icc_profile.read_bytes()
with pikepdf.open(pdf_path, allow_overwriting_input=True) as pdf:
icc_stream = pdf.make_stream(icc_bytes)
icc_stream.N = 4 # CMYK ICC は 4 チャンネル (PDF spec で N が必須)
output_intent = pikepdf.Dictionary(
Type=pikepdf.Name.OutputIntent,
S=pikepdf.Name.GTS_PDFX,
OutputConditionIdentifier=pikepdf.String(profile_name or icc_profile.stem),
DestOutputProfile=icc_stream,
)
pdf.Root.OutputIntents = pikepdf.Array([output_intent])
pdf.save()
CLI側はデフォルトでJapan Color 2001 Coatedを自動で埋め込むようにして、--icc-profile で差し替えできるようにしました。業者を変えたら推奨プロファイルだけ差し替えればいい、という設計です。
なお、ここで付けている OutputIntent のサブタイプ S=/GTS_PDFX は本来PDF/X向けのものですが、このPDF自体は完全なPDF/X準拠ではありません。多くのRIPは通常のPDFとして問題なく処理してくれますが、PDF/X準拠を厳格にチェックする入稿口では「OutputIntentはあるがPDF/X非準拠」と警告が出る可能性はあります。
直し方その2: CMYK値そのものをコート紙前提で組み直す(←色の本体はこちら)
刷り色を実際に決めるのは、こちらの作業です。狙いの赤になるよう、CMYK実数値をコート紙の特性に合わせて手で詰め直し ました。
ひとつ整理しておくと、私たちが目指したのは「最大限ビビッドな赤」ではなく、オレンジに転ばず、安っぽく見えない、締まったブランドレッド です。方針はこうです。
- マゼンタは振り切る(M≈100%): 赤の主成分。ここを最大にして赤みをしっかり出す
- シアンを少量(10〜15%)足す: これは彩度を上げる操作 ではありません。シアンは赤の補色寄りなので、足すと彩度はむしろ下がりますが、オレンジへの転びを抑え、深み・締まりを与える 効果があります。今回はあえて深めの赤を狙ってC15%まで入れました
- 面・グラデはK=0: 黒を混ぜると沈むので、大面積はK抜きでクリアな発色を優先
- 文字だけK=10%: 細い文字は視認性優先で軽く黒を入れて締める
config.toml のdiffで見るとこうなりました。
-ci_grad_start = [0.0, 0.83, 0.74, 0.0] # #F54040 / C=0 で組んでいた
-ci_grad_end = [0.0, 0.40, 0.56, 0.0] # #F5AA77
-back_accent_orange = [0.0, 0.66, 0.70, 0.11] # 文字色
-ci_flat = [0.0, 0.77, 0.77, 0.07] # #EC3737 面用
+ci_grad_start = [0.10, 1.0, 0.90, 0.0] # C +10% / M振り切りで締まった赤
+ci_grad_end = [0.0, 0.50, 0.70, 0.0] # Y 上げてオレンジ寄りのピーチ
+back_accent_orange = [0.15, 0.90, 0.85, 0.10] # 文字は K=10% で締める
+ci_flat = [0.15, 1.0, 0.95, 0.0] # C +15% / K=0 の深めのブランドレッド
ちなみに、Coca-Colaなど有名ブランドレッドの公開CMYK値は C0〜5%程度 が一般的で、C10-15%はそれより 深い赤 の領域です。今回は「鮮やかさ」より「安っぽく見えない締まった赤」を優先して、あえて深めに振りました。いずれにせよ、素のsRGB変換が出力する値とはかなり差があります。
結果: オレンジ転びを抑えた、締まったブランドレッドで刷れるようになった
修正前後を並べるとこんな感じです。右2列はJapan Color 2001 Coatedでの ソフトプルーフ(=コート紙の刷り上がり想定をsRGBに戻して表示したもの)です。

真ん中の列(素のRGB→CMYK変換)が、まさに私が最初にやらかした くすんでオレンジに転んだ赤。右の列(補正後)が、オレンジ転びを抑えて締まらせたブランドレッドです。CMYKのちょっとした数字の差で、刷り上がりの印象がここまで変わります。
入稿前の検証用に、複数パターンのCMYKを A4 1枚に並べて手元のレーザープリンタで刷り比べる スクリプトも用意しました。「実物が返ってくるまで5営業日」のサイクルを回す前に、方向性だけでもオフラインで確認しておくためのものです。
page.insert_text(
(ROW_LEFT, A4_H - 24),
"* レーザープリンタ印刷時はプリンタの色補正 (sRGB シミュレーション等) を OFF にして比較すること。",
fontsize=8, fontname="notojp",
)
レーザープリンタは標準で色補正をかけてくるため、プリンタ側の補正をOFFにして 刷らないと正しく比較できない、という点も見落としやすい注意点でした。
そもそも「モニタで見ている色」が正しいとは限らない
ここまで「モニタの色に近づいた」と書いてきましたが、この前提自体、厳密にはかなり怪しい という話もしておきます。
正確な色を判断するには、本来は次の条件が揃っている必要があります。
- ハードウェアキャリブレーション済みの広色域ディスプレイ
- 測色器(キャリブレーター) で定期的に表示特性を測定・補正していること
- 照明など見る環境まで含めて カラーマネジメントが一致 していること
私がふだん使っている MacBook Pro の内蔵ディスプレイや、外付けの DELL の IPS モニタは、こうしたキャリブレーションをしていません。つまり、そこに表示されている赤が「正しい赤」である保証は、そもそも無い わけです。同じデータを別のモニタで開けば、また違う色に見えます。「モニタの色」と一口に言っても、環境ごとにズレているのが実態です。
これを厳密に突き詰めようとすると、測色器・キャリブレーション対応モニタ・専用ソフトをひと通り揃えることになり、ざっくり 数十万円コース になります。名刺の色合わせのために導入する設備としては、さすがに過剰です。
そこで今回の現実的な落とし所は、次のように整理しました。
- モニタを絶対基準にしない(どのみち正確ではないため)
- 業者推奨のプロファイル(Japan Color 2001 Coated)を「正」として信頼し、データ側を固める
- 色の最終的な正解は、刷り上がった実物(あるいは業者の本機校正)に置く
本文では便宜上「モニタの色で刷る」という表現を使っていますが、正確には 「校正された基準(プロファイル+実物)に対して、モニタ表示をできるだけ寄せる」 というのが実態に近い、ということになります。
この追記の教訓
- 鮮やかな赤はそもそもCMYK色域の外。 素変換では彩度が落ちるので、狙いの赤になるようCMYK実数値を媒体(紙・インク)前提で手で詰める。これが色を決める本体
-
色を直すのはCMYK実数値、OutputIntentは「宣言」。
DeviceCMYKの値はRIPへ基本そのまま渡るので、プロファイルを埋めただけでは刷り色は変わらない。OutputIntentはデータの自己記述とソフトプルーフ基準合わせのため(業者によっては無視・再変換する点も理解しておく) -
Ghostscriptの
-sOutputICCProfileはOutputIntentを埋めてくれない。 pikepdfで後処理して書き込む - シアンは「鮮やかさ」ではなく「深み・締まり」を足す。 シアンは赤の補色寄りなので入れるほど彩度は下がる。狙いが「ビビッド」か「深いブランドレッド」かで方向(C量)は変わる
- 未キャリブレーションのモニタの色は、そもそも「正解」ではない。 数十万円の測色環境を導入しない限り、業者プロファイルと刷り上がった実物を基準にするのが現実的
- そして何より、物理出力のフィードバックは実物が来るまで分からない。 AIにもモニタにも見えない領域で、ここだけは5営業日待って刷り上がりを見て直すしかありませんでした
本編で「AIが弱いのは物理出力のフィードバック」と書きましたが、まさにそこで足をすくわれた格好です。逆に言うと、一度この知見をコード(config.toml のコメントとICC埋め込み)に落とし込んでしまえば、次から同じ轍は踏まない。 物理フィードバックで得た学びを静的な資産に変える、というのはこの手のプロジェクトの王道だなと改めて思いました。
まとめ
- Illustrator+手作業の名刺運用を、CSV+Python+GitHub Actionsで完全に置き換えました
- ReportLab + Ghostscriptで、CMYK・トンボ・塗り足し・フォントアウトライン化を満たした 入稿可能PDF をコードから出力できます
- ただしCMYKは「一発変換」ではダメで、CMYK実数値をコート紙前提で組み直し(色の本体)、さらに 印刷条件をOutputIntent(Japan Color 2001 Coated)として宣言 して、ようやくオレンジ転びを抑えた締まった赤で刷れました(実刷りで判明)
- ロゴSVGのlinearGradient問題は、自前のSVG path パーサで限定的にバイパス
- 印刷業者テンプレからPyMuPDFでトンボ座標を 実測 することで、業者の入稿仕様に完全一致
- デザインがシンプルな会社なら、AIエージェントを 3レビュアー体制 で運用することでタイポグラフィ品質をプロ並みに近づけられます
- 入社オンボーディングと連携すれば、名刺発注は完全自動化が射程に入ります
入社のたびにIllustratorを開いてコピー&ペーストで1人ずつ作っている会社のすべて に同じ手法が刺さると思います。社内便箋、メール署名画像、入社証、社員ID裏、ぜんぶ同じ構造で自動生成できるはず。
おまけ: ソースコード公開について
実装の詳細を読んでいただいた方には申し訳ないのですが、 このリポジトリは現時点では非公開 にしてあります。会社の住所・電話・色定義・社員データなど、機微な情報を分離する作業が残っているからです。
ただ、ニーズがあれば公開準備したいので、この記事に「いいね」が30個以上ついたらソースコードを公開する用に整備します。住所・社員情報・ロゴを差し替え可能なテンプレ形式にして、フォーク1発で別の会社でも使えるようにする予定です。
「うちでも使いたい」「もっと詳しい実装を読みたい」と思った方は、ぜひ❤️ボタンをポチっとお願いします。
おまけ2: 個人的な気づき
- Illustratorを開かなくていい人生 は控えめに言って最高です。集中の流れが切れない
- AIエージェントに 役割分担 させる発想は、コードレビューだけでなくデザインレビューでも非常に強力です。異なるLLMで 並列レビューさせるのが特に効きます
- 名刺やDMのような「データ × デザイン × 物理出力」の領域は、まだまだ手動運用が多くて自動化の余地が大きい。コーポレートエンジニアリングのフロンティアです
- 印刷業界も APIを公開してくれる業者 が出てくれば、コーポレートの大量の手作業がほぼ無人化できます。誰か始めませんか
おまけ3: 昔つくった類似プロジェクト
実は 画像をプログラムで生成するタイプのツール は昔も作ったことがあって、コロナ初期にTwitterアイコンに「STAY HOME」みたいなフレームを自動で被せるWebサービスをやってました。
これは入力画像にPNG/SVGの装飾をオーバーレイして、Twitter用の 正方形ピクセル画像 を出力するだけのシンプルなもので、短期間で作れました。
今回の名刺PDF生成と比べると、ピクセル画像と印刷物の世界はぜんぜん別物 だなと改めて感じます。ピクセル画像は「ブラウザで見られればOK」だけど、印刷物は
- CMYK色空間(RGB入稿は基本NG)
- 塗り足し3mm(断裁ズレ対策)
- トンボ(断裁位置・センター位置の指示マーク)
- フォントアウトライン化(業者環境のフォント有無に依存しないため)
- 解像度・線幅の物理的下限(インクの滲み込み)
- 入稿テンプレへの正確な位置一致
と、画面で完結する画像生成より 守るべきルールが一段増えた印象 です。1mmずれると断裁線がズレ、6ptで刷ると掠れて読めない、というフィードバックが 実物が刷り上がるまで分からない のがやっかい。
ピクセル画像なら「あれ違うな」→直す→F5、で30秒で次のイテレーション。印刷物だと「あれ違うな」→直す→入稿→印刷→納品で 5営業日 。だからこそ、静的に潰せる制約はコード側でしっかり固めておいたほうがいい、という発想に落ち着きました。
それでは、また!
Discussion