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

105 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +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(xu: gws.UomValue, ppi: int) -> gws.UomValue: 

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

128 

129 Args: 

130 xu: 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 = xu 

140 if u == gws.Uom.px: 

141 return xu 

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(xy: gws.Size, ppi: int) -> gws.Size: 

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

149 

150 Args: 

151 xy: Size in millimetres. 

152 ppi: Pixels per inch. 

153 

154 Returns: 

155 Size in pixels. 

156 """ 

157 x, y = xy 

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

159 

160 

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

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

163 

164 Args: 

165 xyu: 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 = xyu 

175 if u == gws.Uom.px: 

176 return xyu 

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 

182## 

183 

184 

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

186 """Convert pixels to millimetres. 

187 

188 Args: 

189 x: Number of pixels. 

190 ppi: Pixels per inch. 

191 

192 Returns: 

193 Millimetres. 

194 """ 

195 return x * (MM_PER_IN / ppi) 

196 

197 

198def to_mm(xu: gws.UomValue, ppi: int) -> gws.UomValue: 

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

200 

201 Args: 

202 xu: Value in ``mm`` or ``px``. 

203 ppi: Pixels per inch. 

204 

205 Returns: 

206 The value in millimetres. 

207 

208 Raises: 

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

210 """ 

211 x, u = xu 

212 if u == gws.Uom.mm: 

213 return xu 

214 if u == gws.Uom.px: 

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

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

217 

218 

219def size_px_to_mm(xy: gws.Size, ppi: int) -> gws.Size: 

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

221 

222 Args: 

223 xy: Size in pixels. 

224 ppi: Pixels per inch. 

225 

226 Returns: 

227 Size in millimetres. 

228 """ 

229 x, y = xy 

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

231 

232 

233def size_to_mm(xyu: gws.UomSize, ppi: int) -> gws.UomSize: 

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

235 

236 Args: 

237 xyu: Size in ``mm`` or ``px``. 

238 ppi: Pixels per inch. 

239 

240 Returns: 

241 Size in millimetres. 

242 

243 Raises: 

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

245 """ 

246 x, y, u = xyu 

247 if u == gws.Uom.mm: 

248 return xyu 

249 if u == gws.Uom.px: 

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

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

252 

253 

254def to_str(xu: gws.UomValue) -> str: 

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

256 

257 Whole numbers are written without a decimal part. 

258 

259 Args: 

260 xu: Value with a unit. 

261 

262 Returns: 

263 A string like ``5mm``. 

264 """ 

265 x, u = xu 

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

267 return sx + str(u) 

268 

269 

270## 

271 

272 

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

274 ^ 

275 (?P<number> 

276 -? 

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

278 | 

279 (\.\d+) 

280 ) 

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

282 $ 

283""") 

284 

285 

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

287 """Parse a value with a unit. 

288 

289 Args: 

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

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

292 

293 Returns: 

294 The value with its unit. 

295 

296 Raises: 

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

298 """ 

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

300 if len(val) == 2: 

301 return parse(f'{val[0]}{val[1]}') 

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

303 

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

305 if not default_unit: 

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

307 return val, default_unit 

308 

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

310 m = _unit_re.match(val) 

311 if not m: 

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

313 

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

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

316 

317 if not u: 

318 if not default_unit: 

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

320 return n, default_unit 

321 

322 return n, u 

323 

324 

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

326 """Parse a point with a unit. 

327 

328 Args: 

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

330 

331 Returns: 

332 The point with its unit. 

333 

334 Raises: 

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

336 """ 

337 

338 v = gws.u.to_list(val) 

339 

340 if len(v) == 3: 

341 v = [f'{v[0]}{v[2]}', f'{v[1]}{v[2]}'] 

342 

343 if len(v) == 2: 

344 n1, u1 = parse(v[0]) 

345 n2, u2 = parse(v[1]) 

346 if u1 != u2: 

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

348 return n1, n2, u1 

349 

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

351 

352 

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

354 """Parse an extent with a unit. 

355 

356 Args: 

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

358 

359 Returns: 

360 The extent with its unit. 

361 

362 Raises: 

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

364 """ 

365 

366 v = gws.u.to_list(val) 

367 

368 if len(v) == 5: 

369 v = [f'{v[0]}{v[4]}', f'{v[1]}{v[2]}', f'{v[2]}{v[4]}', f'{v[3]}{v[4]}'] 

370 

371 if len(v) == 4: 

372 n1, u1 = parse(v[0]) 

373 n2, u2 = parse(v[1]) 

374 n3, u3 = parse(v[2]) 

375 n4, u4 = parse(v[3]) 

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

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

378 return n1, n2, n3, n4, u1 

379 

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