claude-token-saver 3.28.1 → 3.28.3
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/README.en.md +31 -0
- package/README.md +31 -0
- package/package.json +1 -1
- package/presets/doc2md/convert.py +123 -0
- package/src/doc2md.cjs +117 -30
- package/src/fig2md.cjs +11 -2
package/README.en.md
CHANGED
|
@@ -461,6 +461,37 @@ Conversion is one-way — editing the cached `.md` changes nothing in the source
|
|
|
461
461
|
|
|
462
462
|
All four formats were exercised end to end on 2026-09-06: 10 docx run replacements plus three consecutive re-saves, a pptx bar-to-line chart swap with an added data point, xlsx value edits and a new row, and a fig text edit with re-encode and re-parse. In every case the original was byte-identical afterwards and the re-converted copy showed the change. One caveat: removing a chart shape from a pptx leaves the old chart XML part orphaned — PowerPoint ignores it, but delete the part and its rels for a clean file. Charts and images never appear in a conversion, so visual edits must be confirmed in the application itself.
|
|
463
463
|
|
|
464
|
+
### DRM-wrapped documents
|
|
465
|
+
|
|
466
|
+
Encryption and DRM are different problems with different answers. Enterprise DRM (Fasoo, MarkAny, SoftCamp and the like) does not password a document — it wraps the whole file, and only processes the vendor's agent has whitelisted ever see plaintext. Python is not one of them, so what sits on disk is ciphertext behind a vendor header, and **no password will open it.**
|
|
467
|
+
|
|
468
|
+
The first bytes decide which story to tell: a zip header means a truncated download, an OLE container means a password, and neither means the file is not that format at all.
|
|
469
|
+
|
|
470
|
+
```
|
|
471
|
+
✗ bad-archive: File is not a zip file → download it again
|
|
472
|
+
✗ encrypted: password-protected Office file → ask for an unlocked copy
|
|
473
|
+
✗ drm-protected: DRM-wrapped file (FASOO) → ask for a copy released from DRM
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Vendor names are matched only to say which client to go to; the classification stands without recognising the vendor. PDFs are judged the same way through their public DRM security-handler names (FOPN_foweb, EBX_HANDLER, Adobe.APS).
|
|
477
|
+
|
|
478
|
+
### Locked documents, and Windows
|
|
479
|
+
|
|
480
|
+
**A password-protected document is a state, not an error.** Office encrypts by wrapping the package in an OLE compound file rather than a zip, so opening one as a zip used to report "not a zip file" — which reads as a broken download and sends the user after the wrong problem. It is now identified before conversion:
|
|
481
|
+
|
|
482
|
+
```
|
|
483
|
+
✗ encrypted: password-protected Office file (OLE-wrapped)
|
|
484
|
+
✗ encrypted: password-protected PDF
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
The model is told to ask for an unlocked copy. This tool never asks for or stores a password, and never blocks the original `Read`, so work continues either way. A PDF that merely restricts printing still opens and still converts — checked against a false positive — and a legacy `.xls`, which is an OLE file by design, is not mistaken for an encrypted one.
|
|
488
|
+
|
|
489
|
+
**Windows is supported.** For teams with Windows machines:
|
|
490
|
+
|
|
491
|
+
- The Python search uses the `py -3` launcher. `python3` is rarely on PATH there, and a bare `python` may be the Store alias stub that opens a web page instead of running anything. Venv interpreters are looked for at `Scripts\python.exe`.
|
|
492
|
+
- The `.fig` parser installs through `npm.cmd` via the shell, and the package spec dropped its caret (`openfig-core@0.4.x`): in cmd.exe `^` is the escape character and never reaches npm.
|
|
493
|
+
- The background install and every child process set `windowsHide`, so no console window appears in the middle of someone's prompt.
|
|
494
|
+
|
|
464
495
|
`claude-token-saver doc2md --clean` empties the conversion cache; `doc2md off` removes the hook. Removal filters for this tool's own entry, so anything else you registered under `PreToolUse` stays.
|
|
465
496
|
|
|
466
497
|
## 🌐 Behind a gateway (Bedrock / Vertex)
|
package/README.md
CHANGED
|
@@ -457,6 +457,37 @@ PDF 는 첨부하면 모델이 내용을 그대로 읽습니다. 대신 페이
|
|
|
457
457
|
|
|
458
458
|
네 형식 모두 실제로 몇 바퀴 돌려 검증했습니다(2026-09-06): docx 텍스트 치환 10건과 3회 연속 재저장, pptx 막대→꺾은선 차트 교체와 데이터 행 추가, xlsx 값 정정·행 추가, fig 텍스트 수정·재인코드·재파싱. 전 케이스에서 원본은 바이트 그대로였고, 수정본 재변환에 변경 내용이 반영됐습니다. 한 가지 주의: pptx 에서 차트 도형을 제거하면 옛 차트 XML 파트가 고아로 남습니다. PowerPoint 는 무시하지만, 깔끔히 하려면 파트와 rels 도 지우십시오. 차트·이미지 같은 시각 요소는 변환본에 잡히지 않으므로, 시각 편집의 최종 확인은 해당 프로그램에서 해야 합니다.
|
|
459
459
|
|
|
460
|
+
### DRM 으로 보호된 문서
|
|
461
|
+
|
|
462
|
+
암호와 DRM 은 다른 문제이고 해법도 다릅니다. 사내 DRM(파수·마크애니·소프트캠프 등)은 문서에 암호를 거는 것이 아니라 파일 전체를 감싸며, 벤더 에이전트가 허용한 프로그램만 평문을 봅니다. 파이썬은 거기 없으므로 디스크에 있는 것은 벤더 헤더가 붙은 암호문입니다. **암호를 입력해서 풀 수 있는 문제가 아닙니다.**
|
|
463
|
+
|
|
464
|
+
판별은 첫 바이트가 무엇을 말하는지로 갈립니다. zip 헤더면 내려받다 끊긴 파일, OLE 컨테이너면 암호 걸린 문서, 둘 다 아니면 애초에 그 형식이 아닙니다.
|
|
465
|
+
|
|
466
|
+
```
|
|
467
|
+
✗ bad-archive: File is not a zip file ← 다시 받으십시오
|
|
468
|
+
✗ encrypted: password-protected Office file ← 암호를 푼 사본을 요청하십시오
|
|
469
|
+
✗ drm-protected: DRM-wrapped file (FASOO) ← DRM 해제본이나 반출 승인 사본을 요청하십시오
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
벤더 이름은 어느 클라이언트로 가야 하는지 알려 주려고 맞춰 볼 뿐이고, 판별 자체는 벤더를 몰라도 성립합니다. PDF 는 공개된 DRM 보안 핸들러 이름(FOPN_foweb·EBX_HANDLER·Adobe.APS)으로 같은 판정을 합니다.
|
|
473
|
+
|
|
474
|
+
### 암호가 걸린 문서와 Windows
|
|
475
|
+
|
|
476
|
+
**암호 문서는 오류가 아니라 상태로 다룹니다.** 사내에서 받는 문서 중 일부는 암호가 걸려 있습니다. 이전에는 암호 걸린 docx 를 "손상된 zip"이라고 알려서 사용자가 원인을 엉뚱한 곳에서 찾게 만들었습니다. 암호가 걸린 Office 문서는 zip 이 아니라 OLE 복합 문서로 저장되기 때문입니다. 지금은 변환 전에 판별해서 이렇게 알립니다.
|
|
477
|
+
|
|
478
|
+
```
|
|
479
|
+
✗ encrypted: password-protected Office file (OLE-wrapped)
|
|
480
|
+
✗ encrypted: password-protected PDF
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
모델에게는 암호를 푼 사본을 사용자에게 요청하라고 안내합니다. 이 도구는 암호를 묻지도 저장하지도 않습니다. 어느 경우에도 원본 `Read` 를 막지 않으므로 작업이 중단되지 않습니다. 열람은 자유롭고 인쇄만 제한된 PDF 는 암호 문서가 아니므로 그대로 변환합니다(오탐 확인 완료). 구형 `.xls` 도 원래 OLE 형식이라 암호로 오인하지 않습니다.
|
|
484
|
+
|
|
485
|
+
**Windows 를 지원하며, 실제 Windows 러너에서 검증합니다.** 사내에 Windows 사용자가 있어 다음을 맞췄습니다.
|
|
486
|
+
|
|
487
|
+
- 파이썬 탐색이 `py -3` 런처를 씁니다. Windows 에서는 `python3` 가 PATH 에 없는 경우가 많고, 맨 `python` 은 실행 대신 마이크로소프트 스토어를 여는 별칭 스텁일 수 있습니다. venv 기반 인터프리터도 `Scripts\python.exe` 경로로 찾습니다.
|
|
488
|
+
- `.fig` 파서 설치가 `npm.cmd` 를 셸로 호출합니다. 그리고 패키지 지정자에서 캐럿을 뺐습니다(`openfig-core@0.4.x`). cmd.exe 에서 `^` 는 이스케이프 문자라 npm 에 닿기 전에 먹힙니다.
|
|
489
|
+
- 백그라운드 자동 설치와 모든 하위 프로세스에 `windowsHide` 를 걸어, 프롬프트 도중에 콘솔 창이 튀어나오지 않게 했습니다.
|
|
490
|
+
|
|
460
491
|
`claude-token-saver doc2md --clean` 으로 변환 캐시를 비우고, `doc2md off` 로 훅을 제거합니다. 훅 해제는 자기 항목만 골라 지우므로 `PreToolUse` 에 등록해 둔 다른 훅은 그대로 남습니다.
|
|
461
492
|
|
|
462
493
|
## 🌐 Bedrock·Vertex 경유 환경
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.28.
|
|
3
|
+
"version": "3.28.3",
|
|
4
4
|
"description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,6 +43,111 @@ def fail(reason, detail=""):
|
|
|
43
43
|
sys.exit(0)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
# A password-protected OOXML file is not a zip at all: Office wraps the whole
|
|
47
|
+
# package in an OLE compound file whose streams hold the ciphertext. Opening it
|
|
48
|
+
# as a zip therefore reports "not a zip file", which reads as a broken download
|
|
49
|
+
# and sends the user looking for the wrong problem.
|
|
50
|
+
OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def encryption_problem(path, ext):
|
|
54
|
+
"""('encrypted', detail) when the file is password-protected, else None."""
|
|
55
|
+
try:
|
|
56
|
+
with open(path, "rb") as fh:
|
|
57
|
+
head = fh.read(8)
|
|
58
|
+
except OSError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# Legacy .xls is an OLE file by design, so the magic alone proves nothing
|
|
62
|
+
# there. For the modern formats it can only mean encryption.
|
|
63
|
+
if ext in ZIP_EXTS and head == OLE_MAGIC:
|
|
64
|
+
return ("encrypted", "password-protected Office file (OLE-wrapped)")
|
|
65
|
+
|
|
66
|
+
# Some producers keep the zip container and put the ciphertext inside it.
|
|
67
|
+
if ext in ZIP_EXTS:
|
|
68
|
+
try:
|
|
69
|
+
import zipfile
|
|
70
|
+
with zipfile.ZipFile(path) as z:
|
|
71
|
+
names = z.namelist()
|
|
72
|
+
if any(n.startswith("EncryptedPackage") for n in names):
|
|
73
|
+
return ("encrypted", "password-protected Office file")
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
if ext == ".pdf":
|
|
78
|
+
try:
|
|
79
|
+
from pdfminer.pdfparser import PDFParser
|
|
80
|
+
from pdfminer.pdfdocument import PDFDocument
|
|
81
|
+
with open(path, "rb") as fh:
|
|
82
|
+
doc = PDFDocument(PDFParser(fh))
|
|
83
|
+
# An empty owner password is the ordinary "printing restricted"
|
|
84
|
+
# case, which extracts fine. Only a document that refuses to open
|
|
85
|
+
# counts as encrypted here.
|
|
86
|
+
if doc.encryption is not None and not doc.is_extractable:
|
|
87
|
+
return ("encrypted", "password-protected PDF")
|
|
88
|
+
except Exception as e:
|
|
89
|
+
if "password" in str(type(e).__name__).lower() or "password" in str(e).lower():
|
|
90
|
+
return ("encrypted", "password-protected PDF")
|
|
91
|
+
return None
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Enterprise DRM (Fasoo, MarkAny, SoftCamp and the like) does not password a
|
|
96
|
+
# document — it wraps the whole file, and only processes the vendor's agent
|
|
97
|
+
# has whitelisted ever see plaintext. Python is not one of them, so the bytes
|
|
98
|
+
# on disk are ciphertext with a vendor header. Names are matched only to say
|
|
99
|
+
# *which* product to go to; the classification does not depend on them.
|
|
100
|
+
DRM_MARKERS = [b"FASOO", b"MarkAny", b"MAWDRM", b"SoftCamp", b"Sherpa", b"TrustDRM",
|
|
101
|
+
b"DocuGate", b"WISEDRM", b"UNIDOCS", b"SecureDoc"]
|
|
102
|
+
# PDF DRM announces itself as a security handler, and these names are public.
|
|
103
|
+
PDF_DRM_FILTERS = [b"FOPN_foweb", b"EBX_HANDLER", b"Adobe.APS", b"FOPN_fLock"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def drm_hint(head):
|
|
107
|
+
"""Vendor name found in the file header, or None."""
|
|
108
|
+
for marker in DRM_MARKERS:
|
|
109
|
+
if marker in head or marker.decode().encode("utf-16-le") in head:
|
|
110
|
+
return marker.decode()
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def wrapper_problem(path, ext):
|
|
115
|
+
"""('drm-protected', detail) when the container is not the format at all."""
|
|
116
|
+
try:
|
|
117
|
+
with open(path, "rb") as fh:
|
|
118
|
+
head = fh.read(8192)
|
|
119
|
+
except OSError:
|
|
120
|
+
return None
|
|
121
|
+
if not head:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
if ext in ZIP_EXTS:
|
|
125
|
+
# A truncated download still starts with a zip local-file header; a
|
|
126
|
+
# wrapped file does not start with anything of the format at all.
|
|
127
|
+
# Telling those two apart is the difference between "re-download it"
|
|
128
|
+
# and "get it released from DRM".
|
|
129
|
+
if head[:2] == b"PK" or head[:8] == OLE_MAGIC:
|
|
130
|
+
return None
|
|
131
|
+
vendor = drm_hint(head)
|
|
132
|
+
return ("drm-protected",
|
|
133
|
+
"DRM-wrapped file (%s)" % vendor if vendor else "the file is not an Office container at all")
|
|
134
|
+
|
|
135
|
+
if ext == ".pdf":
|
|
136
|
+
if not head.startswith(b"%PDF"):
|
|
137
|
+
vendor = drm_hint(head)
|
|
138
|
+
return ("drm-protected",
|
|
139
|
+
"DRM-wrapped file (%s)" % vendor if vendor else "the file is not a PDF at all")
|
|
140
|
+
try:
|
|
141
|
+
with open(path, "rb") as fh:
|
|
142
|
+
blob = fh.read(2_000_000)
|
|
143
|
+
except OSError:
|
|
144
|
+
return None
|
|
145
|
+
for f in PDF_DRM_FILTERS:
|
|
146
|
+
if f in blob:
|
|
147
|
+
return ("drm-protected", "PDF with a DRM security handler (%s)" % f.decode())
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
46
151
|
def check_zip(path):
|
|
47
152
|
"""Classify an archive before opening it as a document.
|
|
48
153
|
|
|
@@ -189,6 +294,19 @@ def main():
|
|
|
189
294
|
fail("missing", path)
|
|
190
295
|
|
|
191
296
|
ext = os.path.splitext(path)[1].lower()
|
|
297
|
+
|
|
298
|
+
# Checked before anything else opens the file: an encrypted document is a
|
|
299
|
+
# normal thing to receive, not a failure to report as corruption.
|
|
300
|
+
locked = encryption_problem(path, ext)
|
|
301
|
+
if locked:
|
|
302
|
+
fail(locked[0], locked[1])
|
|
303
|
+
|
|
304
|
+
# After the password check, because an OLE-wrapped Office file is an
|
|
305
|
+
# encrypted document rather than a DRM-wrapped one.
|
|
306
|
+
wrapped = wrapper_problem(path, ext)
|
|
307
|
+
if wrapped:
|
|
308
|
+
fail(wrapped[0], wrapped[1])
|
|
309
|
+
|
|
192
310
|
if ext in ZIP_EXTS:
|
|
193
311
|
problem = check_zip(path)
|
|
194
312
|
if problem:
|
|
@@ -224,6 +342,11 @@ def main():
|
|
|
224
342
|
result = MarkItDown().convert(path)
|
|
225
343
|
text = (result.text_content or "").strip()
|
|
226
344
|
except Exception as e:
|
|
345
|
+
# markitdown surfaces the password failure from whichever backend hit
|
|
346
|
+
# it, so the type name is the reliable part.
|
|
347
|
+
blob = (type(e).__name__ + " " + str(e)).lower()
|
|
348
|
+
if "password" in blob or "encrypted" in blob:
|
|
349
|
+
fail("encrypted", "the file is password-protected")
|
|
227
350
|
fail("convert-failed", e)
|
|
228
351
|
|
|
229
352
|
if not text:
|
package/src/doc2md.cjs
CHANGED
|
@@ -233,28 +233,55 @@ function writeCache(filePath, markdown, extra) {
|
|
|
233
233
|
// during the first-use auto-install, where the window is about a second wide.
|
|
234
234
|
const PROBE = 'from markitdown import MarkItDown';
|
|
235
235
|
|
|
236
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Interpreters to try, as `{ bin, args }` so the Windows launcher can carry
|
|
240
|
+
* its version flag. `py -3` is how a Windows box usually reaches Python at
|
|
241
|
+
* all: `python3` is rarely on PATH there, and a bare `python` may be the App
|
|
242
|
+
* Execution Alias stub that opens the Microsoft Store instead of running
|
|
243
|
+
* anything.
|
|
244
|
+
*/
|
|
245
|
+
function interpreterCandidates() {
|
|
246
|
+
const out = [];
|
|
247
|
+
if (process.env.CTS_DOC2MD_PYTHON) out.push({ bin: process.env.CTS_DOC2MD_PYTHON, args: [] });
|
|
248
|
+
// The tool's own venv, created by `doc2md install-converter`. First because
|
|
249
|
+
// it is the only one this tool controls: telling people to `pip install`
|
|
250
|
+
// into the system interpreter is how a token-saving CLI ends up owning a
|
|
251
|
+
// break in someone else's project.
|
|
252
|
+
out.push({ bin: managedPython(), args: [] });
|
|
253
|
+
if (IS_WINDOWS) {
|
|
254
|
+
out.push(
|
|
255
|
+
{ bin: path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'markitdown', 'Scripts', 'python.exe'), args: [] },
|
|
256
|
+
{ bin: 'py', args: ['-3'] },
|
|
257
|
+
{ bin: 'python', args: [] },
|
|
258
|
+
);
|
|
259
|
+
} else {
|
|
260
|
+
out.push(
|
|
261
|
+
{ bin: path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'markitdown', 'bin', 'python'), args: [] },
|
|
262
|
+
{ bin: path.join(os.homedir(), '.local', 'bin', 'markitdown-python'), args: [] },
|
|
263
|
+
{ bin: 'python3', args: [] },
|
|
264
|
+
{ bin: 'python', args: [] },
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
236
270
|
let interpreterCache;
|
|
237
271
|
function findInterpreter() {
|
|
238
272
|
if (interpreterCache !== undefined) return interpreterCache;
|
|
239
|
-
const
|
|
240
|
-
if (process.env.CTS_DOC2MD_PYTHON) candidates.push(process.env.CTS_DOC2MD_PYTHON);
|
|
241
|
-
candidates.push(
|
|
242
|
-
// The tool's own venv, created by `doc2md install-converter`. First
|
|
243
|
-
// because it is the only one this tool controls: telling people to
|
|
244
|
-
// `pip install` into the system interpreter is how a token-saving CLI
|
|
245
|
-
// ends up owning a break in someone else's project.
|
|
246
|
-
managedPython(),
|
|
247
|
-
path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'markitdown', 'bin', 'python'),
|
|
248
|
-
path.join(os.homedir(), '.local', 'bin', 'markitdown-python'),
|
|
249
|
-
'python3',
|
|
250
|
-
'python',
|
|
251
|
-
);
|
|
252
|
-
for (const bin of candidates) {
|
|
273
|
+
for (const { bin, args } of interpreterCandidates()) {
|
|
253
274
|
try {
|
|
254
|
-
const probe = spawnSync(bin, ['-c', PROBE], {
|
|
275
|
+
const probe = spawnSync(bin, [...args, '-c', PROBE], {
|
|
276
|
+
timeout: 20_000,
|
|
277
|
+
stdio: 'ignore',
|
|
278
|
+
windowsHide: true,
|
|
279
|
+
});
|
|
255
280
|
if (probe.status === 0) {
|
|
256
|
-
|
|
257
|
-
|
|
281
|
+
// Only a bare interpreter is cached as a string; the launcher form
|
|
282
|
+
// keeps its flag, since dropping it would run the wrong Python.
|
|
283
|
+
interpreterCache = args.length ? { bin, args } : bin;
|
|
284
|
+
return interpreterCache;
|
|
258
285
|
}
|
|
259
286
|
} catch { /* candidate unusable, try the next */ }
|
|
260
287
|
}
|
|
@@ -262,6 +289,11 @@ function findInterpreter() {
|
|
|
262
289
|
return null;
|
|
263
290
|
}
|
|
264
291
|
|
|
292
|
+
/** Split a `findInterpreter()` result into the pair spawnSync wants. */
|
|
293
|
+
function interpreterParts(found) {
|
|
294
|
+
return typeof found === 'string' ? { bin: found, args: [] } : found;
|
|
295
|
+
}
|
|
296
|
+
|
|
265
297
|
const CONVERTER = path.join(__dirname, '..', 'presets', 'doc2md', 'convert.py');
|
|
266
298
|
|
|
267
299
|
/** Path to the interpreter inside the venv this tool manages. */
|
|
@@ -295,18 +327,33 @@ const EDIT_LIBS = ['python-pptx', 'python-docx', 'openpyxl'];
|
|
|
295
327
|
* Homebrew interpreter wins over the system one regardless of PATH order.
|
|
296
328
|
*/
|
|
297
329
|
function findVenvBase() {
|
|
298
|
-
const candidates =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
330
|
+
const candidates = IS_WINDOWS
|
|
331
|
+
? [
|
|
332
|
+
// The launcher first, asked for each version in turn: it knows about
|
|
333
|
+
// installs that never touched PATH, which is the normal case on
|
|
334
|
+
// Windows.
|
|
335
|
+
{ bin: 'py', args: ['-3.14'] }, { bin: 'py', args: ['-3.13'] },
|
|
336
|
+
{ bin: 'py', args: ['-3.12'] }, { bin: 'py', args: ['-3.11'] },
|
|
337
|
+
{ bin: 'py', args: ['-3.10'] }, { bin: 'py', args: ['-3'] },
|
|
338
|
+
{ bin: 'python', args: [] },
|
|
339
|
+
]
|
|
340
|
+
: [
|
|
341
|
+
{ bin: 'python3.14', args: [] }, { bin: 'python3.13', args: [] },
|
|
342
|
+
{ bin: 'python3.12', args: [] }, { bin: 'python3.11', args: [] },
|
|
343
|
+
{ bin: 'python3.10', args: [] },
|
|
344
|
+
{ bin: 'python3', args: [] }, { bin: 'python', args: [] },
|
|
345
|
+
];
|
|
346
|
+
for (const { bin, args } of candidates) {
|
|
347
|
+
const r = spawnSync(bin, [...args, '-c', 'import sys; print("%d.%d" % sys.version_info[:2])'], {
|
|
304
348
|
encoding: 'utf8',
|
|
305
349
|
timeout: 20_000,
|
|
350
|
+
windowsHide: true,
|
|
306
351
|
});
|
|
307
352
|
if (r.status !== 0) continue;
|
|
308
353
|
const [major, minor] = String(r.stdout).trim().split('.').map(Number);
|
|
309
|
-
if (major > 3 || (major === 3 && minor >= 10))
|
|
354
|
+
if (major > 3 || (major === 3 && minor >= 10)) {
|
|
355
|
+
return { bin, args, version: `python ${major}.${minor}` };
|
|
356
|
+
}
|
|
310
357
|
}
|
|
311
358
|
return null;
|
|
312
359
|
}
|
|
@@ -332,7 +379,11 @@ function installConverter({ onProgress = () => {} } = {}) {
|
|
|
332
379
|
};
|
|
333
380
|
}
|
|
334
381
|
onProgress(`creating ${venv} (${base.version})`);
|
|
335
|
-
const r = spawnSync(base.bin, ['-m', 'venv', venv], {
|
|
382
|
+
const r = spawnSync(base.bin, [...(base.args || []), '-m', 'venv', venv], {
|
|
383
|
+
encoding: 'utf8',
|
|
384
|
+
timeout: 180_000,
|
|
385
|
+
windowsHide: true,
|
|
386
|
+
});
|
|
336
387
|
if (r.status !== 0) {
|
|
337
388
|
return { ok: false, reason: 'no-python', detail: (r.stderr || 'venv creation failed').slice(0, 300) };
|
|
338
389
|
}
|
|
@@ -342,6 +393,7 @@ function installConverter({ onProgress = () => {} } = {}) {
|
|
|
342
393
|
const install = spawnSync(target, ['-m', 'pip', 'install', '--quiet', MARKITDOWN_SPEC, ...EDIT_LIBS], {
|
|
343
394
|
encoding: 'utf8',
|
|
344
395
|
timeout: 900_000,
|
|
396
|
+
windowsHide: true,
|
|
345
397
|
});
|
|
346
398
|
if (install.status !== 0) {
|
|
347
399
|
return { ok: false, reason: 'pip-failed', detail: (install.stderr || '').slice(0, 400) };
|
|
@@ -349,7 +401,7 @@ function installConverter({ onProgress = () => {} } = {}) {
|
|
|
349
401
|
|
|
350
402
|
// The probe is the actual acceptance test: pip can exit 0 and still leave an
|
|
351
403
|
// interpreter that cannot import what was asked for.
|
|
352
|
-
const probe = spawnSync(target, ['-c', PROBE], { timeout: 60_000, stdio: 'ignore' });
|
|
404
|
+
const probe = spawnSync(target, ['-c', PROBE], { timeout: 60_000, stdio: 'ignore', windowsHide: true });
|
|
353
405
|
if (probe.status !== 0) {
|
|
354
406
|
return { ok: false, reason: 'import-failed', detail: 'installed, but markitdown does not import' };
|
|
355
407
|
}
|
|
@@ -361,8 +413,9 @@ function installConverter({ onProgress = () => {} } = {}) {
|
|
|
361
413
|
/**
|
|
362
414
|
* Convert one file. Returns `{ ok: true, cacheFile, meta }`, or
|
|
363
415
|
* `{ ok: false, reason, detail }` where reason is one of:
|
|
364
|
-
* no-markitdown | python-too-old | no-figparser |
|
|
365
|
-
*
|
|
416
|
+
* no-markitdown | python-too-old | no-figparser | encrypted |
|
|
417
|
+
* drm-protected | too-large | sensitive | unsafe-archive | no-text |
|
|
418
|
+
* convert-failed | timeout
|
|
366
419
|
*
|
|
367
420
|
* Every failure is a reason to leave the original Read alone, never to break
|
|
368
421
|
* it. That is the whole contract with the hook.
|
|
@@ -446,10 +499,12 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
|
|
|
446
499
|
}
|
|
447
500
|
if (!python) return { ok: false, reason: 'no-markitdown' };
|
|
448
501
|
|
|
449
|
-
const
|
|
502
|
+
const py = interpreterParts(python);
|
|
503
|
+
const run = spawnSync(py.bin, [...py.args, converter, filePath], {
|
|
450
504
|
encoding: 'utf8',
|
|
451
505
|
timeout: CONVERT_TIMEOUT_MS,
|
|
452
506
|
maxBuffer: MAX_MARKDOWN_BYTES * 4,
|
|
507
|
+
windowsHide: true,
|
|
453
508
|
});
|
|
454
509
|
if (run.error && run.error.code === 'ETIMEDOUT') return { ok: false, reason: 'timeout' };
|
|
455
510
|
if (run.status !== 0) {
|
|
@@ -522,6 +577,9 @@ function ensureConverterInstalled({ waitMs = 15_000 } = {}) {
|
|
|
522
577
|
const child = spawn(process.execPath, [cli, 'doc2md', 'install-converter'], {
|
|
523
578
|
detached: true,
|
|
524
579
|
stdio: 'ignore',
|
|
580
|
+
// Without this Windows pops a console window for the install, in the
|
|
581
|
+
// middle of someone's prompt.
|
|
582
|
+
windowsHide: true,
|
|
525
583
|
});
|
|
526
584
|
child.unref();
|
|
527
585
|
} catch {
|
|
@@ -623,6 +681,21 @@ function decideForRead(context, opts = {}) {
|
|
|
623
681
|
+ ` 진행 상황: ${INSTALL_HINT} 를 직접 실행하면 설치 로그를 볼 수 있습니다.`,
|
|
624
682
|
};
|
|
625
683
|
}
|
|
684
|
+
if (result.reason === 'drm-protected') {
|
|
685
|
+
return {
|
|
686
|
+
deny: false,
|
|
687
|
+
reason: `[doc2md] ${name} 는 DRM 으로 보호된 파일이라 변환하지 못했습니다(${result.detail || ''}).\n`
|
|
688
|
+
+ ' DRM 은 암호 입력으로 풀리지 않습니다. 벤더 에이전트가 허용한 프로그램에서만 평문이 보이므로, '
|
|
689
|
+
+ '사용자에게 DRM 클라이언트에서 연 뒤 해제본으로 저장하거나 반출 승인을 받은 사본을 요청하십시오.',
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
if (result.reason === 'encrypted') {
|
|
693
|
+
return {
|
|
694
|
+
deny: false,
|
|
695
|
+
reason: `[doc2md] ${name} 는 암호가 걸린 문서라 변환하지 못했습니다. `
|
|
696
|
+
+ '사용자에게 암호를 푼 사본을 요청하십시오. 이 도구는 암호를 묻거나 저장하지 않습니다.',
|
|
697
|
+
};
|
|
698
|
+
}
|
|
626
699
|
if (result.reason === 'python-too-old') {
|
|
627
700
|
if (noticeAlreadyShown()) return null;
|
|
628
701
|
markNoticeShown();
|
|
@@ -690,7 +763,11 @@ function decideForRead(context, opts = {}) {
|
|
|
690
763
|
function documentPathsIn(text) {
|
|
691
764
|
if (typeof text !== 'string' || !text) return [];
|
|
692
765
|
const exts = TARGET_EXTENSIONS.map((e) => e.slice(1)).join('|');
|
|
693
|
-
|
|
766
|
+
// Backslashes count as path characters, not just separators to tolerate:
|
|
767
|
+
// `C:\\Users\\me\\deck.pptx` and `\\\\server\\share\\deck.pptx` are how
|
|
768
|
+
// Windows users write a path, and without them the scanner silently sees no
|
|
769
|
+
// documents at all on that platform.
|
|
770
|
+
const re = new RegExp(`[@'"\`]?((?:[A-Za-z]:)?[~./\\\\][^\\s'"\`]*\\.(?:${exts}))`, 'gi');
|
|
694
771
|
const found = [];
|
|
695
772
|
for (const m of text.matchAll(re)) {
|
|
696
773
|
const raw = m[1];
|
|
@@ -753,6 +830,14 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
753
830
|
lines.push(lang === 'ko'
|
|
754
831
|
? ` ${name}: 변환기를 설치하는 중입니다(첫 실행에만 걸립니다). 설치가 끝나면 다음 요청부터 자동 변환됩니다.`
|
|
755
832
|
: ` ${name}: the converter is installing now (first run only). It will convert automatically from the next request.`);
|
|
833
|
+
} else if (result.reason === 'drm-protected') {
|
|
834
|
+
lines.push(lang === 'ko'
|
|
835
|
+
? ` ${name}: DRM 으로 보호된 파일입니다(${result.detail || ''}). 암호로는 풀 수 없으므로, DRM 클라이언트에서 저장한 해제본이나 반출 승인 사본을 달라고 사용자에게 요청하십시오.`
|
|
836
|
+
: ` ${name}: the file is DRM-wrapped (${result.detail || ''}). A password will not open it — ask the user for a copy released from DRM.`);
|
|
837
|
+
} else if (result.reason === 'encrypted') {
|
|
838
|
+
lines.push(lang === 'ko'
|
|
839
|
+
? ` ${name}: 암호가 걸린 문서입니다. 암호를 푼 사본을 달라고 사용자에게 요청하십시오.`
|
|
840
|
+
: ` ${name}: the document is password-protected. Ask the user for an unlocked copy.`);
|
|
756
841
|
} else if (result.reason === 'python-too-old') {
|
|
757
842
|
lines.push(lang === 'ko'
|
|
758
843
|
? ` ${name}: 변환기가 Python 3.10 이상을 요구하는데 PATH 에 없습니다(macOS 기본은 3.9). \`brew install python\` 후 다시 시도하도록 사용자에게 안내하십시오.`
|
|
@@ -888,6 +973,8 @@ module.exports = {
|
|
|
888
973
|
installConverter,
|
|
889
974
|
ensureConverterInstalled,
|
|
890
975
|
findVenvBase,
|
|
976
|
+
interpreterCandidates,
|
|
977
|
+
interpreterParts,
|
|
891
978
|
clearNotice,
|
|
892
979
|
cacheDir,
|
|
893
980
|
cachePathFor,
|
package/src/fig2md.cjs
CHANGED
|
@@ -28,7 +28,10 @@ const fs = require('node:fs');
|
|
|
28
28
|
const path = require('node:path');
|
|
29
29
|
const { spawnSync } = require('node:child_process');
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// `0.4.x` rather than `^0.4.1` on purpose: the Windows install goes through
|
|
32
|
+
// cmd.exe, where `^` is the escape character and would be eaten before npm
|
|
33
|
+
// ever saw it. The range is the same one caret means for a 0.x package.
|
|
34
|
+
const FIG_PARSER_SPEC = 'openfig-core@0.4.x';
|
|
32
35
|
|
|
33
36
|
/** Where the on-demand parser install lives, under the tool's state dir. */
|
|
34
37
|
function managedFigDir(userDataDir) {
|
|
@@ -56,10 +59,16 @@ function installFigParser(userDataDir, { onProgress = () => {} } = {}) {
|
|
|
56
59
|
fs.writeFileSync(pkgJson, JSON.stringify({ name: 'doc2md-fig', private: true }) + '\n');
|
|
57
60
|
}
|
|
58
61
|
onProgress(`installing ${FIG_PARSER_SPEC}`);
|
|
59
|
-
|
|
62
|
+
// npm on Windows is npm.cmd, which spawnSync cannot execute directly — it
|
|
63
|
+
// needs the shell. Elsewhere the shell is avoided, since the package spec
|
|
64
|
+
// would then go through shell quoting for no benefit.
|
|
65
|
+
const isWindows = process.platform === 'win32';
|
|
66
|
+
const r = spawnSync(isWindows ? 'npm.cmd' : 'npm', ['install', '--no-audit', '--no-fund', '--silent', FIG_PARSER_SPEC], {
|
|
60
67
|
cwd: dir,
|
|
61
68
|
encoding: 'utf8',
|
|
62
69
|
timeout: 300_000,
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
shell: isWindows,
|
|
63
72
|
});
|
|
64
73
|
if (r.status !== 0) {
|
|
65
74
|
return { ok: false, reason: 'npm-failed', detail: (r.stderr || '').slice(0, 400) };
|