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.
- figtune/__init__.py +5 -0
- figtune/cli.py +285 -0
- figtune/config.py +50 -0
- figtune/core/__init__.py +2 -0
- figtune/core/apply.py +146 -0
- figtune/core/autowrap.py +210 -0
- figtune/core/canon.py +256 -0
- figtune/core/codegen.py +283 -0
- figtune/core/drag.py +213 -0
- figtune/core/fingerprint.py +124 -0
- figtune/core/gridlayout.py +149 -0
- figtune/core/history.py +113 -0
- figtune/core/hit.py +434 -0
- figtune/core/inline.py +179 -0
- figtune/core/introspect.py +183 -0
- figtune/core/layout.py +113 -0
- figtune/core/montage.py +310 -0
- figtune/core/montage_build.py +550 -0
- figtune/core/normalize.py +383 -0
- figtune/core/palette.py +90 -0
- figtune/core/parse.py +423 -0
- figtune/core/props.py +1114 -0
- figtune/core/provenance.py +233 -0
- figtune/core/runner.py +257 -0
- figtune/core/selector.py +254 -0
- figtune/core/session.py +625 -0
- figtune/core/snap.py +105 -0
- figtune/core/spec.py +162 -0
- figtune/core/typefaces.py +248 -0
- figtune/i18n/__init__.py +113 -0
- figtune/i18n/en.py +523 -0
- figtune/office/FigTune.bas +242 -0
- figtune/office/__init__.py +0 -0
- figtune/office/pptx_link.py +254 -0
- figtune/office/vba_bridge.py +65 -0
- figtune/office/vector.py +105 -0
- figtune/ui/__init__.py +0 -0
- figtune/ui/qt/__init__.py +0 -0
- figtune/ui/qt/direct.py +160 -0
- figtune/ui/qt/fontpicker.py +307 -0
- figtune/ui/qt/fonts.py +58 -0
- figtune/ui/qt/gridpicker.py +138 -0
- figtune/ui/qt/inspector.py +161 -0
- figtune/ui/qt/main.py +1666 -0
- figtune/ui/qt/minitoolbar.py +157 -0
- figtune/ui/qt/montagedialog.py +364 -0
- figtune/ui/qt/palette.py +188 -0
- figtune/ui/qt/targetdialog.py +127 -0
- figtune/ui/qt/widgets.py +281 -0
- figtune-1.0.0.dist-info/METADATA +750 -0
- figtune-1.0.0.dist-info/RECORD +55 -0
- figtune-1.0.0.dist-info/WHEEL +5 -0
- figtune-1.0.0.dist-info/entry_points.txt +2 -0
- figtune-1.0.0.dist-info/licenses/LICENSE +21 -0
- figtune-1.0.0.dist-info/top_level.txt +1 -0
figtune/__init__.py
ADDED
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
|
figtune/core/__init__.py
ADDED
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)
|