あの頃のプロフィール帳、覚えていますか?
クラスの子に「この紙に◯◯さん/◯◯くんのこと書くの協力してくれない?」と言われたことはありませんか。そう、いわゆるプロフ帳こと、プロフィール帳ですね。
好きな食べ物、将来の夢、推しの話・・・これを読んでいる人の中にも、書いたことがある人はいるのではないでしょうか。
実際に日本の会社でもチームビルディングの要素として、使われたりする例もあるそうです。
大人になった今でも、たまに幼少期の懐かしさに思いを馳せる・・・。そして、SNSの文字数の少ない自己紹介に収まりきらない「自分」を、もっと自由に、もっと美しく表現したい。
きっかけは、平沢進さんのファン(通称:馬の骨)の間で親しまれている「馬骨プロフィール」という文化でした。(特定のテーマに沿ってファン同士が自己紹介し合う、Xなどで活発な文化です)。
そのような素敵な文化を、会員登録なし・サーバーなし・ブラウザだけでサクッと実現できる場所があればいいなと思い、CharaDropを作りました。
CharaDropとは
ブラウザだけで完結する、プロフィールシートジェネレーターです。
- 登録不要・完全無料: バックエンドを持たないので、個人情報がサーバーに送られることは一切ありません。
- 画像で共有: SVG/PNG形式で書き出し可能。SNSやLINEグループでそのまま交換できます。
- リアルタイムプレビュー: 入力した瞬間にデザインへ反映。完成形を見ながら編集できます。
PC版
スマホ版
技術スタック:Vue単体でどこまで戦えるかやってみる
このプロジェクトでは、依存パッケージを vue のみに絞ることに挑戦しました。
特に大きなこだわりがあったわけではないのですが、一度素の状態で作ってみたいと思ったのです。
| カテゴリ | 選定 |
|---|---|
| フレームワーク | Vue 3 (Composition API) |
| ビルドツール | Vite 7 |
| UIライブラリ | なし(すべて素のCSS) |
| 状態管理 | なし(reactive変数で完結) |
| 画像処理 | なし(Canvas API直接操作) |
VuetifyもPiniaもhtml2canvasも使わない。Vue 3の標準機能とブラウザAPIの組み合わせだけで、どこまで実用的なアプリを作れるかを追求しています。
技術的なこだわりポイント
1. SVGテキストの自動折り返し
日本語を意識した独自アルゴリズム
SVGの <text> 要素には、HTMLの word-wrap に相当する自動折り返し機能がありません。ここが最大の技術的チャレンジでした。
文字幅の推定
フォントメトリクスに頼らず、文字種ごとの幅係数で高速に推定しています。
function charDisplayWidth(char: string, fontSize: number): number {
const code = char.codePointAt(0) ?? 0;
// ひらがな・カタカナ・CJK統合漢字・全角記号 → fontSize × 1.0
// ASCII・半角 → fontSize × 0.58
const isFullWidth =
(code >= 0x3040 && code <= 0x30FF) || // ひらがな・カタカナ
(code >= 0x4E00 && code <= 0x9FFF) || // CJK統合漢字
(code >= 0xFF01 && code <= 0xFF60); // 全角記号
return isFullWidth ? fontSize : fontSize * 0.58;
}
Canvas APIの measureText() を使えばピクセル精度が出ますが、フォントの読み込みタイミングに依存するため、安定しません(いや、する方法もあると思いますが自分が未熟なのもあり知らず・・・)。
そこで、 係数ベースの推定のほうが安定して動作すると思いました。SVGという閉じた世界では、この精度で十分実用的なのではないかと。
禁則処理
日本語の組版ルールとして「行頭に来てはいけない文字」があります。句読点や閉じ括弧が行頭に来ると、見た目が崩れてしまいます。これ地味にカッコ悪かったので避けました。
const LINE_START_FORBIDDEN = new Set([
'。', '、', '!', '?', '!', '?', '…',
')', ')', '】', ']', '』', '」', '・', '―', '〜', '~'
]);
折り返し後に禁則文字が行頭に来てしまう場合は、前の行に引き込む処理を入れています。
自然な改行位置の探索
単純に文字幅で切ると、単語の途中で改行されてしまいます。そこで自然な区切り文字の位置を記憶し、優先的にそこで改行します。
const BREAK_AFTER = new Set([
' ', '/', '・', '、', '。', '!', '?', '!', '?', '…', '―'
]);
const MIN_FILL_RATIO = 0.5; // 行の50%以上埋まっていれば自然改行を採用
ただし、区切り位置があまりに早い(行の半分未満)場合は採用せず、文字位置で切ります。MIN_FILL_RATIO = 0.5 という閾値で、見た目の均一さと読みやすさのバランスを取っています。
折り返し後の垂直中央揃え
複数行になったテキストを、元の1行分のスペースに対して垂直中央に配置する計算です。
function centerFirstLineY(
centerY: number, lineCount: number, fontSize: number, lineHeight = 1.2
): number {
// N行分の高さの中心がcenterYに来るよう、1行目のY座標を逆算
return centerY - ((lineCount - 1) / 2) * fontSize * lineHeight;
}
3行・fontSize=30の場合、行間を含めた全体高さは 2 × 30 × 1.2 = 72px。その中心がテンプレート上の指定位置に来るよう、1行目を centerY - 36px に配置します。
2. テンプレート駆動の動的フォントサイズ
文字が長くて枠からはみ出す、という現象はプロフィール系UIでは絶対に避けたかった問題でした。CSSなら font-size: clamp() が使えますが、SVGにはそれがありません。
そこで、テンプレートのJSON定義にフォントサイズルールを持たせる設計にしました。ようは、文字が一定の閾値を超えたら、フォントサイズをどんどん小さくしていくという仕組みです。
{
"oneWord": {
"label": "一言",
"maxLength": 40,
"font": {
"family": "Noto Sans JP",
"baseSize": 30,
"rules": [
{ "over": 12, "size": 28 },
{ "over": 17, "size": 26 },
{ "over": 23, "size": 24 },
{ "over": 30, "size": 22 },
{ "over": 37, "size": 20 }
]
}
}
}
解決ロジックはシンプルです。ルールを降順ソートして最初にマッチしたものを採用します。
export function resolveFontSize(text: string, fontRule: FontConfig): number {
let size = fontRule.baseSize;
const sortedRules = [...fontRule.rules].sort((a, b) => b.over - a.over);
for (const rule of sortedRules) {
if (text.length > rule.over) {
size = rule.size;
break;
}
}
return size;
}
この設計のポイントは、フォントサイズの制御がコードではなくテンプレートデータにあることです。新しいテンプレートを追加するとき、JSONを編集するだけでフィールドごとの文字サイズ挙動を調整できます。
3. 完全クライアントサイドのPNG書き出し
SVGをCanvas経由でPNGに変換する。言葉にすると簡単ですが、ここに大きな罠があります。
外部URLを参照するリソース(フォント・画像)を含むSVGをCanvasに描画すると、セキュリティ制約で toBlob() が使えなくなります。 いわゆる「汚染されたCanvas(tainted canvas)」問題です。
解決策:書き出し時にすべてのリソースをBase64化してSVG内部に埋め込む
// Google Fontsの外部URLをBase64 Data URIに置換
const fontUrlRegex = /url\(['"]?(https?:\/\/[^'")\s]+\.woff2?)['"]?\)/g;
// url('https://fonts.gstatic.com/...woff2')
// → url('data:application/octet-stream;base64,...')
// 画像も同様に埋め込み
// <image href="/backgrounds/bg1.jpg">
// → <image href="data:image/jpeg;base64,...">
この「自己完結SVG」をCanvasに描画することで、CORSの制約を完全に回避しています。
const targetHeight = 2048;
const scale = targetHeight / templateHeight; // 1350px → 2048px
canvas.width = Math.round(templateWidth * scale);
canvas.height = targetHeight;
2048pxという出力サイズは、SNSでの表示品質とファイルサイズのバランスを考慮した値です。ちょっと解像度気持ち綺麗な方が良いかな・・・という個人的な感覚でやってますが。
この処理もサーバーを一切介さず、ブラウザのCPUパワーだけで高解像度な画像を生成します。
4. Piniaなしの状態管理・・・モジュールスコープを活用したシングルトンパターン
Vue 3のComposition APIには、あまり知られていない特性があります。モジュールスコープで宣言されたreactiveオブジェクトは、どのコンポーネントから呼び出しても同じインスタンスを参照するということです。
// useProfile.ts
// ↓ モジュールスコープ(関数の外)に置くことで、アプリ全体で1つだけ存在する
const profileValues = reactive<Record<string, string>>({});
const profileOptions = reactive<Record<string, unknown>>({});
const snsAccounts = reactive<SnsAccount[]>([]);
export function useProfile() {
// どのコンポーネントがこれを呼んでも、上の同じオブジェクトを返す
return { values: profileValues, options: profileOptions, snsAccounts };
}
Piniaの defineStore がやっていることの本質は、まさにこれです。小〜中規模のアプリで、DevToolsによるストア監視が不要であれば、この素朴なパターンで十分に機能します。
LocalStorageへの自動永続化
let isInitializing = false;
watch(
() => [profileValues, profileOptions, snsAccounts],
() => {
if (isInitializing) return; // 復元中の書き込みを防止
saveToLocalStorage();
},
{ deep: true }
);
isInitializing フラグがないと、LocalStorageからの復元時にwatcherが発火し、復元途中の不完全なデータで上書きしてしまいます。地味ですが、状態復元と自動保存の組み合わせでは必要な処理です。
5. SVGテンプレートと変数置換──デザインとロジックの分離
テンプレートのSVGファイルには {{変数名}} のプレースホルダーを埋め込んでおき、実行時に値を注入します。
<!-- template.svg(抜粋) -->
<text font-size="{{fontSize.name}}" font-family="{{fontFamily.name}}">
{{value.name}}
</text>
置換ロジックはごくシンプルです。
export function replaceVariables(svg: string, variables: Record<string, string>): string {
let result = svg;
for (const [key, value] of Object.entries(variables)) {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
}
return result;
}
この設計のメリットは、SVGテンプレートをデザインツール(Figma、Illustratorなど)で作成し、テキスト部分をプレースホルダーに差し替えるだけで新しいテンプレートが作れることです。プログラミング不要で、テンプレートのバリエーションを増やしていけます。
SNSアカウント欄のように動的に要素数が変わる部分は、SVG要素自体をJavaScriptで生成して変数として注入しています。
// SNSアカウントごとにSVG要素を動的生成
const snsElements: string[] = [];
snsAccounts.forEach((account, index) => {
const y = baseY + index * lineHeight;
snsElements.push(`<text x="${x}" y="${y}" ...>${prefix}${account.handle}</text>`);
});
variables['snsAccountsElements'] = snsElements.join('');
初回訪問者へのツール理解をどうするか?・・・サンプルデータによるオンボーディング
技術とは少し違う話ですが、UXとして大事にしたポイントがあります。
それは 初めてアクセスしたユーザーには、空のフォームではなくサンプルデータが入った状態を見せる 、ということです。
onMounted(async () => {
const loaded = await loadFromLocalStorage();
if (!loaded) {
await importFromJson(SAMPLE_PROFILE); // 初回はサンプルを表示
}
});
「何ができるアプリなのか」を言葉で説明するより、完成形を見せてから編集してもらう方が伝わるかと思いそうしています。「実際にフォームを触って値を書き換えると、プレビューがリアルタイムに変わっていく」という体験が、チュートリアルとして有効になるんじゃないかと思いました。
アーキテクチャ全体図
User Input (ProfileForm)
→ useProfile (reactive state, LocalStorage自動保存)
→ useSvgGenerator (フォントサイズ解決 → テキスト折り返し → 変数置換)
→ PreviewCanvas (リアルタイム表示)
→ useImageExport (Base64埋め込み → Canvas → PNG/SVGダウンロード)
すべてのデータの流れが一方向であること、そして各composableが明確な責務を持っていることがポイントです。循環参照を防ぐため、composable間の依存方向は厳密に管理しています(ProfileがSvgGeneratorを知らない、SvgGeneratorがImageExportを知らない)。
まとめ:ライブラリに頼らない挑戦の結果
今回の開発を通じて実感したのは、「ライブラリを使わない」ことは、単純な「車輪の再発明」にはならないメリットが多数あったということです。
- SVGのテキスト折り返し → CSSのword-wrapは使えない。自分で組版ルールを理解する必要があった。
- PNG書き出し → html2canvasは使わない。ブラウザのセキュリティモデルを理解する必要があった。
- 状態管理 → Piniaは使わない。Vue 3のモジュールシステムの特性を理解する必要があった。
勿論「これ使えばもっと楽にできるよ」というのはあると思いますが、自分で色々実装することで勉強になることもあるな、となりました。
依存を減らすことで、ブラウザやフレームワークが内部で何をしているのかに向き合う機会が増えます。結果として得られた知識は、今後ライブラリを使うときにも活きてくるはずです。
そしてそんな技術で作ったこのサービスが、「もっと自分のことを知ってほしい」「友達のことをもっと知りたい」という素朴な気持ちを、少しだけ叶えやすくする。
CharaDropが誰かの「自己紹介」のきっかけになれば、嬉しいです。
リンク
