Coverage for graphica/gui/export_size.py: 96.2%
98 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""書き出しの大きさ(幅・高さ・単位・解像度)の換算と、描ける大きさかの確認。書き出しのダイアログとプレビューのドックで共有する。
3単位はコンボの値(日本語の元の文字)で見分ける。
4"""
5from PySide6.QtWidgets import QMessageBox
7from graphica.core.i18n import tr
8from graphica.gui import notify
10# 単位を切り替えたときの小数の桁と、入力できる最小値
11UNIT_DECIMALS = {'px': 0, 'in': 2, 'cm': 2, 'mm': 1}
12UNIT_MINIMUM = {'px': 1.0, 'in': 0.01, 'cm': 0.01, 'mm': 0.1}
14# matplotlib(Agg)が描ける1辺の上限(2^16 未満)
15MAX_RASTER_SIDE_PX = 65535
16# これを超える画素数は、描くのに時間とメモリ(1画素 4 バイト)がかかるので確かめてから描く
17LARGE_OUTPUT_PIXELS = 100_000_000
18# プレビューは欄の大きさのこの倍の画素で描けば、縮めて見せても粗くならない
19PREVIEW_OVERSAMPLE = 2.0
20# これより下げると小さな文字が描けない(FreeType が文字の大きさ 1 画素未満を拒む。4pt の文字で 10 dpi 程度が下限)
21MIN_PREVIEW_DPI = 20.0
22# 下限の解像度でもこれを超える図は、プレビューを描かない(描いても文字が見えず、時間とメモリだけかかる)
23MAX_PREVIEW_PIXELS = 4000 * 4000
26def unit_key(unit):
27 """コンボの値 -> 'px' / 'in' / 'cm' / 'mm'。分からなければ None。"""
28 unit = unit or ''
29 if "インチ" in unit:
30 return 'in'
31 if "ミリメートル" in unit:
32 return 'mm'
33 if "センチメートル" in unit:
34 return 'cm'
35 if "ピクセル" in unit:
36 return 'px'
37 return None
40def _units_per_inch(key, dpi):
41 # 逆数を掛けず割る(800 * (1/300) と 800 / 300 は最後のビットが違い、書き出す図の大きさが変わる)
42 return {'in': 1.0, 'mm': 25.4, 'cm': 2.54, 'px': dpi}.get(key)
45def size_in_inches(width, height, unit, dpi):
46 """幅と高さをインチにする。分からない単位なら 8 × 6。"""
47 per_inch = _units_per_inch(unit_key(unit), dpi)
48 if per_inch is None:
49 return 8, 6
50 return width / per_inch, height / per_inch
53def convert_length(value, from_unit, to_unit, dpi):
54 """同じ長さを別の単位で表した値。どちらかの単位が分からなければそのまま。"""
55 source = _units_per_inch(unit_key(from_unit), dpi)
56 target = _units_per_inch(unit_key(to_unit), dpi)
57 if source is None or target is None:
58 return value
59 return value / source * target
62def output_pixel_size(width_in, height_in, dpi):
63 """ラスタで書き出したときの画素数(余白を詰める前のおおよその大きさ)。"""
64 return round(width_in * dpi), round(height_in * dpi)
67def preview_dpi(width_in, height_in, dpi, target_width_px, target_height_px):
68 """プレビューを描く解像度。欄に合う画素数まで下げ、書き出しの解像度は超えない。大きすぎて描かないなら None。
70 文字や線の太さはポイント単位なので、解像度を下げても見た目の比率は変わらない。
71 """
72 if width_in <= 0 or height_in <= 0: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 return dpi
74 fit = min(target_width_px * PREVIEW_OVERSAMPLE / width_in, target_height_px * PREVIEW_OVERSAMPLE / height_in)
75 chosen = min(float(dpi), max(MIN_PREVIEW_DPI, fit))
76 width_px, height_px = output_pixel_size(width_in, height_in, chosen)
77 if width_px * height_px > MAX_PREVIEW_PIXELS:
78 return None
79 return chosen
82def preview_too_large_text(width_in, height_in, dpi):
83 width_px, height_px = output_pixel_size(width_in, height_in, dpi)
84 return tr("図が大きすぎるためプレビューを表示しません\n(画像にすると 約 {width} × {height} px)").format(
85 width=f"{width_px:,}", height=f"{height_px:,}")
88def confirm_output_size(parent, width_in, height_in, dpi):
89 """描ける大きさなら True。1辺が上限を超えるなら知らせて False、とても大きいなら確かめる。"""
90 width_px, height_px = output_pixel_size(width_in, height_in, dpi)
91 if max(width_px, height_px) > MAX_RASTER_SIDE_PX:
92 notify.warning(
93 parent, tr("画像が大きすぎます"),
94 tr("{width} × {height} px になり、1辺が {limit} px を超えるので描けません。\n"
95 "幅・高さか解像度を小さくしてください。").format(
96 width=f"{width_px:,}", height=f"{height_px:,}", limit=f"{MAX_RASTER_SIDE_PX:,}"))
97 return False
98 if width_px * height_px > LARGE_OUTPUT_PIXELS:
99 reply = notify.question(
100 parent, tr("大きな画像"),
101 tr("{width} × {height} px(約 {megapixels:,.0f} 万画素)になります。\n"
102 "時間がかかったり、メモリが足りなくなったりするおそれがあります。続けますか?").format(
103 # 日本語は万画素、英語は百万画素で数える。format は使わない名前を無視する
104 width=f"{width_px:,}", height=f"{height_px:,}",
105 megapixels=width_px * height_px / 10_000, megapixels_m=width_px * height_px / 1_000_000),
106 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
107 QMessageBox.StandardButton.No,
108 )
109 return reply == QMessageBox.StandardButton.Yes
110 return True
113class ExportSizeFields:
114 """幅・高さ・単位・解像度の欄をまとめて扱う。単位を切り替えたら同じ大きさになるよう数字を換算し、出力の画素数を見せる。
116 単位のコンボにほかの処理(プレビューの描き直しなど)をつなぐなら、これを作った後につなぐ(換算が先に済むように)。
117 """
119 def __init__(self, width_spinbox, height_spinbox, unit_combo, dpi_spinbox, pixel_label=None):
120 self.width_spinbox = width_spinbox
121 self.height_spinbox = height_spinbox
122 self.unit_combo = unit_combo
123 self.dpi_spinbox = dpi_spinbox
124 self.pixel_label = pixel_label
125 self._unit = unit_combo.currentData()
126 self._apply_unit_format(self._unit)
128 unit_combo.currentIndexChanged.connect(self._on_unit_changed)
129 if pixel_label is not None: 129 ↛ exitline 129 didn't return from function '__init__' because the condition on line 129 was always true
130 for spin_box in (width_spinbox, height_spinbox, dpi_spinbox):
131 spin_box.valueChanged.connect(self.update_pixel_label)
132 unit_combo.currentIndexChanged.connect(self.update_pixel_label)
133 self.update_pixel_label()
135 def size_in_inches(self):
136 return size_in_inches(self.width_spinbox.value(), self.height_spinbox.value(),
137 self.unit_combo.currentData(), self.dpi_spinbox.value())
139 def _apply_unit_format(self, unit):
140 key = unit_key(unit)
141 if key is None: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 return
143 for spin_box in (self.width_spinbox, self.height_spinbox):
144 spin_box.setDecimals(UNIT_DECIMALS[key])
145 spin_box.setMinimum(UNIT_MINIMUM[key])
147 def _on_unit_changed(self, _index):
148 new_unit = self.unit_combo.currentData()
149 old_unit, self._unit = self._unit, new_unit
150 dpi = self.dpi_spinbox.value()
151 spin_boxes = (self.width_spinbox, self.height_spinbox)
152 # 桁を変えると今の値が丸められるので、先に両方を読んでおく
153 values = [convert_length(spin_box.value(), old_unit, new_unit, dpi) for spin_box in spin_boxes]
154 # 換算の途中の値でプレビューが描き直されないよう、欄の通知を止める(描き直しは単位のコンボの通知で1回)
155 blocked = [spin_box.blockSignals(True) for spin_box in spin_boxes]
156 try:
157 self._apply_unit_format(new_unit)
158 for spin_box, value in zip(spin_boxes, values):
159 spin_box.setValue(value)
160 finally:
161 for spin_box, was_blocked in zip(spin_boxes, blocked):
162 spin_box.blockSignals(was_blocked)
164 def update_pixel_label(self, *_args):
165 width_in, height_in = self.size_in_inches()
166 width_px, height_px = output_pixel_size(width_in, height_in, self.dpi_spinbox.value())
167 self.pixel_label.setText(tr("画像にすると 約 {width} × {height} px").format(
168 width=f"{width_px:,}", height=f"{height_px:,}"))