日本語縦書きWebアプリ「ことのはっと」を作った話

はじめに

美しい縦書きの名言画像を作成できるWebアプリ「ことのはっと」を開発しました。画像生成に特化して、なるべくシンプルに作ることを心がけました。

最初は「わたしの名言ジェネレーター」という名前で始まりましたが、「ことのはっと」という愛着のある名前に変更されました。日本語の「言の葉」と、発見や驚きを表す「はっと」を組み合わせた名前です。

開発過程では、特に日本語縦書き表示の実装や、ユーザー体験を重視した設計について詳しく説明します。

開発の出発点:初期要件の整理

「ことのはっと」は、テキストを入力すると美しい縦書きの画像を生成するWebアプリです。Canvas APIを使って文字を描画し、Web Share APIで画像を共有できます。PWA(Progressive Web App)として動作するため、スマートフォンのホーム画面に追加して、ネイティブアプリのように使用できます。

最初の希望は、「縦書きでテキストを表示し、グラデーション背景を持つ画像を生成したい」。文字サイズは自動で最適化され、画像の縦横比は3対4にしたい。そして、生成した画像をSNSや写真アプリに直接共有できるようにしたいと考えました。

プロジェクトの基本機能と技術スタック

フレームワークを使わず、バニラJavaScript(純粋なJavaScript)で実装しました。この選択により、軽量で高速なアプリケーションになりました。描画にはCanvas 2D API、レイアウトにはCSS GridとFlexbox、データ保存にはlocalStorageを使用しています。

Web技術でネイティブアプリと同等の体験を提供する必要がありました。特に画像の共有機能は、各プラットフォームの制約を考慮する必要がありました。iOSとAndroidでは共有方法が異なるため、Web Share APIとダウンロード機能の両方を実装することにしました。

UI設計の大転換:WYSIWYGからモーダル式へ

モーダル式UIの採用

最初は設定パネルとプレビュー画面を左右に分けた設計でしたが、「作成画面とプレビュー画面を分けずに、WYSIWYG(見たままが結果になる)にしたい」と考えました。WYSIWYGとは、編集中の画面と最終結果が同じに見える仕組みのことです。ワープロソフトで文字を入力すると、印刷時と同じフォントで画面に表示されるのがWYSIWYGの例です。

ただし、ちょっと大変そうだったので、「テキスト入力欄は、プレビュー画面をタップするとモーダルに表示されるようにしたらどうか」と考えました。モーダルとは、メイン画面の上に重なって表示される小窓のことです。

実装の詳細

プレビュー画面を中心とした構成に変更し、タップでテキスト入力モーダルが開く仕組みを実装しました。モーダルはフルスクリーンで表示され、テキスト編集環境を提供します。

// モーダル表示の実装例
openModal() {
    this.elements.textInput.value = this.currentText;
    this.elements.modalOverlay.classList.add('active');
    
    // フォーカス設定(アニメーション後)
    setTimeout(() => {
        this.elements.textInput.focus();
    }, 300);
}
Code language: JavaScript (javascript)

ESCキーや外側をタップするとモーダルが閉じる機能も実装し、直感的な操作を実現しました。

縦書き表示はCSSだけでは不十分

日本語の縦書き表示は、CSSのwriting-mode: vertical-rlだけでは完璧に実現できません。というのも、句読点や括弧などの記号類は、縦書き特有の位置調整が必要だからです。

例えば、句点「。」は横書きでは文字の右下に配置されますが、縦書きでは文字の右上に配置されます。読点「、」も同様です。このような細かな調整を行わないと、自然な縦書きにはなりません。

Canvas APIによる文字描画

Canvas APIを使って、文字一つひとつの位置を詳細に制御しました。文字種別に応じて異なる処理を行っています。

drawCharacter(char, x, y, fontSize) {
    if (this.isPunctuation(char)) {
        this.drawPunctuation(char, x, y, fontSize);
    } else if (this.isBracket(char)) {
        this.drawBracket(char, x, y, fontSize);
    } else if (this.isLongVowel(char)) {
        this.drawLongVowel(char, x, y, fontSize);
    } else {
        // 通常の文字
        this.ctx.fillText(char, x, y);
    }
}
Code language: JavaScript (javascript)

句読点の正確な位置調整

句読点の位置は特に重要です。句点「。」は文字の右上やや下に、読点「、」は右上やや上に配置します。これにより、自然な縦書きになります。

