readitdown 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.3
2
+ Name: readitdown
3
+ Version: 0.1.0
4
+ Summary: 문서 파일을 마크다운으로 변환하는 라이브러리 및 CLI
5
+ Author: 정우창
6
+ Author-email: woochang4862@gmail.com
7
+ Requires-Python: >=3.13,<3.14
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Requires-Dist: click (>=8.0.0)
11
+ Requires-Dist: markitdown[all] (>=0.1.4,<0.2.0)
12
+ Requires-Dist: openai (>=1.0.0)
13
+ Requires-Dist: pymupdf (>=1.24.0)
14
+ Requires-Dist: python-dotenv (>=1.0.0)
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "readitdown"
3
+ version = "0.1.0"
4
+ description = "문서 파일을 마크다운으로 변환하는 라이브러리 및 CLI"
5
+ authors = [
6
+ {name = "정우창", email = "woochang4862@gmail.com"}
7
+ ]
8
+ requires-python = ">=3.13,<3.14"
9
+ dependencies = [
10
+ "openai (>=1.0.0)",
11
+ "pymupdf (>=1.24.0)",
12
+ "python-dotenv (>=1.0.0)",
13
+ "markitdown[all] (>=0.1.4,<0.2.0)",
14
+ "click (>=8.0.0)",
15
+ ]
16
+
17
+ [project.scripts]
18
+ readitdown = "readitdown.cli:main"
19
+
20
+ [[tool.poetry.packages]]
21
+ include = "readitdown"
22
+ from = "src"
23
+
24
+ [build-system]
25
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
26
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,25 @@
1
+ """readitdown - 문서를 마크다운으로 변환하는 라이브러리."""
2
+
3
+ from readitdown.converter import (
4
+ ConvertStats,
5
+ convert_dir,
6
+ convert_file,
7
+ supported_formats,
8
+ )
9
+ from readitdown.exceptions import (
10
+ APIKeyMissingError,
11
+ ConversionError,
12
+ Doc2mdError,
13
+ UnsupportedFormatError,
14
+ )
15
+
16
+ __all__ = [
17
+ "convert_file",
18
+ "convert_dir",
19
+ "supported_formats",
20
+ "ConvertStats",
21
+ "Doc2mdError",
22
+ "APIKeyMissingError",
23
+ "ConversionError",
24
+ "UnsupportedFormatError",
25
+ ]
File without changes
@@ -0,0 +1,101 @@
1
+ """HWP/HWPX 백엔드 (LibreOffice 기반, 실험적)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Callable
11
+
12
+ from readitdown.exceptions import ConversionError, LibreOfficeNotFoundError
13
+
14
+ if TYPE_CHECKING:
15
+ from openai import OpenAI
16
+
17
+ HWP_EXTENSIONS = {".hwp", ".hwpx", ".hwt", ".hml", ".hwx", ".gul"}
18
+
19
+ SOFFICE_PATHS = [
20
+ "/Applications/LibreOffice.app/Contents/MacOS/soffice",
21
+ shutil.which("soffice") or "",
22
+ shutil.which("libreoffice") or "",
23
+ ]
24
+
25
+
26
+ def _find_soffice() -> str:
27
+ for p in SOFFICE_PATHS:
28
+ if p and os.path.isfile(p):
29
+ return p
30
+ raise LibreOfficeNotFoundError()
31
+
32
+
33
+ def hwp_to_pdf(hwp_path: Path, output_path: Path) -> Path:
34
+ """HWP/HWPX 파일을 PDF로 변환합니다.
35
+
36
+ Args:
37
+ hwp_path: 변환할 HWP/HWPX 파일 경로
38
+ output_path: 저장할 PDF 경로
39
+
40
+ Returns:
41
+ 저장된 PDF 파일 경로
42
+ """
43
+ hwp_path = hwp_path.resolve()
44
+ if not hwp_path.exists():
45
+ raise FileNotFoundError(f"파일을 찾을 수 없습니다: {hwp_path}")
46
+
47
+ soffice = _find_soffice()
48
+ output_path = output_path.resolve()
49
+ output_path.parent.mkdir(parents=True, exist_ok=True)
50
+
51
+ # LibreOffice는 출력 디렉토리만 지정 가능 → 임시 디렉토리 사용 후 이동
52
+ with tempfile.TemporaryDirectory() as tmpdir:
53
+ result = subprocess.run(
54
+ [
55
+ soffice,
56
+ "--headless",
57
+ "--convert-to",
58
+ "pdf",
59
+ "--outdir",
60
+ tmpdir,
61
+ str(hwp_path),
62
+ ],
63
+ capture_output=True,
64
+ text=True,
65
+ timeout=300,
66
+ )
67
+
68
+ if result.returncode != 0:
69
+ raise ConversionError(f"LibreOffice 변환 실패: {result.stderr.strip()}")
70
+
71
+ generated_pdf = Path(tmpdir) / hwp_path.with_suffix(".pdf").name
72
+ if not generated_pdf.exists():
73
+ raise ConversionError(
74
+ "PDF 파일이 생성되지 않았습니다. "
75
+ "H2Orestart 확장이 설치되어 있는지 확인하세요: unopkg list"
76
+ )
77
+
78
+ shutil.move(str(generated_pdf), str(output_path))
79
+
80
+ return output_path
81
+
82
+
83
+ def convert_hwp(
84
+ client: OpenAI,
85
+ file_path: Path,
86
+ *,
87
+ on_page: Callable[[int, int], None] | None = None,
88
+ ) -> str:
89
+ """HWP/HWPX → PDF 변환 후 PDF → 마크다운 변환.
90
+
91
+ Args:
92
+ client: OpenAI 클라이언트
93
+ file_path: HWP/HWPX 파일 경로
94
+ on_page: 페이지 진행 콜백 (current, total)
95
+ """
96
+ from readitdown.backends.vision import convert_pdf
97
+
98
+ with tempfile.TemporaryDirectory() as tmpdir:
99
+ pdf_path = Path(tmpdir) / file_path.with_suffix(".pdf").name
100
+ hwp_to_pdf(file_path, pdf_path)
101
+ return convert_pdf(client, pdf_path, on_page=on_page)
@@ -0,0 +1,31 @@
1
+ """Office 문서 백엔드 (markitdown 기반)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def convert_office(file_path: Path) -> str:
9
+ """Office 문서(docx, pptx, doc, ppt)를 마크다운으로 변환합니다."""
10
+ from markitdown import MarkItDown
11
+
12
+ md = MarkItDown()
13
+ result = md.convert(str(file_path))
14
+ if result.text_content and len(result.text_content.strip()) > 10:
15
+ return result.text_content
16
+ return f"<!-- {file_path.name} - markitdown 변환 결과 없음 -->\n"
17
+
18
+
19
+ def convert_excel(file_path: Path) -> str:
20
+ """Excel 파일을 markitdown으로 변환합니다."""
21
+ try:
22
+ from markitdown import MarkItDown
23
+
24
+ md = MarkItDown()
25
+ result = md.convert(str(file_path))
26
+ if result.text_content and len(result.text_content.strip()) > 50:
27
+ return result.text_content
28
+ except Exception:
29
+ pass
30
+
31
+ return f"<!-- Excel 파일: {file_path.name} - markitdown 변환 실패 -->\n"
@@ -0,0 +1,77 @@
1
+ """Gemini Vision 백엔드 (PDF, 이미지)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Callable
8
+
9
+ import fitz # pymupdf
10
+
11
+ from readitdown.prompts import PROMPT_IMAGE, PROMPT_PDF
12
+
13
+ if TYPE_CHECKING:
14
+ from openai import OpenAI
15
+
16
+ GEMINI_MODEL = "gemini-2.0-flash"
17
+
18
+
19
+ def _image_to_base64(img_bytes: bytes) -> str:
20
+ return base64.b64encode(img_bytes).decode("utf-8")
21
+
22
+
23
+ def _call_gemini(client: OpenAI, b64_img: str, prompt: str) -> str:
24
+ response = client.chat.completions.create(
25
+ model=GEMINI_MODEL,
26
+ messages=[
27
+ {
28
+ "role": "user",
29
+ "content": [
30
+ {"type": "text", "text": prompt},
31
+ {
32
+ "type": "image_url",
33
+ "image_url": {"url": f"data:image/png;base64,{b64_img}"},
34
+ },
35
+ ],
36
+ }
37
+ ],
38
+ max_tokens=4096,
39
+ )
40
+ return response.choices[0].message.content
41
+
42
+
43
+ def convert_pdf(
44
+ client: OpenAI,
45
+ pdf_path: Path,
46
+ *,
47
+ on_page: Callable[[int, int], None] | None = None,
48
+ ) -> str:
49
+ """PDF 파일을 마크다운으로 변환합니다.
50
+
51
+ Args:
52
+ client: OpenAI 클라이언트
53
+ pdf_path: PDF 파일 경로
54
+ on_page: 페이지 진행 콜백 (current, total)
55
+ """
56
+ doc = fitz.open(str(pdf_path))
57
+ pages_md = []
58
+ total = len(doc)
59
+
60
+ for i, page in enumerate(doc):
61
+ if on_page:
62
+ on_page(i + 1, total)
63
+ mat = fitz.Matrix(2, 2)
64
+ pix = page.get_pixmap(matrix=mat)
65
+ b64 = _image_to_base64(pix.tobytes("png"))
66
+ md = _call_gemini(client, b64, PROMPT_PDF)
67
+ pages_md.append(f"<!-- Page {i + 1} -->\n{md}")
68
+
69
+ doc.close()
70
+ return "\n\n---\n\n".join(pages_md)
71
+
72
+
73
+ def convert_image(client: OpenAI, img_path: Path) -> str:
74
+ """이미지 파일을 마크다운으로 변환합니다."""
75
+ img_bytes = img_path.read_bytes()
76
+ b64 = _image_to_base64(img_bytes)
77
+ return _call_gemini(client, b64, PROMPT_IMAGE)
@@ -0,0 +1,88 @@
1
+ """readitdown CLI (Click 기반)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from readitdown.converter import convert_dir, convert_file, supported_formats
10
+
11
+
12
+ @click.group()
13
+ def main():
14
+ """readitdown - 문서를 마크다운으로 변환합니다."""
15
+
16
+
17
+ @main.command()
18
+ @click.argument("path", type=click.Path(exists=True, path_type=Path))
19
+ @click.option("-o", "--output", type=click.Path(path_type=Path), default=None, help="출력 파일 경로")
20
+ @click.option("--api-key", envvar="GEMINI_API_KEY", default=None, help="Gemini API 키")
21
+ def file(path: Path, output: Path | None, api_key: str | None):
22
+ """단일 파일을 마크다운으로 변환합니다."""
23
+
24
+ def on_page(current: int, total: int):
25
+ click.echo(f" 페이지 {current}/{total}...", nl=False)
26
+
27
+ try:
28
+ result = convert_file(path, api_key=api_key, on_page=on_page)
29
+ except Exception as e:
30
+ raise click.ClickException(str(e)) from e
31
+
32
+ click.echo()
33
+
34
+ if output:
35
+ output.parent.mkdir(parents=True, exist_ok=True)
36
+ output.write_text(result, encoding="utf-8")
37
+ click.echo(f"저장: {output} ({len(result):,}자)")
38
+ else:
39
+ click.echo(result)
40
+
41
+
42
+ @main.command()
43
+ @click.argument("input_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
44
+ @click.argument("output_dir", type=click.Path(path_type=Path))
45
+ @click.option("--skip-existing", is_flag=True, help="이미 변환된 파일 건너뛰기")
46
+ @click.option("--only", type=str, default=None, help="파일명 필터 (예: '경기도')")
47
+ @click.option("--api-key", envvar="GEMINI_API_KEY", default=None, help="Gemini API 키")
48
+ def dir(input_dir: Path, output_dir: Path, skip_existing: bool, only: str | None, api_key: str | None):
49
+ """디렉토리 내 모든 문서를 일괄 변환합니다."""
50
+
51
+ def on_file(path: Path, status: str):
52
+ name = path.name
53
+ if status == "skipped":
54
+ click.echo(f"[건너뜀] {name}")
55
+ elif status == "converting":
56
+ click.echo(f"[변환중] {name}")
57
+ elif status.startswith("done:"):
58
+ chars = int(status.split(":")[1])
59
+ click.echo(f" → 완료 ({chars:,}자)")
60
+
61
+ def on_page(current: int, total: int):
62
+ click.echo(f" 페이지 {current}/{total}...", nl=False)
63
+
64
+ def on_error(path: Path, error: Exception):
65
+ click.echo(f" → 오류: {error}", err=True)
66
+
67
+ stats = convert_dir(
68
+ input_dir,
69
+ output_dir,
70
+ api_key=api_key,
71
+ skip_existing=skip_existing,
72
+ only=only,
73
+ on_file=on_file,
74
+ on_page=on_page,
75
+ on_error=on_error,
76
+ )
77
+
78
+ click.echo(f"\n완료: 성공 {stats.success}, 건너뜀 {stats.skipped}, 실패 {stats.failed}")
79
+
80
+
81
+ @main.command()
82
+ def formats():
83
+ """지원하는 파일 형식을 출력합니다."""
84
+ for category, exts in supported_formats().items():
85
+ click.echo(f"{category}:")
86
+ for ext in exts:
87
+ click.echo(f" {ext}")
88
+ click.echo()
@@ -0,0 +1,33 @@
1
+ """OpenAI 클라이언트 팩토리 (Gemini Vision API용, 3-tier 키 해석)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from dotenv import load_dotenv
8
+ from openai import OpenAI
9
+
10
+ from readitdown.exceptions import APIKeyMissingError
11
+
12
+ GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
13
+
14
+
15
+ def create_client(api_key: str | None = None) -> OpenAI:
16
+ """OpenAI 클라이언트를 생성합니다.
17
+
18
+ API 키 해석 순서:
19
+ 1. 명시적 파라미터
20
+ 2. 환경변수 GEMINI_API_KEY
21
+ 3. .env 파일 (python-dotenv)
22
+ """
23
+ if api_key is None:
24
+ api_key = os.environ.get("GEMINI_API_KEY")
25
+
26
+ if api_key is None:
27
+ load_dotenv()
28
+ api_key = os.environ.get("GEMINI_API_KEY")
29
+
30
+ if not api_key:
31
+ raise APIKeyMissingError()
32
+
33
+ return OpenAI(api_key=api_key, base_url=GEMINI_BASE_URL)
@@ -0,0 +1,174 @@
1
+ """메인 변환 라우터."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ from openai import OpenAI
10
+
11
+ from readitdown.client import create_client
12
+ from readitdown.exceptions import UnsupportedFormatError
13
+
14
+ HWP_EXTENSIONS = {".hwp", ".hwpx", ".hwt", ".hml", ".hwx", ".gul"}
15
+ MARKITDOWN_EXTENSIONS = {".docx", ".pptx", ".doc", ".ppt"}
16
+ VISION_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png"}
17
+ EXCEL_EXTENSIONS = {".xlsx", ".xls"}
18
+ SUPPORTED_EXTENSIONS = HWP_EXTENSIONS | MARKITDOWN_EXTENSIONS | VISION_EXTENSIONS | EXCEL_EXTENSIONS
19
+
20
+
21
+ @dataclass
22
+ class ConvertStats:
23
+ """디렉토리 변환 통계."""
24
+
25
+ success: int = 0
26
+ skipped: int = 0
27
+ failed: int = 0
28
+ errors: list[tuple[str, str]] = field(default_factory=list)
29
+
30
+
31
+ def convert_file(
32
+ file_path: str | Path,
33
+ *,
34
+ api_key: str | None = None,
35
+ client: OpenAI | None = None,
36
+ on_page: Callable[[int, int], None] | None = None,
37
+ ) -> str:
38
+ """단일 파일을 마크다운으로 변환합니다.
39
+
40
+ Args:
41
+ file_path: 변환할 파일 경로
42
+ api_key: Gemini API 키 (없으면 환경변수/.env에서 탐색)
43
+ client: 기존 OpenAI 클라이언트 (재사용 시)
44
+ on_page: PDF 페이지 진행 콜백 (current, total)
45
+
46
+ Returns:
47
+ 변환된 마크다운 문자열
48
+
49
+ Raises:
50
+ UnsupportedFormatError: 지원하지 않는 형식
51
+ ConversionError: 변환 실패
52
+ APIKeyMissingError: API 키 미설정 (Vision 백엔드 필요 시)
53
+ """
54
+ file_path = Path(file_path)
55
+ ext = file_path.suffix.lower()
56
+
57
+ if ext not in SUPPORTED_EXTENSIONS:
58
+ raise UnsupportedFormatError(ext)
59
+
60
+ # Vision 백엔드가 필요한 형식은 클라이언트 생성
61
+ needs_vision = ext in (HWP_EXTENSIONS | VISION_EXTENSIONS)
62
+ if needs_vision and client is None:
63
+ client = create_client(api_key)
64
+
65
+ if ext in HWP_EXTENSIONS:
66
+ from readitdown.backends.hwp import convert_hwp
67
+
68
+ return convert_hwp(client, file_path, on_page=on_page)
69
+ elif ext == ".pdf":
70
+ from readitdown.backends.vision import convert_pdf
71
+
72
+ return convert_pdf(client, file_path, on_page=on_page)
73
+ elif ext in {".jpg", ".jpeg", ".png"}:
74
+ from readitdown.backends.vision import convert_image
75
+
76
+ return convert_image(client, file_path)
77
+ elif ext in EXCEL_EXTENSIONS:
78
+ from readitdown.backends.office import convert_excel
79
+
80
+ return convert_excel(file_path)
81
+ elif ext in MARKITDOWN_EXTENSIONS:
82
+ from readitdown.backends.office import convert_office
83
+
84
+ return convert_office(file_path)
85
+
86
+ raise UnsupportedFormatError(ext)
87
+
88
+
89
+ def convert_dir(
90
+ input_dir: str | Path,
91
+ output_dir: str | Path,
92
+ *,
93
+ api_key: str | None = None,
94
+ skip_existing: bool = False,
95
+ only: str | None = None,
96
+ on_file: Callable[[Path, str], None] | None = None,
97
+ on_page: Callable[[int, int], None] | None = None,
98
+ on_error: Callable[[Path, Exception], None] | None = None,
99
+ ) -> ConvertStats:
100
+ """디렉토리 내 모든 지원 파일을 일괄 변환합니다.
101
+
102
+ Args:
103
+ input_dir: 원본 파일 디렉토리
104
+ output_dir: 변환 결과 저장 디렉토리
105
+ api_key: Gemini API 키
106
+ skip_existing: 이미 변환된 파일 건너뛰기
107
+ only: 파일명 필터 문자열
108
+ on_file: 파일 처리 콜백 (path, status)
109
+ on_page: PDF 페이지 진행 콜백 (current, total)
110
+ on_error: 오류 발생 콜백 (path, exception)
111
+
112
+ Returns:
113
+ ConvertStats 통계 객체
114
+ """
115
+ input_dir = Path(input_dir)
116
+ output_dir = Path(output_dir)
117
+ stats = ConvertStats()
118
+
119
+ # 파일 수집
120
+ files = sorted(
121
+ f
122
+ for f in input_dir.rglob("*")
123
+ if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS
124
+ )
125
+
126
+ if only:
127
+ files = [f for f in files if only in str(f)]
128
+
129
+ if not files:
130
+ return stats
131
+
132
+ client = create_client(api_key)
133
+
134
+ for file_path in files:
135
+ rel = file_path.relative_to(input_dir)
136
+ out_path = output_dir / rel.with_suffix(".md")
137
+
138
+ if skip_existing and out_path.exists():
139
+ if on_file:
140
+ on_file(file_path, "skipped")
141
+ stats.skipped += 1
142
+ continue
143
+
144
+ if on_file:
145
+ on_file(file_path, "converting")
146
+
147
+ out_path.parent.mkdir(parents=True, exist_ok=True)
148
+
149
+ try:
150
+ result = convert_file(
151
+ file_path, client=client, on_page=on_page
152
+ )
153
+ out_path.write_text(result, encoding="utf-8")
154
+ if on_file:
155
+ on_file(file_path, f"done:{len(result)}")
156
+ stats.success += 1
157
+ except Exception as e:
158
+ if on_error:
159
+ on_error(file_path, e)
160
+ stats.failed += 1
161
+ stats.errors.append((str(rel), str(e)))
162
+
163
+ return stats
164
+
165
+
166
+ def supported_formats() -> dict[str, list[str]]:
167
+ """지원 형식을 카테고리별로 반환합니다."""
168
+ return {
169
+ "HWP (LibreOffice)": sorted(HWP_EXTENSIONS),
170
+ "PDF (Gemini Vision)": sorted(VISION_EXTENSIONS - {".jpg", ".jpeg", ".png"}),
171
+ "이미지 (Gemini Vision)": sorted({".jpg", ".jpeg", ".png"}),
172
+ "Excel (markitdown)": sorted(EXCEL_EXTENSIONS),
173
+ "Office (markitdown)": sorted(MARKITDOWN_EXTENSIONS),
174
+ }
@@ -0,0 +1,33 @@
1
+ """doc2md 커스텀 예외."""
2
+
3
+
4
+ class Doc2mdError(Exception):
5
+ """doc2md 기본 예외."""
6
+
7
+
8
+ class APIKeyMissingError(Doc2mdError):
9
+ """API 키가 설정되지 않았을 때 발생."""
10
+
11
+ def __init__(self, msg: str = "GEMINI_API_KEY가 설정되지 않았습니다."):
12
+ super().__init__(msg)
13
+
14
+
15
+ class ConversionError(Doc2mdError):
16
+ """문서 변환 중 오류 발생."""
17
+
18
+
19
+ class UnsupportedFormatError(Doc2mdError):
20
+ """지원하지 않는 파일 형식."""
21
+
22
+ def __init__(self, ext: str):
23
+ super().__init__(f"지원하지 않는 형식입니다: {ext}")
24
+ self.ext = ext
25
+
26
+
27
+ class LibreOfficeNotFoundError(Doc2mdError):
28
+ """LibreOffice가 설치되지 않았을 때 발생."""
29
+
30
+ def __init__(self):
31
+ super().__init__(
32
+ "LibreOffice를 찾을 수 없습니다. 설치하세요: brew install --cask libreoffice"
33
+ )
@@ -0,0 +1,19 @@
1
+ """Gemini Vision API 프롬프트 (한국어)."""
2
+
3
+ PROMPT_PDF = (
4
+ "이 PDF 페이지를 마크다운으로 변환해주세요. "
5
+ "표가 있으면 마크다운 테이블로, 제목은 적절한 헤딩으로, 목록은 리스트로 변환하세요. "
6
+ "원문 내용을 빠짐없이 포함하세요. 마크다운만 출력하고 다른 설명은 하지 마세요."
7
+ )
8
+
9
+ PROMPT_IMAGE = (
10
+ "이 이미지의 내용을 마크다운으로 변환해주세요. "
11
+ "텍스트가 있으면 그대로 옮기고, 표가 있으면 마크다운 테이블로 변환하세요. "
12
+ "마크다운만 출력하고 다른 설명은 하지 마세요."
13
+ )
14
+
15
+ PROMPT_EXCEL = (
16
+ "이 엑셀 시트 이미지를 마크다운 테이블로 변환해주세요. "
17
+ "모든 셀 데이터를 빠짐없이 포함하세요. "
18
+ "마크다운만 출력하고 다른 설명은 하지 마세요."
19
+ )
File without changes