dsh-linked-workspace 0.5.12 → 0.6.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/CHANGELOG.md +17 -0
- package/README.en.md +22 -0
- package/README.md +12 -0
- package/docs/API.md +10 -0
- package/lib/client.js +438 -12
- package/lib/index.js +256 -2
- package/package.json +3 -2
- package/scripts/doc_tool.py +405 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Document bridge for dsh-linked-workspace.
|
|
3
|
+
|
|
4
|
+
Reads/edits Markdown, PDF, DOCX, XLSX and PPTX files. It is intentionally a
|
|
5
|
+
small, dependency-light helper: python-docx / openpyxl / python-pptx / PyMuPDF
|
|
6
|
+
are only needed when the corresponding format is used.
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import base64
|
|
10
|
+
import datetime
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
MAX_PDF_PAGES = 40
|
|
17
|
+
MAX_PDF_WIDTH = 1100
|
|
18
|
+
MAX_SHEET_ROWS = 200
|
|
19
|
+
MAX_SHEET_COLS = 40
|
|
20
|
+
MAX_PPTX_SLIDES = 80
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fail(message):
|
|
24
|
+
print(json.dumps({"ok": False, "error": str(message)}, ensure_ascii=False))
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def emit(data):
|
|
29
|
+
print(json.dumps({"ok": True, "data": data}, ensure_ascii=False))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def json_value(value):
|
|
33
|
+
if value is None:
|
|
34
|
+
return None
|
|
35
|
+
if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
|
|
36
|
+
return value.isoformat()
|
|
37
|
+
if isinstance(value, (int, float, bool, str)):
|
|
38
|
+
return value
|
|
39
|
+
return str(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── DOCX ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def read_docx(path):
|
|
45
|
+
from docx import Document
|
|
46
|
+
from docx.table import Table
|
|
47
|
+
from docx.text.paragraph import Paragraph
|
|
48
|
+
|
|
49
|
+
doc = Document(path)
|
|
50
|
+
paragraphs = []
|
|
51
|
+
for paragraph in doc.paragraphs:
|
|
52
|
+
paragraphs.append({
|
|
53
|
+
"style": paragraph.style.name if paragraph.style is not None else "",
|
|
54
|
+
"text": paragraph.text,
|
|
55
|
+
})
|
|
56
|
+
tables = []
|
|
57
|
+
for table in doc.tables:
|
|
58
|
+
rows = []
|
|
59
|
+
for row in table.rows:
|
|
60
|
+
rows.append([cell.text for cell in row.cells])
|
|
61
|
+
tables.append(rows)
|
|
62
|
+
emit({"kind": "docx", "paragraphs": paragraphs, "tables": tables})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write_docx(source, data, out):
|
|
66
|
+
from docx import Document
|
|
67
|
+
|
|
68
|
+
doc = Document(source)
|
|
69
|
+
paragraphs = data.get("paragraphs")
|
|
70
|
+
if isinstance(paragraphs, list):
|
|
71
|
+
for index, block in enumerate(paragraphs):
|
|
72
|
+
if index >= len(doc.paragraphs):
|
|
73
|
+
break
|
|
74
|
+
if isinstance(block, dict) and isinstance(block.get("text"), str):
|
|
75
|
+
doc.paragraphs[index].text = block["text"]
|
|
76
|
+
tables = data.get("tables")
|
|
77
|
+
if isinstance(tables, list):
|
|
78
|
+
for table_index, rows in enumerate(tables):
|
|
79
|
+
if table_index >= len(doc.tables) or not isinstance(rows, list):
|
|
80
|
+
continue
|
|
81
|
+
table = doc.tables[table_index]
|
|
82
|
+
for row_index, row in enumerate(rows):
|
|
83
|
+
if row_index >= len(table.rows) or not isinstance(row, list):
|
|
84
|
+
continue
|
|
85
|
+
for cell_index, value in enumerate(row):
|
|
86
|
+
if cell_index >= len(table.rows[row_index].cells):
|
|
87
|
+
continue
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
table.rows[row_index].cells[cell_index].text = value
|
|
90
|
+
doc.save(out)
|
|
91
|
+
emit({"kind": "docx", "path": out})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── XLSX ──────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def read_xlsx(path):
|
|
97
|
+
from openpyxl import load_workbook
|
|
98
|
+
|
|
99
|
+
workbook = load_workbook(path, data_only=False)
|
|
100
|
+
sheets = []
|
|
101
|
+
for sheet in workbook.worksheets[:20]:
|
|
102
|
+
rows = []
|
|
103
|
+
max_row = min(sheet.max_row or 0, MAX_SHEET_ROWS)
|
|
104
|
+
max_col = min(sheet.max_column or 0, MAX_SHEET_COLS)
|
|
105
|
+
for row in sheet.iter_rows(min_row=1, max_row=max_row, min_col=1, max_col=max_col):
|
|
106
|
+
rows.append([json_value(cell.value) for cell in row])
|
|
107
|
+
sheets.append({
|
|
108
|
+
"name": sheet.title,
|
|
109
|
+
"rows": rows,
|
|
110
|
+
"maxRow": max_row,
|
|
111
|
+
"maxCol": max_col,
|
|
112
|
+
"frozen": bool(sheet.freeze_panes),
|
|
113
|
+
})
|
|
114
|
+
emit({"kind": "xlsx", "sheets": sheets, "sheetCount": len(workbook.worksheets)})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def write_xlsx(source, data, out):
|
|
118
|
+
from openpyxl import load_workbook
|
|
119
|
+
|
|
120
|
+
workbook = load_workbook(source)
|
|
121
|
+
updates = data.get("updates") or []
|
|
122
|
+
for update in updates:
|
|
123
|
+
if not isinstance(update, dict):
|
|
124
|
+
continue
|
|
125
|
+
sheet_name = update.get("sheet")
|
|
126
|
+
cell = update.get("cell")
|
|
127
|
+
value = update.get("value")
|
|
128
|
+
if not isinstance(sheet_name, str) or not isinstance(cell, str):
|
|
129
|
+
continue
|
|
130
|
+
if sheet_name not in workbook.sheetnames:
|
|
131
|
+
continue
|
|
132
|
+
if not re.match(r"^[A-Za-z]{1,3}[1-9][0-9]*$", cell):
|
|
133
|
+
continue
|
|
134
|
+
if isinstance(value, str) and value.startswith("="):
|
|
135
|
+
workbook[sheet_name][cell] = value
|
|
136
|
+
elif isinstance(value, str) and re.match(r"^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$", value.strip()):
|
|
137
|
+
number = value.strip()
|
|
138
|
+
workbook[sheet_name][cell] = float(number) if "." in number else int(number)
|
|
139
|
+
elif value == "":
|
|
140
|
+
workbook[sheet_name][cell] = None
|
|
141
|
+
else:
|
|
142
|
+
workbook[sheet_name][cell] = value
|
|
143
|
+
workbook.save(out)
|
|
144
|
+
emit({"kind": "xlsx", "path": out})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── PPTX ──────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def read_pptx(path):
|
|
150
|
+
from pptx import Presentation
|
|
151
|
+
|
|
152
|
+
presentation = Presentation(path)
|
|
153
|
+
slides = []
|
|
154
|
+
for slide_index, slide in enumerate(presentation.slides):
|
|
155
|
+
if slide_index >= MAX_PPTX_SLIDES:
|
|
156
|
+
break
|
|
157
|
+
texts = []
|
|
158
|
+
for shape_index, shape in enumerate(slide.shapes):
|
|
159
|
+
if not getattr(shape, "has_text_frame", False):
|
|
160
|
+
continue
|
|
161
|
+
text = shape.text_frame.text
|
|
162
|
+
if text.strip() == "":
|
|
163
|
+
continue
|
|
164
|
+
texts.append({
|
|
165
|
+
"shape": shape_index,
|
|
166
|
+
"name": getattr(shape, "name", ""),
|
|
167
|
+
"text": text,
|
|
168
|
+
})
|
|
169
|
+
slides.append({"index": slide_index, "texts": texts})
|
|
170
|
+
emit({"kind": "pptx", "slides": slides, "slideCount": len(presentation.slides)})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def write_pptx(source, data, out):
|
|
174
|
+
from pptx import Presentation
|
|
175
|
+
|
|
176
|
+
presentation = Presentation(source)
|
|
177
|
+
updates = data.get("updates") or []
|
|
178
|
+
for update in updates:
|
|
179
|
+
if not isinstance(update, dict):
|
|
180
|
+
continue
|
|
181
|
+
slide_index = update.get("slide")
|
|
182
|
+
shape_index = update.get("shape")
|
|
183
|
+
text = update.get("text")
|
|
184
|
+
if not isinstance(slide_index, int) or not isinstance(shape_index, int) or not isinstance(text, str):
|
|
185
|
+
continue
|
|
186
|
+
if slide_index < 0 or slide_index >= len(presentation.slides):
|
|
187
|
+
continue
|
|
188
|
+
slide = presentation.slides[slide_index]
|
|
189
|
+
if shape_index < 0 or shape_index >= len(slide.shapes):
|
|
190
|
+
continue
|
|
191
|
+
shape = slide.shapes[shape_index]
|
|
192
|
+
if not getattr(shape, "has_text_frame", False):
|
|
193
|
+
continue
|
|
194
|
+
shape.text_frame.text = text
|
|
195
|
+
presentation.save(out)
|
|
196
|
+
emit({"kind": "pptx", "path": out})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── PDF ───────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def read_pdf(path):
|
|
202
|
+
import fitz
|
|
203
|
+
|
|
204
|
+
doc = fitz.open(path)
|
|
205
|
+
pages = []
|
|
206
|
+
for index, page in enumerate(doc):
|
|
207
|
+
if index >= MAX_PDF_PAGES:
|
|
208
|
+
break
|
|
209
|
+
rect = page.rect
|
|
210
|
+
scale = min(1.5, MAX_PDF_WIDTH / max(rect.width, 1))
|
|
211
|
+
pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False)
|
|
212
|
+
pages.append({
|
|
213
|
+
"index": index,
|
|
214
|
+
"width": rect.width,
|
|
215
|
+
"height": rect.height,
|
|
216
|
+
"rotation": page.rotation,
|
|
217
|
+
"png": "data:image/png;base64," + base64.b64encode(pixmap.tobytes("png")).decode("ascii"),
|
|
218
|
+
})
|
|
219
|
+
metadata = doc.metadata or {}
|
|
220
|
+
emit({
|
|
221
|
+
"kind": "pdf",
|
|
222
|
+
"pageCount": doc.page_count,
|
|
223
|
+
"metadata": {
|
|
224
|
+
"title": metadata.get("title", ""),
|
|
225
|
+
"author": metadata.get("author", ""),
|
|
226
|
+
"subject": metadata.get("subject", ""),
|
|
227
|
+
},
|
|
228
|
+
"pages": pages,
|
|
229
|
+
"truncated": doc.page_count > MAX_PDF_PAGES,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def write_pdf(source, data, out):
|
|
234
|
+
import fitz
|
|
235
|
+
|
|
236
|
+
doc = fitz.open(source)
|
|
237
|
+
metadata = data.get("metadata") or {}
|
|
238
|
+
if isinstance(metadata, dict):
|
|
239
|
+
current = doc.metadata or {}
|
|
240
|
+
current.update({
|
|
241
|
+
"title": metadata.get("title", current.get("title", "")),
|
|
242
|
+
"author": metadata.get("author", current.get("author", "")),
|
|
243
|
+
"subject": metadata.get("subject", current.get("subject", "")),
|
|
244
|
+
})
|
|
245
|
+
doc.set_metadata(current)
|
|
246
|
+
for item in data.get("rotations") or []:
|
|
247
|
+
if not isinstance(item, dict):
|
|
248
|
+
continue
|
|
249
|
+
index = item.get("index")
|
|
250
|
+
degrees = item.get("degrees")
|
|
251
|
+
if isinstance(index, int) and 0 <= index < doc.page_count and isinstance(degrees, int):
|
|
252
|
+
page = doc[index]
|
|
253
|
+
page.set_rotation(degrees % 360)
|
|
254
|
+
deleted = [item for item in (data.get("deleted") or []) if isinstance(item, int)]
|
|
255
|
+
for index in sorted(set(deleted), reverse=True):
|
|
256
|
+
if 0 <= index < doc.page_count and doc.page_count > 1:
|
|
257
|
+
doc.delete_page(index)
|
|
258
|
+
doc.save(out, garbage=3, deflate=True)
|
|
259
|
+
doc.close()
|
|
260
|
+
emit({"kind": "pdf", "path": out})
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── create / Markdown ─────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
def create(kind, out):
|
|
266
|
+
if kind == "md":
|
|
267
|
+
with open(out, "w", encoding="utf-8") as handle:
|
|
268
|
+
handle.write("")
|
|
269
|
+
elif kind == "docx":
|
|
270
|
+
from docx import Document
|
|
271
|
+
|
|
272
|
+
document = Document()
|
|
273
|
+
document.add_paragraph("")
|
|
274
|
+
document.save(out)
|
|
275
|
+
elif kind == "xlsx":
|
|
276
|
+
from openpyxl import Workbook
|
|
277
|
+
|
|
278
|
+
workbook = Workbook()
|
|
279
|
+
workbook.active.title = "Sheet1"
|
|
280
|
+
workbook.save(out)
|
|
281
|
+
elif kind == "pptx":
|
|
282
|
+
from pptx import Presentation
|
|
283
|
+
|
|
284
|
+
presentation = Presentation()
|
|
285
|
+
layout = presentation.slide_layouts[1] if len(presentation.slide_layouts) > 1 else presentation.slide_layouts[0]
|
|
286
|
+
slide = presentation.slides.add_slide(layout)
|
|
287
|
+
slide.shapes.title.text = "New presentation"
|
|
288
|
+
presentation.save(out)
|
|
289
|
+
elif kind == "pdf":
|
|
290
|
+
import fitz
|
|
291
|
+
|
|
292
|
+
document = fitz.open()
|
|
293
|
+
document.new_page()
|
|
294
|
+
document.save(out)
|
|
295
|
+
document.close()
|
|
296
|
+
else:
|
|
297
|
+
raise ValueError(f"unsupported kind: {kind}")
|
|
298
|
+
emit({"kind": kind, "path": out})
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def markdown_to_html(path):
|
|
302
|
+
import markdown
|
|
303
|
+
|
|
304
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
305
|
+
text = handle.read()
|
|
306
|
+
body = markdown.markdown(text, extensions=["extra", "sane_lists", "tables", "fenced_code"])
|
|
307
|
+
html = """<!doctype html><html><head><meta charset="utf-8"><style>
|
|
308
|
+
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.65;color:#1f2328;max-width:900px;margin:0 auto;padding:28px;font-size:15px}
|
|
309
|
+
h1,h2,h3,h4{line-height:1.3;margin:1.2em 0 .55em} h1{font-size:1.8em;border-bottom:1px solid #e5e7eb;padding-bottom:.25em}
|
|
310
|
+
pre{background:#f6f8fa;padding:12px;border-radius:8px;overflow:auto} code{font-family:ui-monospace,Consolas,monospace;font-size:.92em}
|
|
311
|
+
pre code{background:none;padding:0} blockquote{border-left:4px solid #d0d7de;margin:0;padding:0 1em;color:#57606a}
|
|
312
|
+
table{border-collapse:collapse;margin:1em 0} th,td{border:1px solid #d0d7de;padding:6px 10px;text-align:left}
|
|
313
|
+
img{max-width:100%} a{color:#0969da} hr{border:0;border-top:1px solid #d0d7de;margin:1.5em 0}
|
|
314
|
+
</style></head><body>""" + body + "</body></html>"
|
|
315
|
+
return html
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def markdown_preview(path):
|
|
319
|
+
html = markdown_to_html(path)
|
|
320
|
+
emit({"kind": "markdown-preview", "html": html})
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def markdown_pdf(path, out):
|
|
324
|
+
import fitz
|
|
325
|
+
|
|
326
|
+
html = markdown_to_html(path)
|
|
327
|
+
story = fitz.Story(html=html)
|
|
328
|
+
writer = fitz.DocumentWriter(out)
|
|
329
|
+
mediabox = fitz.paper_rect("a4")
|
|
330
|
+
page_rect = mediabox + (48, 48, -48, -48)
|
|
331
|
+
more = True
|
|
332
|
+
while more:
|
|
333
|
+
device = writer.begin_page(mediabox)
|
|
334
|
+
more, _ = story.place(page_rect)
|
|
335
|
+
story.draw(device)
|
|
336
|
+
writer.end_page()
|
|
337
|
+
writer.close()
|
|
338
|
+
emit({"kind": "pdf", "path": out})
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ── CLI ───────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
def main():
|
|
344
|
+
parser = argparse.ArgumentParser(description="dsh-linked-workspace document bridge")
|
|
345
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
346
|
+
|
|
347
|
+
read = sub.add_parser("read")
|
|
348
|
+
read.add_argument("--kind", required=True)
|
|
349
|
+
read.add_argument("--file", required=True)
|
|
350
|
+
|
|
351
|
+
write = sub.add_parser("write")
|
|
352
|
+
write.add_argument("--kind", required=True)
|
|
353
|
+
write.add_argument("--file", required=True)
|
|
354
|
+
write.add_argument("--data", required=True)
|
|
355
|
+
write.add_argument("--out", required=True)
|
|
356
|
+
|
|
357
|
+
create_parser = sub.add_parser("create")
|
|
358
|
+
create_parser.add_argument("--kind", required=True)
|
|
359
|
+
create_parser.add_argument("--out", required=True)
|
|
360
|
+
|
|
361
|
+
preview = sub.add_parser("md-preview")
|
|
362
|
+
preview.add_argument("--file", required=True)
|
|
363
|
+
|
|
364
|
+
pdf = sub.add_parser("md-pdf")
|
|
365
|
+
pdf.add_argument("--file", required=True)
|
|
366
|
+
pdf.add_argument("--out", required=True)
|
|
367
|
+
|
|
368
|
+
args = parser.parse_args()
|
|
369
|
+
try:
|
|
370
|
+
if args.command == "read":
|
|
371
|
+
if args.kind == "docx":
|
|
372
|
+
read_docx(args.file)
|
|
373
|
+
elif args.kind == "xlsx":
|
|
374
|
+
read_xlsx(args.file)
|
|
375
|
+
elif args.kind == "pptx":
|
|
376
|
+
read_pptx(args.file)
|
|
377
|
+
elif args.kind == "pdf":
|
|
378
|
+
read_pdf(args.file)
|
|
379
|
+
else:
|
|
380
|
+
raise ValueError(f"unsupported kind: {args.kind}")
|
|
381
|
+
elif args.command == "write":
|
|
382
|
+
with open(args.data, "r", encoding="utf-8") as handle:
|
|
383
|
+
data = json.load(handle)
|
|
384
|
+
if args.kind == "docx":
|
|
385
|
+
write_docx(args.file, data, args.out)
|
|
386
|
+
elif args.kind == "xlsx":
|
|
387
|
+
write_xlsx(args.file, data, args.out)
|
|
388
|
+
elif args.kind == "pptx":
|
|
389
|
+
write_pptx(args.file, data, args.out)
|
|
390
|
+
elif args.kind == "pdf":
|
|
391
|
+
write_pdf(args.file, data, args.out)
|
|
392
|
+
else:
|
|
393
|
+
raise ValueError(f"unsupported kind: {args.kind}")
|
|
394
|
+
elif args.command == "create":
|
|
395
|
+
create(args.kind, args.out)
|
|
396
|
+
elif args.command == "md-preview":
|
|
397
|
+
markdown_preview(args.file)
|
|
398
|
+
elif args.command == "md-pdf":
|
|
399
|
+
markdown_pdf(args.file, args.out)
|
|
400
|
+
except Exception as error: # pragma: no cover - CLI boundary
|
|
401
|
+
fail(error)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
if __name__ == "__main__":
|
|
405
|
+
main()
|