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

1"""書き出しの大きさ(幅・高さ・単位・解像度)の換算と、描ける大きさかの確認。書き出しのダイアログとプレビューのドックで共有する。 

2 

3単位はコンボの値(日本語の元の文字)で見分ける。 

4""" 

5from PySide6.QtWidgets import QMessageBox 

6 

7from graphica.core.i18n import tr 

8from graphica.gui import notify 

9 

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} 

13 

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 

24 

25 

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 

38 

39 

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) 

43 

44 

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 

51 

52 

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 

60 

61 

62def output_pixel_size(width_in, height_in, dpi): 

63 """ラスタで書き出したときの画素数(余白を詰める前のおおよその大きさ)。""" 

64 return round(width_in * dpi), round(height_in * dpi) 

65 

66 

67def preview_dpi(width_in, height_in, dpi, target_width_px, target_height_px): 

68 """プレビューを描く解像度。欄に合う画素数まで下げ、書き出しの解像度は超えない。大きすぎて描かないなら None。 

69 

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 

80 

81 

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:,}") 

86 

87 

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 

111 

112 

113class ExportSizeFields: 

114 """幅・高さ・単位・解像度の欄をまとめて扱う。単位を切り替えたら同じ大きさになるよう数字を換算し、出力の画素数を見せる。 

115 

116 単位のコンボにほかの処理(プレビューの描き直しなど)をつなぐなら、これを作った後につなぐ(換算が先に済むように)。 

117 """ 

118 

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) 

127 

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() 

134 

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()) 

138 

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]) 

146 

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) 

163 

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:,}"))