Coverage for paintmaskanimator/color_chart.py: 43%

834 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Editable colour-chart window and Paint Mask Group (``.pmag``) data. 

2 

3The chart is deliberately separate from the live used-colour list. It stores 

4planned parent/child relationships and role tags, then applies only the colours 

5that actually exist in the current document when the user asks it to. 

6""" 

7from __future__ import annotations 

8 

9import copy 

10import json 

11import math 

12import re 

13from pathlib import Path 

14 

15from PySide6.QtCore import QEvent, QMimeData, QPointF, QRectF, QSize, Qt, Signal 

16from PySide6.QtGui import QColor, QDrag, QPainter, QPen 

17from PySide6.QtWidgets import ( 

18 QComboBox, 

19 QDialog, 

20 QDialogButtonBox, 

21 QFormLayout, 

22 QHBoxLayout, 

23 QLabel, 

24 QLineEdit, 

25 QListWidget, 

26 QListWidgetItem, 

27 QMessageBox, 

28 QPushButton, 

29 QScrollArea, 

30 QSizePolicy, 

31 QToolButton, 

32 QVBoxLayout, 

33 QWidget, 

34) 

35 

36from .errors import OperationError 

37from .i18n import tr 

38from .color_picker_dialog import ColorPickerDialog 

39 

40 

41PMAG_FORMAT = "PaintMaskGroup" 

42PMAG_VERSION = 1 

43MAX_CHART_TILES = 1024 

44MAX_CHART_CHILDREN = 4096 

45MAX_TAGS = 1024 

46TAG_MIME = "application/x-paintmaskanimator-color-tag" 

47 

48 

49def _hex_color(value): 

50 color = QColor(str(value or "")) 

51 if not color.isValid(): 

52 return None 

53 return color.name(QColor.NameFormat.HexRgb).upper() 

54 

55 

56def _tag(value): 

57 return str(value or "").strip()[:32] 

58 

59 

60def _unique_id(value, used, fallback_index): 

61 base = re.sub(r"[^A-Za-z0-9_-]+", "_", str(value or "")).strip("_") 

62 base = base[:64] or f"group_{fallback_index}" 

63 candidate = base 

64 suffix = 2 

65 while candidate in used: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true

66 candidate = f"{base}_{suffix}" 

67 suffix += 1 

68 used.add(candidate) 

69 return candidate 

70 

71 

72def empty_color_chart(): 

73 return { 

74 "format": PMAG_FORMAT, 

75 "format_version": PMAG_VERSION, 

76 "groups": [], 

77 "parent_tags": [], 

78 "tag_library": ["Base"], 

79 "tag_colors": {"Base": "#FFFFFF"}, 

80 "group_names": {}, 

81 "tag_order": [], 

82 "tiles": [], 

83 } 

84 

85 

86def normalize_color_chart(data): 

87 """Return a bounded canonical chart, accepting every old PMAG variant.""" 

88 source = dict(data or {}) if isinstance(data, dict) else {} 

89 raw_colors = source.get("tag_colors", {}) 

90 tag_colors = {"Base": "#FFFFFF"} 

91 if isinstance(raw_colors, dict): 91 ↛ 98line 91 didn't jump to line 98 because the condition on line 91 was always true

92 for raw_name, raw_color in list(raw_colors.items())[:MAX_TAGS]: 

93 name = _tag(raw_name) 

94 color = _hex_color(raw_color) 

95 if name and color: 95 ↛ 92line 95 didn't jump to line 92 because the condition on line 95 was always true

96 tag_colors[name] = color 

97 

98 raw_library = source.get("tag_library", []) 

99 library = [] 

100 if isinstance(raw_library, list): 100 ↛ 105line 100 didn't jump to line 105 because the condition on line 100 was always true

101 for value in raw_library[:MAX_TAGS]: 

102 name = _tag(value) 

103 if name and name not in library: 103 ↛ 101line 103 didn't jump to line 101 because the condition on line 103 was always true

104 library.append(name) 

105 if "Base" in library: 105 ↛ 107line 105 didn't jump to line 107 because the condition on line 105 was always true

106 library.remove("Base") 

107 library.insert(0, "Base") 

108 

109 raw_order = source.get("tag_order", []) 

110 order = [] 

111 if isinstance(raw_order, list): 111 ↛ 117line 111 didn't jump to line 117 because the condition on line 111 was always true

112 for value in raw_order[:MAX_TAGS]: 

113 name = _tag(value) 

114 if name and name.casefold() != "base" and name not in order: 114 ↛ 112line 114 didn't jump to line 112 because the condition on line 114 was always true

115 order.append(name) 

116 

117 raw_tiles = source.get("tiles") 

118 tiles = [] 

119 used_ids = set() 

120 total_children = 0 

121 if isinstance(raw_tiles, list): 121 ↛ 164line 121 didn't jump to line 164 because the condition on line 121 was always true

122 for index, raw_tile in enumerate(raw_tiles[:MAX_CHART_TILES], 1): 

123 if not isinstance(raw_tile, dict): 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true

124 continue 

125 raw_parent = raw_tile.get("parent") 

126 if raw_parent is None: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 raw_parent = {} 

128 if not isinstance(raw_parent, dict): 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true

129 continue 

130 parent_color = _hex_color(raw_parent.get("color")) 

131 placeholder = bool(raw_tile.get("placeholder")) or parent_color is None 

132 parent_tag = "" if placeholder else (_tag(raw_parent.get("tag")) or "Base") 

133 children = [] 

134 seen_children = set() 

135 raw_children = raw_tile.get("children", []) 

136 if not isinstance(raw_children, list): 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true

137 raw_children = [] 

138 for raw_child in raw_children: 

139 if total_children >= MAX_CHART_CHILDREN: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 break 

141 if not isinstance(raw_child, dict): 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true

142 continue 

143 child_color = _hex_color(raw_child.get("color")) 

144 child_tag = _tag(raw_child.get("tag")) 

145 if not child_color or child_color in seen_children: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 continue 

147 if not placeholder and child_color == parent_color: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true

148 continue 

149 children.append({"color": child_color, "tag": child_tag}) 

150 seen_children.add(child_color) 

151 total_children += 1 

152 if placeholder and not children: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 continue 

154 tile_id = _unique_id(raw_tile.get("id"), used_ids, index) 

155 tiles.append({ 

156 "id": tile_id, 

157 "name": str(raw_tile.get("name", "")).strip()[:64], 

158 "parent": {"color": None if placeholder else parent_color, "tag": parent_tag}, 

159 "placeholder": placeholder, 

160 "children": children, 

161 }) 

162 else: 

163 # Convert the original flat relationship representation. 

164 raw_groups = source.get("groups", []) 

165 if not isinstance(raw_groups, list): 

166 raw_groups = [] 

167 raw_parent_tags = source.get("parent_tags", []) 

168 if not isinstance(raw_parent_tags, list): 

169 raw_parent_tags = [] 

170 parent_tags = {} 

171 for entry in raw_parent_tags[:MAX_CHART_TILES]: 

172 if not isinstance(entry, dict): 

173 continue 

174 parent = _hex_color(entry.get("parent")) 

175 name = _tag(entry.get("tag")) 

176 if parent and name: 

177 parent_tags[parent] = name 

178 raw_names = source.get("group_names", {}) 

179 group_names = raw_names if isinstance(raw_names, dict) else {} 

180 by_parent = {} 

181 parent_order = [] 

182 for entry in raw_groups[:MAX_CHART_CHILDREN]: 

183 if not isinstance(entry, dict): 

184 continue 

185 parent = _hex_color(entry.get("parent")) 

186 child = _hex_color(entry.get("child")) 

187 if not parent or not child or parent == child: 

188 continue 

189 if parent not in by_parent: 

190 by_parent[parent] = [] 

191 parent_order.append(parent) 

192 if child not in {item["color"] for item in by_parent[parent]}: 

193 by_parent[parent].append({"color": child, "tag": _tag(entry.get("tag"))}) 

194 standalone = source.get("standalone", []) 

195 if not isinstance(standalone, list): 

196 standalone = [] 

197 for parent in parent_order: 

198 tile_id = _unique_id("", used_ids, len(tiles) + 1) 

199 tiles.append({ 

200 "id": tile_id, 

201 "name": str(group_names.get(parent, "")).strip()[:64], 

202 "parent": {"color": parent, "tag": parent_tags.get(parent, "Base")}, 

203 "placeholder": False, 

204 "children": by_parent[parent], 

205 }) 

206 for entry in standalone[:MAX_CHART_TILES - len(tiles)]: 

207 if not isinstance(entry, dict): 

208 continue 

209 color = _hex_color(entry.get("color")) 

210 if not color: 

211 continue 

212 tile_id = _unique_id("", used_ids, len(tiles) + 1) 

213 tiles.append({ 

214 "id": tile_id, 

215 "name": "", 

216 "parent": {"color": color, "tag": _tag(entry.get("tag")) or "Base"}, 

217 "placeholder": False, 

218 "children": [], 

219 }) 

220 

221 groups = [] 

222 parent_tags_out = [] 

223 group_names_out = {} 

224 seen_parent_tags = set() 

225 for tile in tiles: 

226 parent = tile["parent"]["color"] 

227 parent_tag = tile["parent"]["tag"] 

228 if parent: 

229 if tile["name"]: 

230 group_names_out[parent] = tile["name"] 

231 if parent_tag and parent not in seen_parent_tags: 231 ↛ 234line 231 didn't jump to line 234 because the condition on line 231 was always true

232 parent_tags_out.append({"parent": parent, "tag": parent_tag}) 

233 seen_parent_tags.add(parent) 

234 for child in tile["children"]: 

235 groups.append({ 

236 "child": child["color"], 

237 "parent": parent, 

238 "tag": child["tag"], 

239 }) 

240 if parent_tag and parent_tag not in library: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true

241 library.append(parent_tag) 

242 for child in tile["children"]: 

243 name = child["tag"] 

244 if name and name not in library: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 library.append(name) 

246 

247 for name in list(tag_colors): 

248 if name not in library: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 library.append(name) 

250 for name in library: 

251 tag_colors.setdefault(name, "#FFFFFF" if name == "Base" else "#58667A") 

252 for name in library: 

253 if name.casefold() != "base" and name not in order: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 order.append(name) 

255 

256 return { 

257 "format": PMAG_FORMAT, 

258 "format_version": PMAG_VERSION, 

259 "groups": groups, 

260 "parent_tags": parent_tags_out, 

261 "tag_library": library[:MAX_TAGS], 

262 "tag_colors": {name: tag_colors[name] for name in library[:MAX_TAGS]}, 

263 "group_names": group_names_out, 

264 "tag_order": order[:MAX_TAGS], 

265 "tiles": tiles, 

266 } 

267 

268 

269def merge_color_charts(existing, current): 

270 """Append new palette relationships without losing earlier colour variants.""" 

271 old = normalize_color_chart(existing) 

272 new = normalize_color_chart(current) 

273 merged = copy.deepcopy(old) 

274 by_parent = { 

275 tile["parent"]["color"]: tile 

276 for tile in merged["tiles"] 

277 if not tile["placeholder"] and tile["parent"]["color"] 

278 } 

279 for source_tile in new["tiles"]: 

280 parent = source_tile["parent"]["color"] 

281 target = by_parent.get(parent) if parent else None 

282 if target is None: 

283 added = copy.deepcopy(source_tile) 

284 added["id"] = _unique_id( 

285 added.get("id"), {tile["id"] for tile in merged["tiles"]}, 

286 len(merged["tiles"]) + 1, 

287 ) 

288 merged["tiles"].append(added) 

289 if parent: 

290 by_parent[parent] = added 

291 continue 

292 existing_children = {child["color"] for child in target["children"]} 

293 for child in source_tile["children"]: 

294 if child["color"] not in existing_children: 

295 target["children"].append(copy.deepcopy(child)) 

296 existing_children.add(child["color"]) 

297 if source_tile["parent"]["tag"]: 

298 target["parent"]["tag"] = source_tile["parent"]["tag"] 

299 if source_tile["name"]: 

300 target["name"] = source_tile["name"] 

301 merged["tag_library"] = list(dict.fromkeys( 

302 [*old["tag_library"], *new["tag_library"]] 

303 )) 

304 merged["tag_colors"] = {**old["tag_colors"], **new["tag_colors"]} 

305 merged["tag_order"] = list(dict.fromkeys( 

306 [*old["tag_order"], *new["tag_order"]] 

307 )) 

308 return normalize_color_chart(merged) 

309 

310 

311def _tile(chart, group_id): 

312 return next((item for item in chart["tiles"] if item["id"] == group_id), None) 

313 

314 

315def move_chart_color(data, source_hit, target_hit=None): 

316 """Move one colour occurrence between chart tiles and return a new chart.""" 

317 chart = normalize_color_chart(data) 

318 source = _tile(chart, source_hit.get("group_id")) 

319 if source is None: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 return chart, False 

321 role = source_hit.get("role") 

322 if role == "parent": 

323 if source.get("placeholder"): 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true

324 return chart, False 

325 moved = dict(source["parent"]) 

326 if source["children"]: 326 ↛ 330line 326 didn't jump to line 330 because the condition on line 326 was always true

327 source["parent"] = {"color": None, "tag": ""} 

328 source["placeholder"] = True 

329 else: 

330 chart["tiles"].remove(source) 

331 elif role == "child": 331 ↛ 339line 331 didn't jump to line 339 because the condition on line 331 was always true

332 index = int(source_hit.get("index", -1)) 

333 if index < 0 or index >= len(source["children"]): 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true

334 return chart, False 

335 moved = source["children"].pop(index) 

336 if source.get("placeholder") and not source["children"]: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 chart["tiles"].remove(source) 

338 else: 

339 return chart, False 

340 

341 target = _tile(chart, target_hit.get("group_id")) if target_hit else None 

342 if target is None or target_hit is None: 

343 used = {item["id"] for item in chart["tiles"]} 

344 chart["tiles"].append({ 

345 "id": _unique_id("", used, len(chart["tiles"]) + 1), 

346 "name": "", 

347 "parent": { 

348 "color": moved["color"], 

349 "tag": moved.get("tag") or "Base", 

350 }, 

351 "placeholder": False, 

352 "children": [], 

353 }) 

354 return normalize_color_chart(chart), True 

355 

356 target_role = target_hit.get("role") 

357 if target_role == "parent" and target.get("placeholder"): 357 ↛ 366line 357 didn't jump to line 366 because the condition on line 357 was always true

358 target["parent"] = { 

359 "color": moved["color"], 

360 "tag": moved.get("tag") or "Base", 

361 } 

362 target["placeholder"] = False 

363 else: 

364 # A parent dropped on another group becomes a child. Base describes a 

365 # parent role, so do not carry it into a child role. 

366 child = { 

367 "color": moved["color"], 

368 "tag": "" if moved.get("tag") == "Base" else moved.get("tag", ""), 

369 } 

370 target["children"] = [ 

371 item for item in target["children"] 

372 if item["color"] != child["color"] 

373 ] 

374 if target_role == "child": 

375 insert_at = max(0, min( 

376 int(target_hit.get("index", len(target["children"]))), 

377 len(target["children"]), 

378 )) 

379 target["children"].insert(insert_at, child) 

380 else: 

381 target["children"].append(child) 

382 return normalize_color_chart(chart), True 

383 

384 

385def assign_chart_tag(data, hit, tag_name): 

386 chart = normalize_color_chart(data) 

387 tile = _tile(chart, hit.get("group_id")) 

388 name = _tag(tag_name) 

389 if tile is None or (hit.get("role") == "parent" and tile.get("placeholder")): 

390 return chart, False 

391 if hit.get("role") == "parent": 

392 tile["parent"]["tag"] = name or "Base" 

393 elif hit.get("role") == "child": 

394 index = int(hit.get("index", -1)) 

395 if index < 0 or index >= len(tile["children"]): 

396 return chart, False 

397 tile["children"][index]["tag"] = name 

398 else: 

399 return chart, False 

400 if name and name not in chart["tag_library"]: 

401 chart["tag_library"].append(name) 

402 chart["tag_colors"][name] = "#58667A" 

403 return normalize_color_chart(chart), True 

404 

405 

406class ColorTagDialog(QDialog): 

407 def __init__(self, name="", color="#58667A", *, lock_name=False, parent=None): 

408 super().__init__(parent) 

409 self.setWindowTitle(tr("カラーチャートのタグ")) 

410 self._color = QColor(color) 

411 if not self._color.isValid(): 

412 self._color = QColor("#58667A") 

413 layout = QVBoxLayout(self) 

414 form = QFormLayout() 

415 self.name_edit = QLineEdit(str(name)) 

416 self.name_edit.setMaxLength(32) 

417 self.name_edit.setEnabled(not lock_name) 

418 self.color_button = QPushButton() 

419 self.color_button.clicked.connect(self._choose_color) 

420 self._refresh_color_button() 

421 form.addRow(tr("タグ名"), self.name_edit) 

422 form.addRow(tr("タグ色"), self.color_button) 

423 layout.addLayout(form) 

424 buttons = QDialogButtonBox( 

425 QDialogButtonBox.StandardButton.Ok 

426 | QDialogButtonBox.StandardButton.Cancel 

427 ) 

428 buttons.accepted.connect(self.accept) 

429 buttons.rejected.connect(self.reject) 

430 layout.addWidget(buttons) 

431 

432 def _choose_color(self): 

433 def apply(color): 

434 self._color = QColor(color) 

435 self._refresh_color_button() 

436 

437 color = ColorPickerDialog.get_color( 

438 self._color, self, tr("タグ色"), preview=apply 

439 ) 

440 if color.isValid(): 

441 apply(color) 

442 

443 def _refresh_color_button(self): 

444 value = self._color.name(QColor.NameFormat.HexRgb).upper() 

445 self.color_button.setText(value) 

446 self.color_button.setStyleSheet(f"background:{value};") 

447 

448 def values(self): 

449 return _tag(self.name_edit.text()), self._color.name( 

450 QColor.NameFormat.HexRgb 

451 ).upper() 

452 

453 

454class TagListWidget(QListWidget): 

455 orderChanged = Signal(object) 

456 

457 def __init__(self, parent=None): 

458 super().__init__(parent) 

459 self.setDragEnabled(True) 

460 self.setAcceptDrops(True) 

461 self.setDropIndicatorShown(True) 

462 self.setSelectionMode(QListWidget.SelectionMode.SingleSelection) 

463 

464 def startDrag(self, _supported_actions): 

465 item = self.currentItem() 

466 if item is None: 

467 return 

468 mime = QMimeData() 

469 mime.setData(TAG_MIME, item.text().encode("utf-8")) 

470 drag = QDrag(self) 

471 drag.setMimeData(mime) 

472 drag.exec(Qt.DropAction.MoveAction | Qt.DropAction.CopyAction) 

473 

474 def dragEnterEvent(self, event): 

475 if event.mimeData().hasFormat(TAG_MIME): 

476 event.acceptProposedAction() 

477 return 

478 super().dragEnterEvent(event) 

479 

480 def dragMoveEvent(self, event): 

481 if event.mimeData().hasFormat(TAG_MIME): 

482 event.acceptProposedAction() 

483 return 

484 super().dragMoveEvent(event) 

485 

486 def dropEvent(self, event): 

487 if not event.mimeData().hasFormat(TAG_MIME): 

488 super().dropEvent(event) 

489 return 

490 name = bytes(event.mimeData().data(TAG_MIME)).decode("utf-8", "replace") 

491 source_row = next( 

492 (row for row in range(self.count()) if self.item(row).text() == name), 

493 -1, 

494 ) 

495 target_item = self.itemAt(event.position().toPoint()) 

496 target_row = self.row(target_item) if target_item is not None else self.count() 

497 if source_row > 0: # Base remains first. 

498 item = self.takeItem(source_row) 

499 if source_row < target_row: 

500 target_row -= 1 

501 self.insertItem(max(1, target_row), item) 

502 self.setCurrentItem(item) 

503 self.orderChanged.emit([ 

504 self.item(row).text() for row in range(1, self.count()) 

505 ]) 

506 event.acceptProposedAction() 

507 

508 

509class ColorChartCanvas(QWidget): 

510 colorPicked = Signal(QColor) 

511 tileMoveRequested = Signal(object, object) 

512 tagDropRequested = Signal(object, str) 

513 tileTagEditRequested = Signal(object) 

514 groupNameChanged = Signal(str, str) 

515 

516 TILE_WIDTH = 184 

517 TILE_HEIGHT = 112 

518 HEADER_HEIGHT = 24 

519 GAP = 8 

520 COLUMNS = 3 

521 

522 def __init__(self, parent=None): 

523 super().__init__(parent) 

524 self.chart = empty_color_chart() 

525 self.groups = [] 

526 self.zoom = 1.0 

527 self.scroll_area: QScrollArea | None = None 

528 self._color_hits = [] 

529 self._header_hits = [] 

530 self._drag_mode = "" 

531 self._drag_start = QPointF() 

532 self._drag_last = QPointF() 

533 self._drag_scroll = (0, 0) 

534 self._drag_zoom = 1.0 

535 self._pressed_hit = None 

536 self._space_down = False 

537 self._ctrl_down = False 

538 self._name_editor = None 

539 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 

540 self.setMouseTracking(True) 

541 self.setAcceptDrops(True) 

542 self._update_canvas_size() 

543 self._update_mode_cursor() 

544 

545 def set_chart(self, chart): 

546 self.chart = normalize_color_chart(chart) 

547 rank = {name: index for index, name in enumerate(self.chart["tag_order"])} 

548 self.groups = [] 

549 for tile in self.chart["tiles"]: 

550 children = list(enumerate(tile["children"])) 

551 children.sort(key=lambda pair: ( 

552 rank.get(pair[1]["tag"], len(rank) + 1), 

553 pair[1]["tag"].casefold(), 

554 pair[1]["color"], 

555 )) 

556 self.groups.append({ 

557 "id": tile["id"], 

558 "name": tile["name"], 

559 "parent": tile["parent"]["color"], 

560 "parent_tag": tr("未設定") if tile["placeholder"] else tile["parent"]["tag"], 

561 "placeholder": tile["placeholder"], 

562 "children": children, 

563 }) 

564 self._update_canvas_size() 

565 self.update() 

566 

567 def _base_size(self): 

568 count = max(1, len(self.groups)) 

569 columns = min(self.COLUMNS, count) 

570 rows = max(1, int(math.ceil(count / self.COLUMNS))) 

571 return ( 

572 columns * self.TILE_WIDTH + (columns + 1) * self.GAP, 

573 rows * (self.HEADER_HEIGHT + self.TILE_HEIGHT) + (rows + 1) * self.GAP, 

574 ) 

575 

576 def _update_canvas_size(self): 

577 width, height = self._base_size() 

578 size = QSize(max(1, math.ceil(width * self.zoom)), max(1, math.ceil(height * self.zoom))) 

579 self.setMinimumSize(size) 

580 self.resize(size) 

581 

582 @staticmethod 

583 def _text_color(color): 

584 brightness = (color.red() * 299 + color.green() * 587 + color.blue() * 114) / 1000 

585 return QColor("#111111" if brightness >= 145 else "#FFFFFF") 

586 

587 @staticmethod 

588 def _draw_checker(painter, rect, size=10): 

589 left, top = int(rect.left()), int(rect.top()) 

590 right, bottom = int(math.ceil(rect.right())), int(math.ceil(rect.bottom())) 

591 for y in range(top, bottom, size): 

592 for x in range(left, right, size): 

593 color = QColor("#EEEEEE") if ((x - left) // size + (y - top) // size) % 2 == 0 else QColor("#B8B8B8") 

594 painter.fillRect(QRectF(x, y, size, size), color) 

595 

596 def paintEvent(self, _event): 

597 painter = QPainter(self) 

598 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) 

599 painter.scale(self.zoom, self.zoom) 

600 self._color_hits = [] 

601 self._header_hits = [] 

602 for position, group in enumerate(self.groups): 

603 row, column = divmod(position, self.COLUMNS) 

604 x = self.GAP + column * (self.TILE_WIDTH + self.GAP) 

605 y = self.GAP + row * (self.HEADER_HEIGHT + self.TILE_HEIGHT + self.GAP) 

606 header = QRectF(x, y, self.TILE_WIDTH, self.HEADER_HEIGHT) 

607 painter.fillRect(header, QColor("#E6E6E6")) 

608 painter.setPen(QColor("#777777")) 

609 painter.drawRect(header) 

610 painter.setPen(QColor("#222222")) 

611 painter.drawText( 

612 header.adjusted(4, 1, -4, -1), 

613 Qt.AlignmentFlag.AlignCenter, 

614 group["name"] or tr("(グループ名)"), 

615 ) 

616 self._header_hits.append({"rect": QRectF(header), "group_id": group["id"]}) 

617 

618 body_y = y + self.HEADER_HEIGHT 

619 parent_rect = QRectF(x, body_y, self.TILE_WIDTH / 2, self.TILE_HEIGHT) 

620 if group["placeholder"]: 

621 self._draw_checker(painter, parent_rect) 

622 parent_color = QColor("#C8C8C8") 

623 else: 

624 parent_color = QColor(group["parent"]) 

625 painter.fillRect(parent_rect, parent_color) 

626 painter.setPen(QPen(QColor(0, 0, 0, 110), 1)) 

627 painter.drawRect(parent_rect) 

628 painter.setPen(QColor("#303030") if group["placeholder"] else self._text_color(parent_color)) 

629 painter.drawText(parent_rect.adjusted(3, 3, -3, -3), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, group["parent_tag"]) 

630 self._color_hits.append({ 

631 "rect": QRectF(parent_rect), "group_id": group["id"], 

632 "role": "parent", "index": -1, "color": group["parent"] or "", 

633 "tag": group["parent_tag"], 

634 }) 

635 

636 children = group["children"] 

637 child_height = self.TILE_HEIGHT / max(1, len(children)) 

638 for child_position, (source_index, child) in enumerate(children): 

639 rect = QRectF( 

640 x + self.TILE_WIDTH / 2, 

641 body_y + child_position * child_height, 

642 self.TILE_WIDTH / 2, 

643 child_height, 

644 ) 

645 color = QColor(child["color"]) 

646 painter.fillRect(rect, color) 

647 painter.setPen(QColor(0, 0, 0, 110)) 

648 painter.drawRect(rect) 

649 painter.setPen(self._text_color(color)) 

650 painter.drawText(rect.adjusted(3, 1, -3, -1), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, child["tag"]) 

651 self._color_hits.append({ 

652 "rect": QRectF(rect), "group_id": group["id"], 

653 "role": "child", "index": source_index, 

654 "color": child["color"], "tag": child["tag"], 

655 }) 

656 if self._drag_mode == "tile" and self._pressed_hit and self._movement() > 4: 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true

657 point = QPointF(self._drag_last.x() / self.zoom, self._drag_last.y() / self.zoom) 

658 preview = QRectF(point.x() - 34, point.y() - 18, 68, 36) 

659 color = QColor(self._pressed_hit.get("color", "")) 

660 if color.isValid(): 

661 painter.setOpacity(0.72) 

662 painter.fillRect(preview, color) 

663 painter.setOpacity(1.0) 

664 painter.setPen(QPen(QColor("#FFFFFF"), 2)) 

665 painter.drawRect(preview) 

666 painter.end() 

667 

668 def _movement(self): 

669 return math.hypot(self._drag_last.x() - self._drag_start.x(), self._drag_last.y() - self._drag_start.y()) 

670 

671 def _base_position(self, position): 

672 return QPointF(position.x() / self.zoom, position.y() / self.zoom) 

673 

674 def _hit_at(self, position): 

675 point = self._base_position(position) 

676 return next((dict(hit, rect=QRectF(hit["rect"])) for hit in reversed(self._color_hits) if hit["rect"].contains(point)), None) 

677 

678 def _header_at(self, position): 

679 point = self._base_position(position) 

680 return next((hit for hit in self._header_hits if hit["rect"].contains(point)), None) 

681 

682 def _update_mode_cursor(self): 

683 if self._drag_mode == "pan": 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true

684 cursor = Qt.CursorShape.ClosedHandCursor 

685 elif self._space_down and self._ctrl_down: 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true

686 cursor = Qt.CursorShape.SizeVerCursor 

687 elif self._space_down: 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true

688 cursor = Qt.CursorShape.OpenHandCursor 

689 else: 

690 cursor = Qt.CursorShape.CrossCursor 

691 self.setCursor(cursor) 

692 

693 def handle_hold_key_event(self, event): 

694 pressed = event.type() == QEvent.Type.KeyPress 

695 if event.key() == Qt.Key.Key_Space: 

696 self._space_down = pressed 

697 elif event.key() == Qt.Key.Key_Control: 

698 self._ctrl_down = pressed 

699 self._update_mode_cursor() 

700 

701 def keyPressEvent(self, event): 

702 if event.key() in (Qt.Key.Key_Space, Qt.Key.Key_Control) and not event.isAutoRepeat(): 

703 self.handle_hold_key_event(event) 

704 event.accept() 

705 return 

706 super().keyPressEvent(event) 

707 

708 def keyReleaseEvent(self, event): 

709 if event.key() in (Qt.Key.Key_Space, Qt.Key.Key_Control) and not event.isAutoRepeat(): 

710 self.handle_hold_key_event(event) 

711 event.accept() 

712 return 

713 super().keyReleaseEvent(event) 

714 

715 def clear_hold_keys(self): 

716 self._space_down = False 

717 self._ctrl_down = False 

718 self._drag_mode = "" 

719 self._update_mode_cursor() 

720 

721 def _set_zoom(self, value, anchor=None): 

722 value = max(0.35, min(3.0, float(value))) 

723 if abs(value - self.zoom) < 1e-6: 

724 return 

725 old_zoom = self.zoom 

726 anchor = QPointF(anchor or QPointF(self.width() / 2, self.height() / 2)) 

727 base_anchor = QPointF(anchor.x() / old_zoom, anchor.y() / old_zoom) 

728 scroll = self.scroll_area 

729 viewport_anchor = QPointF(anchor) 

730 if scroll is not None: 

731 viewport_anchor -= QPointF(scroll.horizontalScrollBar().value(), scroll.verticalScrollBar().value()) 

732 self.zoom = value 

733 self._update_canvas_size() 

734 if scroll is not None: 

735 scroll.horizontalScrollBar().setValue(int(base_anchor.x() * value - viewport_anchor.x())) 

736 scroll.verticalScrollBar().setValue(int(base_anchor.y() * value - viewport_anchor.y())) 

737 self.update() 

738 

739 def wheelEvent(self, event): 

740 steps = event.angleDelta().y() / 120.0 

741 if steps: 

742 self._set_zoom(self.zoom * (1.12 ** steps), event.position()) 

743 event.accept() 

744 

745 def mousePressEvent(self, event): 

746 self.setFocus(Qt.FocusReason.MouseFocusReason) 

747 self._drag_start = QPointF(event.position()) 

748 self._drag_last = QPointF(event.position()) 

749 self._drag_scroll = ( 

750 self.scroll_area.horizontalScrollBar().value(), 

751 self.scroll_area.verticalScrollBar().value(), 

752 ) if self.scroll_area is not None else (0, 0) 

753 self._drag_zoom = self.zoom 

754 self._pressed_hit = self._hit_at(event.position()) 

755 ctrl_space = self._space_down and self._ctrl_down 

756 if event.button() == Qt.MouseButton.MiddleButton or (event.button() == Qt.MouseButton.LeftButton and self._space_down and not ctrl_space): 

757 self._drag_mode = "pan" 

758 elif event.button() == Qt.MouseButton.LeftButton and ctrl_space: 

759 self._drag_mode = "zoom" 

760 elif event.button() == Qt.MouseButton.LeftButton and self._pressed_hit and self._pressed_hit.get("color"): 

761 self._drag_mode = "tile" 

762 else: 

763 self._drag_mode = "pick" 

764 self._update_mode_cursor() 

765 event.accept() 

766 

767 def mouseMoveEvent(self, event): 

768 self._drag_last = QPointF(event.position()) 

769 delta = self._drag_last - self._drag_start 

770 if self._drag_mode == "pan" and self.scroll_area is not None: 

771 self.scroll_area.horizontalScrollBar().setValue(int(self._drag_scroll[0] - delta.x())) 

772 self.scroll_area.verticalScrollBar().setValue(int(self._drag_scroll[1] - delta.y())) 

773 elif self._drag_mode == "zoom": 

774 self._set_zoom(self._drag_zoom * (1.012 ** -delta.y()), self._drag_start) 

775 elif self._drag_mode == "tile" and self._movement() > 4: 

776 try: 

777 self.grabMouse() 

778 except RuntimeError: 

779 pass 

780 self.update() 

781 event.accept() 

782 

783 def mouseReleaseEvent(self, event): 

784 self._drag_last = QPointF(event.position()) 

785 movement = self._movement() 

786 if self._drag_mode == "tile" and self._pressed_hit is not None: 

787 if movement <= 4: 

788 color = QColor(self._pressed_hit.get("color", "")) 

789 if color.isValid(): 

790 self.colorPicked.emit(color) 

791 else: 

792 target = self._hit_at(event.position()) 

793 if target and all(target.get(key) == self._pressed_hit.get(key) for key in ("group_id", "role", "index")): 

794 target = None 

795 self.tileMoveRequested.emit(dict(self._pressed_hit), target) 

796 elif self._drag_mode == "pick" and movement <= 4: 

797 hit = self._hit_at(event.position()) 

798 if hit: 

799 color = QColor(hit.get("color", "")) 

800 if color.isValid(): 

801 self.colorPicked.emit(color) 

802 try: 

803 self.releaseMouse() 

804 except RuntimeError: 

805 pass 

806 self._drag_mode = "" 

807 self._pressed_hit = None 

808 self._update_mode_cursor() 

809 self.update() 

810 event.accept() 

811 

812 def mouseDoubleClickEvent(self, event): 

813 if event.button() == Qt.MouseButton.LeftButton: 

814 header = self._header_at(event.position()) 

815 if header: 

816 self._begin_name_edit(header) 

817 event.accept() 

818 return 

819 hit = self._hit_at(event.position()) 

820 if hit and hit.get("color"): 

821 self.tileTagEditRequested.emit(hit) 

822 event.accept() 

823 return 

824 super().mouseDoubleClickEvent(event) 

825 

826 def _begin_name_edit(self, header): 

827 if self._name_editor is not None: 

828 self._name_editor.deleteLater() 

829 tile = _tile(self.chart, header["group_id"]) 

830 if tile is None: 

831 return 

832 editor = QLineEdit(tile["name"], self) 

833 rect = header["rect"] 

834 editor.setGeometry( 

835 math.floor(rect.x() * self.zoom), math.floor(rect.y() * self.zoom), 

836 max(80, math.ceil(rect.width() * self.zoom)), 

837 max(22, math.ceil(rect.height() * self.zoom)), 

838 ) 

839 editor.setMaxLength(64) 

840 self._name_editor = editor 

841 editor.editingFinished.connect( 

842 lambda group_id=header["group_id"], current=editor: 

843 self._finish_name_edit(group_id, current) 

844 ) 

845 editor.show() 

846 editor.setFocus() 

847 editor.selectAll() 

848 

849 def _finish_name_edit(self, group_id, editor): 

850 if self._name_editor is not editor: 

851 return 

852 self._name_editor = None 

853 value = editor.text().strip()[:64] 

854 editor.deleteLater() 

855 self.groupNameChanged.emit(group_id, value) 

856 

857 def dragEnterEvent(self, event): 

858 if event.mimeData().hasFormat(TAG_MIME): 

859 event.acceptProposedAction() 

860 return 

861 super().dragEnterEvent(event) 

862 

863 def dragMoveEvent(self, event): 

864 if event.mimeData().hasFormat(TAG_MIME) and self._hit_at(event.position()): 

865 event.acceptProposedAction() 

866 return 

867 event.ignore() 

868 

869 def dropEvent(self, event): 

870 hit = self._hit_at(event.position()) 

871 if event.mimeData().hasFormat(TAG_MIME) and hit: 

872 name = bytes(event.mimeData().data(TAG_MIME)).decode("utf-8", "replace") 

873 self.tagDropRequested.emit(hit, name) 

874 event.acceptProposedAction() 

875 return 

876 event.ignore() 

877 

878 

879class ColorChartPanel(QWidget): 

880 chartChanged = Signal(object) 

881 captureRequested = Signal() 

882 applyRequested = Signal() 

883 saveRequested = Signal() 

884 loadRequested = Signal() 

885 colorPicked = Signal(QColor) 

886 

887 def __init__(self, parent=None): 

888 super().__init__(parent) 

889 self.chart = empty_color_chart() 

890 self.setMinimumSize(440, 260) 

891 layout = QVBoxLayout(self) 

892 layout.setContentsMargins(4, 4, 4, 4) 

893 layout.setSpacing(4) 

894 note = QLabel(tr( 

895 "親子色とタグを画像から独立して保持します。" 

896 "タイルはドラッグで移動、ダブルクリックでタグを変更できます。" 

897 )) 

898 note.setWordWrap(True) 

899 layout.addWidget(note) 

900 self.summary = QLabel(tr("未登録")) 

901 layout.addWidget(self.summary) 

902 

903 body = QHBoxLayout() 

904 body.setContentsMargins(0, 0, 0, 0) 

905 self.tile_scroll = QScrollArea() 

906 self.tile_scroll.setWidgetResizable(False) 

907 self.tile_scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 

908 self.tile_canvas = ColorChartCanvas() 

909 self.tile_canvas.scroll_area = self.tile_scroll 

910 self.tile_scroll.setWidget(self.tile_canvas) 

911 body.addWidget(self.tile_scroll, 1) 

912 

913 tag_box = QWidget() 

914 tag_box.setObjectName("colorChartTagManager") 

915 tag_box.setFixedWidth(132) 

916 tag_layout = QVBoxLayout(tag_box) 

917 tag_layout.setContentsMargins(6, 2, 2, 2) 

918 tag_layout.addWidget(QLabel(tr("タグ"))) 

919 self.tag_list = TagListWidget() 

920 self.tag_list.setToolTip(tr( 

921 "ドラッグ:色タイルへ割り当て/一覧内で順序変更\n" 

922 "ダブルクリック:名前とタグ色を編集" 

923 )) 

924 tag_layout.addWidget(self.tag_list, 1) 

925 tag_buttons = QHBoxLayout() 

926 add_tag = QToolButton() 

927 add_tag.setText("+") 

928 remove_tag = QToolButton() 

929 remove_tag.setText("-") 

930 tag_buttons.addWidget(add_tag) 

931 tag_buttons.addWidget(remove_tag) 

932 tag_layout.addLayout(tag_buttons) 

933 body.addWidget(tag_box) 

934 layout.addLayout(body, 1) 

935 

936 capture = QPushButton(tr("現在の親子付けを登録")) 

937 apply_button = QPushButton(tr("チャートを使用色へ適用")) 

938 file_row = QHBoxLayout() 

939 save_button = QPushButton(tr("PMAG保存")) 

940 load_button = QPushButton(tr("PMAG読込")) 

941 file_row.addWidget(save_button) 

942 file_row.addWidget(load_button) 

943 layout.addWidget(capture) 

944 layout.addWidget(apply_button) 

945 layout.addLayout(file_row) 

946 

947 capture.clicked.connect(self.captureRequested) 

948 apply_button.clicked.connect(self.applyRequested) 

949 save_button.clicked.connect(self.saveRequested) 

950 load_button.clicked.connect(self.loadRequested) 

951 add_tag.clicked.connect(self._add_tag) 

952 remove_tag.clicked.connect(self._remove_tag) 

953 self.tag_list.itemDoubleClicked.connect(lambda item: self._edit_tag(item.text())) 

954 self.tag_list.orderChanged.connect(self._set_tag_order) 

955 self.tile_canvas.colorPicked.connect(self.colorPicked) 

956 self.tile_canvas.tileMoveRequested.connect(self._move_color) 

957 self.tile_canvas.tagDropRequested.connect(self._assign_tag) 

958 self.tile_canvas.tileTagEditRequested.connect(self._choose_tile_tag) 

959 self.tile_canvas.groupNameChanged.connect(self._rename_group) 

960 self.set_chart(self.chart) 

961 

962 def set_chart(self, chart, *, emit=False): 

963 self.chart = normalize_color_chart(chart) 

964 self.tile_canvas.set_chart(self.chart) 

965 self._refresh_tags() 

966 child_count = sum(len(tile["children"]) for tile in self.chart["tiles"]) 

967 self.summary.setText( 

968 tr("{group_count}グループ・{child_count}子色").format( 

969 group_count=len(self.chart["tiles"]), child_count=child_count 

970 ) 

971 if self.chart["tiles"] else tr("未登録") 

972 ) 

973 if emit: 973 ↛ 974line 973 didn't jump to line 974 because the condition on line 973 was never true

974 self.chartChanged.emit(copy.deepcopy(self.chart)) 

975 

976 def _refresh_tags(self): 

977 current = self.tag_list.currentItem().text() if self.tag_list.currentItem() else "" 

978 self.tag_list.clear() 

979 order = ["Base", *self.chart["tag_order"]] 

980 order.extend(name for name in self.chart["tag_library"] if name not in order) 

981 for name in order: 

982 if name not in self.chart["tag_library"]: 982 ↛ 983line 982 didn't jump to line 983 because the condition on line 982 was never true

983 continue 

984 item = QListWidgetItem(name) 

985 color = QColor(self.chart["tag_colors"].get(name, "#58667A")) 

986 item.setBackground(color) 

987 item.setForeground(ColorChartCanvas._text_color(color)) 

988 self.tag_list.addItem(item) 

989 if name == current: 989 ↛ 990line 989 didn't jump to line 990 because the condition on line 989 was never true

990 self.tag_list.setCurrentItem(item) 

991 

992 def _move_color(self, source, target): 

993 chart, changed = move_chart_color(self.chart, source, target) 

994 if changed: 

995 self.set_chart(chart, emit=True) 

996 

997 def _assign_tag(self, hit, name): 

998 chart, changed = assign_chart_tag(self.chart, hit, name) 

999 if changed: 

1000 self.set_chart(chart, emit=True) 

1001 

1002 def _choose_tile_tag(self, hit): 

1003 choices = [tr("(タグなし)"), *self.chart["tag_library"]] 

1004 current = hit.get("tag", "") 

1005 if current == "未設定": 

1006 return 

1007 index = choices.index(current) if current in choices else 0 

1008 value, accepted = QComboBox(), False 

1009 # QInputDialog is avoided here so the choices retain the chart order and 

1010 # the dialog remains easy to test with ordinary Qt widgets. 

1011 dialog = QDialog(self) 

1012 dialog.setWindowTitle(tr("色タイルのタグ")) 

1013 box = QVBoxLayout(dialog) 

1014 value.addItems(choices) 

1015 value.setCurrentIndex(index) 

1016 box.addWidget(value) 

1017 buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) 

1018 buttons.accepted.connect(dialog.accept) 

1019 buttons.rejected.connect(dialog.reject) 

1020 box.addWidget(buttons) 

1021 accepted = dialog.exec() == QDialog.DialogCode.Accepted 

1022 if accepted: 

1023 self._assign_tag(hit, "" if value.currentIndex() == 0 else value.currentText()) 

1024 

1025 def _rename_group(self, group_id, name): 

1026 chart = normalize_color_chart(self.chart) 

1027 tile = _tile(chart, group_id) 

1028 if tile is not None and tile["name"] != name: 

1029 tile["name"] = str(name).strip()[:64] 

1030 self.set_chart(chart, emit=True) 

1031 

1032 def _set_tag_order(self, order): 

1033 chart = normalize_color_chart(self.chart) 

1034 chart["tag_order"] = [name for name in order if name != "Base"] 

1035 self.set_chart(chart, emit=True) 

1036 

1037 def _add_tag(self): 

1038 dialog = ColorTagDialog(parent=self) 

1039 if dialog.exec() != QDialog.DialogCode.Accepted: 

1040 return 

1041 name, color = dialog.values() 

1042 if not name: 

1043 return 

1044 chart = normalize_color_chart(self.chart) 

1045 if name not in chart["tag_library"]: 

1046 chart["tag_library"].append(name) 

1047 if name != "Base": 

1048 chart["tag_order"].append(name) 

1049 chart["tag_colors"][name] = color 

1050 self.set_chart(chart, emit=True) 

1051 

1052 def _edit_tag(self, old_name): 

1053 old_name = _tag(old_name) 

1054 if not old_name: 

1055 return 

1056 dialog = ColorTagDialog( 

1057 old_name, 

1058 self.chart["tag_colors"].get(old_name, "#58667A"), 

1059 lock_name=old_name == "Base", 

1060 parent=self, 

1061 ) 

1062 if dialog.exec() != QDialog.DialogCode.Accepted: 

1063 return 

1064 new_name, color = dialog.values() 

1065 if old_name == "Base": 

1066 new_name = "Base" 

1067 if not new_name: 

1068 return 

1069 chart = normalize_color_chart(self.chart) 

1070 if new_name != old_name and new_name in chart["tag_library"]: 

1071 QMessageBox.information( 

1072 self, tr("タグ編集"), tr("同じ名前のタグが既にあります。") 

1073 ) 

1074 return 

1075 for tile in chart["tiles"]: 

1076 if tile["parent"]["tag"] == old_name: 

1077 tile["parent"]["tag"] = new_name 

1078 for child in tile["children"]: 

1079 if child["tag"] == old_name: 

1080 child["tag"] = new_name 

1081 chart["tag_library"] = [new_name if name == old_name else name for name in chart["tag_library"]] 

1082 chart["tag_order"] = [new_name if name == old_name else name for name in chart["tag_order"]] 

1083 chart["tag_colors"].pop(old_name, None) 

1084 chart["tag_colors"][new_name] = color 

1085 self.set_chart(chart, emit=True) 

1086 

1087 def _remove_tag(self): 

1088 item = self.tag_list.currentItem() 

1089 if item is None or item.text() == "Base": 

1090 return 

1091 name = item.text() 

1092 chart = normalize_color_chart(self.chart) 

1093 for tile in chart["tiles"]: 

1094 if tile["parent"]["tag"] == name: 

1095 tile["parent"]["tag"] = "Base" 

1096 for child in tile["children"]: 

1097 if child["tag"] == name: 

1098 child["tag"] = "" 

1099 chart["tag_library"] = [value for value in chart["tag_library"] if value != name] 

1100 chart["tag_order"] = [value for value in chart["tag_order"] if value != name] 

1101 chart["tag_colors"].pop(name, None) 

1102 self.set_chart(chart, emit=True) 

1103 

1104 

1105def read_pmag(path): 

1106 source = Path(path) 

1107 if source.stat().st_size > 16 * 1024 * 1024: 1107 ↛ 1108line 1107 didn't jump to line 1108 because the condition on line 1107 was never true

1108 raise OperationError(tr("PMAGファイルが大きすぎます。")) 

1109 payload = json.loads(source.read_text(encoding="utf-8")) 

1110 if not isinstance(payload, dict) or payload.get("format") != PMAG_FORMAT: 1110 ↛ 1111line 1110 didn't jump to line 1111 because the condition on line 1110 was never true

1111 raise OperationError(tr("PaintMaskGroup形式ではありません。")) 

1112 return normalize_color_chart(payload) 

1113 

1114 

1115def write_pmag(path, chart): 

1116 destination = Path(path) 

1117 destination.write_text( 

1118 json.dumps(normalize_color_chart(chart), ensure_ascii=False, indent=2), 

1119 encoding="utf-8", 

1120 )