Coverage for pyPhotoAlbum/snapping.py: 95%

209 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2Snapping system for pyPhotoAlbum 

3Provides grid snapping, edge snapping, and custom guide snapping 

4""" 

5 

6import math 

7from typing import Any, Dict, List, Tuple, Optional 

8from dataclasses import dataclass 

9 

10 

11@dataclass 

12class Guide: 

13 """Represents a snapping guide (vertical or horizontal line)""" 

14 

15 position: float # Position in mm 

16 orientation: str # 'vertical' or 'horizontal' 

17 

18 def serialize(self) -> dict: 

19 """Serialize guide to dictionary""" 

20 return {"position": self.position, "orientation": self.orientation} 

21 

22 @staticmethod 

23 def deserialize(data: dict) -> "Guide": 

24 """Deserialize guide from dictionary""" 

25 return Guide(position=data.get("position", 0), orientation=data.get("orientation", "vertical")) 

26 

27 

28@dataclass 

29class SnapResizeParams: 

30 """Parameters for snap resize operations""" 

31 

32 position: Tuple[float, float] 

33 size: Tuple[float, float] 

34 dx: float 

35 dy: float 

36 resize_handle: str 

37 page_size: Tuple[float, float] 

38 dpi: int = 300 

39 project: Optional[Any] = None 

40 

41 

42class SnappingSystem: 

43 """Manages snapping behavior for layout elements""" 

44 

45 def __init__(self, snap_threshold_mm: float = 5.0): 

46 """ 

47 Initialize snapping system 

48 

49 Args: 

50 snap_threshold_mm: Distance in mm within which snapping occurs 

51 """ 

52 self.snap_threshold_mm = snap_threshold_mm 

53 self.grid_size_mm = 10.0 # Grid spacing in mm 

54 self.snap_to_grid = False 

55 self.snap_to_edges = True 

56 self.snap_to_guides = True 

57 self.guides: List[Guide] = [] 

58 

59 def add_guide(self, position: float, orientation: str): 

60 """Add a new guide""" 

61 guide = Guide(position=position, orientation=orientation) 

62 self.guides.append(guide) 

63 return guide 

64 

65 def remove_guide(self, guide: Guide): 

66 """Remove a guide""" 

67 if guide in self.guides: 

68 self.guides.remove(guide) 

69 

70 def clear_guides(self): 

71 """Remove all guides""" 

72 self.guides.clear() 

73 

74 def snap_position( 

75 self, 

76 position: Tuple[float, float], 

77 size: Tuple[float, float], 

78 page_size: Tuple[float, float], 

79 dpi: int = 300, 

80 project=None, 

81 ) -> Tuple[float, float]: 

82 """ 

83 Apply snapping to a position using combined distance threshold 

84 

85 Args: 

86 position: Current position (x, y) in pixels 

87 size: Element size (width, height) in pixels 

88 page_size: Page size (width, height) in mm 

89 dpi: DPI for conversion 

90 project: Optional project for global snapping settings 

91 

92 Returns: 

93 Snapped position (x, y) in pixels 

