figtune 1.0.0__py3-none-any.whl

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 (55) hide show
  1. figtune/__init__.py +5 -0
  2. figtune/cli.py +285 -0
  3. figtune/config.py +50 -0
  4. figtune/core/__init__.py +2 -0
  5. figtune/core/apply.py +146 -0
  6. figtune/core/autowrap.py +210 -0
  7. figtune/core/canon.py +256 -0
  8. figtune/core/codegen.py +283 -0
  9. figtune/core/drag.py +213 -0
  10. figtune/core/fingerprint.py +124 -0
  11. figtune/core/gridlayout.py +149 -0
  12. figtune/core/history.py +113 -0
  13. figtune/core/hit.py +434 -0
  14. figtune/core/inline.py +179 -0
  15. figtune/core/introspect.py +183 -0
  16. figtune/core/layout.py +113 -0
  17. figtune/core/montage.py +310 -0
  18. figtune/core/montage_build.py +550 -0
  19. figtune/core/normalize.py +383 -0
  20. figtune/core/palette.py +90 -0
  21. figtune/core/parse.py +423 -0
  22. figtune/core/props.py +1114 -0
  23. figtune/core/provenance.py +233 -0
  24. figtune/core/runner.py +257 -0
  25. figtune/core/selector.py +254 -0
  26. figtune/core/session.py +625 -0
  27. figtune/core/snap.py +105 -0
  28. figtune/core/spec.py +162 -0
  29. figtune/core/typefaces.py +248 -0
  30. figtune/i18n/__init__.py +113 -0
  31. figtune/i18n/en.py +523 -0
  32. figtune/office/FigTune.bas +242 -0
  33. figtune/office/__init__.py +0 -0
  34. figtune/office/pptx_link.py +254 -0
  35. figtune/office/vba_bridge.py +65 -0
  36. figtune/office/vector.py +105 -0
  37. figtune/ui/__init__.py +0 -0
  38. figtune/ui/qt/__init__.py +0 -0
  39. figtune/ui/qt/direct.py +160 -0
  40. figtune/ui/qt/fontpicker.py +307 -0
  41. figtune/ui/qt/fonts.py +58 -0
  42. figtune/ui/qt/gridpicker.py +138 -0
  43. figtune/ui/qt/inspector.py +161 -0
  44. figtune/ui/qt/main.py +1666 -0
  45. figtune/ui/qt/minitoolbar.py +157 -0
  46. figtune/ui/qt/montagedialog.py +364 -0
  47. figtune/ui/qt/palette.py +188 -0
  48. figtune/ui/qt/targetdialog.py +127 -0
  49. figtune/ui/qt/widgets.py +281 -0
  50. figtune-1.0.0.dist-info/METADATA +750 -0
  51. figtune-1.0.0.dist-info/RECORD +55 -0
  52. figtune-1.0.0.dist-info/WHEEL +5 -0
  53. figtune-1.0.0.dist-info/entry_points.txt +2 -0
  54. figtune-1.0.0.dist-info/licenses/LICENSE +21 -0
  55. figtune-1.0.0.dist-info/top_level.txt +1 -0
