pdfcn は、PDF用のReactコンポーネントを shadcn/ui と同じレジストリ形式とCLIワークフローで配る部品集だ。npx shadcn@latest add で自分のリポジトリにコードが降ってきて、あとは自分のものとして書き換える。star 2.4k、fork 118、MIT。描画のベースは Takumi と Forme の2つから選べる、というのが最大の売り文句になっている。実物を入れて、両方のベースでPDFを描いて確かめた。
- ・shadcn/uiのレジストリ形式でPDF部品を配る。`registry.json` の項目は**101**
- ・**公開元は shadcn 本人ではない**。組織ページが自ら「shadcnとの提携はない」と明記
- ・ブロック1本を入れると**19ファイル**が自分のリポジトリへ。単体コンポーネントでも9ファイル
- ・描画ベースは Takumi と Forme の2つ。**同じブロックでも改ページもサイズも一致しない**
- ・ヘッドレスブラウザ不要。出力は PDF 1.7・テキスト抽出可・フォントはサブセット埋め込み
- ・リポジトリに**レジストリを宣伝するためのAgent Skill**が同梱されている
コンポーネントを配って回る仕組みそのものの位置づけはデザインシステムとは?仕組み・構成要素・有名事例をエンジニア向けに整理する【2026年版】にまとめてある。本記事はその応用として、「shadcn流の配り方をPDFに持ち込むと何が起きるか」を1つ分解する。
pdfcnとは:shadcn/uiと同じ配り方でPDF部品を配る
まず名前の話から片づけておきたい。公開元は Shadcn Labs(shadcn-labs)という組織で、shadcn/ui の作者本人とは別だ。これは推測ではなく、組織ページに “Not endorsed by or affiliated with shadcn.” と自分で書いてある。所在地はインド、掲げる文句は「shadcn/uiエコシステムの限界を押し広げる」。同じ組織には termcn(star 1,168)、editorcn(316)、emailcn(295)、ogimagecn(239)、framecn(143)、shadercn(133)といった 〜cn 系レジストリが並び、公開リポジトリは24件ある。shadcnレジストリを量産する工房と理解するのが正確だ。
pdfcn 自体の中身はモノレポで、apps/web にドキュメントサイトとレジストリが同居している。ルートの package.json は private で、npmパッケージとしては配布されない。Node.js 20.9以上・pnpm 10以上、ビルドは Turborepo、リンタとフォーマッタは Rust 製の oxlint と oxfmt で、フックは lefthook が握っている。タグもリリースも0件で、最終コミットは 2026-09-24、open issues は27件・open PRs は11件だった。star 2.4k に対してこの数字なので、勢いはあるが固まってはいない段階と読める。バージョンで固定する手段が無いので、業務に入れるならコミットハッシュを控えておくのが現実的だ。
配られる中身は apps/web/registry.json に全部書いてある。items は 101項目。
takumi/ 名前空間が46項目、forme/ 名前空間が46項目、そしてテーマが9項目。46の内訳はどちらも UI 24・ブロック 20・lib 2 で、名前まで含めて完全に対称だった。ブロックは invoice や report、certificate といった「1枚の書類まるごと」のテンプレートで、UIはその部品(text table section page-header key-value など)にあたる。
テーマは9種(professional・modern・minimal・executive・corporate・elegant・vivid・forest・blueprint)で、どれも4ファイル構成の「トークン束」だ。コンポーネント側は PdfcnThemeProvider から色やサイズを受け取る作りになっていて、PDF生成の見た目をテーマの差し替えだけで変えられるのが狙いだと読める。トークンを配って見た目を統一するという発想はshadcn studio - テーマ生成機能付きshadcn/uiコンポーネント集の活用法で扱ったWeb側の仕組みとまったく同じで、それを紙面に持ち込んだ格好になる。
配信の実体は静的JSONだ。apps/web/public/r/ 以下に、各ベースぶん55ファイル(46項目+テーマ9)が生成されて置かれている。中身はコンポーネントのソースをそのまま文字列で抱えた files 配列と、dependencies(npmパッケージ)、registryDependencies(同じレジストリ内の他項目)。shadcn CLI はこれを読んで、自分のプロジェクトのパスへ書き出す。サーバーサイドの仕掛けは何もない。
実測:1コマンドで19ファイル、レンダリングは161ms
実際に入れてみる。本記事の環境からは公開サイトへ到達できなかったため、リポジトリに生成済みの public/r/ をローカルのHTTPサーバーで配り、components.json の registries でそこを指した。CLIから見れば公開レジストリと同じ経路になる。
# apps/web/public/r をローカルで配り、components.json に登録する
# "registries": { "@pdfcn": "http://127.0.0.1:8099/{name}.json" }
npx shadcn@latest add "@pdfcn/takumi/invoice-classic" --yes
# √ Created 19 files:
# src/lib/pdf-themes/professional.ts src/lib/pdf-primitives.tsx src/lib/pdf-svg.tsx
# src/types/pdf-themes.ts src/types/pdf-components.ts src/lib/resolve-color.ts
# src/components/pdf/theme-provider.tsx .../key-value .../page-footer .../page-header
# .../pdf-image .../section .../table(3) .../text
# src/components/pdf/blocks/invoice-classic/invoice-classic.tsx (+ .types.ts)
19ファイル。ブロック本体は2ファイルで、残り17は registryDependencies に書かれた8項目が芋づるで入ってきたぶんだ。npm側も takumi-pdf と @takumi-rs/helpers が自動で追加された。比較のために単体コンポーネントの @pdfcn/takumi/text を空のプロジェクトへ入れると、こちらは9ファイル。テーマ・型定義・プリミティブ・色解決が最小セットとして必ず付いてくる構造になっている。
この「芋づる」がきちんと効くようになったのは最近らしい。リポジトリの最終コミット(2026-09-24)のメッセージが fix(registry): publish complete install dependency graphs で、まさに依存グラフを完全に公開する修正だった。実際、今回の導入では registryDependencies に並んだ8項目(utils key-value page-footer page-header pdf-image section table text)がすべて解決され、取りこぼしは無い。裏を返せば、それ以前に入れた人は欠けたファイルに当たっていた可能性があるということでもあり、古い手順メモを持っている場合は入れ直したほうが早い。
なお registryDependencies は @pdfcn/takumi/table のように自分の名前空間を明示する書き方になっている。つまり CLI 側で @pdfcn がどこを指すか解決できる状態、すなわち components.json の registries が設定されていることが前提だ。公式ドキュメントの npx shadcn@latest add @pdfcn/takumi/text という1行を打つ前に、この登録が済んでいる必要がある。
これは shadcn 流の作法そのもので、良し悪しの話ではない。ただ「Copy, paste, and ship」「you own the code」というのは、19ファイルぶんのコードを自分で保守するという意味でもある。アップデートは npx shadcn add の再実行による上書きになるので、手を入れた箇所とのマージは自分の責任になる。ここはSquare UI解説|shadcn/uiレイアウト20種を実機デモで全検証|MITから独自ライセンスへで扱ったレイアウト集や、tweakcnとは|shadcn/uiのテーマをノーコードで視覚編集しCSSを吐く使い方を実機解説のテーマ編集と同じ前提だ。
静的JSON 101項目"] --> B["shadcn CLI
registries で名前空間を解決"] B --> C["自分のリポジトリ
ブロック1本で19ファイル"] C --> D["takumi ベース
takumi-pdf の render()"] C --> E["forme ベース
formepdf/core の renderDocument()"] D --> F["PDF 1.7
テキスト抽出可・フォント埋め込み"] E --> F G["registryDependencies
テーマ・型・プリミティブ"] --> C
入ったコードを実際に描いてみる。ブロックは InvoiceClassicDocument を公開していて、data を渡さなければ同梱のサンプルデータで描かれる。
import { writeFile } from "node:fs/promises";
import { googleFonts } from "@takumi-rs/helpers";
import { render } from "takumi-pdf";
import { InvoiceClassicDocument } from "@/components/pdf/blocks/invoice-classic/invoice-classic";
const pdf = await render(<InvoiceClassicDocument />, {
size: "a4",
fonts: await googleFonts(["Inter"]),
});
await writeFile("invoice.pdf", pdf);
// font fetch ms: 93 render ms: 161 bytes: 13313
161ミリ秒で13,313バイト。file コマンドは「PDF document, version 1.7, 2 page(s)」を返し、テキスト抽出では1ページ目から458字が取れた。画像は0で、FontFile2(サブセット埋め込み)が入っている。ヘッドレスブラウザを一切起動せずに、テキストが選択できるベクターPDFが出ている、という宣伝どおりの結果だった。Takumi側はレイアウトとPDFエンジンをWebAssemblyで回す実装で、node_modules も13MBしかない。
ただし2ページ目に注意が要る。抽出されたのは「Thank you for your business! Page 1 of 1」の40字だけ、つまりフッターだけが2ページ目に溢れていた。しかもその「Page 1 of 1」はブロックのソースに rightText="Page 1 of 1" と文字列で直書きされている。takumi-pdf 側には PageNumber / TotalPages というプリミティブが用意されているのに、ブロックはそれを使っていない。配られたコードをそのまま出荷すると「2ページなのに Page 1 of 1」と印字されるわけで、ここは自分で直す前提のテンプレートと受け取るのが正しい。
もう1つ、ドキュメントの使用例にある <Page size="A4"> は、Takumiベースでは効かない。pdf-primitives.tsx の Page は size: _size と受け取って捨てており、実際のページサイズは render() の size オプションで決まる。書いても害はないが、そこを変えてもページサイズは変わらない。
「2つのレンダリングベース」を両方動かして比べる
pdfcn の看板は「Two rendering bases」だ。同じ invoice-classic を forme/ 名前空間から入れ直して、同じサンプルデータで描いた。
import { renderDocument } from "@formepdf/core";
import { InvoiceClassicDocument } from "@/components/pdf/blocks/invoice-classic/invoice-classic";
const pdf = await renderDocument(<InvoiceClassicDocument />);
// render ms: 97 bytes: 24320 → PDF 1.7, 1 page(s)
数字を並べるとこうなる。
| 項目 | takumi ベース | forme ベース |
|---|---|---|
| 描画ライブラリ | takumi-pdf 0.15.0(+@takumi-rs/helpers 2.14.0) |
@formepdf/core / @formepdf/react 0.25.0 |
| 呼び出し | render(jsx, { size, fonts }) |
renderDocument(jsx) |
| レンダリング時間 | 161ms | 97ms |
| 出力サイズ | 13,313バイト | 24,320バイト |
| ページ数(同一データ) | 2ページ(フッターが溢れた) | 1ページ |
| 用紙 | 595×842pt(A4) | 595×842pt(A4) |
| テキスト抽出 | 1ページ目458字・画像0 | 498字・画像0 |
| フォント指定 | fonts に明示(Google Fonts取得93ms) |
呼び出し時の指定なしで完結 |
node_modules |
13MB | 57MB |
APIは揃えてあるが、出力は揃わない。 同じブロック名・同じデータで、片方は1ページ、片方は2ページになる。ファイルサイズも1.8倍違う。これは不具合というより、レイアウトエンジンが別物である以上当然の結果だ。pdfcn が保証しているのは「コンポーネントの書き味を揃えること」であって、「ピクセル等価な出力」ではない。
フォントの扱いも差が出た。Takumi側は render() に fonts を渡す設計で、READMEの例にならって googleFonts(["Inter"]) を使うと実行時にGoogle Fontsへ取りに行く。今回は93ミリ秒で取得できたが、これは外向き通信が要るということでもあり、閉じた環境やコールドスタートの多いサーバーレスでは自前のフォントファイルを渡す形に変えたほうがいい。Forme側は呼び出しにフォント指定なしで完結した。どちらも生成物には FontFile2 としてサブセットが埋め込まれていたので、出力PDFが外部フォントに依存することはない。差が出るのは生成する瞬間だけだ。
実務的な含意ははっきりしている。ベースは最初に選び、途中で乗り換えない。乗り換えるなら全ブロックの出力を目視で確認し直す必要がある。逆に、選定段階で両方を試せるのは利点で、今回のように19ファイル入れて描くだけなら数分で終わる。フォントを明示したいなら Takumi、呼び出しを短く済ませたいなら Forme、というのが実測から見た第一印象になった。
検証環境:Linux 6.18.44/Node.js 24.21.0/2026-09-29。リポジトリを git clone --depth 1 し、apps/web/public/r をローカルHTTPサーバーで配って components.json の registries から参照した。npx shadcn@latest add を @pdfcn/takumi/invoice-classic・@pdfcn/forme/invoice-classic・@pdfcn/takumi/text の3通りで実行し、生成ファイルを数えている。描画は esbuild でバンドルして Node.js で実行し、file と pypdf でページ数・テキスト抽出・画像数・用紙サイズを確認した。時間はいずれも1回の実行値。未検証:公開サイト(pdfcn.vercel.app)へは到達できていない。当環境の外向き通信が遮断されているため、公開レジストリ経由の導入、ドキュメントのライブプレビュー、公開されている全ブロックの見た目は確認していない。また ui.shadcn.com も同様に遮断されており、tailwind.baseColor を空にして色定義の取得を迂回している(PDF側の描画には影響しない)。101項目のうち実際に描いたのは invoice-classic のみで、残りのブロック・テーマ9種の出力は未確認。star 2.4k・fork 118・open issues 27 はリポジトリページの表示値。
リポジトリに同梱された「レジストリを宣伝するスキル」
最後に、このリポジトリで一番目を引いたものに触れておきたい。ルートに .agents/ と skills-lock.json がある。中身は launch-shadcn-registry というAgent Skillで、SKILL.md が8,710バイト、参照ドキュメント6本、検証スクリプト、テンプレート2本、そして evals/evals.json まで揃っている。
やることは「自作のshadcnレジストリを世に出す手順一式」だ。Phase 1でレジストリJSONの公開状態を検証(コンポーネントJSONが404でないか、公式ディレクトリに @scope が重複していないか)、Phase 2で公式 shadcn-ui/ui と registry.directory、shadcntemplates、awesome系2本への申請物を生成、Phase 3で gh を使ってPRを起票、Phase 4でX・Reddit・Dev.to・Hacker News向けの投稿文を下書き、Phase 5で追跡。PR起票については「ユーザーが明示的に全部出せと言わない限り、PRごとに確認を取ること」と明記されている。
evals/evals.json(1,725バイト)が同梱されているのも目を引いた。スキルが意図どおりに発火し、意図どおりの成果物を出すかを機械で採点する仕組みで、広報手順をプロンプトではなくテスト付きの資産として扱っていることになる。
skills-lock.json は、このスキルを shadcn-labs/skills(star 24)から取ってきたものとして computedHash で固定している。スキルを依存物として扱い、ロックファイルでピン留めする発想で、同じ組織が24本のレジストリを回していることを思えば筋が通っている。1本作るたびに同じ広報作業が発生するなら、それを手順として書き出してエージェントに回させるのは自然な最適化だ。
見方は分かれるところだと思う。OSSの配布と宣伝がここまで定型化・自動化されたという事実は、レジストリを出す側には実用的なテンプレートであり、受け取る側には「star数がどう積まれているか」を考える材料でもある。少なくとも、リポジトリを評価するときに .agents/ を開く価値は上がった。コードだけでなく、そのプロジェクトが自分をどう広めるつもりなのかが、そこに書かれていることがある。
pdfcnを採るときに押さえる点
・shadcn本人のものではない:組織ページが自ら提携なしと明記。品質の話ではなく、出所を正しく把握するという話
・入るのは19ファイル:ブロック1本で本体2+依存17。単体コンポーネントでも9。保守対象は自分に移る
・ベースは最初に決める:Takumi と Forme は同じブロックでも改ページ結果もサイズも違う。乗り換えは全書類の見直しとセット
・テンプレートの直書きを直す:rightText="Page 1 of 1" のような文字列がそのまま入っている。takumi-pdf の PageNumber / TotalPages に差し替える
・Page の size は効かない(takumiベース):実際の用紙は render() のオプション側
・ヘッドレスブラウザは不要:出力は PDF 1.7・テキスト抽出可・フォントはサブセット埋め込み。サーバーレスにも載せやすい
・まだリリースタグが無い:open issues 27・open PRs 11、最終コミット 2026-09-24。固定したいならコミットを控えておく
・Node.js 20.9以上・pnpm 10以上:リポジトリ側の engines 指定
・ライセンスはMIT:LICENSE の実体も MIT(Copyright (c) 2026 Shadcn Labs)
総括。 pdfcn がやっているのは、「PDFを作る」という古くて面倒な作業を、shadcn/uiで慣れた手つきに寄せることだ。npx shadcn add で請求書テンプレートが19ファイル降ってきて、render() を1回呼べば161ミリ秒でA4のPDFが出る。この体験の滑らかさは実際に触ると分かるし、ヘッドレスブラウザを立てずに済む構成は運用側にも効く。部品の設計も素直で、コピーされたコードはそのまま読める規模だった。
一方で、配られたものは完成品ではなく出発点だ。「Page 1 of 1」の直書きも、効かない size プロパティも、それ自体は些細だが、そのまま出荷すると読者ではなく自分の顧客が困る類のものだ。shadcn流の配り方は「自分のコードになる」ことが利点であり、同時に「自分で直す」ことが前提でもある。採用するなら、1本だけ入れて実際に描き、PDFを開いて隅まで見る——それを選定の最初の30分に入れておきたい。今回その30分でつまずいたのは、直書きのページ番号と効かない size プロパティという、どちらもPDFを1枚開けば気づく種類のものだった。逆に言えば、開かずに採用しなければ気づかない種類のものでもある。
参照ソース
・shadcn-labs/pdfcn(公式リポジトリ) — README・apps/web/registry.json・public/r/・.agents/・LICENSE を 2026-09-29 に確認
・takumi-pdf(npmレジストリ) — 実際に動かした 0.15.0 の配布メタデータとREADME
・shadcn/ui 公式サイト — 相乗りしているレジストリ形式とCLIの出どころ