@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.
@@ -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
- wb_values = load_workbook(_common.long_str(input_path), data_only=True)
726
- wb_formulas = load_workbook(_common.long_str(input_path), data_only=False)
789
+ # 비표준 셀값(NaN/Infinity) 사전 정제 + openpyxl 로드. 정제본 임시파일은 마지막에 unlink.
790
+ _xlsx_cleanups: list[Path] = []
727
791
  try:
728
- _common.mkdir(sheets_dir)
729
- # openpyxl 의 sheetnames 는 일반 Worksheet 와 Chartsheet 둘 다 포함.
730
- # 시트 순서 그대로 idx 통합 부여 (사용자 워크북 순서 보존).
731
- # 일반 Worksheet COM Excel PNG export 대상, Chartsheet 차트 데이터만 추출.
732
- idx_counter = 0
733
- for name in wb_values.sheetnames:
734
- obj = wb_values[name]
735
- idx_counter += 1
736
- idx = f"{idx_counter:02d}"
737
- safe_name = _common.slugify_filename(name, max_len=40)
738
- if isinstance(obj, Worksheet):
739
- sheet_names.append((idx, safe_name, name))
740
- else:
741
- # Chartsheet 등 비-worksheet
742
- chart_sheet_names.append((idx, safe_name, name))
743
-
744
- # COM Excel 호출: 데이터 영역 → ChartObject + Range.CopyPicture 시트별 PNG.
745
- # 시트별 (last_row, last_col) 도 같이 반환되어 .jsonl 이 같은 데이터 영역으로 통일됨.
746
- # PNG export 실패한 시트는 sheet_png_skipped 사유 (silent skip 금지).
747
- with _common.com_lock():
748
- sheet_ranges, sheet_png_skipped = _excel_export_sheet_pngs(input_path, sheets_dir, sheet_names)
749
-
750
- for idx, safe_name, raw_name in sheet_names:
751
- ws_v = wb_values[raw_name]
752
- ws_f = wb_formulas[raw_name]
753
-
754
- # COM Find 결과가 있으면 그 범위, 없으면 openpyxl max_row/max_column fallback.
755
- last_row, last_col = sheet_ranges.get(raw_name, (ws_v.max_row, ws_v.max_column))
756
- sheet_dims[idx] = (last_row, last_col)
757
-
758
- jsonl_lines, formula_n = _sheet_to_jsonl(ws_v, ws_f, last_row, last_col)
759
- _common.write_text(sheets_dir / f"{idx}_{safe_name}.jsonl", "\n".join(jsonl_lines))
760
- sheet_formula_count[idx] = formula_n
761
-
762
- for chart_idx, chart in enumerate(getattr(ws_f, "_charts", []), start=1):
763
- data = _extract_openpyxl_chart_data(chart)
764
- _common.mkdir(charts_dir)
765
- chart_filename = f"sheet{idx}_chart{chart_idx:02d}.data.json"
766
- _common.write_text(
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
- chart_sheet_chart_files[idx] = chart_filename
806
-
807
- # 워크북 단위 메타 (defined names·pivots·sheet codeName 등) — 시트 jsonl 외부 분리.
808
- wb_meta = _workbook_meta(wb_formulas, input_path)
809
- # VBA 시트 객체명 raw 시트명 매핑 (시트 codeName 기반)
810
- sheet_code_map: dict[str, str] = {}
811
- for ws in wb_formulas.worksheets:
812
- code = getattr(ws.sheet_properties, "codeName", None)
813
- if code:
814
- sheet_code_map[code] = ws.title
815
- if sheet_code_map:
816
- wb_meta["sheet_code_map"] = sheet_code_map
817
- if wb_meta:
818
- _common.write_text(
819
- out_dir / "workbook.meta.json",
820
- json.dumps(wb_meta, ensure_ascii=False, indent=2),
821
- )
822
- finally:
823
- wb_values.close()
824
- wb_formulas.close()
825
-
826
- # 시트 PNG 데이터 영역(Find 범위) 만 캡처 → 데이터 영역 밖 이미지는 누락될 수 있음 →
827
- # raw 이미지를 시트+셀 위치 정보 포함해서 별도 보존.
828
- sheet_images = _extract_xlsx_images_with_position(input_path, out_dir, sheet_names)
829
- attachment_links = _extract_zip_media(
830
- input_path,
831
- out_dir,
832
- media_zip_prefix="xl/media/",
833
- embed_zip_prefix="xl/embeddings/",
834
- )
835
-
836
- # 시트별 산출물 풀목록 — 일반 시트 + chart sheet 통합, 시트 순서 (idx) 대로
837
- sheet_summary_map: dict[str, str] = {}
838
- for idx, safe_name, raw_name in sheet_names:
839
- last_row, last_col = sheet_dims.get(idx, (0, 0))
840
- formula_n = sheet_formula_count.get(idx, 0)
841
- png_path = sheets_dir / f"{idx}_{safe_name}.png"
842
- if png_path.exists():
843
- parts = [f"`sheets/{idx}_{safe_name}.png`", "`.jsonl`"]
844
- else:
845
- # PNG 미생성 worker 가 사유 전달 (16-bit cap / COM 실패 등)
846
- reason = sheet_png_skipped.get(raw_name, "사유 미상")
847
- parts = [f"`sheets/{idx}_{safe_name}.jsonl`", f"(PNG 미생성 — {reason})"]
848
- chart_refs = sheet_charts.get(idx, [])
849
- if chart_refs:
850
- parts.append("(차트: " + ", ".join(f"`charts/{c}`" for c in chart_refs) + ")")
851
- img_refs = sheet_images.get(raw_name, [])
852
- if img_refs:
853
- parts.append("(이미지: " + ", ".join(f"`images/{n}`" for n in img_refs) + ")")
854
- meta = f"({last_row}행×{last_col}열"
855
- if formula_n:
856
- meta += f", 수식 {formula_n}개"
857
- meta += ")"
858
- sheet_summary_map[idx] = " ".join(parts) + " " + meta
859
-
860
- for idx, safe_name, raw_name in chart_sheet_names:
861
- chart_filename = chart_sheet_chart_files.get(idx)
862
- if chart_filename:
863
- sheet_summary_map[idx] = f"`charts/{chart_filename}` (chart sheet \"{raw_name}\")"
864
- else:
865
- sheet_summary_map[idx] = f"(chart sheet — \"{raw_name}\", 차트 데이터 추출 실패)"
866
-
867
- # idx 순서대로 통합
868
- for idx in sorted(sheet_summary_map.keys()):
869
- sheet_summaries.append(sheet_summary_map[idx])
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
- sections: dict[str, list[str]] = {}
875
- if sheet_summaries:
876
- sections[f"시트 (총 {len(sheet_summaries)}개)"] = sheet_summaries
877
- if macro_modules:
878
- sections[f"VBA 매크로 ( {len(macro_modules)}개)"] = [f"`macros/{m}`" for m in macro_modules]
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
- _common.write_readme(
881
- out_dir,
882
- source_name=source_name,
883
- source_size=source_size,
884
- tool=("openpyxl + COM Excel + ZIP " + tool_extra).strip(),
885
- loss_notes=(
886
- "셀 서식(바탕색·border·폰트)·frozen·dims 미보존 (필요 시 _source.xlsx 직접 추출). "
887
- "시각은 시트별 PNG, 분석 데이터(셀값·number_format·수식·merges·hyperlinks·comments) 는 "
888
- "시트별 .jsonl 줄=한 행(좌표 명시), 워크북 단위 메타(defined names 등) 는 workbook.meta.json."
889
- ),
890
- sections=sections or None,
891
- attachments=attachment_links,
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
  # ====================================================================
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-use
3
3
  description: 사용자 요청을 분석해 워크스페이스의 sd-* 스킬 중 가장 적합한 스킬을 추천하는 스킬. Use when 어떤 sd-* 스킬을 써야 할지 모를 때.
4
+ model: haiku
4
5
  ---
5
6
 
6
7
  `/sd-use <요청>` 의 `<요청>` 에 적합한 sd-* 스킬을 추천만 출력하고 종료. 추천 대상 스킬의 자동 실행·산출물 생성 금지.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simplysm/sd-claude",
3
- "version": "14.0.83",
3
+ "version": "14.0.85",
4
4
  "description": "심플리즘 패키지 - Claude Code 셋업",
5
5
  "author": "심플리즘",
6
6
  "license": "Apache-2.0",