【React × MarkltDown】 様々なファイル形式をMarkdownに変換するWebアプリケーションを作成してみた

## はじめに
初めましての人もそうでない人も明けましておめでとうございます！(記事作成時：2025年1月上旬)
年が明けてから（おそらく）初めての記事になります！
2025年も皆さんが健康でいられるよう、陰ながら応援しています！

さて今回2025年を飾る最初の記事は、MarkltDownを使って色々なファイル形式をMarkdownに変換していこうと思います！

大体1ヶ月くらい前に、Microsoftが「いろんなファイル形式をMarkdownに変換できるPythonライブラリを作ったよ！」ということで、インターネットで[MarkltDown]と調べると、たくさんの参考記事が上がっています！
それくらい今注目されています！

ただ多くの記事はGoogle ColabとPythonを使った記事が多く、Reactなどのフロントエンドと組み合わせた記事がパッと見なかったので、今回はReactと組み合わせて簡単なMarkdown変換アプリを作成しようと思います！

ぜひ最後までご覧ください！

## ディレクトリ構成
```
md_app
│
├── frontend/
│    ├── public/
│    ├── src/
│    │   ├── App.tsx
│    │   ├── Components/
│    │   │   ├── ConversionResult.tsx
│    │   │   ├── FileUploader.tsx
│    │   │   └── MarkdownPreview.tsx
│    │   ├── Pages/
│    │   │   └── Home.tsx
│    │   ├── Themes/
│    │   │   └── ThemeProvider.tsx
│    │   └── ...
│    └── ...
└── backend/
     └── app.py
```

## 作ってみた
**環境構築**
```
mkdir md_app
cd md_app
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install axios @types/axios @mui/material @emotion/react @emotion/styled
cd ..
mkdir backend
cd backend
touch app.py
pip install flask flask-cors
pip install git+https://github.com/microsoft/markitdown.git
```

**コーディング**
```tsx:src/ app.tsx
// frontend/src/App.tsx
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { CssBaseline } from '@mui/material';
import { ThemeProvider } from './theme/ThemeProvider';
import Home from './pages/Home';

const App = () => {
  return (
    <ThemeProvider>
      <CssBaseline />
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
        </Routes>
      </Router>
    </ThemeProvider>
  );
};

export default App;
```

```components/ ConversionResult.tsx
// frontend/src/components/ConversionResult.tsx
import { Paper, Typography } from '@mui/material';

interface ConversionResultProps {
  convertedText: string;
}

const ConversionResult = ({ convertedText }: ConversionResultProps) => {
  return (
    <Paper sx={{ p: 2, mt: 2, minHeight: '200px' }}>
      {convertedText ? (
        <Typography component="pre" sx={{ whiteSpace: 'pre-wrap' }}>
          {convertedText}
        </Typography>
      ) : (
        <Typography color="text.secondary">
          変換されたテキストがここに表示されます
        </Typography>
      )}
    </Paper>
  );
};

export default ConversionResult;
```

```tsx:components/ FileUploader.tsx
// frontend/src/components/FileUploader.tsx
import { useState } from 'react';
import { Button, Box, CircularProgress, Alert } from '@mui/material';
import axios from 'axios';

interface FileUploaderProps {
  setConvertedText: (text: string) => void;
}

const FileUploader = ({ setConvertedText }: FileUploaderProps) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    const formData = new FormData();
    formData.append('file', file);

    setIsLoading(true);
    setError(null);
    
    try {
      const response = await axios.post('http://127.0.0.1:5000/convert', formData);
      setConvertedText(response.data.text);
    } catch (error: any) {
      console.error('Error converting file:', error);
      setError(error.response?.data?.error || 'ファイルの変換中にエラーが発生しました。');
      setConvertedText('');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Box sx={{ my: 2 }}>
      <input
        accept=".pdf,.docx,.pptx,.xlsx,.html,.jpg,.jpeg,.png,.csv,.json,.xml,.mp3,.wav"
        style={{ display: 'none' }}
        id="raised-button-file"
        type="file"
        onChange={handleFileUpload}
      />
      <label htmlFor="raised-button-file">
        <Button variant="contained" component="span" disabled={isLoading}>
          {isLoading ? <CircularProgress size={24} /> : 'ファイルを選択'}
        </Button>
      </label>
      {error && (
        <Alert severity="error" sx={{ mt: 2 }}>
          {error}
        </Alert>
      )}
    </Box>
  );
};

export default FileUploader;
```