94 """ 

95 x, y = position 

96 width, height = size 

97 page_width_mm, page_height_mm = page_size 

98 

99 # Use project settings if available, otherwise use local settings 

100 if project: 

101 snap_to_grid = project.snap_to_grid 

102 snap_to_edges = project.snap_to_edges 

103 snap_to_guides = project.snap_to_guides 

104 grid_size_mm = project.grid_size_mm 

105 snap_threshold_mm = project.snap_threshold_mm 

106 else: 

107 snap_to_grid = self.snap_to_grid 

108 snap_to_edges = self.snap_to_edges 

109 snap_to_guides = self.snap_to_guides 

110 grid_size_mm = self.grid_size_mm 

111 snap_threshold_mm = self.snap_threshold_mm 

112 

113 # Convert threshold from mm to pixels 

114 snap_threshold_px = snap_threshold_mm * dpi / 25.4 

115 

116 # Collect all potential snap points for both edges of the element 

117 snap_points = [] 

118 

119 # 1. Page edge snap points 

120 if snap_to_edges: 

121 page_width_px = page_width_mm * dpi / 25.4 

122 page_height_px = page_height_mm * dpi / 25.4 

123 

124 # Corners where element's top-left can snap 

125 snap_points.extend( 

126 [ 

127 (0, 0), # Top-left corner 

128 (page_width_px - width, 0), # Top-right corner 

129 (0, page_height_px - height), # Bottom-left corner 

130 (page_width_px - width, page_height_px - height), # Bottom-right corner 

131 ] 

132 ) 

133 

134 # Edge positions (element aligned to edge on one axis) 

135 snap_points.extend( 

136 [ 

137 (0, y), # Left edge 

138 (page_width_px - width, y), # Right edge 

139 (x, 0), # Top edge 

140 (x, page_height_px - height), # Bottom edge 

141 ] 

142 ) 

143 

144 # 2. Grid snap points 

145 if snap_to_grid: 

146 grid_size_px = grid_size_mm * dpi / 25.4 

147 page_width_px = page_width_mm * dpi / 25.4 

148 page_height_px = page_height_mm * dpi / 25.4 

149 

150 # Calculate grid intersection points within range 

151 x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px 

152 x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px) 

153 y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px 

154 y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px) 

155 

156 grid_x = x_start 

157 while grid_x <= x_end: 

158 grid_y = y_start 

159 while grid_y <= y_end: 

160 snap_points.append((grid_x, grid_y)) 

161 # Also snap element's far edge to grid 

162 if grid_x >= width: 

163 snap_points.append((grid_x - width, grid_y)) 

164 if grid_y >= height: 

165 snap_points.append((grid_x, grid_y - height)) 

166 grid_y += grid_size_px 

167 grid_x += grid_size_px 

168 

169 # 3. Guide snap points 

170 if snap_to_guides: 

171 vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "vertical"] 

172 horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "horizontal"] 

173 

174 # Guide intersections (when both vertical and horizontal guides exist) 

175 for vg in vertical_guides: 

176 for hg in horizontal_guides: 

177 snap_points.append((vg, hg)) 

178 # Also snap element's far edge to intersections 

179 snap_points.append((vg - width, hg)) 

180 snap_points.append((vg, hg - height)) 

181 snap_points.append((vg - width, hg - height)) 

182 

183 # Find the nearest snap point using Euclidean distance 

184 best_snap_point = None 

185 best_distance = snap_threshold_px 

186 

187 for snap_x, snap_y in snap_points: 

188 distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2) 

189 if distance < best_distance: 

190 best_snap_point = (snap_x, snap_y) 

191 best_distance = distance 

192 

193 # Return snapped position or original position 

194 if best_snap_point: 

195 return best_snap_point 

196 else: 

197 return (x, y) 

198 

199 def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]: 

200 """ 

201 Apply snapping during resize operations 

202 

203 Args: 

204 params: SnapResizeParams containing all resize parameters 

205 

206 Returns: 

207 Tuple of (snapped_position, snapped_size) in pixels 

