Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/fontTools/misc/transform.py: 40%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

128 statements  

1"""Affine 2D transformation matrix class. 

2 

3The Transform class implements various transformation matrix operations, 

4both on the matrix itself, as well as on 2D coordinates. 

5 

6Transform instances are effectively immutable: all methods that operate on the 

7transformation itself always return a new instance. This has as the 

8interesting side effect that Transform instances are hashable, ie. they can be 

9used as dictionary keys. 

10 

11This module exports the following symbols: 

12 

13Transform 

14 this is the main class 

15Identity 

16 Transform instance set to the identity transformation 

17Offset 

18 Convenience function that returns a translating transformation 

19Scale 

20 Convenience function that returns a scaling transformation 

21 

22The DecomposedTransform class implements a transformation with separate 

23translate, rotation, scale, skew, and transformation-center components. 

24 

25:Example: 

26 

27 >>> t = Transform(2, 0, 0, 3, 0, 0) 

28 >>> t.transformPoint((100, 100)) 

29 (200, 300) 

30 >>> t = Scale(2, 3) 

31 >>> t.transformPoint((100, 100)) 

32 (200, 300) 

33 >>> t.transformPoint((0, 0)) 

34 (0, 0) 

35 >>> t = Offset(2, 3) 

36 >>> t.transformPoint((100, 100)) 

37 (102, 103) 

38 >>> t.transformPoint((0, 0)) 

39 (2, 3) 

40 >>> t2 = t.scale(0.5) 

41 >>> t2.transformPoint((100, 100)) 

42 (52.0, 53.0) 

43 >>> import math 

44 >>> t3 = t2.rotate(math.pi / 2) 

45 >>> t3.transformPoint((0, 0)) 

46 (2.0, 3.0) 

47 >>> t3.transformPoint((100, 100)) 

48 (-48.0, 53.0) 

49 >>> t = Identity.scale(0.5).translate(100, 200).skew(0.1, 0.2) 

50 >>> t.transformPoints([(0, 0), (1, 1), (100, 100)]) 

51 [(50.0, 100.0), (50.550167336042726, 100.60135501775433), (105.01673360427253, 160.13550177543362)] 

52 >>> 

53""" 

54 

55from __future__ import annotations 

56 

57import math 

58from typing import NamedTuple 

59from dataclasses import dataclass 

60 

61 

62__all__ = ["Transform", "Identity", "Offset", "Scale", "DecomposedTransform"] 

63 

64 

65_EPSILON = 1e-15 

66_ONE_EPSILON = 1 - _EPSILON 

67_MINUS_ONE_EPSILON = -1 + _EPSILON 

68 

69 

70def _normSinCos(v: float) -> float: 

71 if abs(v) < _EPSILON: 

72 v = 0 

73 elif v > _ONE_EPSILON: 

74 v = 1 

75 elif v < _MINUS_ONE_EPSILON: 

76 v = -1 

77 return v 

78 

79 

80class Transform(NamedTuple): 

