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.
@@ -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()