208 """ 

209 x, y = params.position 

210 width, height = params.size 

211 page_width_mm, page_height_mm = params.page_size 

212 

213 # Use project settings if available, otherwise use local settings 

214 if params.project: 

215 snap_threshold_mm = params.project.snap_threshold_mm 

216 else: 

217 snap_threshold_mm = self.snap_threshold_mm 

218 

219 # Convert threshold from mm to pixels 

220 snap_threshold_px = snap_threshold_mm * params.dpi / 25.4 

221 

222 # Calculate new position and size based on resize handle 

223 new_x, new_y = x, y 

224 new_width, new_height = width, height 

225 

226 # Apply resize based on handle 

227 if params.resize_handle in ["nw", "n", "ne"]: 

228 # Top edge moving 

229 new_y = y + params.dy 

230 new_height = height - params.dy 

231 

232 if params.resize_handle in ["sw", "s", "se"]: 

233 # Bottom edge moving 

234 new_height = height + params.dy 

235 

236 if params.resize_handle in ["nw", "w", "sw"]: 

237 # Left edge moving 

238 new_x = x + params.dx 

239 new_width = width - params.dx 

240 

241 if params.resize_handle in ["ne", "e", "se"]: 

242 # Right edge moving 

243 new_width = width + params.dx 

244 

245 # Now apply snapping to the edges that are being moved 

246 # Use _snap_edge_to_targets consistently for all edges 

247 

248 # Snap left edge (for nw, w, sw handles) 

249 if params.resize_handle in ["nw", "w", "sw"]: 

250 # Try to snap the left edge 

251 snapped_left = self._snap_edge_to_targets( 

252 new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project 

253 ) 

254 if snapped_left is not None: 

255 # Adjust width to compensate for position change 

256 width_adjustment = new_x - snapped_left 

257 new_x = snapped_left 

258 new_width += width_adjustment 

259 

260 # Snap right edge (for ne, e, se handles) 

261 if params.resize_handle in ["ne", "e", "se"]: 

262 # Calculate right edge position 

263 right_edge = new_x + new_width 

264 # Try to snap the right edge 

265 snapped_right = self._snap_edge_to_targets( 

266 right_edge, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project 

267 ) 

268 if snapped_right is not None: 

269 new_width = snapped_right - new_x 

270 

271 # Snap top edge (for nw, n, ne handles) 

272 if params.resize_handle in ["nw", "n", "ne"]: 

273 # Try to snap the top edge 

274 snapped_top = self._snap_edge_to_targets( 

275 new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project 

276 ) 

277 if snapped_top is not None: 

278 # Adjust height to compensate for position change 

279 height_adjustment = new_y - snapped_top 

280 new_y = snapped_top 

281 new_height += height_adjustment 

282 

283 # Snap bottom edge (for sw, s, se handles) 

284 if params.resize_handle in ["sw", "s", "se"]: 

285 # Calculate bottom edge position 

286 bottom_edge = new_y + new_height 

287 # Try to snap the bottom edge 

288 snapped_bottom = self._snap_edge_to_targets( 

289 bottom_edge, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project 

290 ) 

291 if snapped_bottom is not None: 

292 new_height = snapped_bottom - new_y 

293 

294 # Ensure minimum size 

295 min_size = 10 # Minimum 10 pixels 

296 new_width = max(new_width, min_size) 

297 new_height = max(new_height, min_size) 

298 

299 return ((new_x, new_y), (new_width, new_height)) 

300 

301 def _snap_edge_to_targets( 

302 self, 

303 edge_position: float, 

304 page_size_mm: float, 

305 dpi: int, 

306 snap_threshold_px: float, 

307 orientation: str, 

308 project=None, 

309 ) -> Optional[float]: 

310 """ 

311 Snap an edge position to available targets (grid, edges, guides) 

312 

313 Args: 

314 edge_position: Current edge position in pixels 

315 page_size_mm: Page size along axis in mm 

316 dpi: DPI for conversion 

317 snap_threshold_px: Snap threshold in pixels 

318 orientation: 'vertical' for x-axis, 'horizontal' for y-axis 

319 project: Optional project for global snapping settings 

320 

321 Returns: 

322 Snapped edge position in pixels, or None if no snap 

