document-adapter 0.7.2__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {document_adapter-0.7.2/document_adapter.egg-info → document_adapter-0.8.0}/PKG-INFO +1 -1
  2. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/base.py +61 -0
  3. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/hwpx_adapter.py +15 -5
  4. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/pptx_adapter.py +71 -0
  5. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/tools.py +86 -0
  6. {document_adapter-0.7.2 → document_adapter-0.8.0/document_adapter.egg-info}/PKG-INFO +1 -1
  7. {document_adapter-0.7.2 → document_adapter-0.8.0}/pyproject.toml +1 -1
  8. {document_adapter-0.7.2 → document_adapter-0.8.0}/tests/test_smoke.py +72 -0
  9. {document_adapter-0.7.2 → document_adapter-0.8.0}/LICENSE +0 -0
  10. {document_adapter-0.7.2 → document_adapter-0.8.0}/NOTICE +0 -0
  11. {document_adapter-0.7.2 → document_adapter-0.8.0}/README.md +0 -0
  12. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/__init__.py +0 -0
  13. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/docx_adapter.py +0 -0
  14. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/hwpx_core/__init__.py +0 -0
  15. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/hwpx_core/constants.py +0 -0
  16. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/hwpx_core/grid.py +0 -0
  17. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/hwpx_core/package.py +0 -0
  18. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/hwpx_core/paragraph.py +0 -0
  19. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter/mcp_server.py +0 -0
  20. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter.egg-info/SOURCES.txt +0 -0
  21. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter.egg-info/dependency_links.txt +0 -0
  22. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter.egg-info/entry_points.txt +0 -0
  23. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter.egg-info/requires.txt +0 -0
  24. {document_adapter-0.7.2 → document_adapter-0.8.0}/document_adapter.egg-info/top_level.txt +0 -0
  25. {document_adapter-0.7.2 → document_adapter-0.8.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: document-adapter
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: LLM-friendly document template editing (DOCX/PPTX/HWPX) with MCP server and Claude API tool-use support
5
5
  Author-email: Son Seongjun <sonsj97@plateer.com>
6
6
  License: MIT
@@ -183,6 +183,38 @@ class TableSchema:
183
183
  return d
184
184
 
185
185
 
186
+ @dataclass
187
+ class ShapeInfo:
188
+ """PPTX 의 표 외 shape (textbox / placeholder / 도형 내 텍스트) 메타.
189
+
190
+ ``shape_id`` 는 슬라이드 내 고유 숫자. ``name`` 은 사람 이름 (예: "Title 1").
191
+ ``text`` 는 full text. ``text_preview`` 는 40자 컷.
192
+ """
193
+ slide_index: int
194
+ shape_id: int
195
+ name: str
196
+ kind: str # shape 종류 (placeholder / text_box / group / picture 등)
197
+ has_text: bool
198
+ text: str = ""
199
+ text_preview: str = ""
200
+ placeholder_type: str | None = None
201
+
202
+ def to_dict(self) -> dict[str, Any]:
203
+ d = {
204
+ "slide_index": self.slide_index,
205
+ "shape_id": self.shape_id,
206
+ "name": self.name,
207
+ "kind": self.kind,
208
+ "has_text": self.has_text,
209
+ "text_preview": self.text_preview,
210
+ }
211
+ if self.text and self.text != self.text_preview:
212
+ d["text"] = self.text
213
+ if self.placeholder_type:
214
+ d["placeholder_type"] = self.placeholder_type
215
+ return d
216
+
217
+
186
218
  @dataclass
187
219
  class CellContent:
188
220
  """단일 셀의 전체 내용 + 병합/중첩 메타 + 크기 힌트.
@@ -304,6 +336,35 @@ class DocumentAdapter(ABC):
304
336
  def append_row(self, table_index: int, values: list[str]) -> None:
305
337
  """표 끝에 새 행 추가."""
306
338
 
339
+ # ---- shape text (v0.8+, PPTX 전용) ----
340
+ def get_shapes(
341
+ self,
342
+ slide_index: int | None = None,
343
+ min_text_len: int = 1,
344
+ max_preview: int = 40,
345
+ ) -> list[ShapeInfo]:
346
+ """표 외 shape (textbox / placeholder / 도형 텍스트) 목록.
347
+
348
+ PPTX 에서만 실질적 의미가 있다. DOCX/HWPX 는 기본 빈 리스트 반환
349
+ (해당 포맷은 표와 paragraph 위주로 편집).
350
+ """
351
+ return []
352
+
353
+ def set_shape_text(
354
+ self,
355
+ slide_index: int,
356
+ shape_id: int,
357
+ text: str,
358
+ ) -> str:
359
+ """shape 의 텍스트 프레임을 text 로 교체. 기존 run-level 포맷 보존.
360
+
361
+ Returns: 기존 텍스트.
362
+ PPTX 만 지원 — 그 외 포맷은 NotImplementedForFormat.
363
+ """
364
+ raise NotImplementedForFormat(
365
+ f"{self.format} does not support shape-level text editing"
366
+ )
367
+
307
368
  # ---- label-based form filling ----
308
369
  def fill_form(
309
370
  self,
@@ -118,11 +118,21 @@ class HwpxAdapter(DocumentAdapter):
118
118
  yield from walk(child_tbl, child_parent, section_name)
119
119
 
120
120
  for section_name, root in self._pkg.iter_section_roots():
121
- # 최상위 <hp:tbl> 찾기: root > hp:p > hp:run > hp:tbl
122
- for p in root.findall(HP_P):
123
- for run in p.findall(HP_RUN):
124
- for tbl in run.findall(HP_TBL):
125
- yield from walk(tbl, "", section_name)
121
+ # 섹션의 모든 <hp:tbl> descendant 중 "top-level" 만 선별.
122
+ # top-level = <hp:tc> (cell) 의 descendant 가 아닌 것.
123
+ # 이러면 <hp:p>/<hp:run>/<hp:ctrl>/<hp:header>/<hp:footer>/도형 등
124
+ # 어디에 놓여있든 표를 누락 없이 발견 (xgen-doc2chunk 의 _process_ctrl
125
+ # 가 처리하던 범위 포함). cell 내부 <hp:tbl> 은 walk 재귀에서 처리.
126
+ for tbl in root.iter(HP_TBL):
127
+ parent = tbl.getparent()
128
+ is_in_cell = False
129
+ while parent is not None:
130
+ if parent.tag == HP_TC:
131
+ is_in_cell = True
132
+ break
133
+ parent = parent.getparent()
134
+ if not is_in_cell:
135
+ yield from walk(tbl, "", section_name)
126
136
 
127
137
  def _get_table(self, table_index: int) -> tuple[etree._Element, str]:
128
138
  """flat_index로 (tbl_element, section_part_name) 반환."""
@@ -23,6 +23,7 @@ from .base import (
23
23
  MergeInfo,
24
24
  MergedCellWriteError,
25
25
  NotImplementedForFormat,
26
+ ShapeInfo,
26
27
  TableIndexError,
27
28
  TableSchema,
28
29
  )
@@ -270,6 +271,76 @@ class PptxAdapter(DocumentAdapter):
270
271
  char_count=len(text),
271
272
  )
272
273
 
274
+ # ---- shape text (v0.8+) ----
275
+ def get_shapes(
276
+ self,
277
+ slide_index: int | None = None,
278
+ min_text_len: int = 1,
279
+ max_preview: int = 40,
280
+ ) -> list[ShapeInfo]:
281
+ """표 외 shape (textbox / placeholder / 도형 텍스트) 수집.
282
+
283
+ ``slide_index`` 는 1-based. None 이면 전체.
284
+ ``min_text_len`` 미만의 텍스트는 제외 (0 을 주면 빈 shape 도 포함).
285
+ """
286
+ shapes_out: list[ShapeInfo] = []
287
+ for s_idx, slide in enumerate(self._prs.slides, 1):
288
+ if slide_index is not None and s_idx != slide_index:
289
+ continue
290
+ for shape in slide.shapes:
291
+ if shape.has_table:
292
+ continue # 표는 get_tables 로
293
+ if not shape.has_text_frame:
294
+ continue
295
+ text = (shape.text_frame.text or "").strip()
296
+ if len(text) < min_text_len:
297
+ continue
298
+ ph_type = None
299
+ try:
300
+ ph = shape.placeholder_format
301
+ if ph is not None and ph.type is not None:
302
+ ph_type = str(ph.type).rsplit(".", 1)[-1]
303
+ except ValueError:
304
+ pass
305
+ kind = "placeholder" if ph_type else "text_box"
306
+ shapes_out.append(
307
+ ShapeInfo(
308
+ slide_index=s_idx,
309
+ shape_id=shape.shape_id,
310
+ name=shape.name,
311
+ kind=kind,
312
+ has_text=bool(text),
313
+ text=text,
314
+ text_preview=text[:max_preview],
315
+ placeholder_type=ph_type,
316
+ )
317
+ )
318
+ return shapes_out
319
+
320
+ def set_shape_text(
321
+ self,
322
+ slide_index: int,
323
+ shape_id: int,
324
+ text: str,
325
+ ) -> str:
326
+ """shape 의 텍스트를 text 로 교체. 기존 run-level 포맷 보존."""
327
+ for s_idx, slide in enumerate(self._prs.slides, 1):
328
+ if s_idx != slide_index:
329
+ continue
330
+ for shape in slide.shapes:
331
+ if shape.shape_id != shape_id:
332
+ continue
333
+ if not shape.has_text_frame:
334
+ raise ValueError(
335
+ f"shape {shape_id} on slide {slide_index} has no text frame"
336
+ )
337
+ old = shape.text_frame.text or ""
338
+ _set_text_frame_preserving_format(shape.text_frame, text)
339
+ return old
340
+ raise ValueError(
341
+ f"shape not found: slide_index={slide_index}, shape_id={shape_id}"
342
+ )
343
+
273
344
  # ---- editing ----
274
345
  def render_template(self, context: dict[str, Any]) -> None:
275
346
  """paragraph 단위로 {{key}}를 치환. run이 쪼개진 경우를 처리하기 위해
@@ -172,6 +172,53 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
172
172
  "required": ["path", "table_index", "values"],
173
173
  },