figtune/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """figtune — matplotlib figure를 GUI로 미세조정하고 코드로 남기는 도구."""
2
+
3
+ # 버전의 유일한 출처. pyproject가 이 값을 읽는다(dynamic version).
4
+ # 두 곳에 적으면 한쪽만 고쳐져 배포판과 패키지가 서로 다른 버전을 말한다.
5
+ __version__ = "1.0.0"
figtune/cli.py ADDED
@@ -0,0 +1,285 @@
1
+ """figtune 명령줄 진입점.
2
+
3
+ 서브커맨드는 PowerPoint 애드인이 호출할 안정된 계약이기도 하다.
4
+ 출력 형식과 종료 코드를 함부로 바꾸지 말 것.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from . import i18n
13
+ from .i18n import t as _t
14
+
15
+ SUBCOMMANDS = {"edit", "render", "refresh", "merge", "normalize"}
16
+
17
+
18
+ def _build_parser() -> argparse.ArgumentParser:
19
+ ap = argparse.ArgumentParser(
20
+ prog="figtune",
21
+ description=_t("matplotlib figure를 GUI로 미세조정하고 코드로 남깁니다."))
22
+ ap.add_argument("--lang", choices=(*i18n.LANGUAGES, "auto"),
23
+ help=_t("메시지 언어 (기본: 시스템 설정을 따름)"))
24
+ sub = ap.add_subparsers(dest="cmd")
25
+
26
+ def common(p):
27
+ p.add_argument("--python", metavar="PATH",
28
+ help=_t("스크립트를 실행할 인터프리터 "
29
+ "(다른 venv/얼려진 앱인 경우)"))
30
+ p.add_argument("--dpi", type=int, default=300)
31
+ return p
32
+
33
+ e = common(sub.add_parser(
34
+ "edit", help=_t("GUI로 편집 (PowerPoint 애드인이 호출)")))
35
+ e.add_argument("script")
36
+ e.add_argument("--spec-in", metavar="PATH",
37
+ help=_t("시작 spec (없으면 디스크에서)"))
38
+ e.add_argument("--spec-out", metavar="PATH",
39
+ help=_t("저장 시 spec을 여기에도 기록"))
40
+ e.add_argument("--png-out", metavar="PATH",
41
+ help=_t("저장 시 이미지를 여기에도 기록"))
42
+
43
+ r = common(sub.add_parser("render", help=_t("GUI 없이 이미지만 생성")))
44
+ r.add_argument("script")
45
+ r.add_argument("--spec-in", metavar="PATH")
46
+ r.add_argument("-o", "--out", required=True)
47
+
48
+ f = common(sub.add_parser(
49
+ "refresh", help=_t("pptx 안의 figtune 그림을 모두 재생성")))
50
+ f.add_argument("deck")
51
+ f.add_argument("-o", "--out", help=_t("다른 파일로 저장 (기본: 덮어쓰기)"))
52
+ f.add_argument("--check", action="store_true",
53
+ help=_t("쓰지 않고 무엇이 바뀌는지만 보고"))
54
+
55
+ m = common(sub.add_parser(
56
+ "merge", help=_t("여러 패널 스크립트를 하나의 figure로 합침")))
57
+ m.add_argument("scripts", nargs="+", help=_t("패널 스크립트들 (배치 순서대로)"))
58
+ m.add_argument("-o", "--out", required=True,
59
+ help=_t("생성할 병합 스크립트 (.py)"))
60
+ m.add_argument("--rows", type=int)
61
+ m.add_argument("--cols", type=int)
62
+ m.add_argument("--panel-size", default="4x3", metavar="WxH",
63
+ help=_t("패널 하나의 크기(인치). 기본 4x3"))
64
+ m.add_argument("--labels", default="({a})",
65
+ help=_t("패널 라벨 서식. 기본 '({a})'. 끄려면 빈 문자열"))
66
+ m.add_argument("--svg", metavar="PATH",
67
+ help=_t("모드 B가 불가능할 때 SVG 합성으로 대신 출력"))
68
+ m.add_argument("--edit", action="store_true", help=_t("생성 후 GUI를 연다"))
69
+ m.add_argument("--normalize", action="store_true",
70
+ help=_t("정규형이 아닌 스크립트를 먼저 *_norm.py로 변환"))
71
+
72
+ z = sub.add_parser("normalize", help=_t("스크립트를 정규형으로 변환"))
73
+ z.add_argument("scripts", nargs="+")
74
+ z.add_argument("--check", action="store_true",
75
+ help=_t("변환하지 않고 판정만"))
76
+ z.add_argument("--in-place", action="store_true",
77
+ help=_t("원본을 덮어쓴다 (기본은 *_norm.py 생성)"))
78
+ return ap
79
+
80
+
81
+ def _grid(n: int, rows: int | None, cols: int | None) -> tuple[int, int]:
82
+ """행/열이 지정되지 않으면 논문에서 흔한 모양으로 고른다."""
83
+ if rows and cols:
84
+ return rows, cols
85
+ if rows:
86
+ return rows, -(-n // rows)
87
+ if cols:
88
+ return -(-n // cols), cols
89
+ return ({1: (1, 1), 2: (1, 2), 3: (1, 3), 4: (2, 2),
90
+ 5: (2, 3), 6: (2, 3), 8: (2, 4), 9: (3, 3)}.get(n)
91
+ or (-(-n // 3), 3))
92
+
93
+
94
+ def _load_spec(path):
95
+ from .core.spec import Spec
96
+ return Spec.load(path) if path else None
97
+
98
+
99
+ def _early_lang(argv: list[str]) -> str | None:
100
+ """도움말 문구도 번역되어야 하므로 파서를 만들기 전에 언어를 정한다."""
101
+ for i, a in enumerate(argv):
102
+ if a == "--lang" and i + 1 < len(argv):
103
+ return argv[i + 1]
104
+ if a.startswith("--lang="):
105
+ return a.split("=", 1)[1]
106
+ return None
107
+
108
+
109
+ def _insert_default_subcommand(argv: list[str]) -> None:
110
+ """하위 호환: `figtune script.py` 는 `figtune edit script.py`.
111
+
112
+ 앞에 붙은 전역 옵션(--lang en 등)을 건너뛰고 첫 위치 인자를 본다.
113
+ """
114
+ i = 0
115
+ while i < len(argv) and argv[i].startswith("-"):
116
+ i += 2 if argv[i] == "--lang" else 1
117
+ if i < len(argv) and argv[i] not in SUBCOMMANDS:
118
+ argv.insert(i, "refresh" if argv[i].lower().endswith(".pptx") else "edit")
119
+
120
+
121
+ def main(argv=None) -> int:
122
+ argv = list(sys.argv[1:] if argv is None else argv)
123
+ i18n.init(_early_lang(argv))
124
+ _insert_default_subcommand(argv)
125
+
126
+ args = _build_parser().parse_args(argv)
127
+ if args.cmd is None:
128
+ _build_parser().print_help()
129
+ return 2
130
+
131
+ if args.cmd == "refresh":
132
+ deck = Path(args.deck)
133
+ if not deck.exists():
134
+ print(_t("파일 없음: {path}", path=deck), file=sys.stderr)
135
+ return 2
136
+ from .office import pptx_link as PL
137
+ result = PL.refresh_deck(deck, args.out, python=args.python,
138
+ check_only=args.check)
139
+ verb = _t("변경예정") if args.check else _t("갱신")
140
+ for label in result.updated:
141
+ d = result.data_changes.get(label) or {}
142
+ marks = []
143
+ if d.get("changed"):
144
+ marks.append(_t("데이터 수정 {n}", n=len(d["changed"])))
145
+ if d.get("added"):
146
+ marks.append(_t("추가 {n}", n=len(d["added"])))
147
+ if d.get("removed"):
148
+ marks.append(_t("사라짐 {n}", n=len(d["removed"])))
149
+ note = f" [{', '.join(marks)}]" if marks else ""
150
+ print(f"{verb} {label}{note}")
151
+ for label, why in result.skipped:
152
+ print(_t("건너뜀 {label}: {why}", label=label, why=why),
153
+ file=sys.stderr)
154
+ print(result.summary())
155
+ return 1 if result.skipped and not result.updated else 0
156
+
157
+ if args.cmd == "normalize":
158
+ return _normalize(args)
159
+
160
+ if args.cmd == "merge":
161
+ return _merge(args)
162
+
163
+ script = Path(args.script)
164
+ if not script.exists():
165
+ print(_t("파일 없음: {path}", path=script), file=sys.stderr)
166
+ return 2
167
+
168
+ if args.cmd == "render":
169
+ import matplotlib
170
+ matplotlib.use("Agg")
171
+ from .core.session import Session
172
+ s = Session(python=args.python)
173
+ rep = s.open(script, spec=_load_spec(args.spec_in))
174
+ if rep.stale:
175
+ print(_t("경고: 지문 불일치 {n}건", n=len(rep.stale)),
176
+ file=sys.stderr)
177
+ s.export(args.out, dpi=args.dpi)
178
+ print(args.out)
179
+ return 0
180
+
181
+ from .ui.qt.main import launch
182
+ return launch(script, python=args.python, spec_in=args.spec_in,
183
+ spec_out=args.spec_out, png_out=args.png_out, dpi=args.dpi,
184
+ lang=args.lang)
185
+
186
+
187
+ def _normalize(args) -> int:
188
+ from .core import normalize as N
189
+
190
+ rc = 0
191
+ for name in args.scripts:
192
+ p = Path(name)
193
+ if not p.exists():
194
+ print(_t("파일 없음: {path}", path=p), file=sys.stderr)
195
+ rc = 2
196
+ continue
197
+ if args.check:
198
+ rep = N.analyze(p.read_text(encoding="utf-8"))
199
+ mark = "OK " if rep.ok else _t("불가")
200
+ print(f"{mark} {p}: {rep.reasons[0]}")
201
+ for extra in rep.reasons[1:]:
202
+ print(f" {extra}")
203
+ rc = rc or (0 if rep.ok else 1)
204
+ continue
205
+ dst, rep = N.normalize_file(p, in_place=args.in_place)
206
+ if dst is None:
207
+ print(f"{_t('불가')} {p}", file=sys.stderr)
208
+ for r in rep.reasons:
209
+ print(f" {r}", file=sys.stderr)
210
+ rc = 1
211
+ else:
212
+ note = _t(" (이미 정규형)") if rep.n_plot_stmts == 0 else ""
213
+ print(f"OK {p} → {dst}{note}")
214
+ return rc
215
+
216
+
217
+ def _merge(args) -> int:
218
+ from .core.montage_build import (MontageSpec, PanelRef,
219
+ can_use_subplot_mode,
220
+ generate_subplot_script)
221
+
222
+ scripts = [Path(s) for s in args.scripts]
223
+ missing = [str(s) for s in scripts if not s.exists()]
224
+ if missing:
225
+ print(_t("파일 없음: {path}", path=", ".join(missing)), file=sys.stderr)
226
+ return 2
227
+
228
+ if getattr(args, "normalize", False):
229
+ from .core import normalize as N
230
+ converted = []
231
+ for sc in scripts:
232
+ dst, rep = N.normalize_file(sc)
233
+ if dst is None:
234
+ print(_t("정규화 실패: {path}", path=sc), file=sys.stderr)
235
+ for r in rep.reasons:
236
+ print(f" {r}", file=sys.stderr)
237
+ return 1
238
+ if dst != sc:
239
+ print(_t("정규화 {src} → {dst}", src=sc, dst=dst))
240
+ converted.append(dst)
241
+ scripts = converted
242
+
243
+ rows, cols = _grid(len(scripts), args.rows, args.cols)
244
+ try:
245
+ w, h = (float(v) for v in args.panel_size.lower().split("x"))
246
+ except ValueError:
247
+ print(_t("--panel-size 형식은 WxH 입니다 (예: 4x3)"), file=sys.stderr)
248
+ return 2
249
+
250
+ mspec = MontageSpec(rows=rows, cols=cols,
251
+ label_template=args.labels or "",
252
+ auto_label=bool(args.labels),
253
+ panels=[PanelRef(script=str(s.resolve()))
254
+ for s in scripts])
255
+
256
+ out = Path(args.out)
257
+ try:
258
+ generate_subplot_script(mspec, out, base_dir=Path.cwd(),
259
+ panel_size=(w, h))
260
+ except ValueError as exc:
261
+ print(str(exc), file=sys.stderr)
262
+ if not args.svg:
263
+ print(_t("\n원본을 고칠 수 없다면 --svg PATH 로 SVG 합성을 쓰세요. "
264
+ "다만 그 결과는 하나의 Figure가 아니며 통째로 편집할 수 "
265
+ "없습니다."), file=sys.stderr)
266
+ return 1
267
+ from .core import montage as M
268
+ from .core.montage_build import build as build_montage
269
+ result = build_montage(mspec, base_dir=Path.cwd(), python=args.python)
270
+ M.write(result, args.svg)
271
+ print(_t("{path} (SVG 합성 — 하나의 Figure가 아닙니다)", path=args.svg))
272
+ return 0
273
+
274
+ print(_t("{path} ({rows}×{cols}, 패널 {n}개)",
275
+ path=out, rows=rows, cols=cols, n=len(scripts)))
276
+ print(_t("평범한 matplotlib 스크립트입니다. figtune으로 열어 편집하세요:"))
277
+ print(f" figtune {out}")
278
+ if args.edit:
279
+ from .ui.qt.main import launch
280
+ return launch(out, python=args.python, lang=args.lang)
281
+ return 0
282
+
283
+
284
+ if __name__ == "__main__":
285
+ raise SystemExit(main())
figtune/config.py ADDED
@@ -0,0 +1,50 @@
1
+ """사용자 설정 파일.
2
+
3
+ 세션을 넘어 기억해야 하는 것만 담는다 (언어 선택, 한 번만 띄우는 안내 등).
4
+ 그림이나 spec에 영향을 주는 값은 절대 넣지 않는다 — 그런 값이 여기 들어가면
5
+ 같은 spec이 사람마다 다른 그림을 내게 되고, 재현 가능성이 무너진다.
6
+
7
+ 설정을 못 읽거나 못 써도 동작은 계속되어야 한다. 설정은 편의일 뿐이다.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+
17
+ def path() -> Path:
18
+ if os.name == "nt":
19
+ base = os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming"
20
+ else:
21
+ base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
22
+ return Path(base) / "figtune" / "config.json"
23
+
24
+
25
+ def load() -> dict:
26
+ try:
27
+ data = json.loads(path().read_text(encoding="utf-8"))
28
+ except (OSError, ValueError):
29
+ return {}
30
+ return data if isinstance(data, dict) else {}
31
+
32
+
33
+ def get(key: str, default=None):
34
+ return load().get(key, default)
35
+
36
+
37
+ def set(key: str, value) -> None: # noqa: A001 - 설정 키 접근자라 이 이름이 맞다
38
+ """value가 None이면 키를 지운다 (= 기본값으로 되돌림)."""
39
+ cfg = load()
40
+ if value is None:
41
+ cfg.pop(key, None)
42
+ else:
43
+ cfg[key] = value
44
+ p = path()
45
+ try:
46
+ p.parent.mkdir(parents=True, exist_ok=True)
47
+ p.write_text(json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
48
+ encoding="utf-8")
49
+ except OSError:
50
+ pass
@@ -0,0 +1,2 @@
1
+ from .session import Session, LoadReport # noqa: F401
2
+ from .spec import Spec, UserText # noqa: F401
figtune/core/apply.py ADDED
@@ -0,0 +1,146 @@
1
+ """spec → 살아있는 Figure.
2
+
3
+ 적용 순서가 중요하다. 범례는 handle/label이 확정된 뒤에 마지막으로 만들어야
4
+ 하고, 레이아웃 엔진은 수동 위치 조정과 충돌하므로 별도 취급한다.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from . import props as P
10
+ from . import selector as sel
11
+ from .spec import Spec, UserText
12
+
13
+ # 적용 순서와 정규 정렬 순서는 같은 표를 쓴다. 둘이 어긋나면 "저장한 대로
14
+ # 다시 적용된다"는 보장이 깨진다.
15
+ from .canon import KIND_ORDER as _ORDER
16
+
17
+
18
+ class ApplyReport:
19
+ def __init__(self):
20
+ self.failed: list[tuple[str, str, str]] = [] # (path, prop, 사유)
21
+
22
+ def fail(self, path, prop, why):
23
+ self.failed.append((path, prop, str(why)))
24
+
25
+ def __bool__(self):
26
+ return not self.failed
27
+
28
+
29
+ def transform_for(ax, coords: str):
30
+ if coords == "data":
31
+ return ax.transData
32
+ if coords == "figure":
33
+ return ax.figure.transFigure
34
+ return ax.transAxes
35
+
36
+
37
+ def ensure_text(fig, t: UserText):
38
+ """user text artist를 만들거나 찾아서 최신 상태로 만든다."""
39
+ try:
40
+ ax = fig.axes[t.axes]
41
+ except IndexError:
42
+ return None
43
+ art = None
44
+ for cand in ax.texts:
45
+ if getattr(cand, "_figtune_id", None) == t.id:
46
+ art = cand
47
+ break
48
+ if art is None:
49
+ art = ax.text(t.position[0], t.position[1], t.text,
50
+ transform=transform_for(ax, t.coords))
51
+ art._figtune_id = t.id
52
+ else:
53
+ art.set_text(t.text)
54
+ art.set_position((t.position[0], t.position[1]))
55
+ art.set_transform(transform_for(ax, t.coords))
56
+ for k, v in t.style().items():
57
+ try:
58
+ getattr(art, f"set_{k}")(v)
59
+ except Exception:
60
+ pass
61
+ return art
62
+
63
+
64
+ def apply_spec(fig, spec: Spec, report: ApplyReport | None = None) -> ApplyReport:
65
+ report = report or ApplyReport()
66
+
67
+ # user text를 먼저 만들어야 그에 대한 override를 걸 수 있다
68
+ for t in spec.texts:
69
+ try:
70
+ ensure_text(fig, t)
71
+ except Exception as exc:
72
+ report.fail(sel.usertext(t.axes, t.id), "-", exc)
73
+
74
+ def rank(path: str) -> int:
75
+ try:
76
+ return _ORDER.get(sel.parse(path).kind, 8)
77
+ except sel.SelectorError:
78
+ return 8
79
+
80
+ for path in sorted(spec.overrides, key=rank):
81
+ over = spec.overrides[path]
82
+ try:
83
+ kind = sel.parse(path).kind
84
+ except sel.SelectorError as exc:
85
+ report.fail(path, "-", exc)
86
+ continue
87
+
88
+ if kind == "figlegend":
89
+ try:
90
+ P.apply_fig_legend(fig, over)
91
+ except Exception as exc:
92
+ report.fail(path, "figlegend", exc)
93
+ continue
94
+
95
+ if kind == "legend":
96
+ try:
97
+ P.apply_legend(fig.axes[sel.parse(path).axes], over)
98
+ except Exception as exc:
99
+ report.fail(path, "legend", exc)
100
+ continue
101
+
102
+ view_keys = {"elev", "azim", "roll", "dist_zoom"}
103
+ if kind == "axes" and view_keys & set(over):
104
+ try:
105
+ P.apply_view(fig.axes[sel.parse(path).axes], over)
106
+ except Exception as exc:
107
+ report.fail(path, "view", exc)
108
+
109
+ for name, value in over.items():
110
+ if kind == "axes" and name in view_keys:
111
+ continue
112
+ try:
113
+ P.apply(fig, path, name, value)
114
+ except Exception as exc:
115
+ report.fail(path, name, exc)
116
+
117
+ return report
118
+
119
+
120
+ def apply_one(fig, spec: Spec, path: str, name: str, value) -> None:
121
+ """GUI 편집 한 건을 spec과 figure에 동시에 반영한다."""
122
+ spec.set(path, name, value)
123
+ if sel.parse(path).kind == "legend":
124
+ P.apply_legend(fig.axes[sel.parse(path).axes], spec.of(path))
125
+ else:
126
+ P.apply(fig, path, name, value)
127
+
128
+
129
+ def sync_text(fig, spec: Spec, tid: str) -> None:
130
+ t = spec.text_by_id(tid)
131
+ if t is not None:
132
+ ensure_text(fig, t)
133
+
134
+
135
+ def remove_text(fig, spec: Spec, tid: str) -> None:
136
+ t = spec.text_by_id(tid)
137
+ if t is None:
138
+ return
139
+ try:
140
+ ax = fig.axes[t.axes]
141
+ for cand in list(ax.texts):
142
+ if getattr(cand, "_figtune_id", None) == tid:
143
+ cand.remove()
144
+ except IndexError:
145
+ pass
146
+ spec.remove_text(tid)