81 """2x2 transformation matrix plus offset, a.k.a. Affine transform. 

82 Transform instances are immutable: all transforming methods, eg. 

83 rotate(), return a new Transform instance. 

84 

85 :Example: 

86 

87 >>> t = Transform() 

88 >>> t 

89 <Transform [1 0 0 1 0 0]> 

90 >>> t.scale(2) 

91 <Transform [2 0 0 2 0 0]> 

92 >>> t.scale(2.5, 5.5) 

93 <Transform [2.5 0 0 5.5 0 0]> 

94 >>> 

95 >>> t.scale(2, 3).transformPoint((100, 100)) 

96 (200, 300) 

97 

98 Transform's constructor takes six arguments, all of which are 

99 optional, and can be used as keyword arguments:: 

100 

101 >>> Transform(12) 

102 <Transform [12 0 0 1 0 0]> 

103 >>> Transform(dx=12) 

104 <Transform [1 0 0 1 12 0]> 

105 >>> Transform(yx=12) 

106 <Transform [1 0 12 1 0 0]> 

107 

108 Transform instances also behave like sequences of length 6:: 

109 

110 >>> len(Identity) 

111 6 

112 >>> list(Identity) 

113 [1, 0, 0, 1, 0, 0] 

114 >>> tuple(Identity) 

115 (1, 0, 0, 1, 0, 0) 

116 

117 Transform instances are comparable:: 

118 

119 >>> t1 = Identity.scale(2, 3).translate(4, 6) 

120 >>> t2 = Identity.translate(8, 18).scale(2, 3) 

121 >>> t1 == t2 

122 1 

123 

124 But beware of floating point rounding errors:: 

125 

126 >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6) 

127 >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3) 

128 >>> t1 

129 <Transform [0.2 0 0 0.3 0.08 0.18]> 

130 >>> t2 

131 <Transform [0.2 0 0 0.3 0.08 0.18]> 

132 >>> t1 == t2 

133 0 

134 

135 Transform instances are hashable, meaning you can use them as 

136 keys in dictionaries:: 

137 

138 >>> d = {Scale(12, 13): None} 

139 >>> d 

140 {<Transform [12 0 0 13 0 0]>: None} 

141 

142 But again, beware of floating point rounding errors:: 

143 

144 >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6) 

145 >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3) 

146 >>> t1 

147 <Transform [0.2 0 0 0.3 0.08 0.18]> 

148 >>> t2 

149 <Transform [0.2 0 0 0.3 0.08 0.18]> 

150 >>> d = {t1: None} 

151 >>> d 

152 {<Transform [0.2 0 0 0.3 0.08 0.18]>: None} 

153 >>> d[t2] 

154 Traceback (most recent call last): 

155 File "<stdin>", line 1, in ? 

156 KeyError: <Transform [0.2 0 0 0.3 0.08 0.18]> 

157 """ 

158 

159 xx: float = 1 

160 xy: float = 0 

161 yx: float = 0 

162 yy: float = 1 

163 dx: float = 0 

164 dy: float = 0 

165 

166 def transformPoint(self, p): 

167 """Transform a point. 

168 

169 :Example: 

170 

171 >>> t = Transform() 

172 >>> t = t.scale(2.5, 5.5) 

173 >>> t.transformPoint((100, 100)) 

174 (250.0, 550.0) 

175 """ 

176 (x, y) = p 

177 xx, xy, yx, yy, dx, dy = self 

178 return (xx * x + yx * y + dx, xy * x + yy * y + dy) 

179 

180 def transformPoints(self, points): 

181 """Transform a list of points. 

182 

183 :Example: 

184 

185 >>> t = Scale(2, 3) 

186 >>> t.transformPoints([(0, 0), (0, 100), (100, 100), (100, 0)]) 

187 [(0, 0), (0, 300), (200, 300), (200, 0)] 

188 >>> 

189 """ 

190 xx, xy, yx, yy, dx, dy = self 

191 return [(xx * x + yx * y + dx, xy * x + yy * y + dy) for x, y in points] 

192 

193 def transformVector(self, v): 

194 """Transform an (dx, dy) vector, treating translation as zero. 

195 

196 :Example: 

197 

198 >>> t = Transform(2, 0, 0, 2, 10, 20) 

199 >>> t.transformVector((3, -4)) 

200 (6, -8) 

201 >>> 

202 """ 

203 (dx, dy) = v 

204 xx, xy, yx, yy = self[:4] 

205 return (xx * dx + yx * dy, xy * dx + yy * dy) 

206 

207 def transformVectors(self, vectors): 

208 """Transform a list of (dx, dy) vector, treating translation as zero. 

209 

210 :Example: 

211 >>> t = Transform(2, 0, 0, 2, 10, 20) 

212 >>> t.transformVectors([(3, -4), (5, -6)]) 

213 [(6, -8), (10, -12)] 

214 >>> 

215 """ 