174
174
  },
175
+ {
176
+ "name": "get_shapes",
177
+ "description": (
178
+ "**PPTX 전용**. 표 외 shape (textbox / placeholder / 도형 내 텍스트) 목록을 "
179
+ "반환한다. PPTX 는 17 슬라이드에 걸쳐 표 4 개 외에 수십 개의 textbox / "
180
+ "placeholder 로 내용이 채워져 있으므로, 전체 편집에는 get_tables 만으로 "
181
+ "부족하다. "
182
+ "반환: shapes[] — 각 항목에 slide_index (1-based), shape_id (슬라이드 내 고유 "
183
+ "숫자), name, kind (placeholder/text_box), text/text_preview, placeholder_type. "
184
+ "DOCX/HWPX 는 빈 리스트 반환 (표와 paragraph 중심이라 shape 편집 개념 약함)."
185
+ ),
186
+ "input_schema": {
187
+ "type": "object",
188
+ "properties": {
189
+ "path": {"type": "string"},
190
+ "slide_index": {
191
+ "type": "integer",
192
+ "description": "1-based. 생략 시 전체 슬라이드.",
193
+ },
194
+ "min_text_len": {
195
+ "type": "integer",
196
+ "default": 1,
197
+ "description": "이 값 미만 길이의 텍스트 shape 는 제외.",
198
+ },
199
+ },
200
+ "required": ["path"],
201
+ },
202
+ },
203
+ {
204
+ "name": "set_shape_text",
205
+ "description": (
206
+ "**PPTX 전용**. shape (textbox/placeholder) 의 텍스트를 교체한다. run-level "
207
+ "포맷 (폰트/크기/색상) 은 보존. get_shapes 로 slide_index + shape_id 파악 후 "
208
+ "호출. 표 셀은 이 도구 아닌 set_cell 을 쓸 것."
209
+ ),
210
+ "input_schema": {
211
+ "type": "object",
212
+ "properties": {
213
+ "path": {"type": "string"},
214
+ "slide_index": {"type": "integer", "description": "1-based"},
215
+ "shape_id": {"type": "integer"},
216
+ "text": {"type": "string"},
217
+ "output_path": {"type": "string"},
218
+ },
219
+ "required": ["path", "slide_index", "shape_id", "text"],
220
+ },
221
+ },
175
222
  {
176
223
  "name": "fill_form",
177
224
  "description": (
@@ -364,6 +411,43 @@ def append_row(path: str, table_index: int, values: list[str],
364
411
  }
365
412
 
366
413
 
414
+ def get_shapes(path: str, slide_index: int | None = None,
415
+ min_text_len: int = 1) -> dict[str, Any]:
416
+ doc = load(path)
417
+ try:
418
+ shapes = doc.get_shapes(slide_index=slide_index, min_text_len=min_text_len)
419
+ finally:
420
+ doc.close()
421
+ return {
422
+ "format": doc.format,
423
+ "source": str(path),
424
+ "shape_count": len(shapes),
425
+ "shapes": [s.to_dict() for s in shapes],
426
+ }
427
+
428
+
429
+ def set_shape_text(path: str, slide_index: int, shape_id: int, text: str,
430
+ output_path: str | None = None) -> dict[str, Any]:
431
+ target = Path(output_path) if output_path else Path(path)
432
+ if output_path and Path(path) != target:
433
+ shutil.copy2(path, target)
434
+
435
+ doc = load(target)
436
+ try:
437
+ old = doc.set_shape_text(slide_index, shape_id, text)
438
+ doc.save()
439
+ finally:
440
+ doc.close()
441
+
442
+ return {
443
+ "output_path": str(target),
444
+ "slide_index": slide_index,
445
+ "shape_id": shape_id,
446
+ "previous_text": old,
447
+ "new_text": text,
448
+ }
449
+
450
+
367
451
  def fill_form(path: str, data: dict[str, str],
368
452
  direction: str = "auto", strict: bool = False,
369
453
  output_path: str | None = None) -> dict[str, Any]:
@@ -392,6 +476,8 @@ TOOL_HANDLERS = {
392
476
  "append_to_cell": append_to_cell,
393
477
  "append_row": append_row,
394
478
  "fill_form": fill_form,
479
+ "get_shapes": get_shapes,
480
+ "set_shape_text": set_shape_text,
395
481
  }
396
482
 
397
483
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: document-adapter
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: LLM-friendly document template editing (DOCX/PPTX/HWPX) with MCP server and Claude API tool-use support
5
5
  Author-email: Son Seongjun <sonsj97@plateer.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "document-adapter"
7
- version = "0.7.2"
7
+ version = "0.8.0"
8
8
  description = "LLM-friendly document template editing (DOCX/PPTX/HWPX) with MCP server and Claude API tool-use support"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1207,3 +1207,75 @@ def test_fill_form_label_normalization(tmp_path: Path) -> None:
1207
1207
 
1208
1208
  assert result["not_found"] == []
1209
1209
  assert len(result["filled"]) == 1
1210
+
1211
+
1212
+ # ---- v0.7.3: <hp:ctrl> 내부 테이블 탐지 회귀 방지 ----
1213
+
1214
+ def _make_hwpx_with_ctrl_embedded_table(path: Path) -> None:
1215
+ """기본 2x2 표 1 개에 더해 <hp:ctrl> 내부에 동일 구조 표를 1 개 추가.
1216
+
1217
+ HWPX 양식에서 header/footer/footNote 등은 <hp:ctrl> 하위에 들어가므로
1218
+ LLM 이 편집해야 할 표가 ctrl 내부에 숨어있을 수 있다. adapter 가 이를
1219
+ top-level 로 인식하고 편집 가능해야 한다.
1220
+ """
1221
+ import zipfile
1222
+ from copy import deepcopy
1223
+ from lxml import etree
1224
+
1225
+ # 1. 기본 HWPX 생성
1226
+ doc = HwpxDocument.new()
1227
+ doc.add_paragraph("before")
1228
+ doc.add_table(2, 2)
1229
+ doc.add_paragraph("after")
1230
+ doc.save_to_path(path)
1231
+ doc.close()
1232
+
1233
+ HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
1234
+
1235
+ # 2. section0.xml 에 <hp:ctrl> 래핑된 tbl 추가
1236
+ with zipfile.ZipFile(path, "r") as zin:
1237
+ section_xml = zin.read("Contents/section0.xml")
1238
+ other = {n: zin.read(n) for n in zin.namelist() if n != "Contents/section0.xml"}
1239
+ infos = {n: zin.getinfo(n) for n in zin.namelist()}
1240
+
1241
+ root = etree.fromstring(section_xml)
1242
+ existing_tbl = next(root.iter(f"{HP}tbl"))
1243
+ cloned = deepcopy(existing_tbl)
1244
+ new_p = etree.SubElement(root, f"{HP}p")
1245
+ new_run = etree.SubElement(new_p, f"{HP}run")
1246
+ new_ctrl = etree.SubElement(new_run, f"{HP}ctrl")
1247
+ new_ctrl.append(cloned)
1248
+
1249
+ modified_xml = etree.tostring(
1250
+ root, xml_declaration=True, encoding="UTF-8", standalone=True
1251
+ )
1252
+ with zipfile.ZipFile(path, "w") as zout:
1253
+ for name, info in infos.items():
1254
+ data = modified_xml if name == "Contents/section0.xml" else other[name]
1255
+ new_info = zipfile.ZipInfo(filename=name, date_time=info.date_time)
1256
+ new_info.compress_type = info.compress_type
1257
+ zout.writestr(new_info, data)
1258
+
1259
+
1260
+ def test_hwpx_ctrl_embedded_table_is_found(tmp_path: Path) -> None:
1261
+ """<hp:ctrl> 내부 tbl 이 top-level 로 발견되고 편집 가능해야 한다 (v0.7.3 회귀 방지)."""
1262
+ src = tmp_path / "ctrl_embedded.hwpx"
1263
+ _make_hwpx_with_ctrl_embedded_table(src)
1264
+
1265
+ adapter = load(src)
1266
+ try:
1267
+ tables = adapter.get_tables()
1268
+ # 2 개 (top-level + ctrl 내부) 전부 발견
1269
+ assert len(tables) == 2, f"expected 2 tables (top-level + ctrl inner), got {len(tables)}"
1270
+ # 두 번째 (ctrl 내부) 표 편집도 가능해야
1271
+ adapter.set_cell(1, 0, 0, "__CTRL_INNER_EDIT__")
1272
+ adapter.save()
1273
+ finally:
1274
+ adapter.close()
1275
+
1276
+ adapter2 = load(src)
1277
+ try:
1278
+ cell = adapter2.get_cell(1, 0, 0)
1279
+ assert cell.text.strip() == "__CTRL_INNER_EDIT__"
1280
+ finally:
1281
+ adapter2.close()