reborn-token 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/cli.mjs +187 -0
- package/lib/office.mjs +177 -0
- package/lib/pdf.mjs +184 -0
- package/lib/tokens.mjs +78 -0
- package/package.json +40 -0
- package/skill/SKILL.md +80 -0
- package/skill/references/PDF/355/206/240/355/201/260.md +69 -0
- package//354/225/210/353/202/264/354/212/244/355/202/254.md +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 REBORN LABS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# 토큰 절반 (reborn-token)
|
|
2
|
+
|
|
3
|
+
**PDF·워드·엑셀·PPT 를 제목과 표를 살린 마크다운으로 바꿔 클로드에 넣습니다.**
|
|
4
|
+
전부 로컬. API 키 없음. 네트워크 안 씁니다. MIT.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
npx reborn-token
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
그다음 클로드에게 **"이 PDF 마크다운으로 바꿔줘"** 라고 말하면 됩니다.
|
|
11
|
+
터미널에서 바로 쓰려면 `npx reborn-token md contract.pdf`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 왜 필요한가 — 우리 주장이 아니라 공식 문서입니다
|
|
16
|
+
|
|
17
|
+
Anthropic 문서(`build-with-claude/pdf-support`, 2026-08-24 확인)에서 **그대로 옮깁니다**:
|
|
18
|
+
|
|
19
|
+
> The system converts each page of the document into an image.
|
|
20
|
+
> The text from each page is extracted and provided alongside each page's image.
|
|
21
|
+
|
|
22
|
+
> **Text token costs**: Each page typically uses **1,500–3,000 tokens per page**
|
|
23
|
+
> **Image token costs**: **Because each page is converted into an image**, the same
|
|
24
|
+
> image-based cost calculations are applied.
|
|
25
|
+
|
|
26
|
+
즉 PDF 를 그대로 넣으면 **한 페이지에 텍스트와 이미지가 둘 다** 실립니다.
|
|
27
|
+
|
|
28
|
+
| 20쪽 계약서 | |
|
|
29
|
+
|---|---|
|
|
30
|
+
| 텍스트 | 20 × 1,500~3,000 = **30,000~60,000 토큰** |
|
|
31
|
+
| 이미지 | **20장** (페이지마다 1장) — 그 위에 더해집니다 |
|
|
32
|
+
|
|
33
|
+
질문을 하나도 하기 전에 들어가는 양입니다.
|
|
34
|
+
**마크다운에는 페이지도 이미지도 없습니다.** 이미지 몫이 통째로 사라집니다.
|
|
35
|
+
|
|
36
|
+
## 무엇을 바꾸나
|
|
37
|
+
|
|
38
|
+
| 형식 | 어떻게 |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `pdf` | 좌표로 줄을 다시 세워 **제목·문단·표**를 살립니다 |
|
|
41
|
+
| `docx` | 문단 스타일(제목)과 표를 그대로 |
|
|
42
|
+
| `xlsx` | 시트마다 마크다운 표 하나 |
|
|
43
|
+
| `pptx` | 슬라이드별 제목 + 글머리 |
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
npx reborn-token md contract.pdf proposal.docx pricing.xlsx
|
|
47
|
+
npx reborn-token md contract.pdf -o ./out
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
찍어 주는 것:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
✓ contract.pdf → contract.md
|
|
54
|
+
변환 전 20쪽 × 텍스트 1,500~3,000토큰 = 30,000~60,000
|
|
55
|
+
+ 페이지 이미지 20장 (페이지마다 1장) ← 여기가 한 번 더 붙는다
|
|
56
|
+
변환 후 텍스트 약 118토큰 (글자 436자 · 추정)
|
|
57
|
+
+ 페이지 이미지 0
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 무엇이 살아남나
|
|
61
|
+
|
|
62
|
+
| 살아남는 것 | 버리는 것 |
|
|
63
|
+
|---|---|
|
|
64
|
+
| 제목 계층 (`#` `##`) | 페이지 이미지 |
|
|
65
|
+
| 문단·줄바꿈 | 그림·차트·도형 |
|
|
66
|
+
| **표** (마크다운 표로) | 글꼴·색·여백 |
|
|
67
|
+
|
|
68
|
+
## 이 도구가 **하지 않는** 것
|
|
69
|
+
|
|
70
|
+
정직하게 적습니다.
|
|
71
|
+
|
|
72
|
+
- **스캔본은 못 바꿉니다.** 글자가 없는 PDF 는 가져올 텍스트가 없습니다.
|
|
73
|
+
그 자리에서 멈추고 이유를 알려 주며, **빈 파일을 만들어 놓고 "변환했다"고 하지 않습니다.**
|
|
74
|
+
그런 문서는 원본을 그대로 클로드에 넣는 편이 낫습니다.
|
|
75
|
+
- **차트·도형의 뜻은 옮기지 못합니다.** 글자만 가져옵니다.
|
|
76
|
+
- **요금을 계산해 주지 않습니다.** 토큰과 요금은 다른 이야기입니다.
|
|
77
|
+
- 변환 후 토큰 수는 **추정**입니다. 정확한 값은 Anthropic 의 token counting 으로만 나오고,
|
|
78
|
+
그건 API 키가 필요합니다. 없는 정확도를 광고하지 않습니다.
|
|
79
|
+
- **아무것도 서버로 보내지 않습니다.** 네트워크를 쓰지 않습니다.
|
|
80
|
+
|
|
81
|
+
## 설치 확인
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
npx reborn-token --check
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
설치기는 파일을 복사하고 끝내지 않고 **변환기를 실제로 불러** 확인한 뒤에
|
|
88
|
+
완료라고 말합니다. 파일만 있고 안 도는 것은 깔린 게 아닙니다.
|
|
89
|
+
|
|
90
|
+
## 요구사항
|
|
91
|
+
|
|
92
|
+
- Node.js 18 이상
|
|
93
|
+
- PDF 변환에 `pdfjs-dist`(Apache-2.0)를 씁니다 — `npx` 가 알아서 받습니다.
|
|
94
|
+
워드·엑셀·PPT 는 **의존성 0**(Node 내장 zlib 로 zip 을 직접 읽습니다).
|
|
95
|
+
|
|
96
|
+
## 지우려면
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
rm -rf ~/.claude/skills/pdf-to-md
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
REBORN LABS · MIT · https://rebornlabs.kr/token?utm_source=installer&utm_medium=cli&utm_campaign=token
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 토큰 절반 — PDF·워드·엑셀을 **깔끔한 마크다운**으로 바꾼다.
|
|
4
|
+
*
|
|
5
|
+
* npx reborn-token 스킬 설치 (+ 설치 뒤 실제로 돌려 확인)
|
|
6
|
+
* npx reborn-token md <파일...> 변환 → 파일 옆에 .md
|
|
7
|
+
* npx reborn-token md <파일> -o out 나갈 폴더 지정
|
|
8
|
+
* npx reborn-token --check 설치 상태만 본다
|
|
9
|
+
*
|
|
10
|
+
* ★왜 만들었나 — Anthropic 공식 문서(2026-08-24 확인):
|
|
11
|
+
* "The system converts each page of the document into an image.
|
|
12
|
+
* The text from each page is extracted and provided alongside each page's image."
|
|
13
|
+
* 즉 PDF 를 그대로 넣으면 **페이지마다 텍스트와 이미지가 둘 다** 실린다.
|
|
14
|
+
* 마크다운에는 페이지도 이미지도 없다 — 이미지 몫이 통째로 사라진다.
|
|
15
|
+
*
|
|
16
|
+
* ★전부 로컬에서 돈다. 네트워크를 쓰지 않고, API 키가 필요 없다.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const 여기 = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const c = { g: "\x1b[32m", r: "\x1b[31m", y: "\x1b[33m", d: "\x1b[90m", cy: "\x1b[36m", b: "\x1b[1m", 0: "\x1b[0m" };
|
|
25
|
+
|
|
26
|
+
const 인자 = process.argv.slice(2);
|
|
27
|
+
const 첫 = 인자[0];
|
|
28
|
+
|
|
29
|
+
/* ══ 변환 ═════════════════════════════════════════════════════════════ */
|
|
30
|
+
async function 변환명령(입력) {
|
|
31
|
+
const o = 입력.indexOf("-o");
|
|
32
|
+
let 나갈곳 = null;
|
|
33
|
+
if (o >= 0) { 나갈곳 = 입력[o + 1]; 입력.splice(o, 2); }
|
|
34
|
+
const 파일들 = 입력.filter((a) => !a.startsWith("-"));
|
|
35
|
+
|
|
36
|
+
if (!파일들.length) {
|
|
37
|
+
console.error(`${c.r}✗ 바꿀 파일을 주세요${c[0]} ${c.d}예: npx reborn-token md contract.pdf${c[0]}`);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let 실패 = 0;
|
|
42
|
+
for (const f of 파일들) {
|
|
43
|
+
try { await 하나변환(f, 나갈곳); }
|
|
44
|
+
catch (e) { 실패++; console.error(`${c.r}✗ ${basename(f)}${c[0]} — ${String(e.message || e).slice(0, 160)}`); }
|
|
45
|
+
}
|
|
46
|
+
process.exit(실패 ? 1 : 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function 하나변환(경로, 나갈곳) {
|
|
50
|
+
if (!existsSync(경로)) throw new Error("파일이 없습니다");
|
|
51
|
+
const 확장 = extname(경로).toLowerCase();
|
|
52
|
+
const { 요약 } = await import(`file://${join(여기, "lib", "tokens.mjs").replace(/\\/g, "/")}`);
|
|
53
|
+
|
|
54
|
+
let md = "";
|
|
55
|
+
let 쪽수 = 0;
|
|
56
|
+
|
|
57
|
+
if (확장 === ".pdf") {
|
|
58
|
+
const { pdf읽기, 쪽을마크다운 } = await import(`file://${join(여기, "lib", "pdf.mjs").replace(/\\/g, "/")}`);
|
|
59
|
+
const r = await pdf읽기(경로);
|
|
60
|
+
쪽수 = r.쪽수;
|
|
61
|
+
md = r.쪽들.map((줄들) => 쪽을마크다운(줄들)).filter(Boolean).join("\n\n");
|
|
62
|
+
/* ★★스캔본은 **조용히 빈 파일을 내지 않는다.** 그건 「변환됐다」가 아니다.
|
|
63
|
+
여기서 멈추고 왜 안 되는지 말한다 — 빈 .md 를 받아 든 사람은 원인을 못 찾는다. */
|
|
64
|
+
if (md.replace(/\s/g, "").length < 20) {
|
|
65
|
+
throw new Error(`글자가 거의 없습니다(${쪽수}쪽) — 스캔본이거나 이미지로만 된 PDF 입니다. `
|
|
66
|
+
+ `이런 파일은 마크다운으로 바꿀 텍스트 자체가 없어, 그대로 클로드에 넣는 편이 낫습니다.`);
|
|
67
|
+
}
|
|
68
|
+
} else if (확장 === ".docx") {
|
|
69
|
+
const { docx읽기 } = await import(`file://${join(여기, "lib", "office.mjs").replace(/\\/g, "/")}`);
|
|
70
|
+
md = docx읽기(경로);
|
|
71
|
+
} else if (확장 === ".xlsx") {
|
|
72
|
+
const { xlsx읽기 } = await import(`file://${join(여기, "lib", "office.mjs").replace(/\\/g, "/")}`);
|
|
73
|
+
md = xlsx읽기(경로);
|
|
74
|
+
} else if (확장 === ".pptx") {
|
|
75
|
+
const { pptx읽기 } = await import(`file://${join(여기, "lib", "office.mjs").replace(/\\/g, "/")}`);
|
|
76
|
+
md = pptx읽기(경로);
|
|
77
|
+
} else {
|
|
78
|
+
throw new Error(`아직 못 바꾸는 형식입니다(${확장 || "확장자 없음"}) — pdf · docx · xlsx · pptx 를 지원합니다`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!md.trim()) throw new Error("가져올 글자가 없습니다");
|
|
82
|
+
|
|
83
|
+
const 폴더 = 나갈곳 ? resolve(나갈곳) : dirname(resolve(경로));
|
|
84
|
+
mkdirSync(폴더, { recursive: true });
|
|
85
|
+
const 나감 = join(폴더, basename(경로, extname(경로)) + ".md");
|
|
86
|
+
writeFileSync(나감, md.replace(/\n{3,}/g, "\n\n").trim() + "\n", "utf8");
|
|
87
|
+
|
|
88
|
+
console.log(`\n${c.g}✓${c[0]} ${basename(경로)} → ${c.b}${basename(나감)}${c[0]}`);
|
|
89
|
+
if (쪽수) console.log(요약(쪽수, md));
|
|
90
|
+
else console.log(` ${c.d}글자 ${md.length.toLocaleString("ko-KR")}자 · 페이지 이미지 0${c[0]}`);
|
|
91
|
+
console.log(` ${c.d}${나감}${c[0]}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ══ 설치 ═════════════════════════════════════════════════════════════ */
|
|
95
|
+
const 스킬이름 = "pdf-to-md";
|
|
96
|
+
const 대상 = join(homedir(), ".claude", "skills", 스킬이름);
|
|
97
|
+
|
|
98
|
+
function 파일목록(뿌리) {
|
|
99
|
+
const 나온다 = [];
|
|
100
|
+
const 걷기 = (d) => {
|
|
101
|
+
for (const 이름 of readdirSync(d)) {
|
|
102
|
+
const p = join(d, 이름);
|
|
103
|
+
if (statSync(p).isDirectory()) 걷기(p);
|
|
104
|
+
else 나온다.push(p);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
걷기(뿌리);
|
|
108
|
+
return 나온다;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** ★파일이 있다는 것으로 통과시키지 않는다. **실제로 한 장을 바꿔 본다.** */
|
|
112
|
+
async function 진짜도나() {
|
|
113
|
+
const md = join(대상, "SKILL.md");
|
|
114
|
+
if (!existsSync(md)) return { 됨: false, 이유: "SKILL.md 가 없습니다" };
|
|
115
|
+
if (!/^---[\s\S]*?\bname:\s*pdf-to-md\b/m.test(readFileSync(md, "utf8"))) {
|
|
116
|
+
return { 됨: false, 이유: "SKILL.md 의 머리말이 깨졌습니다(name 을 못 읽습니다)" };
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const { docx읽기 } = await import(`file://${join(여기, "lib", "office.mjs").replace(/\\/g, "/")}`);
|
|
120
|
+
const { 텍스트토큰추정 } = await import(`file://${join(여기, "lib", "tokens.mjs").replace(/\\/g, "/")}`);
|
|
121
|
+
if (typeof docx읽기 !== "function") return { 됨: false, 이유: "office 변환기를 못 불렀습니다" };
|
|
122
|
+
if (텍스트토큰추정("가나다 abcd") <= 0) return { 됨: false, 이유: "토큰 추정기가 0 을 냅니다" };
|
|
123
|
+
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
124
|
+
if (!pdfjs?.getDocument) return { 됨: false, 이유: "pdfjs 를 못 불렀습니다" };
|
|
125
|
+
return { 됨: true };
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return { 됨: false, 이유: `변환기를 부르다 걸렸습니다 — ${String(e.message || e).slice(0, 120)}` };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function 설치확인만() {
|
|
132
|
+
머리말();
|
|
133
|
+
const r = await 진짜도나();
|
|
134
|
+
if (r.됨) console.log(`${c.g}✓ 설치되어 있고 변환기가 실제로 뜹니다${c[0]} ${c.d}(${대상})${c[0]}\n`);
|
|
135
|
+
else console.log(`${c.y}· 아직 없습니다${c[0]} ${c.d}— ${r.이유}${c[0]}\n`);
|
|
136
|
+
process.exit(r.됨 ? 0 : 1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function 머리말() {
|
|
140
|
+
console.log(`\n${c.b}토큰 절반${c[0]} ${c.d}· REBORN LABS · MIT${c[0]}`);
|
|
141
|
+
console.log(`${c.d}PDF·워드·엑셀을 깔끔한 마크다운으로 바꿔, 페이지 이미지 토큰을 없앱니다.${c[0]}\n`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function 설치() {
|
|
145
|
+
머리말();
|
|
146
|
+
const 원본 = join(여기, "skill");
|
|
147
|
+
if (!existsSync(원본)) {
|
|
148
|
+
console.error(`${c.r}✗ 패키지 안에 skill/ 폴더가 없습니다${c[0]} — 배포가 잘못된 것입니다.`);
|
|
149
|
+
process.exit(2);
|
|
150
|
+
}
|
|
151
|
+
let 쓴수 = 0;
|
|
152
|
+
for (const src of 파일목록(원본)) {
|
|
153
|
+
const 상대 = relative(원본, src);
|
|
154
|
+
const dst = join(대상, 상대);
|
|
155
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
156
|
+
writeFileSync(dst, readFileSync(src));
|
|
157
|
+
쓴수++;
|
|
158
|
+
console.log(` ${c.d}·${c[0]} ${상대}`);
|
|
159
|
+
}
|
|
160
|
+
console.log(`\n${c.d}${쓴수}개 파일 → ${대상}${c[0]}`);
|
|
161
|
+
console.log(`${c.d}변환기를 실제로 불러 확인하는 중…${c[0]}`);
|
|
162
|
+
|
|
163
|
+
const r = await 진짜도나();
|
|
164
|
+
if (!r.됨) {
|
|
165
|
+
console.error(`\n${c.r}✗ 깔았는데 확인에서 걸렸습니다${c[0]} — ${r.이유}\n`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log(`\n${c.g}${c.b}✓ 설치 완료${c[0]} ${c.d}— 변환기가 실제로 떴습니다.${c[0]}`);
|
|
170
|
+
console.log(`\n${c.y}★ 클로드 코드를 껐다 켜야 스킬이 잡힙니다.${c[0]}`);
|
|
171
|
+
console.log(`\n${c.b}쓰는 법${c[0]}`);
|
|
172
|
+
console.log(` 클로드에게 그냥 말하세요 — ${c.cy}"이 PDF 마크다운으로 바꿔줘"${c[0]}`);
|
|
173
|
+
console.log(` 터미널에서 바로: ${c.cy}npx reborn-token md contract.pdf${c[0]}`);
|
|
174
|
+
console.log(` ${c.d}pdf · docx · xlsx · pptx → 제목과 표를 살린 .md${c[0]}\n`);
|
|
175
|
+
console.log(`${c.d}설명 · https://rebornlabs.kr/token?utm_source=installer&utm_medium=cli&utm_campaign=token${c[0]}`);
|
|
176
|
+
/* ★무료 배포물 안에 유료 제품 안내를 둔다. 숨기지 않는다. */
|
|
177
|
+
console.log(`${c.d}매일 자동 발행까지 필요하시면 · https://rebornlabs.kr/claudekit?utm_source=installer&utm_medium=cli&utm_campaign=token${c[0]}\n`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ══ 실행 ═════════════════════════════════════════════════════════════
|
|
181
|
+
★★맨 위에서 부르면 안 된다. 최상단 await 는 **선언 순서대로** 돈다 —
|
|
182
|
+
아래에 `const` 로 둔 값(대상 · 스킬이름)이 아직 초기화되기 전이라
|
|
183
|
+
`Cannot access '대상' before initialization` 으로 죽는다.
|
|
184
|
+
2026-08-24 에 실제로 그랬다. 실행은 파일 끝에서 한다. */
|
|
185
|
+
if (첫 === "md" || 첫 === "convert" || 첫 === "변환") await 변환명령(인자.slice(1));
|
|
186
|
+
else if (인자.includes("--check")) await 설치확인만();
|
|
187
|
+
else await 설치();
|
package/lib/office.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOCX · XLSX · PPTX → 마크다운. **의존성 0.**
|
|
3
|
+
*
|
|
4
|
+
* ★오피스 파일은 전부 **XML 을 담은 zip** 이다. 그래서 라이브러리 없이 읽을 수 있다 —
|
|
5
|
+
* Node 의 `zlib.inflateRawSync` 와 zip 중앙 디렉터리만 알면 된다.
|
|
6
|
+
* 패키지에 무거운 의존성을 하나 덜 넣는 만큼 `npx` 가 빨라진다.
|
|
7
|
+
*
|
|
8
|
+
* ★안 하는 것도 적어 둔다: 이미지·차트·도형은 **버린다**. 글자만 가져온다.
|
|
9
|
+
* 그게 이 도구의 목적이다(이미지 토큰을 없애는 것).
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { inflateRawSync } from "node:zlib";
|
|
13
|
+
|
|
14
|
+
/* ── 최소 zip 리더 ─────────────────────────────────────────────────────
|
|
15
|
+
중앙 디렉터리(EOCD → CD)를 읽고 필요한 항목만 푼다. */
|
|
16
|
+
function zip열기(경로) {
|
|
17
|
+
const buf = readFileSync(경로);
|
|
18
|
+
//: EOCD 서명 0x06054b50 을 뒤에서부터 찾는다(주석이 붙어 있을 수 있다).
|
|
19
|
+
let eocd = -1;
|
|
20
|
+
for (let i = buf.length - 22; i >= 0 && i > buf.length - 22 - 65536; i--) {
|
|
21
|
+
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
22
|
+
}
|
|
23
|
+
if (eocd < 0) throw new Error("zip 형식이 아니다(EOCD 없음)");
|
|
24
|
+
const 개수 = buf.readUInt16LE(eocd + 10);
|
|
25
|
+
let p = buf.readUInt32LE(eocd + 16);
|
|
26
|
+
|
|
27
|
+
const 목록 = new Map();
|
|
28
|
+
for (let n = 0; n < 개수; n++) {
|
|
29
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) break;
|
|
30
|
+
const 방식 = buf.readUInt16LE(p + 10);
|
|
31
|
+
const 압축크기 = buf.readUInt32LE(p + 20);
|
|
32
|
+
const 이름길이 = buf.readUInt16LE(p + 28);
|
|
33
|
+
const 여분길이 = buf.readUInt16LE(p + 30);
|
|
34
|
+
const 주석길이 = buf.readUInt16LE(p + 32);
|
|
35
|
+
const 로컬 = buf.readUInt32LE(p + 42);
|
|
36
|
+
const 이름 = buf.toString("utf8", p + 46, p + 46 + 이름길이);
|
|
37
|
+
목록.set(이름, { 방식, 압축크기, 로컬 });
|
|
38
|
+
p += 46 + 이름길이 + 여분길이 + 주석길이;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
이름들: () => [...목록.keys()],
|
|
43
|
+
읽기(이름) {
|
|
44
|
+
const e = 목록.get(이름);
|
|
45
|
+
if (!e) return null;
|
|
46
|
+
//: 로컬 헤더에서 실제 데이터 시작 위치를 다시 계산한다(중앙 디렉터리 값과 다를 수 있다).
|
|
47
|
+
const lh = e.로컬;
|
|
48
|
+
if (buf.readUInt32LE(lh) !== 0x04034b50) return null;
|
|
49
|
+
const n = buf.readUInt16LE(lh + 26);
|
|
50
|
+
const x = buf.readUInt16LE(lh + 28);
|
|
51
|
+
const 시작 = lh + 30 + n + x;
|
|
52
|
+
const 데이터 = buf.subarray(시작, 시작 + e.압축크기);
|
|
53
|
+
return e.방식 === 0 ? 데이터 : inflateRawSync(데이터);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const 태그없애기 = (s) => s.replace(/<[^>]*>/g, "");
|
|
59
|
+
const 언이스케이프 = (s) => s
|
|
60
|
+
.replace(/</g, "<").replace(/>/g, ">")
|
|
61
|
+
.replace(/"/g, '"').replace(/'/g, "'")
|
|
62
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
|
63
|
+
.replace(/&/g, "&");
|
|
64
|
+
const 정리 = (s) => 언이스케이프(태그없애기(s)).replace(/\s+/g, " ").trim();
|
|
65
|
+
|
|
66
|
+
/** DOCX — 문단(제목 스타일 반영) + 표. */
|
|
67
|
+
export function docx읽기(경로) {
|
|
68
|
+
const z = zip열기(경로);
|
|
69
|
+
const xml = z.읽기("word/document.xml");
|
|
70
|
+
if (!xml) throw new Error("word/document.xml 이 없다 — DOCX 가 아니다");
|
|
71
|
+
const s = xml.toString("utf8");
|
|
72
|
+
|
|
73
|
+
const 나옴 = [];
|
|
74
|
+
//: 본문을 문단(w:p)과 표(w:tbl) 단위로 순서대로 훑는다.
|
|
75
|
+
const re = /<w:tbl[\s>][\s\S]*?<\/w:tbl>|<w:p[\s>][\s\S]*?<\/w:p>/g;
|
|
76
|
+
let m;
|
|
77
|
+
while ((m = re.exec(s))) {
|
|
78
|
+
const 조각 = m[0];
|
|
79
|
+
if (조각.startsWith("<w:tbl")) {
|
|
80
|
+
const 행 = [];
|
|
81
|
+
const 행re = /<w:tr[\s>][\s\S]*?<\/w:tr>/g;
|
|
82
|
+
let r;
|
|
83
|
+
while ((r = 행re.exec(조각))) {
|
|
84
|
+
const 칸 = [...r[0].matchAll(/<w:tc[\s>][\s\S]*?<\/w:tc>/g)]
|
|
85
|
+
.map((c) => 정리(c[0]).replace(/\|/g, "\\|"));
|
|
86
|
+
if (칸.length) 행.push(칸);
|
|
87
|
+
}
|
|
88
|
+
if (행.length >= 1 && 행[0].length >= 2) {
|
|
89
|
+
나옴.push("");
|
|
90
|
+
나옴.push(`| ${행[0].join(" | ")} |`);
|
|
91
|
+
나옴.push(`| ${행[0].map(() => "---").join(" | ")} |`);
|
|
92
|
+
for (const r2 of 행.slice(1)) 나옴.push(`| ${r2.join(" | ")} |`);
|
|
93
|
+
나옴.push("");
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const 글 = 정리(조각);
|
|
98
|
+
if (!글) continue;
|
|
99
|
+
const st = (조각.match(/<w:pStyle[^>]*w:val="([^"]+)"/) || [])[1] || "";
|
|
100
|
+
const h = (st.match(/Heading(\d)/i) || st.match(/제목\s*(\d)/) || [])[1];
|
|
101
|
+
if (/^Title$/i.test(st)) 나옴.push(`# ${글}`);
|
|
102
|
+
else if (h) 나옴.push(`${"#".repeat(Math.min(6, Number(h)))} ${글}`);
|
|
103
|
+
else 나옴.push(글);
|
|
104
|
+
}
|
|
105
|
+
return 나옴.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** XLSX — 시트마다 마크다운 표 하나. */
|
|
109
|
+
export function xlsx읽기(경로) {
|
|
110
|
+
const z = zip열기(경로);
|
|
111
|
+
//: 공유 문자열 테이블(같은 글자를 한 번만 저장하는 곳)
|
|
112
|
+
const ss = z.읽기("xl/sharedStrings.xml");
|
|
113
|
+
const 공유 = ss
|
|
114
|
+
? [...ss.toString("utf8").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => 정리(m[1]))
|
|
115
|
+
: [];
|
|
116
|
+
|
|
117
|
+
//: 시트 이름 — workbook.xml 순서가 sheet1.xml, sheet2.xml … 과 같다고 보지 않는다.
|
|
118
|
+
const wb = z.읽기("xl/workbook.xml");
|
|
119
|
+
const 이름들 = wb
|
|
120
|
+
? [...wb.toString("utf8").matchAll(/<sheet[^>]*name="([^"]+)"/g)].map((m) => 언이스케이프(m[1]))
|
|
121
|
+
: [];
|
|
122
|
+
|
|
123
|
+
const 시트파일 = z.이름들().filter((n) => /^xl\/worksheets\/sheet\d+\.xml$/.test(n))
|
|
124
|
+
.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
|
|
125
|
+
|
|
126
|
+
const 나옴 = [];
|
|
127
|
+
시트파일.forEach((f, i) => {
|
|
128
|
+
const x = z.읽기(f);
|
|
129
|
+
if (!x) return;
|
|
130
|
+
const s = x.toString("utf8");
|
|
131
|
+
const 행 = [];
|
|
132
|
+
for (const r of s.matchAll(/<row[\s>][\s\S]*?<\/row>/g)) {
|
|
133
|
+
const 칸 = [];
|
|
134
|
+
for (const c of r[0].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
|
135
|
+
const 속성 = c[1];
|
|
136
|
+
const 안 = c[2];
|
|
137
|
+
const t = (속성.match(/\st="([^"]+)"/) || [])[1];
|
|
138
|
+
const v = (안.match(/<v>([\s\S]*?)<\/v>/) || [])[1];
|
|
139
|
+
let 값 = "";
|
|
140
|
+
if (t === "s") 값 = 공유[Number(v)] ?? "";
|
|
141
|
+
else if (t === "inlineStr") 값 = 정리(안);
|
|
142
|
+
else 값 = v ? 언이스케이프(v) : "";
|
|
143
|
+
칸.push(String(값).replace(/\|/g, "\\|"));
|
|
144
|
+
}
|
|
145
|
+
//: 완전히 빈 행은 버린다.
|
|
146
|
+
if (칸.some((k) => k !== "")) 행.push(칸);
|
|
147
|
+
}
|
|
148
|
+
if (!행.length) return;
|
|
149
|
+
const 폭 = Math.max(...행.map((r) => r.length));
|
|
150
|
+
const 채움 = (r) => [...r, ...Array(폭 - r.length).fill("")];
|
|
151
|
+
나옴.push(`## ${이름들[i] || `시트 ${i + 1}`}`, "");
|
|
152
|
+
나옴.push(`| ${채움(행[0]).join(" | ")} |`);
|
|
153
|
+
나옴.push(`| ${Array(폭).fill("---").join(" | ")} |`);
|
|
154
|
+
for (const r of 행.slice(1)) 나옴.push(`| ${채움(r).join(" | ")} |`);
|
|
155
|
+
나옴.push("");
|
|
156
|
+
});
|
|
157
|
+
return 나옴.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** PPTX — 슬라이드마다 제목 + 글머리. */
|
|
161
|
+
export function pptx읽기(경로) {
|
|
162
|
+
const z = zip열기(경로);
|
|
163
|
+
const 장 = z.이름들().filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))
|
|
164
|
+
.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
|
|
165
|
+
const 나옴 = [];
|
|
166
|
+
장.forEach((f, i) => {
|
|
167
|
+
const x = z.읽기(f);
|
|
168
|
+
if (!x) return;
|
|
169
|
+
const s = x.toString("utf8");
|
|
170
|
+
const 줄 = [...s.matchAll(/<a:p>([\s\S]*?)<\/a:p>/g)].map((m) => 정리(m[1])).filter(Boolean);
|
|
171
|
+
if (!줄.length) return;
|
|
172
|
+
나옴.push(`## 슬라이드 ${i + 1} — ${줄[0]}`, "");
|
|
173
|
+
for (const t of 줄.slice(1)) 나옴.push(`- ${t}`);
|
|
174
|
+
나옴.push("");
|
|
175
|
+
});
|
|
176
|
+
return 나옴.join("\n");
|
|
177
|
+
}
|
package/lib/pdf.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF → 줄·제목·표 구조를 살린 마크다운.
|
|
3
|
+
*
|
|
4
|
+
* ★왜 좌표로 다시 조립하나 — PDF 는 「글자를 어디에 찍어라」의 목록이지 문서가 아니다.
|
|
5
|
+
* 추출한 조각을 그냥 이어 붙이면 **띄어쓰기와 줄바꿈이 통째로 사라진다**
|
|
6
|
+
* (`REBORNLABS📍우리동네AI마케팅...`). 그래서 y 로 줄을 묶고, x 간격으로 공백을 넣는다.
|
|
7
|
+
*
|
|
8
|
+
* ★표는 **보수적으로만** 잡는다. x 좌표가 맞아떨어지는 열이 2개 이상이고 그런 줄이
|
|
9
|
+
* 2줄 이상 이어질 때만 표로 본다. 애매하면 그냥 문단으로 둔다 —
|
|
10
|
+
* **없는 표를 만들어 내는 것이 표를 놓치는 것보다 나쁘다.**
|
|
11
|
+
*
|
|
12
|
+
* ★스캔본(글자가 0개)은 **조용히 빈 파일을 내지 않는다.** 그건 「변환됐다」가 아니다.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
/** pdfjs 의 CJK 글꼴 매핑표 위치. 이게 없으면 한글·일본어 PDF 에서 글자가 깨진다. */
|
|
21
|
+
function cMap경로() {
|
|
22
|
+
try {
|
|
23
|
+
const p = require.resolve("pdfjs-dist/package.json");
|
|
24
|
+
const d = join(dirname(p), "cmaps") + "/";
|
|
25
|
+
return existsSync(join(dirname(p), "cmaps")) ? d : null;
|
|
26
|
+
} catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function pdf읽기(경로) {
|
|
30
|
+
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
31
|
+
const cMapUrl = cMap경로();
|
|
32
|
+
const doc = await pdfjs.getDocument({
|
|
33
|
+
data: new Uint8Array(readFileSync(경로)),
|
|
34
|
+
useSystemFonts: true,
|
|
35
|
+
...(cMapUrl ? { cMapUrl, cMapPacked: true } : {}),
|
|
36
|
+
verbosity: 0,
|
|
37
|
+
}).promise;
|
|
38
|
+
|
|
39
|
+
const 쪽들 = [];
|
|
40
|
+
for (let i = 1; i <= doc.numPages; i++) {
|
|
41
|
+
const page = await doc.getPage(i);
|
|
42
|
+
const tc = await page.getTextContent();
|
|
43
|
+
쪽들.push(줄만들기(tc.items));
|
|
44
|
+
}
|
|
45
|
+
return { 쪽수: doc.numPages, 쪽들 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 조각들을 y 로 묶어 줄로, x 간격으로 공백을 넣어 되살린다. */
|
|
49
|
+
function 줄만들기(items) {
|
|
50
|
+
const 조각 = items
|
|
51
|
+
.filter((it) => typeof it.str === "string")
|
|
52
|
+
.map((it) => ({
|
|
53
|
+
글: it.str,
|
|
54
|
+
x: it.transform[4],
|
|
55
|
+
y: it.transform[5],
|
|
56
|
+
w: it.width || 0,
|
|
57
|
+
크기: Math.abs(it.transform[3]) || Math.abs(it.transform[0]) || 0,
|
|
58
|
+
}))
|
|
59
|
+
.filter((c) => c.글.length);
|
|
60
|
+
|
|
61
|
+
if (!조각.length) return [];
|
|
62
|
+
|
|
63
|
+
//: 같은 줄 판정 — 글자 높이의 절반 안이면 같은 줄로 본다.
|
|
64
|
+
const 평균크기 = 조각.reduce((a, c) => a + c.크기, 0) / 조각.length || 10;
|
|
65
|
+
const 허용 = Math.max(1.5, 평균크기 * 0.5);
|
|
66
|
+
|
|
67
|
+
조각.sort((a, b) => (b.y - a.y) || (a.x - b.x));
|
|
68
|
+
const 줄들 = [];
|
|
69
|
+
for (const c of 조각) {
|
|
70
|
+
const 끝 = 줄들[줄들.length - 1];
|
|
71
|
+
if (끝 && Math.abs(끝.y - c.y) <= 허용) { 끝.조각.push(c); 끝.y = (끝.y + c.y) / 2; }
|
|
72
|
+
else 줄들.push({ y: c.y, 조각: [c] });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return 줄들.map((L) => {
|
|
76
|
+
L.조각.sort((a, b) => a.x - b.x);
|
|
77
|
+
let 글 = "";
|
|
78
|
+
let 앞 = null;
|
|
79
|
+
for (const c of L.조각) {
|
|
80
|
+
if (앞) {
|
|
81
|
+
const 틈 = c.x - (앞.x + 앞.w);
|
|
82
|
+
//: 글자 폭의 1/4 을 넘게 벌어져 있으면 공백이 있었던 것이다.
|
|
83
|
+
if (틈 > Math.max(0.8, 앞.크기 * 0.22)) 글 += " ";
|
|
84
|
+
}
|
|
85
|
+
글 += c.글;
|
|
86
|
+
앞 = c;
|
|
87
|
+
}
|
|
88
|
+
const 크기 = Math.max(...L.조각.map((c) => c.크기));
|
|
89
|
+
const x0 = Math.min(...L.조각.map((c) => c.x));
|
|
90
|
+
return { 글: 글.replace(/\s+/g, " ").trim(), 크기, y: L.y, x: x0, 조각: L.조각 };
|
|
91
|
+
}).filter((L) => L.글.length);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 줄들 → 마크다운. 제목은 글자 크기로, 표는 x 정렬로. */
|
|
95
|
+
export function 쪽을마크다운(줄들, { 본문크기 = null } = {}) {
|
|
96
|
+
if (!줄들.length) return "";
|
|
97
|
+
const 크기들 = 줄들.map((L) => L.크기).sort((a, b) => a - b);
|
|
98
|
+
const 본문 = 본문크기 ?? 크기들[Math.floor(크기들.length / 2)] ?? 10;
|
|
99
|
+
|
|
100
|
+
const 나옴 = [];
|
|
101
|
+
let i = 0;
|
|
102
|
+
while (i < 줄들.length) {
|
|
103
|
+
const 표 = 표잡기(줄들, i, 본문);
|
|
104
|
+
if (표) { 나옴.push(표.md); i = 표.끝; continue; }
|
|
105
|
+
|
|
106
|
+
const L = 줄들[i];
|
|
107
|
+
const 비 = L.크기 / 본문;
|
|
108
|
+
if (비 >= 1.45) 나옴.push(`# ${L.글}`);
|
|
109
|
+
else if (비 >= 1.18) 나옴.push(`## ${L.글}`);
|
|
110
|
+
else 나옴.push(L.글);
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
//: 문단 사이 빈 줄 — 제목 앞뒤는 반드시 띄운다.
|
|
114
|
+
const 줄바꿈 = [];
|
|
115
|
+
for (let k = 0; k < 나옴.length; k++) {
|
|
116
|
+
const t = 나옴[k];
|
|
117
|
+
if (k && (t.startsWith("#") || 나옴[k - 1].startsWith("#") || t.startsWith("|") !== 나옴[k - 1].startsWith("|"))) 줄바꿈.push("");
|
|
118
|
+
줄바꿈.push(t);
|
|
119
|
+
}
|
|
120
|
+
return 줄바꿈.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* ★표 잡기 — 보수적으로.
|
|
125
|
+
* `시작` 줄부터 **x 클러스터가 같은 줄**이 연달아 2줄 이상이고 열이 2개 이상일 때만 표로 본다.
|
|
126
|
+
*/
|
|
127
|
+
function 표잡기(줄들, 시작, 본문) {
|
|
128
|
+
/**
|
|
129
|
+
* 한 줄을 칸으로 가른다.
|
|
130
|
+
* ★★칸 사이는 **공백 조각**(width 20~35 짜리 ' ')으로 채워져 있는 경우가 많다.
|
|
131
|
+
* 그걸 조각으로 세면 이웃 간 틈이 0 으로 보여 **표를 통째로 놓친다**
|
|
132
|
+
* (2026-08-24 첫 판이 실제로 그랬다 — 계약서 표가 문단으로 나왔다).
|
|
133
|
+
* 그래서 **빈 조각은 버리고**, 남은 글자 조각 사이의 실제 거리로 칸을 가른다.
|
|
134
|
+
*/
|
|
135
|
+
const 열들 = (L) => {
|
|
136
|
+
const 알맹이 = L.조각.filter((c) => c.글.trim().length);
|
|
137
|
+
if (!알맹이.length) return [];
|
|
138
|
+
const 칸 = [];
|
|
139
|
+
let 현재 = "";
|
|
140
|
+
let 앞 = null;
|
|
141
|
+
let x0 = null;
|
|
142
|
+
for (const c of 알맹이) {
|
|
143
|
+
const 틈 = 앞 ? c.x - (앞.x + 앞.w) : 0;
|
|
144
|
+
/* ★임계값을 크게 잡으면 표를 놓치고, 작게 잡으면 문단이 표가 된다.
|
|
145
|
+
2026-08-24 에 1.2배로 두었더니 **같은 표 안에서 행마다 칸 수가 달라졌다**
|
|
146
|
+
(12.0 vs 13.6 — 임계값 12 에 딱 걸렸다). 0.8 로 내리고,
|
|
147
|
+
진짜 판정은 아래 **행 사이 x 정렬**에 맡긴다. 칸 나누기는 후보를 만드는 단계일 뿐이다. */
|
|
148
|
+
if (앞 && 틈 > Math.max(5, 앞.크기 * 0.8)) {
|
|
149
|
+
칸.push({ 글: 현재.trim(), x: x0 });
|
|
150
|
+
현재 = ""; x0 = null;
|
|
151
|
+
}
|
|
152
|
+
if (x0 === null) x0 = c.x;
|
|
153
|
+
if (현재 && 틈 > Math.max(0.8, 앞.크기 * 0.22)) 현재 += " ";
|
|
154
|
+
현재 += c.글;
|
|
155
|
+
앞 = c;
|
|
156
|
+
}
|
|
157
|
+
if (현재.trim()) 칸.push({ 글: 현재.trim(), x: x0 });
|
|
158
|
+
return 칸;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const 첫 = 열들(줄들[시작]);
|
|
162
|
+
if (첫.length < 2) return null;
|
|
163
|
+
|
|
164
|
+
const 행 = [첫];
|
|
165
|
+
let i = 시작 + 1;
|
|
166
|
+
while (i < 줄들.length) {
|
|
167
|
+
const c = 열들(줄들[i]);
|
|
168
|
+
if (c.length !== 첫.length) break;
|
|
169
|
+
//: 열 시작 x 가 서로 어긋나면 표가 아니다(우연히 칸이 갈린 문단).
|
|
170
|
+
const 맞음 = c.every((k, n) => Math.abs(k.x - 첫[n].x) <= Math.max(8, 본문 * 1.2));
|
|
171
|
+
if (!맞음) break;
|
|
172
|
+
행.push(c);
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
if (행.length < 2) return null;
|
|
176
|
+
|
|
177
|
+
const esc = (t) => String(t).replace(/\|/g, "\\|");
|
|
178
|
+
const md = [
|
|
179
|
+
`| ${행[0].map((k) => esc(k.글)).join(" | ")} |`,
|
|
180
|
+
`| ${행[0].map(() => "---").join(" | ")} |`,
|
|
181
|
+
...행.slice(1).map((r) => `| ${r.map((k) => esc(k.글)).join(" | ")} |`),
|
|
182
|
+
].join("\n");
|
|
183
|
+
return { md, 끝: i };
|
|
184
|
+
}
|
package/lib/tokens.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 토큰 **추정**. 추정이라고 부르고, 어디서 온 숫자인지 같이 적는다.
|
|
3
|
+
*
|
|
4
|
+
* ★★지어낸 숫자를 쓰지 않는다. 두 값의 출처가 다르다:
|
|
5
|
+
*
|
|
6
|
+
* ① PDF 를 그대로 넣을 때 — **Anthropic 공식 문서의 값**을 그대로 쓴다.
|
|
7
|
+
* https://platform.claude.com/docs/en/build-with-claude/pdf-support (2026-08-24 확인)
|
|
8
|
+
* · "Text token costs: Each page typically uses 1,500–3,000 tokens per page"
|
|
9
|
+
* · "Image token costs: Because each page is converted into an image,
|
|
10
|
+
* the same image-based cost calculations are applied."
|
|
11
|
+
* 즉 **페이지마다 텍스트 + 이미지가 둘 다** 실린다.
|
|
12
|
+
*
|
|
13
|
+
* ② 마크다운으로 바꾼 뒤 — 글자 수에서 낸 **어림값**이다. 정확한 값은
|
|
14
|
+
* Anthropic 의 token counting 으로만 나온다(API 키가 필요하다).
|
|
15
|
+
* 그래서 화면에 「추정」이라고 쓴다. 없는 정확도를 광고하지 않는다.
|
|
16
|
+
*
|
|
17
|
+
* ★이미지 토큰만은 추정이 아니다 — 마크다운에는 페이지도 이미지도 없으므로 **0 이다.**
|
|
18
|
+
* 이 도구가 파는 것이 정확히 그 항목이다.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 공식 문서의 페이지당 텍스트 토큰 범위. */
|
|
22
|
+
export const 쪽당텍스트 = { 최소: 1500, 최대: 3000 };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 글자 수 → 텍스트 토큰 어림값.
|
|
26
|
+
* ★한글과 라틴 문자는 토큰 밀도가 다르다. 한 가지 비율로 뭉뚱그리면
|
|
27
|
+
* 한글 문서에서 크게 빗나간다. 문자 종류를 세어 따로 나눈다.
|
|
28
|
+
* (한글 ≈ 1.5자/토큰 · 라틴 ≈ 4자/토큰 — 어림값이다)
|
|
29
|
+
*/
|
|
30
|
+
export function 텍스트토큰추정(글) {
|
|
31
|
+
const s = String(글 || "");
|
|
32
|
+
let 한 = 0, 라 = 0, 기타 = 0;
|
|
33
|
+
for (const ch of s) {
|
|
34
|
+
const c = ch.codePointAt(0);
|
|
35
|
+
if ((c >= 0xac00 && c <= 0xd7a3) || (c >= 0x1100 && c <= 0x11ff) || (c >= 0x3130 && c <= 0x318f)) 한++;
|
|
36
|
+
else if (c >= 0x20 && c < 0x2500) 라++;
|
|
37
|
+
else 기타++;
|
|
38
|
+
}
|
|
39
|
+
return Math.round(한 / 1.5 + 라 / 4 + 기타 / 1.2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* PDF 를 그대로 넣었을 때 실리는 양(공식 문서 기준).
|
|
44
|
+
* ★이미지 토큰은 페이지 크기에 따라 달라져 **범위를 못 박지 않는다.**
|
|
45
|
+
* 대신 "페이지마다 이미지가 하나씩 더 붙는다"는 사실을 그대로 전한다.
|
|
46
|
+
*/
|
|
47
|
+
export function 변환전(쪽수) {
|
|
48
|
+
return {
|
|
49
|
+
쪽수,
|
|
50
|
+
텍스트최소: 쪽수 * 쪽당텍스트.최소,
|
|
51
|
+
텍스트최대: 쪽수 * 쪽당텍스트.최대,
|
|
52
|
+
이미지: `${쪽수}장 (페이지마다 1장)`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 마크다운으로 바꾼 뒤. */
|
|
57
|
+
export function 변환후(마크다운) {
|
|
58
|
+
return {
|
|
59
|
+
글자수: String(마크다운 || "").length,
|
|
60
|
+
텍스트추정: 텍스트토큰추정(마크다운),
|
|
61
|
+
이미지: 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const 콤마 = (n) => Number(n).toLocaleString("ko-KR");
|
|
66
|
+
|
|
67
|
+
/** 사람이 읽을 요약 — 화면과 문서가 같은 문장을 쓰도록 여기 한 곳에서 만든다. */
|
|
68
|
+
export function 요약(쪽수, 마크다운) {
|
|
69
|
+
const a = 변환전(쪽수);
|
|
70
|
+
const b = 변환후(마크다운);
|
|
71
|
+
return [
|
|
72
|
+
` 변환 전 ${콤마(쪽수)}쪽 × 텍스트 ${콤마(쪽당텍스트.최소)}~${콤마(쪽당텍스트.최대)}토큰`
|
|
73
|
+
+ ` = ${콤마(a.텍스트최소)}~${콤마(a.텍스트최대)}`,
|
|
74
|
+
` + 페이지 이미지 ${a.이미지} ← 여기가 한 번 더 붙는다`,
|
|
75
|
+
` 변환 후 텍스트 약 ${콤마(b.텍스트추정)}토큰 (글자 ${콤마(b.글자수)}자 · 추정)`,
|
|
76
|
+
` + 페이지 이미지 0`,
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reborn-token",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "PDF·워드·엑셀을 깔끔한 마크다운으로 바꿔 클로드에 넣습니다. PDF 를 그대로 넣으면 페이지마다 텍스트와 이미지가 둘 다 청구되는데(Anthropic 공식 문서), 마크다운에는 페이지 이미지가 없습니다. 제목과 표는 그대로 유지. 전부 로컬, API 키 없음.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"pdf",
|
|
9
|
+
"markdown",
|
|
10
|
+
"docx",
|
|
11
|
+
"xlsx",
|
|
12
|
+
"token",
|
|
13
|
+
"convert",
|
|
14
|
+
"agent-skills",
|
|
15
|
+
"skill"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://rebornlabs.kr/token?utm_source=installer&utm_medium=cli&utm_campaign=token",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://mobility.rebornlabs.kr/cs"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "REBORN LABS",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"reborn-token": "cli.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"cli.mjs",
|
|
29
|
+
"lib/",
|
|
30
|
+
"skill/",
|
|
31
|
+
"README.md",
|
|
32
|
+
"안내스킬.md"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"pdfjs-dist": "^6.2.108"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pdf-to-md
|
|
3
|
+
description: PDF·워드·엑셀·PPT 를 제목과 표를 살린 마크다운으로 바꿔 클로드에 넣게 한다. PDF 를 그대로 넣으면 페이지마다 텍스트와 이미지가 둘 다 청구되는데(Anthropic 공식 문서), 마크다운에는 페이지 이미지가 없어 그 몫이 사라진다. 사용자가 PDF·계약서·보고서·엑셀·워드 파일을 주면서 "읽어줘", "요약해줘", "분석해줘", "정리해줘" 라고 할 때, 또는 "토큰 아끼는 법", "PDF 비용", "문서 변환" 을 물을 때 쓴다. 전부 로컬에서 돌고 API 키가 없다.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# PDF → 마크다운 (pdf-to-md)
|
|
8
|
+
|
|
9
|
+
문서를 **글자만 남긴 마크다운**으로 바꿔서 클로드에 넣는다.
|
|
10
|
+
|
|
11
|
+
## 왜 이걸 먼저 하나
|
|
12
|
+
|
|
13
|
+
Anthropic 공식 문서(`build-with-claude/pdf-support`)가 이렇게 적어 두었다:
|
|
14
|
+
|
|
15
|
+
> The system converts each page of the document into an image.
|
|
16
|
+
> The text from each page is extracted and provided alongside each page's image.
|
|
17
|
+
|
|
18
|
+
> Text token costs: Each page typically uses **1,500–3,000 tokens per page**
|
|
19
|
+
> Image token costs: **Because each page is converted into an image**, the same
|
|
20
|
+
> image-based cost calculations are applied.
|
|
21
|
+
|
|
22
|
+
즉 PDF 를 그대로 넣으면 **한 페이지에 텍스트와 이미지가 둘 다** 실린다.
|
|
23
|
+
20쪽짜리 계약서면 질문 하나 하기도 전에 수만 토큰이 나간다.
|
|
24
|
+
|
|
25
|
+
마크다운에는 **페이지도 이미지도 없다.** 텍스트 몫만 남는다.
|
|
26
|
+
|
|
27
|
+
## 언제 켜지나
|
|
28
|
+
|
|
29
|
+
- 사용자가 **PDF·DOCX·XLSX·PPTX 파일을 주면서** "읽어줘 / 요약해줘 / 분석해줘 / 정리해줘"
|
|
30
|
+
- "이 계약서에서 ○○ 찾아줘", "보고서 핵심만"
|
|
31
|
+
- "PDF 넣으면 왜 비싸?", "토큰 아끼는 법", "문서 변환"
|
|
32
|
+
|
|
33
|
+
## 어떻게 하나
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
npx reborn-token md <파일...>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
파일 옆에 `.md` 가 생긴다. 그다음 **그 `.md` 를 읽어서** 답한다.
|
|
40
|
+
|
|
41
|
+
- 여러 개 한 번에: `npx reborn-token md a.pdf b.docx c.xlsx`
|
|
42
|
+
- 다른 폴더로: `npx reborn-token md a.pdf -o ./out`
|
|
43
|
+
|
|
44
|
+
### 순서
|
|
45
|
+
|
|
46
|
+
1. **먼저 바꾼다.** 사용자가 준 문서를 원본 그대로 읽지 말고 위 명령으로 `.md` 를 만든다.
|
|
47
|
+
2. **바뀐 것을 읽는다.** `.md` 를 읽어 답한다.
|
|
48
|
+
3. **얼마나 줄었는지 한 줄로 말해 준다.** 명령이 찍어 준 「변환 전 / 변환 후」를 그대로 옮긴다.
|
|
49
|
+
★숫자를 지어내지 않는다. 명령이 안 찍은 값은 말하지 않는다.
|
|
50
|
+
|
|
51
|
+
## 무엇이 살아남나
|
|
52
|
+
|
|
53
|
+
| 살아남는 것 | 버리는 것 |
|
|
54
|
+
|---|---|
|
|
55
|
+
| 제목 계층 (`#` `##`) | 페이지 이미지 |
|
|
56
|
+
| 문단·줄바꿈 | 그림·차트·도형 |
|
|
57
|
+
| **표** (마크다운 표로) | 글꼴·색·여백 |
|
|
58
|
+
| 엑셀 시트별 표 | 머리말/꼬리말 장식 |
|
|
59
|
+
|
|
60
|
+
## 안 되는 경우 — 그대로 말한다
|
|
61
|
+
|
|
62
|
+
- **스캔본·이미지로만 된 PDF**: 가져올 글자가 없다. 명령이 그 자리에서 멈추고 이유를 찍는다.
|
|
63
|
+
이럴 때는 **원본 PDF 를 그대로 클로드에 넣는 편이 낫다** — 이미지를 봐야 읽히는 문서이기 때문이다.
|
|
64
|
+
★빈 `.md` 를 만들어 놓고 "변환했다"고 말하지 않는다.
|
|
65
|
+
- **차트·도형을 봐야 하는 문서**: 글자만 남기면 그림의 뜻이 사라진다. 원본을 같이 넣는다.
|
|
66
|
+
- 지원 형식은 `pdf · docx · xlsx · pptx` 넷이다. 그 밖은 명령이 거절한다.
|
|
67
|
+
|
|
68
|
+
## 사용자에게 답할 때
|
|
69
|
+
|
|
70
|
+
1. **먼저 바꾸고 나서 답한다.** "바꿀까요?" 라고 되묻지 않는다 — 되물으면 메시지가 한 번 더 든다.
|
|
71
|
+
2. **줄어든 양을 한 줄만** 덧붙인다. 길게 설명하지 않는다.
|
|
72
|
+
3. 스캔본이면 **바꾸지 못했다고 말하고** 원본을 그대로 쓰자고 제안한다.
|
|
73
|
+
4. 이 도구는 **네트워크를 쓰지 않는다.** "어디로 업로드되나요"에 그렇게 답하면 된다.
|
|
74
|
+
|
|
75
|
+
## 참고
|
|
76
|
+
|
|
77
|
+
- `references/PDF토큰.md` — 공식 문서 인용 원문과 계산 방식
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
REBORN LABS · MIT · https://rebornlabs.kr/token?utm_source=installer&utm_medium=cli&utm_campaign=token
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# PDF 토큰 — 공식 문서 원문과 계산 방식
|
|
2
|
+
|
|
3
|
+
> 아래 인용은 Anthropic 공식 문서에서 **그대로 옮긴 것**이다. 요약이 아니다.
|
|
4
|
+
> 출처: `https://platform.claude.com/docs/en/build-with-claude/pdf-support`
|
|
5
|
+
> 확인한 날: **2026-08-24**
|
|
6
|
+
|
|
7
|
+
## 1. PDF 가 어떻게 처리되나
|
|
8
|
+
|
|
9
|
+
> **How PDF support works**
|
|
10
|
+
> When you send a PDF to Claude, the following steps occur:
|
|
11
|
+
> 1. The system extracts the contents of the document.
|
|
12
|
+
> - **The system converts each page of the document into an image.**
|
|
13
|
+
> - The text from each page is extracted and provided alongside each page's image.
|
|
14
|
+
> 2. Claude analyzes both the text and images to better understand the document.
|
|
15
|
+
|
|
16
|
+
즉 페이지 하나가 **텍스트 한 벌 + 이미지 한 장**으로 들어간다.
|
|
17
|
+
|
|
18
|
+
## 2. 비용
|
|
19
|
+
|
|
20
|
+
> **Estimate your costs**
|
|
21
|
+
> The token count of a PDF file depends on the total text extracted from the document and the number of pages:
|
|
22
|
+
> - **Text token costs**: Each page typically uses **1,500–3,000 tokens per page** depending on content density. Standard API pricing applies with no additional PDF fees.
|
|
23
|
+
> - **Image token costs**: **Because each page is converted into an image**, the same image-based cost calculations are applied.
|
|
24
|
+
|
|
25
|
+
## 3. 그래서 20쪽 계약서는
|
|
26
|
+
|
|
27
|
+
| | 계산 | 결과 |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| 텍스트 | 20쪽 × 1,500~3,000 | **30,000 ~ 60,000 토큰** |
|
|
30
|
+
| 이미지 | 20쪽 × 페이지 이미지 1장 | **그 위에 더해진다** |
|
|
31
|
+
|
|
32
|
+
질문을 하나도 하기 전에 들어가는 양이다.
|
|
33
|
+
|
|
34
|
+
★**이미지 토큰을 숫자로 못 박지 않는다.** 페이지를 어떤 크기의 이미지로 만드는지는
|
|
35
|
+
공개돼 있지 않다. 문서가 말하는 것은 "이미지 비용 계산이 그대로 적용된다"까지다.
|
|
36
|
+
**모르는 것은 모른다고 적는다** — 없는 정확도를 광고하지 않는다.
|
|
37
|
+
|
|
38
|
+
## 4. 마크다운으로 바꾸면
|
|
39
|
+
|
|
40
|
+
- **페이지가 없다** → 페이지 이미지도 없다. 이미지 몫은 **0** 이다. (추정이 아니라 사실이다)
|
|
41
|
+
- 텍스트만 남는다. 이 도구는 그 양을 **글자 수에서 어림잡아** 보여 준다.
|
|
42
|
+
정확한 값은 Anthropic 의 token counting 으로만 나오고 그건 API 키가 필요하다 —
|
|
43
|
+
그래서 화면에 **「추정」** 이라고 쓴다.
|
|
44
|
+
|
|
45
|
+
어림 비율(`lib/tokens.mjs`):
|
|
46
|
+
|
|
47
|
+
| 문자 | 어림 |
|
|
48
|
+
|---|---|
|
|
49
|
+
| 한글 | 1.5자 ≈ 1토큰 |
|
|
50
|
+
| 라틴·숫자·기호 | 4자 ≈ 1토큰 |
|
|
51
|
+
| 그 밖 | 1.2자 ≈ 1토큰 |
|
|
52
|
+
|
|
53
|
+
## 5. 한도 (같은 문서)
|
|
54
|
+
|
|
55
|
+
| 항목 | 값 |
|
|
56
|
+
|---|---|
|
|
57
|
+
| 요청 최대 크기 | 32 MB |
|
|
58
|
+
| 요청당 최대 페이지 | 600 (문맥 창이 1M 미만이면 100) |
|
|
59
|
+
| 형식 | 암호가 걸리지 않은 표준 PDF |
|
|
60
|
+
|
|
61
|
+
## 6. 이 도구가 하지 않는 것
|
|
62
|
+
|
|
63
|
+
- 스캔본(글자가 없는 PDF)은 **바꾸지 못한다.** 그런 문서는 이미지를 봐야 읽히므로
|
|
64
|
+
원본을 그대로 넣는 편이 낫다. 도구는 빈 파일을 만들지 않고 그 자리에서 멈춘다.
|
|
65
|
+
- 차트·도형의 **뜻**은 옮기지 못한다. 글자만 가져온다.
|
|
66
|
+
- 요금을 계산해 주지 않는다. 토큰과 요금은 다른 이야기다.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
REBORN LABS · MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reborn-token-guide
|
|
3
|
+
description: >
|
|
4
|
+
PDF 를 클로드에 넣으면 왜 비용이 두 번 나가는지, 마크다운으로 바꾸면 무엇이 사라지는지,
|
|
5
|
+
그리고 그다음 단계인 무인 자동 발행이 어디서부터 필요한지 알려 준다.
|
|
6
|
+
"PDF 넣으면 왜 비싸", "토큰 아끼는 법", "문서 변환", "계약서 요약",
|
|
7
|
+
"자동으로 올리고 싶다" 같은 이야기가 나올 때 쓴다.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# PDF 는 왜 두 번 청구되나 — 그리고 어디부터는 다른 문제인가
|
|
11
|
+
|
|
12
|
+
> 이 파일은 **리본랩스(REBORN LABS)** 의 무료 설치기가 넣은 것입니다.
|
|
13
|
+
> 필요 없으시면 이 폴더를 지우세요. 지워도 `pdf-to-md` 스킬은 그대로 동작합니다.
|
|
14
|
+
|
|
15
|
+
## 방금 깐 것
|
|
16
|
+
|
|
17
|
+
`pdf-to-md` — 문서를 **제목과 표를 살린 마크다운**으로 바꿔 클로드에 넣습니다.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
npx reborn-token md contract.pdf
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`pdf` · `docx` · `xlsx` · `pptx` 넷을 바꿉니다. 전부 로컬에서 돌고 API 키가 없습니다.
|
|
24
|
+
|
|
25
|
+
## 한 줄 요약
|
|
26
|
+
|
|
27
|
+
Anthropic 공식 문서가 이렇게 적어 두었습니다 —
|
|
28
|
+
**"각 페이지가 이미지로 변환되고, 그 텍스트가 페이지 이미지와 함께 제공된다."**
|
|
29
|
+
그래서 PDF 한 장에 **텍스트와 이미지가 둘 다** 실립니다.
|
|
30
|
+
마크다운에는 페이지가 없으니 이미지 몫이 사라집니다.
|
|
31
|
+
|
|
32
|
+
## 이게 해 주지 않는 것
|
|
33
|
+
|
|
34
|
+
- **스캔본은 못 바꿉니다.** 글자가 없는 PDF 는 원본을 그대로 넣는 편이 낫습니다.
|
|
35
|
+
- 차트·도형의 뜻은 옮기지 못합니다. 글자만 가져옵니다.
|
|
36
|
+
- 요금 청구서를 계산해 주지 않습니다.
|
|
37
|
+
- 정해진 시각에 **사람 없이** 도는 예약 실행
|
|
38
|
+
- 실패했을 때 다시 미는 회복 경로 (무인 운영 사고의 대부분이 여기서 납니다)
|
|
39
|
+
- 채널별 규격 · 중복 차단 · 발행 원장
|
|
40
|
+
|
|
41
|
+
문서를 아껴 넣어도 **매일 알아서 올라가지는 않습니다.** 그건 파이프라인이 하는 일입니다.
|
|
42
|
+
|
|
43
|
+
## 그 다음이 필요하면
|
|
44
|
+
|
|
45
|
+
리본랩스 **클로드킷** — 블로그 · 스레드 · 인스타 · 유튜브 · 음악.
|
|
46
|
+
대본부터 이미지, 업로드, 실패 복구까지 한 덩어리로 돕니다.
|
|
47
|
+
|
|
48
|
+
https://rebornlabs.kr/claudekit?utm_source=installer&utm_medium=cli&utm_campaign=token
|
|
49
|
+
|
|
50
|
+
> 광고입니다. 다만 `pdf-to-md` 는 그것과 무관하게 그냥 쓰셔도 됩니다 — MIT 이고 완전 무료입니다.
|