Coverage for gws-app/gws/lib/uom/__init__.py: 83%

122 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Units of measure. 

2 

3Conversions between map scales and resolutions, between millimetres and pixels, 

4and parsing of values with units. 

5 

6Values with units are represented as tuples, see the ``gws.Uom*`` types: 

7 

8- ``gws.UomValue``: ``(5, gws.Uom.mm)``, 

9- ``gws.UomPoint`` and ``gws.UomSize``: ``(10, 20, gws.Uom.mm)``, 

10- ``gws.UomExtent``: ``(0, 0, 100, 200, gws.Uom.mm)``. 

11 

12Conversions between millimetres and pixels need a resolution in pixels per inch. 

13Scale and resolution conversions use the OGC standard pixel size of 0.28 mm. 

14 

15Example:: 

16 

17 import gws.lib.uom 

18 

19 v = gws.lib.uom.parse('5mm') # (5.0, gws.Uom.mm) 

20 gws.lib.uom.to_px(v, 96) # (18.89..., gws.Uom.px) 

21 gws.lib.uom.to_str(v) # '5mm' 

22 gws.lib.uom.parse_point('10mm,20mm') # (10.0, 20.0, gws.Uom.mm) 

23 gws.lib.uom.res_to_scale(0.28) # 1000 

24""" 

25 

26import re 

27 

28import gws 

29 

30MM_PER_IN = 25.4 

31"""Conversion factor from inch to millimetre.""" 

32 

33PT_PER_IN = 72 

34"""Conversion factor from inch to points.""" 

35 

36OGC_M_PER_PX = 0.00028 

37"""OGC meter per pixel (OGC 06-042, 7.2.4.6.9: 1px = 0.28mm).""" 

38 

39OGC_SCREEN_PPI = MM_PER_IN / (OGC_M_PER_PX * 1000) # 90.71 

40"""Screen pixels per inch according to the OGC standard pixel size.""" 

41 

42PDF_DPI = 96 

43"""Dots per inch in a PDF file.""" 

44 

45# 1 centimeter precision 

46 

47DEFAULT_PRECISION = { 

48 gws.Uom.deg: 7, 

49 gws.Uom.m: 2, 

50} 

51 

52_number = int | float 

53 

54 

55def scale_to_res(x: _number) -> float: 

56 """Convert a scale denominator to a resolution. 

57 

58 Args: 

59 x: Scale denominator. 

60 

61 Returns: 

62 Resolution in metres per pixel, using the OGC pixel size. 

63 """ 

64 # return round(x * OGC_M_PER_PX, 4) 

65 return x * OGC_M_PER_PX 

66 

67 

68def res_to_scale(x: _number) -> int: 

69 """Convert a resolution to a scale denominator. 

70 

71 Args: 

72 x: Resolution in metres per pixel. 

73 

74 Returns: 

75 Scale denominator, using the OGC pixel size. 

76 """ 

77 return int(x / OGC_M_PER_PX) 

78 

79 

80# @TODO imperial units not used yet 

81# 

82# def mm_to_in(x: _number) -> float: 

83# return x / MM_PER_IN 

84# 

85# 

86# def m_to_in(x: _number) -> float: 

87# return (x / MM_PER_IN) * 1000 

88# 

89# 

90# def in_to_mm(x: _number) -> float: 

91# return x * MM_PER_IN 

92# 

93# 

94# def in_to_m(x: _number) -> float: 

95# return (x * MM_PER_IN) / 1000 

96# 

97# 

98# def in_to_px(x, ppi): 

99# return x * ppi 

100# 

101# 

102# def mm_to_pt(x: _number) -> float: 

103# return (x / MM_PER_IN) * PT_PER_IN 

104# 

105# 

106# def pt_to_mm(x: _number) -> float: 

107# return (x / PT_PER_IN) * MM_PER_IN 

108# 

109 

110## 

111 

112 

113def mm_to_px(x: _number, ppi: int) -> float: 

114 """Convert millimetres to pixels. 

115 

116 Args: 

117 x: Millimetres. 

118 ppi: Pixels per inch. 

119 

120 Returns: 

121 Number of pixels. 

122 """ 

123 return x * (ppi / MM_PER_IN) 

124 

125 

126def to_px(v: gws.UomValue, ppi: int) -> gws.UomValue: 

127 """Convert a value with a unit to pixels. 

128 

129 Args: 

130 v: Value in ``px`` or ``mm``. 

131 ppi: Pixels per inch. 

132 

133 Returns: 

134 The value in pixels. 

135 

136 Raises: 

137 ``ValueError``: If the unit is not ``px`` or ``mm``. 

138 """ 

139 x, u = v 

140 if u == gws.Uom.px: 

141 return v 

142 if u == gws.Uom.mm: 

143 return mm_to_px(x, ppi), gws.Uom.px 

144 raise ValueError(f'invalid unit {u!r}') 

145 

146 

147def size_mm_to_px(s: gws.Size, ppi: int) -> gws.Size: 

148 """Convert a size in millimetres to pixels. 