216 xx, xy, yx, yy = self[:4] 

217 return [(xx * dx + yx * dy, xy * dx + yy * dy) for dx, dy in vectors] 

218 

219 def translate(self, x: float = 0, y: float = 0): 

220 """Return a new transformation, translated (offset) by x, y. 

221 

222 :Example: 

223 >>> t = Transform() 

224 >>> t.translate(20, 30) 

225 <Transform [1 0 0 1 20 30]> 

226 >>> 

227 """ 

228 return self.transform((1, 0, 0, 1, x, y)) 

229 

230 def scale(self, x: float = 1, y: float | None = None): 

231 """Return a new transformation, scaled by x, y. The 'y' argument 

232 may be None, which implies to use the x value for y as well. 

233 

234 :Example: 

235 >>> t = Transform() 

236 >>> t.scale(5) 

237 <Transform [5 0 0 5 0 0]> 

238 >>> t.scale(5, 6) 

239 <Transform [5 0 0 6 0 0]> 

240 >>> 

241 """ 

242 if y is None: 

243 y = x 

244 return self.transform((x, 0, 0, y, 0, 0)) 

245 

246 def rotate(self, angle: float): 

247 """Return a new transformation, rotated by 'angle' (radians). 

248 

249 :Example: 

250 >>> import math 

251 >>> t = Transform() 

252 >>> t.rotate(math.pi / 2) 

253 <Transform [0 1 -1 0 0 0]> 

254 >>> 

255 """ 

256 c = _normSinCos(math.cos(angle)) 

257 s = _normSinCos(math.sin(angle)) 

258 return self.transform((c, s, -s, c, 0, 0)) 

259 

260 def skew(self, x: float = 0, y: float = 0): 

261 """Return a new transformation, skewed by x and y. 

262 

263 :Example: 

264 >>> import math 

265 >>> t = Transform() 

266 >>> t.skew(math.pi / 4) 

267 <Transform [1 0 1 1 0 0]> 

268 >>> 

269 """ 

270 return self.transform((1, math.tan(y), math.tan(x), 1, 0, 0)) 

271 

272 def transform(self, other): 

273 """Return a new transformation, transformed by another 

274 transformation. 

275 

276 :Example: 

277 >>> t = Transform(2, 0, 0, 3, 1, 6) 

278 >>> t.transform((4, 3, 2, 1, 5, 6)) 

279 <Transform [8 9 4 3 11 24]> 

280 >>> 

281 """ 

282 xx1, xy1, yx1, yy1, dx1, dy1 = other 

283 xx2, xy2, yx2, yy2, dx2, dy2 = self 

284 return self.__class__( 

285 xx1 * xx2 + xy1 * yx2, 

286 xx1 * xy2 + xy1 * yy2, 

287 yx1 * xx2 + yy1 * yx2, 

288 yx1 * xy2 + yy1 * yy2, 

289 xx2 * dx1 + yx2 * dy1 + dx2, 

290 xy2 * dx1 + yy2 * dy1 + dy2, 

291 ) 

292 

293 def reverseTransform(self, other): 

294 """Return a new transformation, which is the other transformation 

295 transformed by self. self.reverseTransform(other) is equivalent to 

296 other.transform(self). 

297 

298 :Example: 

299 >>> t = Transform(2, 0, 0, 3, 1, 6) 

300 >>> t.reverseTransform((4, 3, 2, 1, 5, 6)) 

301 <Transform [8 6 6 3 21 15]> 

302 >>> Transform(4, 3, 2, 1, 5, 6).transform((2, 0, 0, 3, 1, 6)) 

303 <Transform [8 6 6 3 21 15]> 

304 >>> 

305 """ 

306 xx1, xy1, yx1, yy1, dx1, dy1 = self 

307 xx2, xy2, yx2, yy2, dx2, dy2 = other 

