1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フロントエンド Word から Markdown へのコンバーターを構築する(React + WebAssembly)

1
Posted at

日常の開発では、Word 文書を処理する必要がある場面によく遭遇します。従来の方法では、ファイルをバックエンドサーバーにアップロードし、サーバー側で変換を完了してから結果を返していました。この方法はサーバーの負担を増やすだけでなく、ユーザーデータのプライバシーに関する懸念も生じさせます。

本記事では、WebAssembly(WASM)技術に基づく純粋なフロントエンド実装ソリューションを紹介します。Spire.Doc for JavaScript ライブラリを利用して、ブラウザ内で Word 文書を解析・変換します。

方案の選択

Spire.Doc for JavaScript(またはその無料版)は、フロントエンド環境向けに設計されたドキュメント処理ライブラリであり、WebAssembly 技術を通じて成熟した .NET ドキュメントエンジンをブラウザに移植したものです。以下の機能をサポートします:

  • Word(.docx / .doc)文書の読み書き
  • PDF、HTML、Markdown など多様な形式への変換
  • テキスト、表、画像、スタイルなどの豊富なコンテンツの処理
  • サーバーに依存せず、完全にクライアント側で動作

Word から Markdown への変換という場面において、このライブラリはネイティブの Markdown エクスポートオプションを提供し、Word の内容を仕様に準拠した Markdown テキストに自動変換できるため、開発作業が大幅に簡素化されます。

注:本記事の例は React フレームワークに基づいていますが、コアロジックは Vue やその他のフロントエンドフレームワークにも同様に適用できます。

実装の核心

1. WebAssembly モジュールの読み込み

Spire.Doc の WASM モジュールは Emscripten でコンパイルされ、ドキュメント操作の API を公開しています。読み込みプロセスは以下のとおりです:

const publicUrl = process.env.PUBLIC_URL || '';
const spireModule = await import(
  /* webpackIgnore: true */ `${publicUrl}/spire.doc.js`
);
const rawModule = spireModule.default || spireModule;
window.wasmModule = typeof rawModule === 'function'
  ? await rawModule({
      locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p
    })
  : rawModule;

ここには 2 つの重要なポイントがあります:

  • /* webpackIgnore: true */ コメントを使用して、Webpack のバンドル時にこのインポートが処理されないようにする
  • locateFile コールバックを通じて .wasm ファイルの読み込みパスを指定する

2. 仮想ファイルシステム(VFS)

WASM モジュールはサンドボックス環境で動作するため、ホスト OS のファイルシステムに直接アクセスできません。Emscripten は仮想ファイルシステム(VFS)を提供し、WASM メモリ内でファイルの読み書きをシミュレートできるようにします。

コード内の writeFileToVFS 関数は、ユーザーが選択したファイルを VFS に書き込む処理を実装しています:

const writeFileToVFS = (fileName, arrayBuffer) => {
  const FS = window.dotnetRuntime?.Module?.FS;
  if (!FS) throw new Error('FS オブジェクトが準備できていません');
  FS.writeFile(fileName, new Uint8Array(arrayBuffer));
};

この VFS はブラウザのファイルシステムとは独立しており、すべての操作は WASM メモリ内で完結するため、データがユーザーデバイスから出ないことを保証します。

3. ファイル読み込みと変換フロー

変換フロー全体は 5 つのステップに分かれます:

ステップ 1:ファイルを読み込む

FileReader を使用して、ユーザーが選択したファイルを ArrayBuffer として読み込みます:

const fileBuffer = await new Promise((resolve, reject) => {
  const reader = new FileReader();
  reader.onload = (e) => resolve(e.target.result);
  reader.onerror = (e) => reject(e.target.error);
  reader.readAsArrayBuffer(selectedFile);
});

ステップ 2:VFS に書き込む

ArrayBuffer を Uint8Array に変換してから仮想ファイルシステムに書き込みます:

writeFileToVFS(inputFileName, fileBuffer);

ステップ 3:フォントを読み込む