drawPunctuation(char, x, y, fontSize) {
    this.ctx.font = `${fontSize * 0.7}px ${this.fontFamily}`;
    
    if (char === '。') {
        // 句点は右上やや下
        this.ctx.fillText(char, x + fontSize * 0.3, y - fontSize * 0.1);
    } else if (char === '、') {
        // 読点は右上やや上
        this.ctx.fillText(char, x + fontSize * 0.3, y - fontSize * 0.2);
    }
}
Code language: JavaScript (javascript)

記号類の回転処理

括弧「」『』や長音符「ー」は、90度回転して表示します。これにより、縦書きで自然に見えるようになります。

drawBracket(char, x, y, fontSize) {
    this.ctx.translate(x, y);
    this.ctx.rotate(Math.PI / 2); // 90度回転
    this.ctx.fillText(char, 0, 0);
}
Code language: JavaScript (javascript)

レイアウト調整:ユーザーの自由度を高める

上揃えレイアウトの採用

当初は文字を中央揃えで配置していましたが、「縦書きの文字は上揃えにしてください。そうすれば空白文字で自分で位置を調整できるから」という要望がありました。

この変更により、ユーザーは改行や空白を使って文字の位置を自由に調整できるようになりました。俳句のように短い文章では、適度な余白を作ることで美しいレイアウトが可能になります。

行間の調整

縦書きでは「行間」の概念が横書きと異なります。縦書きの行間とは、縦の列と列の間隔のことです。「文字の行間をもう少し開けたい。例えば行間を2.2にしてみて」という要望に応じて、文字サイズの2.2倍の間隔を設定しました。

const columnSpacing = fontSize * this.lineSpacing; // 2.2倍
const totalColumnsWidth = (lines.length - 1) * columnSpacing;
Code language: JavaScript (javascript)

この調整により、文字が詰まって見えることなく、読みやすい縦書きレイアウトが実現できました。

色彩設計:伝統的な書籍文化への回帰

カラーテーマでは「万年筆で良質な紙に書く体験の再現」というコンセプトで、デジタルでありながら、アナログの書字体験に近い質感を表現しました。

インクの色の選定

万年筆で実際に使われる3色を選定しました。

  • ブラック(#1a1a1a):最も基本的な黒色
  • ブルーブラック(#2c3e50):青みがかった上品な黒
  • グレーブラック(#4a4a4a):柔らかい印象の黒

これらの色は、どれも読みやすさを保ちながら、それぞれ異なる印象を与えます。

紙の色の選定とグラデーション

書籍で実際に使われる4種類の紙色を再現しました。

  • 純白:最も明るい白色のグラデーション
  • 書籍紙:一般的な書籍で使われる薄いクリーム色
  • クリーム紙:より温かみのあるクリーム色
  • 和紙風:日本の伝統的な和紙をイメージした色

各紙色は単色ではなく、微細なグラデーションで表現しています。これにより、平坦な印象を避け、自然な紙の質感を演出できました。

drawBackground(gradientCSS) {
    const gradient = this.parseGradient(gradientCSS);
    const canvasGradient = this.ctx.createLinearGradient(0, 0, this.width, this.height);
    
    gradient.stops.forEach(stop => {
        canvasGradient.addColorStop(stop.position, stop.color);
    });
    
    this.ctx.fillStyle = canvasGradient;
    this.ctx.fillRect(0, 0, this.width, this.height);
}
Code language: JavaScript (javascript)

履歴機能:ユーザー体験の向上

localStorageを活用したデータ管理

「作成したテキストだけを履歴に残せるようにしたい」と思って、履歴機能を実装しました。ブラウザのlocalStorageを使用して、最大50件のテキストを保存できます。

addToHistory(text) {
    const newItem = {
        id: this.generateId(),
        text: trimmedText,
        timestamp: new Date().toISOString()
    };
    
    history.unshift(newItem);
    
    // 最大件数を超えた場合は古いものを削除
    if (history.length > this.maxItems) {
        history.splice(this.maxItems);
    }
    
    this.saveHistory(history);
}
Code language: JavaScript (javascript)

localStorageとは、ブラウザがデータを保存できる仕組みの一つです。アプリを閉じても、次回開いたときにデータが残っています。

スワイプでの削除機能

スマートフォンでよく使われるスワイプ操作で履歴を削除できる機能を実装しました。リスト項目を左にスワイプすると削除ボタンが現れ、タップで削除できます。

this.elements.historyList.addEventListener('touchmove', (e) => {
    if (currentItem) {
        const deltaX = e.touches[0].clientX - startX;
        if (deltaX < -50) {
            currentItem.classList.add('swipe-left');
        } else {
            currentItem.classList.remove('swipe-left');
        }
    }
});
Code language: JavaScript (javascript)

この機能により、直感的な操作で履歴を管理できるようになりました。

PWA実装:ネイティブアプリ並みの体験

Service Workerによるオフライン対応

PWA(Progressive Web App)として動作させるため、Service Workerを実装しました。Service Workerとは、ブラウザがバックグラウンドで実行するプログラムのことです。これにより、インターネット接続がない状態でもアプリを使用できます。

// 静的ファイルのキャッシュ
const STATIC_FILES = [
    '/',
    '/index.html',
    '/styles.css',
    '/js/app.js',
    // 他のファイル...
];

self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(STATIC_CACHE_NAME).then((cache) => {
            return cache.addAll(STATIC_FILES);
        })
    );
});
Code language: PHP (php)