149 

150 Args: 

151 s: Size in millimetres. 

152 ppi: Pixels per inch. 

153 

154 Returns: 

155 Size in pixels. 

156 """ 

157 x, y = s 

158 return mm_to_px(x, ppi), mm_to_px(y, ppi) 

159 

160 

161def size_to_px(s: gws.UomSize, ppi: int) -> gws.UomSize: 

162 """Convert a size with a unit to pixels. 

163 

164 Args: 

165 s: Size in ``px`` or ``mm``. 

166 ppi: Pixels per inch. 

167 

168 Returns: 

169 Size in pixels. 

170 

171 Raises: 

172 ``ValueError``: If the unit is not ``px`` or ``mm``. 

173 """ 

174 x, y, u = s 

175 if u == gws.Uom.px: 

176 return s 

177 if u == gws.Uom.mm: 

178 return mm_to_px(x, ppi), mm_to_px(y, ppi), gws.Uom.px 

179 raise ValueError(f'invalid unit {u!r}') 

180 

181 

182def extent_to_px(ext: gws.UomExtent, ppi: int) -> gws.UomExtent: 

183 """Convert an extent with a unit to pixels. 

184 

185 Args: 

186 ext: Extent in ``px`` or ``mm``. 

187 ppi: Pixels per inch. 

188 

189 Returns: 

190 Extent in pixels. 

191 

192 Raises: 

193 ``ValueError``: If the unit is not ``px`` or ``mm``. 

194 """ 

195 a, b, c, d, u = ext 

196 if u == gws.Uom.px: 

197 return ext 

198 if u == gws.Uom.mm: 

199 return ( 

200 mm_to_px(a, ppi), 

201 mm_to_px(b, ppi), 

202 mm_to_px(c, ppi), 

203 mm_to_px(d, ppi), 

204 gws.Uom.px, 

205 ) 

206 raise ValueError(f'invalid unit {u!r}') 

207 

208 

209## 

210 

211 

212def px_to_mm(x: _number, ppi: int) -> float: 

213 """Convert pixels to millimetres. 

214 

215 Args: 

216 x: Number of pixels. 

217 ppi: Pixels per inch. 

218 

219 Returns: 

220 Millimetres. 

221 """ 

222 return x * (MM_PER_IN / ppi) 

223 

224 

225def to_mm(v: gws.UomValue, ppi: int) -> gws.UomValue: 

226 """Convert a value with a unit to millimetres. 

227 

228 Args: 

229 v: Value in ``mm`` or ``px``. 

230 ppi: Pixels per inch. 

231 

232 Returns: 

233 The value in millimetres. 

234 

235 Raises: 

236 ``ValueError``: If the unit is not ``mm`` or ``px``. 

237 """ 

238 x, u = v 

239 if u == gws.Uom.mm: 

240 return v 

241 if u == gws.Uom.px: 

242 return px_to_mm(x, ppi), gws.Uom.mm 

243 raise ValueError(f'invalid unit {u!r}') 

244 

245 

246def size_px_to_mm(s: gws.Size, ppi: int) -> gws.Size: 

247 """Convert a size in pixels to millimetres. 

248 

249 Args: 

250 s: Size in pixels. 

251 ppi: Pixels per inch. 

252 

253 Returns: 

254 Size in millimetres. 

255 """ 

256 x, y = s 

257 return px_to_mm(x, ppi), px_to_mm(y, ppi) 

258 

259 

260def size_to_mm(s: gws.UomSize, ppi: int) -> gws.UomSize: 

261 """Convert a size with a unit to millimetres. 

262 

263 Args: 

264 s: Size in ``mm`` or ``px``. 

265 ppi: Pixels per inch. 

266 

267 Returns: 

268 Size in millimetres. 

269 

270 Raises: 

271 ``ValueError``: If the unit is not ``mm`` or ``px``. 

272 """ 

273 x, y, u = s 

274 if u == gws.Uom.mm: 

275 return s 

276 if u == gws.Uom.px: 

277 return px_to_mm(x, ppi), px_to_mm(y, ppi), gws.Uom.mm 

278 raise ValueError(f'invalid unit {u!r}') 

279 

280 

281def extent_to_mm(ext: gws.UomExtent, ppi: int) -> gws.UomExtent: 

282 """Convert an extent with a unit to millimetres. 

283 

284 Args: 

285 ext: Extent in ``mm`` or ``px``. 

286 ppi: Pixels per inch. 

287 

288 Returns: 

289 Extent in millimetres. 

290 

291 Raises: 

292 ``ValueError``: If the unit is not ``mm`` or ``px``. 