ドキュメントのレンダリングには通常フォントのサポートが必要です。ここではサーバーから VFS の指定ディレクトリにフォントファイルを読み込みます(Spire.Doc にはフォント読み込み用の補助メソッドが組み込まれています):

await window.spire.FetchFileToVFS(
  'CALIBRI.ttf',
  '/Library/Fonts/',
  `${process.env.PUBLIC_URL}/static/font/`
);

ステップ 4:変換を実行する

Document オブジェクトをインスタンス化し、ファイルを読み込んで Markdown 形式で保存します:

const doc = new wasmModule.Document();
doc.LoadFromFile(inputFileName);
doc.SaveToFile({
  fileName: outputFileName,
  fileFormat: wasmModule.FileFormat.Markdown
});
doc.Dispose();

ここで呼び出されている Document、LoadFromFile、SaveToFile などの API はすべて Spire.Doc for JavaScript によって提供されており、FileFormat.Markdown 列挙値は出力先の形式を示します。

ステップ 5:結果を出力する

VFS から生成された Markdown ファイルの内容を読み取り、Blob を作成してダウンロードをトリガーします:

const mdContent = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([mdContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
// a タグを作成してダウンロードをトリガー...

ユーザーインターフェースの設計

ユーザーエクスペリエンスを向上させるため、インターフェースには以下の設計を採用しています:

ファイル選択のインタラクション

非表示のネイティブ <input type="file"> とカスタムボタンを組み合わせて、より使いやすいファイル選択体験を実現します:

const triggerFileSelect = () => {
  if (loading) return;
  fileInputRef.current?.click();
};

カスタムボタンはスタイルを完全に制御でき、同時に accept 属性を通じてファイルタイプを制限できます。

ステータスフィードバック

ステータス表示エリアで、現在の操作の進捗をユーザーに示します:

  • モジュールの読み込み状態(読み込み中 / 準備完了 / 失敗)
  • ファイル選択状態(選択されたファイル名とサイズ)
  • 変換進捗(読み込み中 / 変換中 / 完了)

ファイル管理

選択済みファイルをクリアする機能を提供し、ユーザーが再選択しやすくします:

const clearSelectedFile = () => {
  setSelectedFile(null);
  setStatus('📂 選択をクリアしました。ドキュメントを再選択してください');
  if (fileInputRef.current) {
    fileInputRef.current.value = '';
  }
};

重要な実装の詳細

モジュール読み込み状態の管理

React の useState を使用して WASM モジュールインスタンスと読み込み状態を管理し、モジュールの準備が完了してからユーザー操作を許可するようにします:

const [wasmModule, setWasmModule] = useState(null);
const [loading, setLoading] = useState(true);

// ボタンの無効化条件
disabled={loading || !wasmModule || !selectedFile}

リソースのクリーンアップ

変換完了後、VFS 内の一時ファイルをクリーンアップして、メモリ使用量の増加を避ける必要があります:

try {
  window.dotnetRuntime.Module.FS.unlink(inputFileName);
  window.dotnetRuntime.Module.FS.unlink(outputFileName);
} catch (_) {
  // クリーンアップ失敗はメインフローに影響しない
}

同時に、ダウンロード完了後は URL.createObjectURL で作成したオブジェクト URL を解放する必要があります:

URL.revokeObjectURL(url);

ファイルタイプの検証

クライアント側でファイルタイプを検証し、サポートされていない形式を事前にブロックします:

const isValid = 
  file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
  file.type === 'application/msword' ||
  file.name.endsWith('.docx') ||
  file.name.endsWith('.doc');

パフォーマンス最適化の提案

1. モジュールのプリロード

アプリケーション起動時に WASM モジュールの読み込みを開始し、ユーザーが変換ボタンをクリックするまで待たずに、ユーザーの待ち時間を短縮できます。

2. フォントキャッシュ

フォントファイルはサイズが大きい(通常数 MB)ため、サーバーのキャッシュ戦略を設定し、重複読み込みを避けることを推奨します。

3. 大きなファイルの処理

大きな Word 文書の場合、変換処理に時間がかかることがあります。進捗表示を追加するか、Web Worker を使用してメインスレッドのブロックを避けることを検討してください。

4. メモリ管理

不要になったオブジェクトを速やかに解放し(Dispose() メソッドを呼び出す)、VFS 内の一時ファイルをクリーンアップして、メモリリークを防ぎます。

完全なコード例

以下は、上記のすべての機能を統合したコアコンポーネントのコードです(React Hooks を使用し、Spire.Doc for JavaScript を統合しています):

import React, { useState, useEffect, useRef } from 'react';

function WordToMarkdown() {
  const [wasmModule, setWasmModule] = useState(null);
  const [loading, setLoading] = useState(true);
  const [status, setStatus] = useState('WASM モジュールを読み込み中...');
  const [selectedFile, setSelectedFile] = useState(null);
  const fileInputRef = useRef(null);

  // WASM モジュールを読み込む(Spire.Doc for JavaScript)
  useEffect(() => {
    (async () => {
      try {
        const publicUrl = process.env.PUBLIC_URL || '';
        const spireModule = await import(
          /* webpackIgnore: true */ `${publicUrl}/spire.doc.js`
        );
        const rawModule = spireModule.default || spireModule;
        window.wasmModule = typeof rawModule === 'function'
          ? await rawModule({
              locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p
            })
          : rawModule;
        setWasmModule(window.wasmModule);
        setStatus('✅ モジュールの準備が完了しました。Word ドキュメントを選択してください');
      } catch (error) {
        console.error(error);
        setStatus('❌ モジュールの読み込みに失敗しました');
      } finally {
        setLoading(false);
      }
    })();
  }, []);

  const handleFileChange = (event) => {
    const file = event.target.files[0];
    if (file) {
      const isValid = 
        file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
        file.type === 'application/msword' ||
        file.name.endsWith('.docx') ||
        file.name.endsWith('.doc');

      if (!isValid) {
        setStatus('⚠️ .docx または .doc 形式のファイルを選択してください');
        setSelectedFile(null);
        event.target.value = '';
        return;
      }

      setSelectedFile(file);
      setStatus(`📄 選択済み: ${file.name} (${(file.size / 1024).toFixed(1)} KB)`);
    }
  };

  const triggerFileSelect = () => {
    if (loading) return;
    fileInputRef.current?.click();
  };

  const clearSelectedFile = () => {
    setSelectedFile(null);
    setStatus('📂 選択をクリアしました。ドキュメントを再選択してください');
    if (fileInputRef.current) {
      fileInputRef.current.value = '';
    }
  };

  const writeFileToVFS = (fileName, arrayBuffer) => {
    const FS = window.dotnetRuntime?.Module?.FS;
    if (!FS) throw new Error('FS オブジェクトが準備できていません');
    FS.writeFile(fileName, new Uint8Array(arrayBuffer));
  };

  const convertWordToMD = async () => {
    const wasmModule = window.wasmModule?.spiredoc;
    if (!wasmModule || !selectedFile) return;

    try {
      setStatus('⏳ ファイルを読み込み中...');

      const fileBuffer = await new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = (e) => resolve(e.target.result);
        reader.onerror = (e) => reject(e.target.error);
        reader.readAsArrayBuffer(selectedFile);
      });

      const inputFileName = selectedFile.name;
      writeFileToVFS(inputFileName, fileBuffer);

      setStatus('⏳ フォントを読み込み中...');
      await window.spire.FetchFileToVFS(
        'CALIBRI.ttf',
        '/Library/Fonts/',
        `${process.env.PUBLIC_URL}/static/font/`
      );

      setStatus('⏳ 変換中です。しばらくお待ちください...');
      const doc = new wasmModule.Document();
      doc.LoadFromFile(inputFileName);

      const outputFileName = 'WordToMarkdown.md';
      doc.SaveToFile({
        fileName: outputFileName,
        fileFormat: wasmModule.FileFormat.Markdown
      });
      doc.Dispose();

      const mdContent = window.dotnetRuntime.Module.FS.readFile(outputFileName);
      const blob = new Blob([mdContent], { type: 'text/markdown;charset=utf-8' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = outputFileName;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);

      try {
        window.dotnetRuntime.Module.FS.unlink(inputFileName);
        window.dotnetRuntime.Module.FS.unlink(outputFileName);
      } catch (_) {}

      setStatus(`✅ 変換に成功しました! ${outputFileName} をダウンロードしました`);
    } catch (error) {
      console.error(error);
      setStatus('❌ 変換に失敗しました: ' + error.message);
    }
  };

  return (
    <div style={{ maxWidth: '600px', margin: '40px auto', padding: '30px', textAlign: 'center' }}>
      <h1>📝 Word → Markdown</h1>
      <p style={{ color: '#666' }}>純粋なフロントエンド · Spire.Doc for JavaScript ベース</p>

      <div style={{
        padding: '10px 16px',
        borderRadius: '8px',
        background: status.includes('❌') || status.includes('⚠️') ? '#fff0f0' : '#f0f7ff',
        color: status.includes('❌') || status.includes('⚠️') ? '#c00' : '#0050b3',
        border: '1px solid ' + (status.includes('❌') || status.includes('⚠️') ? '#ffccc7' : '#d6e4ff'),
        marginBottom: '24px',
        minHeight: '40px',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center'
      }}>
        {status}
      </div>

      <input
        ref={fileInputRef}
        type="file"
        accept=".docx,.doc"
        onChange={handleFileChange}
        style={{ display: 'none' }}
      />

      <button
        onClick={triggerFileSelect}
        disabled={loading}
        style={{
          padding: '12px 28px',
          fontSize: '16px',
          borderRadius: '8px',
          border: '2px dashed #40a9ff',
          background: '#fafafa',
          color: loading ? '#999' : '#0050b3',
          cursor: loading ? 'not-allowed' : 'pointer',
          minWidth: '200px'
        }}
      >
        📂 {loading ? '読み込み中...' : 'Word ドキュメントを選択'}
      </button>

      {selectedFile && (
        <div style={{ marginTop: '12px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px' }}>
          <span style={{ background: '#f6ffed', padding: '4px 16px', borderRadius: '16px', border: '1px solid #b7eb8f' }}>
            📎 {selectedFile.name}
          </span>
          <button onClick={clearSelectedFile} style={{ background: 'none', border: 'none', color: '#ff4d4f', cursor: 'pointer' }}>
            ✕
          </button>
        </div>
      )}

      <button
        onClick={convertWordToMD}
        disabled={loading || !wasmModule || !selectedFile}
        style={{
          marginTop: '20px',
          padding: '12px 48px',
          fontSize: '16px',
          borderRadius: '8px',
          border: 'none',
          background: (loading || !wasmModule || !selectedFile) ? '#d9d9d9' : '#1890ff',
          color: '#fff',
          cursor: (loading || !wasmModule || !selectedFile) ? 'not-allowed' : 'pointer',
          fontWeight: '500',
          boxShadow: (loading || !wasmModule || !selectedFile) ? 'none' : '0 2px 8px rgba(24,144,255,0.4)'
        }}
      >
        🚀 変換してダウンロード
      </button>
    </div>
  );
}

export default WordToMarkdown;

実行とプレビュー

Spire.Doc の WASM リソースを public ディレクトリに配置し、例に従ってフォントファイルのパスを設定します( 📌 インストールチュートリアルを見るにはクリック)。

  • npm start を実行して開発サーバーを起動します。
  • 「Word ドキュメントを選択」をクリックして .docx または .doc ファイルをアップロードします。
  • 「変換してダウンロード」をクリックし、しばらく待つと、ブラウザが生成された Markdown ファイルを自動的にダウンロードします。

ReactWord2MD.png


全体として、ブラウザ側で Word 文書を処理する必要がある場面に対して、本記事は参考になる純粋なフロントエンドソリューションを提供します。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?