308 return self.__class__( 

309 xx1 * xx2 + xy1 * yx2, 

310 xx1 * xy2 + xy1 * yy2, 

311 yx1 * xx2 + yy1 * yx2, 

312 yx1 * xy2 + yy1 * yy2, 

313 xx2 * dx1 + yx2 * dy1 + dx2, 

314 xy2 * dx1 + yy2 * dy1 + dy2, 

315 ) 

316 

317 def inverse(self): 

318 """Return the inverse transformation. 

319 

320 :Example: 

321 >>> t = Identity.translate(2, 3).scale(4, 5) 

322 >>> t.transformPoint((10, 20)) 

323 (42, 103) 

324 >>> it = t.inverse() 

325 >>> it.transformPoint((42, 103)) 

326 (10.0, 20.0) 

327 >>> 

328 """ 

329 if self == Identity: 

330 return self 

331 xx, xy, yx, yy, dx, dy = self 

332 det = xx * yy - yx * xy 

333 xx, xy, yx, yy = yy / det, -xy / det, -yx / det, xx / det 

334 dx, dy = -xx * dx - yx * dy, -xy * dx - yy * dy 

335 return self.__class__(xx, xy, yx, yy, dx, dy) 

336 

337 def toPS(self) -> str: 

338 """Return a PostScript representation 

339 

340 :Example: 

341 

342 >>> t = Identity.scale(2, 3).translate(4, 5) 

343 >>> t.toPS() 

344 '[2 0 0 3 8 15]' 

345 >>> 

346 """ 

347 return "[%s %s %s %s %s %s]" % self 

348 

349 def toDecomposed(self) -> "DecomposedTransform": 

350 """Decompose into a DecomposedTransform.""" 

351 return DecomposedTransform.fromTransform(self) 

352 

353 def __bool__(self) -> bool: 

354 """Returns True if transform is not identity, False otherwise. 

355 

356 :Example: 

357 

358 >>> bool(Identity) 

359 False 

360 >>> bool(Transform()) 

361 False 

362 >>> bool(Scale(1.)) 

363 False 

364 >>> bool(Scale(2)) 

365 True 

366 >>> bool(Offset()) 

367 False 

368 >>> bool(Offset(0)) 

369 False 

370 >>> bool(Offset(2)) 

371 True 

372 """ 

373 return self != Identity 

374 

375 def __repr__(self) -> str: 

376 return "<%s [%g %g %g %g %g %g]>" % ((self.__class__.__name__,) + self) 

377 

378 

379Identity = Transform() 

380 

381 

382def Offset(x: float = 0, y: float = 0) -> Transform: 

383 """Return the identity transformation offset by x, y. 

384 

385 :Example: 

386 >>> Offset(2, 3) 

387 <Transform [1 0 0 1 2 3]> 

388 >>> 

389 """ 

390 return Transform(1, 0, 0, 1, x, y) 

391 

392 

393def Scale(x: float, y: float | None = None) -> Transform: 

394 """Return the identity transformation scaled by x, y. The 'y' argument 

395 may be None, which implies to use the x value for y as well. 

396 

397 :Example: 

398 >>> Scale(2, 3) 

399 <Transform [2 0 0 3 0 0]> 

400 >>> 

401 """ 

402 if y is None: 

403 y = x 

404 return Transform(x, 0, 0, y, 0, 0) 

405 

406 

407@dataclass 

408class DecomposedTransform: 

409 """The DecomposedTransform class implements a transformation with separate 

410 translate, rotation, scale, skew, and transformation-center components. 

411 """ 

412 

413 translateX: float = 0 

414 translateY: float = 0 

415 rotation: float = 0 # in degrees, counter-clockwise 

416 scaleX: float = 1 

417 scaleY: float = 1 

418 skewX: float = 0 # in degrees, clockwise 

419 skewY: float = 0 # in degrees, counter-clockwise 

420 tCenterX: float = 0 

421 tCenterY: float = 0 