Web Share APIの実装

ネイティブアプリと同様の共有体験を提供するため、Web Share APIを実装しました。この技術により、作成した画像を他のアプリに直接送信できます。

async tryWebShare(file, text) {
    const shareData = {
        title: this.shareTitle,
        text: this.createShareText(text),
        files: [file]
    };
    
    if (navigator.canShare(shareData)) {
        await navigator.share(shareData);
        return true;
    }
    
    return false;
}
Code language: JavaScript (javascript)

Web Share APIに対応していないブラウザでは、自動的にダウンロード機能にフォールバック(代替手段に切り替え)します。

ホーム画面への追加

manifest.jsonファイルを作成し、スマートフォンのホーム画面にアプリアイコンを追加できるようにしました。

{
  "name": "ことのはっと",
  "short_name": "ことのはっと",
  "description": "美しい縦書きの名言画像を作成・共有できるWebツール",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#faf8f3",
  "theme_color": "#faf8f3"
}
Code language: JSON / JSON with Comments (json)

フォントの最適化

明朝体の採用

フォント指定では、縦書きで美しく見える明朝体を採用しました。

font-family: 'Times New Roman', 'Yu Mincho', 'Hiragino Mincho ProN', 'Hiragino Mincho Pro', 'HG明朝E', 'MS P明朝', 'MS PMincho', 'MS 明朝', serif;
Code language: JavaScript (javascript)

複数のフォント名を指定することで、異なるOS環境でも適切な明朝体が表示されるようにしました。

文字サイズの自動調整

テキストの長さに応じて、文字サイズを自動で調整する機能を実装しました。短いテキストでは大きく、長いテキストでは小さく表示されます。

calculateOptimalFontSize(lines) {
    const maxChars = Math.max(...lines.map(line => line.length));
    
    // 高さベースの計算
    const heightBasedSize = availableHeight / (maxChars + 2);
    
    // 幅ベースの計算
    const widthBasedSize = availableWidth / ((lines.length - 1) * this.lineSpacing + 1);
    
    // 小さい方を採用
    const fontSize = Math.min(heightBasedSize, widthBasedSize);
    return Math.max(this.minFontSize, Math.min(this.maxFontSize, fontSize));
}
Code language: JavaScript (javascript)

この計算により、どんな長さのテキストでも画面内に美しく収まります。

開発で得られた知見

日本語処理の複雑さ

日本語の縦書き表示は想像以上に複雑でした。ひらがな、漢字、英数字、記号それぞれに適切な処理が必要です。しかし、この複雑さに向き合うことで、より自然で美しい表示を実現できました。

まとめ

「ことのはっと」の開発は、ユーザー中心設計の実践例として価値のあるプロジェクトでした。Canvas APIによる高精度な縦書き表示、Web Share APIを活用した共有機能、PWAによるネイティブアプリ並みの体験を、バニラJavaScriptで実現しました。

最も重要だったのは、ユーザーからのフィードバックを継続的に取り入れながら、段階的に改善を重ねたことです。技術的な実装だけでなく、日本語縦書きの文化的な側面も考慮し、美しく自然な表示を実現できました。

Web技術の標準的な機能だけでも、適切に組み合わせることで高機能なアプリケーションを作成できることが実証されました。