```tsx:components/ MarkdownPreview.tsx
// frontend/src/components/MarkdownPreview.tsx
import { useState } from 'react';
import { 
  Paper, 
  Box, 
  ToggleButtonGroup, 
  ToggleButton, 
  useTheme,
  IconButton,
  styled
} from '@mui/material';
import { LightMode, DarkMode } from '@mui/icons-material';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useColorMode } from '../theme/ThemeProvider';

interface MarkdownPreviewProps {
  content: string;
}

type ViewMode = 'preview' | 'code';

const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({
  backgroundColor: theme.palette.mode === 'dark' ? '#333' : '#f5f5f5',
  '& .MuiToggleButton-root': {
    color: theme.palette.mode === 'dark' ? '#fff' : '#000',
    borderColor: theme.palette.mode === 'dark' ? '#555' : '#ddd',
    '&.Mui-selected': {
      backgroundColor: theme.palette.mode === 'dark' ? '#555' : '#e0e0e0',
      color: theme.palette.mode === 'dark' ? '#fff' : '#000',
      '&:hover': {
        backgroundColor: theme.palette.mode === 'dark' ? '#666' : '#d5d5d5',
      },
    },
    '&:hover': {
      backgroundColor: theme.palette.mode === 'dark' ? '#444' : '#e8e8e8',
    },
  },
}));

const MarkdownPreview = ({ content }: MarkdownPreviewProps) => {
  const [viewMode, setViewMode] = useState<ViewMode>('preview');
  const theme = useTheme();
  const { toggleColorMode } = useColorMode();

  const handleViewChange = (event: React.MouseEvent<HTMLElement>, newMode: ViewMode | null) => {
    if (newMode !== null) {
      setViewMode(newMode);
    }
  };

  return (
    <Box sx={{ mt: 2 }}>
      <Box sx={{ 
        display: 'flex', 
        alignItems: 'center', 
        justifyContent: 'space-between',
        mb: 2 
      }}>
        <StyledToggleButtonGroup
          value={viewMode}
          exclusive
          onChange={handleViewChange}
          aria-label="view mode"
          size="small"
        >
          <ToggleButton value="preview" aria-label="preview">
            プレビュー
          </ToggleButton>
          <ToggleButton value="code" aria-label="code">
            Markdownコード
          </ToggleButton>
        </StyledToggleButtonGroup>

        <IconButton onClick={toggleColorMode} color="inherit">
          {theme.palette.mode === 'dark' ? <LightMode /> : <DarkMode />}
        </IconButton>
      </Box>

      <Paper 
        sx={{ 
          p: 2, 
          minHeight: '200px', 
          maxHeight: '600px', 
          overflow: 'auto',
          backgroundColor: theme.palette.background.paper,
          color: theme.palette.text.primary,
        }}
      >
        {content ? (
          viewMode === 'preview' ? (
            <Box sx={{
              '& table': {
                borderCollapse: 'collapse',
                width: '100%',
                margin: '16px 0'
              },
              '& th, & td': {
                border: `1px solid ${theme.palette.divider}`,
                padding: '8px',
                textAlign: 'left'
              },
              '& th': {
                backgroundColor: theme.palette.mode === 'dark' ? '#333' : '#f5f5f5'
              },
              '& h1, & h2, & h3, & h4, & h5, & h6': {
                margin: '16px 0 8px 0',
                color: theme.palette.text.primary
              },
              '& p': {
                margin: '8px 0',
                color: theme.palette.text.primary
              },
              '& hr': {
                margin: '16px 0',
                border: 'none',
                borderTop: `1px solid ${theme.palette.divider}`
              },
              '& a': {
                color: theme.palette.primary.main
              },
              '& code': {
                backgroundColor: theme.palette.mode === 'dark' ? '#333' : '#f5f5f5',
                padding: '2px 4px',
                borderRadius: '4px',
                color: theme.palette.mode === 'dark' ? '#fff' : '#000',
              }
            }}>
              <ReactMarkdown
                remarkPlugins={[remarkGfm]}
                components={{
                  code({node, inline, className, children, ...props}) {
                    const match = /language-(\w+)/.exec(className || '');
                    return !inline && match ? (
                      <SyntaxHighlighter
                        style={theme.palette.mode === 'dark' ? oneDark : oneLight}
                        language={match[1]}
                        PreTag="div"
                        {...props}
                      >
                        {String(children).replace(/\n$/, '')}
                      </SyntaxHighlighter>
                    ) : (
                      <code className={className} {...props}>
                        {children}
                      </code>
                    );
                  }
                }}
              >
                {content}
              </ReactMarkdown>
            </Box>
          ) : (
            <pre style={{ 
              margin: 0,
              whiteSpace: 'pre-wrap',
              wordBreak: 'break-all',
              fontFamily: 'ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace',
              fontSize: '14px',
              lineHeight: 1.5,
              color: theme.palette.text.primary,
              backgroundColor: theme.palette.background.paper,
            }}>
              {content}
            </pre>
          )
        ) : (
          <p style={{ color: theme.palette.text.secondary }}>
            変換されたMarkdownがここに表示されます
          </p>
        )}
      </Paper>
    </Box>
  );
};

export default MarkdownPreview;
```