422 

423 def __bool__(self): 

424 return ( 

425 self.translateX != 0 

426 or self.translateY != 0 

427 or self.rotation != 0 

428 or self.scaleX != 1 

429 or self.scaleY != 1 

430 or self.skewX != 0 

431 or self.skewY != 0 

432 or self.tCenterX != 0 

433 or self.tCenterY != 0 

434 ) 

435 

436 @classmethod 

437 def fromTransform(self, transform): 

438 """Return a DecomposedTransform() equivalent of this transformation. 

439 The returned solution always has skewY = 0, and angle in the (-180, 180]. 

440 

441 :Example: 

442 >>> DecomposedTransform.fromTransform(Transform(3, 0, 0, 2, 0, 0)) 

443 DecomposedTransform(translateX=0, translateY=0, rotation=0.0, scaleX=3.0, scaleY=2.0, skewX=0.0, skewY=0.0, tCenterX=0, tCenterY=0) 

444 >>> DecomposedTransform.fromTransform(Transform(0, 0, 0, 1, 0, 0)) 

445 DecomposedTransform(translateX=0, translateY=0, rotation=0.0, scaleX=0.0, scaleY=1.0, skewX=0.0, skewY=0.0, tCenterX=0, tCenterY=0) 

446 >>> DecomposedTransform.fromTransform(Transform(0, 0, 1, 1, 0, 0)) 

447 DecomposedTransform(translateX=0, translateY=0, rotation=-45.0, scaleX=0.0, scaleY=1.4142135623730951, skewX=0.0, skewY=0.0, tCenterX=0, tCenterY=0) 

448 """ 

449 # Adapted from an answer on 

450 # https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix 

451 

452 a, b, c, d, x, y = transform 

453 

454 sx = math.copysign(1, a) 

455 if sx < 0: 

456 a *= sx 

457 b *= sx 

458 

459 delta = a * d - b * c 

460 

461 rotation = 0 

462 scaleX = scaleY = 0 

463 skewX = 0 

464 

465 # Apply the QR-like decomposition. 

466 if a != 0 or b != 0: 

467 r = math.sqrt(a * a + b * b) 

468 rotation = math.acos(a / r) if b >= 0 else -math.acos(a / r) 

469 scaleX, scaleY = (r, delta / r) 

470 skewX = math.atan((a * c + b * d) / (r * r)) 

471 elif c != 0 or d != 0: 

472 s = math.sqrt(c * c + d * d) 

473 rotation = math.pi / 2 - ( 

474 math.acos(-c / s) if d >= 0 else -math.acos(c / s) 

475 ) 

476 scaleX, scaleY = (delta / s, s) 

477 else: 

478 # a = b = c = d = 0 

479 pass 

480 

481 return DecomposedTransform( 

482 x, 

483 y, 

484 math.degrees(rotation), 

485 scaleX * sx, 

486 scaleY, 

487 math.degrees(skewX) * sx, 

488 0.0, 

489 0, 

490 0, 

491 ) 

492 

493 def toTransform(self) -> Transform: 

494 """Return the Transform() equivalent of this transformation. 

495 

496 :Example: 

497 >>> DecomposedTransform(scaleX=2, scaleY=2).toTransform() 

498 <Transform [2 0 0 2 0 0]> 

499 >>> 

500 """ 

501 t = Transform() 

502 t = t.translate( 

503 self.translateX + self.tCenterX, self.translateY + self.tCenterY 

504 ) 

505 t = t.rotate(math.radians(self.rotation)) 

506 t = t.scale(self.scaleX, self.scaleY) 

507 t = t.skew(math.radians(self.skewX), math.radians(self.skewY)) 

508 t = t.translate(-self.tCenterX, -self.tCenterY) 

509 return t 

510 

511 

512if __name__ == "__main__": 

513 import sys 

514 import doctest 

515 

516 sys.exit(doctest.testmod().failed)