293 """ 

294 a, b, c, d, u = ext 

295 if u == gws.Uom.mm: 

296 return ext 

297 if u == gws.Uom.px: 

298 return ( 

299 px_to_mm(a, ppi), 

300 px_to_mm(b, ppi), 

301 px_to_mm(c, ppi), 

302 px_to_mm(d, ppi), 

303 gws.Uom.mm, 

304 ) 

305 raise ValueError(f'invalid unit {u!r}') 

306 

307 

308def to_str(v: gws.UomValue) -> str: 

309 """Convert a value with a unit to a string. 

310 

311 Whole numbers are written without a decimal part. 

312 

313 Args: 

314 v: Value with a unit. 

315 

316 Returns: 

317 A string like ``5mm``. 

318 """ 

319 x, u = v 

320 sx = str(int(x)) if (x % 1 == 0) else str(x) 

321 return sx + str(u) 

322 

323 

324## 

325 

326 

327_unit_re = re.compile(r"""(?x) 

328 ^ 

329 (?P<number> 

330 -? 

331 (\d+ (\.\d*)? ) 

332 | 

333 (\.\d+) 

334 ) 

335 (?P<unit> \s* [a-zA-Z]*) 

336 $ 

337""") 

338 

339 

340def parse(val: str | float | tuple | list, default_unit: gws.Uom = None) -> gws.UomValue: 

341 """Parse a value with a unit. 

342 

343 Args: 

344 val: A string like ``'5mm'``, a number, or a pair like ``[5, 'mm']``. 

345 default_unit: Unit for numbers and for strings without a known unit. 

346 

347 Returns: 

348 The value with its unit. 

349 

350 Raises: 

351 ``ValueError``: If the format is invalid, or the unit is missing or unknown and there is no default unit. 

352 """ 

353 if isinstance(val, (list, tuple)): 

354 if len(val) == 2: 

355 return parse(f'{val[0]}{val[1]}', default_unit) 

356 raise ValueError(f'invalid format: {val!r}') 

357 

358 if isinstance(val, (int, float)): 

359 if not default_unit: 

360 raise ValueError(f'missing unit: {val!r}') 

361 return val, default_unit 

362 

363 val = gws.u.to_str(val).strip() 

364 m = _unit_re.match(val) 

365 if not m: 

366 raise ValueError(f'invalid format: {val!r}') 

367 

368 n = float(m.group('number')) 

369 u = getattr(gws.Uom, m.group('unit').strip().lower(), None) 

370 

371 if not u: 

372 if not default_unit: 

373 raise ValueError(f'invalid unit: {val!r}') 

374 return n, default_unit 

375 

376 return n, u 

377 

378 

379def parse_point(val: str | tuple | list) -> gws.UomPoint: 

380 """Parse a point with a unit. 

381 

382 Args: 

383 val: A comma-separated string like ``'1mm,2mm'``, a list like ``['1mm', '2mm']`` or a list like ``[1, 2, 'mm']``. 

384 

385 Returns: 

386 The point with its unit. 

387 

388 Raises: 

389 ``ValueError``: If the point is invalid or the units differ. 

390 """ 

391 

392 v = gws.u.to_list(val) 

393 

394 if len(v) == 3: 

395 x, y, u = v 

396 v = [f'{x}{u}', f'{y}{u}'] 

397 

398 if len(v) == 2: 

399 x, u1 = parse(v[0]) 

400 y, u2 = parse(v[1]) 

401 if u1 != u2: 

402 raise ValueError(f'invalid point units: {u1!r} != {u2!r}') 

403 return x, y, u1 

404 

405 raise ValueError(f'invalid point: {val!r}') 

406 

407 

408def parse_extent(val: str | tuple | list) -> gws.UomExtent: 

409 """Parse an extent with a unit. 

410 

411 Args: 

412 val: A comma-separated string like ``'1mm,2mm,3mm,4mm'``, a list of four strings, or a list like ``[1, 2, 3, 4, 'mm']``. 

413 

414 Returns: 

415 The extent with its unit. 

416 

417 Raises: 

418 ``ValueError``: If the extent is invalid or the units differ. 

419 """ 

420 

421 v = gws.u.to_list(val) 

422 

423 if len(v) == 5: 

424 a, b, c, d, u = v 

425 v = [ 

426 f'{a}{u}', 

427 f'{b}{u}', 

428 f'{c}{u}', 

429 f'{d}{u}', 

430 ] 

431 

432 if len(v) == 4: 

433 a, b, c, d = v 

434 n1, u1 = parse(a) 

435 n2, u2 = parse(b) 

436 n3, u3 = parse(c) 

437 n4, u4 = parse(d) 

438 if u1 != u2 or u1 != u3 or u1 != u4: 

439 raise ValueError(f'invalid extent units: {u1!r} != {u2!r} != {u3!r} != {u4!r}') 

440 return n1, n2, n3, n4, u1 

441 

442 raise ValueError(f'invalid extent: {val!r}')