@simplysm/sd-claude 14.0.83 → 14.0.85
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/claude/rules/sd-design-rules.md +8 -0
- package/claude/sd-system-prompt.md +376 -0
- package/claude/skills/sd-config/SKILL.md +1 -0
- package/claude/skills/sd-demo/SKILL.md +1 -1
- package/claude/skills/sd-dev/SKILL.md +1 -1
- package/claude/skills/sd-docs/SKILL.md +1 -1
- package/claude/skills/sd-impl/SKILL.md +1 -1
- package/claude/skills/sd-review/SKILL.md +2 -2
- package/claude/skills/sd-skill/SKILL.md +1 -1
- package/claude/skills/sd-spec/SKILL.md +6 -7
- package/claude/skills/sd-unpack/SKILL.md +1 -1
- package/claude/skills/sd-unpack/scripts/handlers/__pycache__/office_com.cpython-314.pyc +0 -0
- package/claude/skills/sd-unpack/scripts/handlers/office_com.py +234 -159
- package/claude/skills/sd-use/SKILL.md +1 -0
- package/package.json +1 -1
- package/claude/rules/sd-base-rules.md +0 -307
|
@@ -702,6 +702,70 @@ def _pptx_save_picture(
|
|
|
702
702
|
# XLSX
|
|
703
703
|
# ====================================================================
|
|
704
704
|
|
|
705
|
+
def _xlsx_clean_nonfinite(src: Path, dst: Path) -> None:
|
|
706
|
+
"""xlsx 시트 XML 안 `<v>NaN</v>`/`<v>Infinity</v>`/`<v>-Infinity</v>` 를 제거한 사본을 dst 에 생성.
|
|
707
|
+
|
|
708
|
+
원인: 일부 third-party 라이브러리가 만든 xlsx 가 비유한 부동소수점(NaN/Inf) 을 numeric 셀에
|
|
709
|
+
문자열 그대로 기록 → openpyxl 의 `_cast_number → int('NaN')` 에서 ValueError.
|
|
710
|
+
대응: 시트 XML 의 해당 `<v>` 요소만 제거(해당 셀은 빈 셀 처리). 다른 part(images·drawings·
|
|
711
|
+
styles·shared strings) 는 그대로 복사.
|
|
712
|
+
"""
|
|
713
|
+
pat = re.compile(rb"<v>(?:NaN|Infinity|-Infinity|INF|-INF)</v>")
|
|
714
|
+
with zipfile.ZipFile(_common.long_str(src), "r") as zin, \
|
|
715
|
+
zipfile.ZipFile(_common.long_str(dst), "w", zipfile.ZIP_DEFLATED) as zout:
|
|
716
|
+
for item in zin.infolist():
|
|
717
|
+
data = zin.read(item.filename)
|
|
718
|
+
if item.filename.startswith("xl/worksheets/") and item.filename.endswith(".xml"):
|
|
719
|
+
data = pat.sub(b"", data)
|
|
720
|
+
zout.writestr(item, data)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _safe_load_xlsx_workbooks(
|
|
724
|
+
input_path: Path,
|
|
725
|
+
cleanup_paths: list[Path],
|
|
726
|
+
) -> tuple[Any, Any, Path]:
|
|
727
|
+
"""openpyxl 로 wb_values(data_only=True) + wb_formulas(data_only=False) 둘 다 로드.
|
|
728
|
+
|
|
729
|
+
비표준 셀값(NaN/Infinity 문자열을 numeric 셀에 담은 xlsx) 은 openpyxl 의 strict int cast 로
|
|
730
|
+
ValueError throw. 이 경우 시트 XML 의 비유한값만 제거한 정제본을 임시 파일로 만들어 재시도.
|
|
731
|
+
정제 발생시 임시 파일을 `cleanup_paths` 에 등록(호출자가 finally 에서 unlink).
|
|
732
|
+
|
|
733
|
+
반환: (wb_values, wb_formulas, openpyxl_input_path). openpyxl_input_path 는 후속 openpyxl
|
|
734
|
+
호출(이미지 추출 등) 이 같은 정제본을 재사용하도록 path 노출. 정제 불필요시 input_path 그대로.
|
|
735
|
+
"""
|
|
736
|
+
import tempfile
|
|
737
|
+
from openpyxl import load_workbook
|
|
738
|
+
|
|
739
|
+
def _is_nonfinite_error(e: BaseException) -> bool:
|
|
740
|
+
cur: Optional[BaseException] = e
|
|
741
|
+
while cur is not None:
|
|
742
|
+
msg = str(cur)
|
|
743
|
+
if "NaN" in msg or "Infinity" in msg:
|
|
744
|
+
return True
|
|
745
|
+
cur = cur.__cause__
|
|
746
|
+
return False
|
|
747
|
+
|
|
748
|
+
src_str = _common.long_str(input_path)
|
|
749
|
+
try:
|
|
750
|
+
wb_values = load_workbook(src_str, data_only=True)
|
|
751
|
+
wb_formulas = load_workbook(src_str, data_only=False)
|
|
752
|
+
return wb_values, wb_formulas, input_path
|
|
753
|
+
except ValueError as e:
|
|
754
|
+
if not _is_nonfinite_error(e):
|
|
755
|
+
raise
|
|
756
|
+
|
|
757
|
+
base = _common._ensure_tmp_base()
|
|
758
|
+
fd, tmp_str = tempfile.mkstemp(prefix="sd-unpack-xlsx-clean-", suffix=".xlsx", dir=str(base))
|
|
759
|
+
os.close(fd)
|
|
760
|
+
cleaned = Path(tmp_str)
|
|
761
|
+
# 등록을 정제·로드 전에 수행 → 도중 throw 해도 호출자 finally 가 unlink.
|
|
762
|
+
cleanup_paths.append(cleaned)
|
|
763
|
+
_xlsx_clean_nonfinite(input_path, cleaned)
|
|
764
|
+
wb_values = load_workbook(_common.long_str(cleaned), data_only=True)
|
|
765
|
+
wb_formulas = load_workbook(_common.long_str(cleaned), data_only=False)
|
|
766
|
+
return wb_values, wb_formulas, cleaned
|
|
767
|
+
|
|
768
|
+
|
|
705
769
|
def _run_xlsx(
|
|
706
770
|
input_path: Path,
|
|
707
771
|
out_dir: Path,
|
|
@@ -722,174 +786,185 @@ def _run_xlsx(
|
|
|
722
786
|
sheet_formula_count: dict[str, int] = {}
|
|
723
787
|
sheet_dims: dict[str, tuple[int, int]] = {}
|
|
724
788
|
|
|
725
|
-
|
|
726
|
-
|
|
789
|
+
# 비표준 셀값(NaN/Infinity) 사전 정제 + openpyxl 로드. 정제본 임시파일은 마지막에 unlink.
|
|
790
|
+
_xlsx_cleanups: list[Path] = []
|
|
727
791
|
try:
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
charts_dir / chart_filename,
|
|
768
|
-
json.dumps(data, ensure_ascii=False, indent=2),
|
|
769
|
-
)
|
|
770
|
-
sheet_charts.setdefault(idx, []).append(chart_filename)
|
|
771
|
-
|
|
772
|
-
# Chartsheet 처리: 차트 데이터를 charts/sheet<idx>_chart.data.json 으로 저장
|
|
773
|
-
chart_sheet_chart_files: dict[str, str] = {} # idx -> chart filename
|
|
774
|
-
for idx, safe_name, raw_name in chart_sheet_names:
|
|
775
|
-
cs = wb_formulas[raw_name]
|
|
776
|
-
chart = None
|
|
777
|
-
# Chartsheet.charts 또는 _charts 속성 (openpyxl 버전 따라 다름)
|
|
778
|
-
for attr in ("charts", "_charts"):
|
|
779
|
-
v = getattr(cs, attr, None)
|
|
780
|
-
if v:
|
|
781
|
-
if hasattr(v, "__iter__"):
|
|
782
|
-
try:
|
|
783
|
-
chart = next(iter(v), None)
|
|
784
|
-
except Exception:
|
|
785
|
-
chart = None
|
|
786
|
-
else:
|
|
787
|
-
chart = v
|
|
788
|
-
if chart is not None:
|
|
789
|
-
break
|
|
790
|
-
if chart is None:
|
|
791
|
-
# 단일 chart 속성 fallback
|
|
792
|
-
chart = getattr(cs, "chart", None)
|
|
793
|
-
if chart is not None:
|
|
794
|
-
try:
|
|
792
|
+
wb_values, wb_formulas, openpyxl_input = _safe_load_xlsx_workbooks(input_path, _xlsx_cleanups)
|
|
793
|
+
try:
|
|
794
|
+
_common.mkdir(sheets_dir)
|
|
795
|
+
# openpyxl 의 sheetnames 는 일반 Worksheet 와 Chartsheet 둘 다 포함.
|
|
796
|
+
# 시트 순서 그대로 idx 통합 부여 (사용자 워크북 순서 보존).
|
|
797
|
+
# 일반 Worksheet 만 COM Excel PNG export 대상, Chartsheet 는 차트 데이터만 추출.
|
|
798
|
+
idx_counter = 0
|
|
799
|
+
for name in wb_values.sheetnames:
|
|
800
|
+
obj = wb_values[name]
|
|
801
|
+
idx_counter += 1
|
|
802
|
+
idx = f"{idx_counter:02d}"
|
|
803
|
+
safe_name = _common.slugify_filename(name, max_len=40)
|
|
804
|
+
if isinstance(obj, Worksheet):
|
|
805
|
+
sheet_names.append((idx, safe_name, name))
|
|
806
|
+
else:
|
|
807
|
+
# Chartsheet 등 비-worksheet
|
|
808
|
+
chart_sheet_names.append((idx, safe_name, name))
|
|
809
|
+
|
|
810
|
+
# COM Excel 호출: 데이터 영역 → ChartObject + Range.CopyPicture → 시트별 PNG.
|
|
811
|
+
# 시트별 (last_row, last_col) 도 같이 반환되어 .jsonl 이 같은 데이터 영역으로 통일됨.
|
|
812
|
+
# PNG export 실패한 시트는 sheet_png_skipped 에 사유 (silent skip 금지).
|
|
813
|
+
with _common.com_lock():
|
|
814
|
+
# openpyxl_input 사용: 정제본(NaN 제거) 이 있으면 COM Excel 도 정제본을 열어야 함
|
|
815
|
+
# (Excel 역시 `<v>NaN</v>` 가 있는 xlsx 의 Open 에 실패).
|
|
816
|
+
sheet_ranges, sheet_png_skipped = _excel_export_sheet_pngs(openpyxl_input, sheets_dir, sheet_names)
|
|
817
|
+
|
|
818
|
+
for idx, safe_name, raw_name in sheet_names:
|
|
819
|
+
ws_v = wb_values[raw_name]
|
|
820
|
+
ws_f = wb_formulas[raw_name]
|
|
821
|
+
|
|
822
|
+
# COM Find 결과가 있으면 그 범위, 없으면 openpyxl max_row/max_column fallback.
|
|
823
|
+
last_row, last_col = sheet_ranges.get(raw_name, (ws_v.max_row, ws_v.max_column))
|
|
824
|
+
sheet_dims[idx] = (last_row, last_col)
|
|
825
|
+
|
|
826
|
+
jsonl_lines, formula_n = _sheet_to_jsonl(ws_v, ws_f, last_row, last_col)
|
|
827
|
+
_common.write_text(sheets_dir / f"{idx}_{safe_name}.jsonl", "\n".join(jsonl_lines))
|
|
828
|
+
sheet_formula_count[idx] = formula_n
|
|
829
|
+
|
|
830
|
+
for chart_idx, chart in enumerate(getattr(ws_f, "_charts", []), start=1):
|
|
795
831
|
data = _extract_openpyxl_chart_data(chart)
|
|
796
|
-
except Exception:
|
|
797
|
-
data = None
|
|
798
|
-
if data is not None:
|
|
799
832
|
_common.mkdir(charts_dir)
|
|
800
|
-
chart_filename = f"sheet{idx}_chart.data.json"
|
|
833
|
+
chart_filename = f"sheet{idx}_chart{chart_idx:02d}.data.json"
|
|
801
834
|
_common.write_text(
|
|
802
835
|
charts_dir / chart_filename,
|
|
803
836
|
json.dumps(data, ensure_ascii=False, indent=2),
|
|
804
837
|
)
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
source_name, source_size = _source_meta(input_path, out_dir, source_name_override)
|
|
872
|
-
macro_modules = _extract_macros(_source_path(out_dir, source_name), out_dir)
|
|
838
|
+
sheet_charts.setdefault(idx, []).append(chart_filename)
|
|
839
|
+
|
|
840
|
+
# Chartsheet 처리: 차트 데이터를 charts/sheet<idx>_chart.data.json 으로 저장
|
|
841
|
+
chart_sheet_chart_files: dict[str, str] = {} # idx -> chart filename
|
|
842
|
+
for idx, safe_name, raw_name in chart_sheet_names:
|
|
843
|
+
cs = wb_formulas[raw_name]
|
|
844
|
+
chart = None
|
|
845
|
+
# Chartsheet.charts 또는 _charts 속성 (openpyxl 버전 따라 다름)
|
|
846
|
+
for attr in ("charts", "_charts"):
|
|
847
|
+
v = getattr(cs, attr, None)
|
|
848
|
+
if v:
|
|
849
|
+
if hasattr(v, "__iter__"):
|
|
850
|
+
try:
|
|
851
|
+
chart = next(iter(v), None)
|
|
852
|
+
except Exception:
|
|
853
|
+
chart = None
|
|
854
|
+
else:
|
|
855
|
+
chart = v
|
|
856
|
+
if chart is not None:
|
|
857
|
+
break
|
|
858
|
+
if chart is None:
|
|
859
|
+
# 단일 chart 속성 fallback
|
|
860
|
+
chart = getattr(cs, "chart", None)
|
|
861
|
+
if chart is not None:
|
|
862
|
+
try:
|
|
863
|
+
data = _extract_openpyxl_chart_data(chart)
|
|
864
|
+
except Exception:
|
|
865
|
+
data = None
|
|
866
|
+
if data is not None:
|
|
867
|
+
_common.mkdir(charts_dir)
|
|
868
|
+
chart_filename = f"sheet{idx}_chart.data.json"
|
|
869
|
+
_common.write_text(
|
|
870
|
+
charts_dir / chart_filename,
|
|
871
|
+
json.dumps(data, ensure_ascii=False, indent=2),
|
|
872
|
+
)
|
|
873
|
+
chart_sheet_chart_files[idx] = chart_filename
|
|
874
|
+
|
|
875
|
+
# 워크북 단위 메타 (defined names·pivots·sheet codeName 등) — 시트 jsonl 외부 분리.
|
|
876
|
+
wb_meta = _workbook_meta(wb_formulas, input_path)
|
|
877
|
+
# VBA 시트 객체명 ↔ raw 시트명 매핑 (시트 codeName 기반)
|
|
878
|
+
sheet_code_map: dict[str, str] = {}
|
|
879
|
+
for ws in wb_formulas.worksheets:
|
|
880
|
+
code = getattr(ws.sheet_properties, "codeName", None)
|
|
881
|
+
if code:
|
|
882
|
+
sheet_code_map[code] = ws.title
|
|
883
|
+
if sheet_code_map:
|
|
884
|
+
wb_meta["sheet_code_map"] = sheet_code_map
|
|
885
|
+
if wb_meta:
|
|
886
|
+
_common.write_text(
|
|
887
|
+
out_dir / "workbook.meta.json",
|
|
888
|
+
json.dumps(wb_meta, ensure_ascii=False, indent=2),
|
|
889
|
+
)
|
|
890
|
+
finally:
|
|
891
|
+
wb_values.close()
|
|
892
|
+
wb_formulas.close()
|
|
893
|
+
|
|
894
|
+
# 시트 PNG 는 데이터 영역(Find 범위) 만 캡처 → 데이터 영역 밖 이미지는 누락될 수 있음 →
|
|
895
|
+
# raw 이미지를 시트+셀 위치 정보 포함해서 별도 보존.
|
|
896
|
+
# openpyxl_input 사용: 정제본이 있으면 같은 정제본으로 로드(원본은 openpyxl 가 못 읽음).
|
|
897
|
+
sheet_images = _extract_xlsx_images_with_position(openpyxl_input, out_dir, sheet_names)
|
|
898
|
+
attachment_links = _extract_zip_media(
|
|
899
|
+
input_path,
|
|
900
|
+
out_dir,
|
|
901
|
+
media_zip_prefix="xl/media/",
|
|
902
|
+
embed_zip_prefix="xl/embeddings/",
|
|
903
|
+
)
|
|
873
904
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
905
|
+
# 시트별 산출물 풀목록 — 일반 시트 + chart sheet 통합, 시트 순서 (idx) 대로
|
|
906
|
+
sheet_summary_map: dict[str, str] = {}
|
|
907
|
+
for idx, safe_name, raw_name in sheet_names:
|
|
908
|
+
last_row, last_col = sheet_dims.get(idx, (0, 0))
|
|
909
|
+
formula_n = sheet_formula_count.get(idx, 0)
|
|
910
|
+
png_path = sheets_dir / f"{idx}_{safe_name}.png"
|
|
911
|
+
if png_path.exists():
|
|
912
|
+
parts = [f"`sheets/{idx}_{safe_name}.png`", "`.jsonl`"]
|
|
913
|
+
else:
|
|
914
|
+
# PNG 미생성 — worker 가 사유 전달 (16-bit cap / COM 실패 등)
|
|
915
|
+
reason = sheet_png_skipped.get(raw_name, "사유 미상")
|
|
916
|
+
parts = [f"`sheets/{idx}_{safe_name}.jsonl`", f"(PNG 미생성 — {reason})"]
|
|
917
|
+
chart_refs = sheet_charts.get(idx, [])
|
|
918
|
+
if chart_refs:
|
|
919
|
+
parts.append("(차트: " + ", ".join(f"`charts/{c}`" for c in chart_refs) + ")")
|
|
920
|
+
img_refs = sheet_images.get(raw_name, [])
|
|
921
|
+
if img_refs:
|
|
922
|
+
parts.append("(이미지: " + ", ".join(f"`images/{n}`" for n in img_refs) + ")")
|
|
923
|
+
meta = f"({last_row}행×{last_col}열"
|
|
924
|
+
if formula_n:
|
|
925
|
+
meta += f", 수식 {formula_n}개"
|
|
926
|
+
meta += ")"
|
|
927
|
+
sheet_summary_map[idx] = " ".join(parts) + " " + meta
|
|
879
928
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
929
|
+
for idx, safe_name, raw_name in chart_sheet_names:
|
|
930
|
+
chart_filename = chart_sheet_chart_files.get(idx)
|
|
931
|
+
if chart_filename:
|
|
932
|
+
sheet_summary_map[idx] = f"`charts/{chart_filename}` (chart sheet — \"{raw_name}\")"
|
|
933
|
+
else:
|
|
934
|
+
sheet_summary_map[idx] = f"(chart sheet — \"{raw_name}\", 차트 데이터 추출 실패)"
|
|
935
|
+
|
|
936
|
+
# idx 순서대로 통합
|
|
937
|
+
for idx in sorted(sheet_summary_map.keys()):
|
|
938
|
+
sheet_summaries.append(sheet_summary_map[idx])
|
|
939
|
+
|
|
940
|
+
source_name, source_size = _source_meta(input_path, out_dir, source_name_override)
|
|
941
|
+
macro_modules = _extract_macros(_source_path(out_dir, source_name), out_dir)
|
|
942
|
+
|
|
943
|
+
sections: dict[str, list[str]] = {}
|
|
944
|
+
if sheet_summaries:
|
|
945
|
+
sections[f"시트 (총 {len(sheet_summaries)}개)"] = sheet_summaries
|
|
946
|
+
if macro_modules:
|
|
947
|
+
sections[f"VBA 매크로 (총 {len(macro_modules)}개)"] = [f"`macros/{m}`" for m in macro_modules]
|
|
948
|
+
|
|
949
|
+
_common.write_readme(
|
|
950
|
+
out_dir,
|
|
951
|
+
source_name=source_name,
|
|
952
|
+
source_size=source_size,
|
|
953
|
+
tool=("openpyxl + COM Excel + ZIP " + tool_extra).strip(),
|
|
954
|
+
loss_notes=(
|
|
955
|
+
"셀 서식(바탕색·border·폰트)·frozen·dims 미보존 (필요 시 _source.xlsx 직접 추출). "
|
|
956
|
+
"시각은 시트별 PNG, 분석 데이터(셀값·number_format·수식·merges·hyperlinks·comments) 는 "
|
|
957
|
+
"시트별 .jsonl 한 줄=한 행(좌표 명시), 워크북 단위 메타(defined names 등) 는 workbook.meta.json."
|
|
958
|
+
),
|
|
959
|
+
sections=sections or None,
|
|
960
|
+
attachments=attachment_links,
|
|
961
|
+
)
|
|
962
|
+
finally:
|
|
963
|
+
for _p in _xlsx_cleanups:
|
|
964
|
+
try:
|
|
965
|
+
_p.unlink()
|
|
966
|
+
except Exception:
|
|
967
|
+
pass
|
|
893
968
|
|
|
894
969
|
|
|
895
970
|
# ====================================================================
|