```tsx:Pages/ Home.tsx
// frontend/src/pages/Home.tsx
import { useState } from 'react';
import { Container, Typography, Box } from '@mui/material';
import FileUploader from '../components/FileUploader';
import MarkdownPreview from '../components/MarkdownPreview';

const Home = () => {
  const [convertedText, setConvertedText] = useState<string>('');

  return (
    <Container maxWidth="md">
      <Box sx={{ my: 4 }}>
        <Typography variant="h4" component="h1" gutterBottom>
          MarkItDown 変換ツール
        </Typography>
        <Typography variant="body1" gutterBottom>
          サポートされているファイル形式:
          <ul>
            <li>PDF (.pdf)</li>
            <li>PowerPoint (.pptx)</li>
            <li>Word (.docx)</li>
            <li>Excel (.xlsx)</li>
            <li>Images (.jpg, .png) - EXIF metadata and OCR</li>
            <li>Audio - EXIF metadata and speech transcription</li>
            <li>HTML (.html)</li>
            <li>Text-based formats (CSV, JSON, XML)</li>
          </ul>
        </Typography>
        <FileUploader setConvertedText={setConvertedText} />
        <MarkdownPreview content={convertedText} />
      </Box>
    </Container>
  );
};

export default Home;
```

```tsx:Themes/ ThemeProvider.tsx
// frontend/src/theme/ThemeProvider.tsx
import { createContext, useContext, useMemo, useState } from 'react';
import { createTheme, ThemeProvider as MuiThemeProvider, PaletteMode } from '@mui/material';

const ColorModeContext = createContext({ toggleColorMode: () => {} });

export const useColorMode = () => {
  return useContext(ColorModeContext);
};

export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
  const [mode, setMode] = useState<PaletteMode>('light');

  const colorMode = useMemo(
    () => ({
      toggleColorMode: () => {
        setMode((prevMode) => (prevMode === 'light' ? 'dark' : 'light'));
      },
    }),
    []
  );

  const theme = useMemo(
    () =>
      createTheme({
        palette: {
          mode,
          background: {
            default: mode === 'light' ? '#ffffff' : '#121212',
            paper: mode === 'light' ? '#ffffff' : '#1e1e1e',
          },
        },
      }),
    [mode]
  );

  return (
    <ColorModeContext.Provider value={colorMode}>
      <MuiThemeProvider theme={theme}>{children}</MuiThemeProvider>
    </ColorModeContext.Provider>
  );
};
```

```py:backend/ app.py
# backend/app.py
from flask import Flask, request, jsonify
from flask_cors import CORS
from markitdown import MarkItDown
import os

app = Flask(__name__)
CORS(app)

@app.route('/convert', methods=['POST'])
def convert_file():
    if 'file' not in request.files:
        return jsonify({'error': 'ファイルがありません'}), 400
    
    file = request.files['file']
    if file.filename == '':
        return jsonify({'error': 'ファイルが選択されていません'}), 400

    temp_path = f"temp_{file.filename}"
    file.save(temp_path)

    try:
        md = MarkItDown()
        result = md.convert(temp_path)
        
        return jsonify({
            'text': result.text_content,
            'fileType': os.path.splitext(file.filename)[1][1:]
        })
    except Exception as e:
        return jsonify({'error': str(e)}), 500
    finally:
        if os.path.exists(temp_path):
            os.remove(temp_path)

if __name__ == '__main__':
    app.run(debug=True)
```

上記のコードをコピペ後2つのターミナルを用意して実行してください！

```
npm run dev
```
```
python3 app.py
```

## 実行してみた
**実行すると**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/8c685796-bc7b-4e1b-2fc3-369ff39e159a.png)

無事に完成しました！
ここからPowerPointやExcelを使ってMarkDown化してみようと思います！

今回はGithubに公開されているテスト用のPowerPointとExcelを使ってみようと思います！

### テスト用PowerPoint

https://github.com/microsoft/markitdown/blob/main/tests/test_files/test.pptx

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/387577cb-026c-0fd3-f562-3416e9a393ef.png)


**プレビュー画面**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/cab25fcd-21a7-1fa6-b480-1361d6c9c35d.png)

**MarkDown画面**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/eeb2e6b7-a860-beac-8b48-67cdb9a53047.png)

### テスト用Excel

https://github.com/microsoft/markitdown/blob/main/tests/test_files/test.xlsx

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/9a765b5f-a095-f8dc-0609-38878261044b.png)

**プレビュー画面**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/8a03be4d-37ff-5126-7306-b1dc4f705768.png)

**MarkDown画面**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/03880771-55b1-f2d7-eafe-f2eb440bf1f3.png)

うまく動作することができました！

## おわりに
様々なファイル形式をMarkDownに変換できるので、資料などをドキュメントにまとめることもできますし、ファイルサイズも軽量化することができます！
変換できるものとしては以下の通りです！

- PDF
- PowerPoint
- Word
- Excel
- Images (EXIF metadata and OCR)
- Audio (EXIF metadata and speech transcription)
- HTML
- Text-based formats (CSV, JSON, XML)
- ZIP files (iterates over contents)

MSサービスはもちろんのこと、多くのファイル形式がMarkDown形式に変換できるっぽいです！
皆さんも試してみてはいかがでしょうか？

今回の記事はいかがだったでしょうか！
またどこかの記事でお会いしましょう！