323 """ 

324 # Use project settings if available, otherwise use local settings 

325 if project: 

326 snap_to_grid = project.snap_to_grid 

327 snap_to_edges = project.snap_to_edges 

328 snap_to_guides = project.snap_to_guides 

329 grid_size_mm = project.grid_size_mm 

330 else: 

331 snap_to_grid = self.snap_to_grid 

332 snap_to_edges = self.snap_to_edges 

333 snap_to_guides = self.snap_to_guides 

334 grid_size_mm = self.grid_size_mm 

335 

336 snap_candidates: List[Tuple[float, float]] = [] 

337 

338 # 1. Page edge snapping 

339 if snap_to_edges: 

340 # Snap to start edge (0) 

341 snap_candidates.append((0.0, abs(edge_position - 0))) 

342 

343 # Snap to end edge 

344 page_size_px = page_size_mm * dpi / 25.4 

345 snap_candidates.append((page_size_px, abs(edge_position - page_size_px))) 

346 

347 # 2. Grid snapping 

348 if snap_to_grid: 

349 grid_size_px = grid_size_mm * dpi / 25.4 

350 

351 # Snap to nearest grid line 

352 nearest_grid = round(edge_position / grid_size_px) * grid_size_px 

353 snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid))) 

354 

355 # 3. Guide snapping 

356 if snap_to_guides: 

357 for guide in self.guides: 

358 if guide.orientation == orientation: 

359 guide_pos_px = guide.position * dpi / 25.4 

360 snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px))) 

361 

362 # Find the best snap candidate within threshold 

363 best_snap = None 

364 best_distance = snap_threshold_px 

365 

366 for snap_pos, distance in snap_candidates: 

367 if distance < best_distance: 

368 best_snap = snap_pos 

369 best_distance = distance 

370 

371 return best_snap 

372 

373 def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict: 

374 """ 

375 Get all snap lines for visualization 

376 

377 Args: 

378 page_size: Page size (width, height) in mm 

379 dpi: DPI for conversion 

380 

381 Returns: 

382 Dictionary with 'grid', 'edges', and 'guides' lists 

383 """ 

384 page_width_mm, page_height_mm = page_size 

385 page_width_px = page_width_mm * dpi / 25.4 

386 page_height_px = page_height_mm * dpi / 25.4 

387 

388 result: Dict[str, List[Tuple[str, float]]] = {"grid": [], "edges": [], "guides": []} 

389 

390 # Grid lines 

391 if self.snap_to_grid: 

392 grid_size_px = self.grid_size_mm * dpi / 25.4 

393 

394 # Vertical grid lines 

395 x: float = 0 

396 while x <= page_width_px: 

397 result["grid"].append(("vertical", x)) 

398 x += grid_size_px 

399 

400 # Horizontal grid lines 

401 y: float = 0 

402 while y <= page_height_px: 

403 result["grid"].append(("horizontal", y)) 

404 y += grid_size_px 

405 

406 # Edge lines 

407 if self.snap_to_edges: 

408 result["edges"].extend( 

409 [("vertical", 0), ("vertical", page_width_px), ("horizontal", 0), ("horizontal", page_height_px)] 

410 ) 

411 

412 # Guide lines 

413 if self.snap_to_guides: 

414 for guide in self.guides: 

415 guide_pos_px = guide.position * dpi / 25.4 

416 result["guides"].append((guide.orientation, guide_pos_px)) 

417 

418 return result 

419 

420 def serialize(self) -> dict: 

421 """Serialize snapping system to dictionary""" 

422 return { 

423 "snap_threshold_mm": self.snap_threshold_mm, 

424 "grid_size_mm": self.grid_size_mm, 

425 "snap_to_grid": self.snap_to_grid, 

426 "snap_to_edges": self.snap_to_edges, 

427 "snap_to_guides": self.snap_to_guides, 

428 "guides": [guide.serialize() for guide in self.guides], 

429 } 

430 

431 def deserialize(self, data: dict): 

432 """Deserialize from dictionary""" 

433 self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0) 

434 self.grid_size_mm = data.get("grid_size_mm", 10.0) 

435 self.snap_to_grid = data.get("snap_to_grid", False) 

436 self.snap_to_edges = data.get("snap_to_edges", True) 

437 self.snap_to_guides = data.get("snap_to_guides", True) 

438 

439 self.guides = [] 

440 for guide_data in data.get("guides", []): 

441 self.guides.append(Guide.deserialize(guide_data))