rawmaker 2.40.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. letty/__init__.py +46 -0
  2. letty/cli.py +63 -0
  3. letty/optimizer.py +138 -0
  4. letty/quality/__init__.py +8 -0
  5. letty/quality/whitespace.py +50 -0
  6. letty/strategy.py +8 -0
  7. rawmaker/__init__.py +29 -0
  8. rawmaker/__main__.py +13 -0
  9. rawmaker/__patch__.py +36 -0
  10. rawmaker/cli.py +206 -0
  11. rawmaker/cli_automate.py +69 -0
  12. rawmaker/converter/__init__.py +8 -0
  13. rawmaker/converter/basic.py +174 -0
  14. rawmaker/converter/images.py +168 -0
  15. rawmaker/date.py +83 -0
  16. rawmaker/destination.py +202 -0
  17. rawmaker/error.py +34 -0
  18. rawmaker/features/__init__.py +138 -0
  19. rawmaker/features/annotation.py +254 -0
  20. rawmaker/features/border.py +172 -0
  21. rawmaker/features/boxes.py +153 -0
  22. rawmaker/features/figures.py +24 -0
  23. rawmaker/features/fonts.py +229 -0
  24. rawmaker/features/formula.py +16 -0
  25. rawmaker/features/horizontals.py +132 -0
  26. rawmaker/features/images.py +155 -0
  27. rawmaker/features/line.py +337 -0
  28. rawmaker/features/outlines.py +123 -0
  29. rawmaker/features/text.py +91 -0
  30. rawmaker/fonts/__init__.py +8 -0
  31. rawmaker/fonts/parser.py +354 -0
  32. rawmaker/images/__init__.py +8 -0
  33. rawmaker/images/info.py +35 -0
  34. rawmaker/miner/__init__.py +8 -0
  35. rawmaker/miner/char.py +42 -0
  36. rawmaker/miner/colorspace.py +75 -0
  37. rawmaker/miner/images.py +448 -0
  38. rawmaker/miner/position.py +121 -0
  39. rawmaker/miner/rawchar.py +207 -0
  40. rawmaker/miner/text.py +833 -0
  41. rawmaker/miner/underline.py +66 -0
  42. rawmaker/parameter.py +130 -0
  43. rawmaker/patch/__init__.py +8 -0
  44. rawmaker/patch/ltchar.py +79 -0
  45. rawmaker/reader.py +97 -0
  46. rawmaker/text/__init__.py +8 -0
  47. rawmaker/text/chars.py +24 -0
  48. rawmaker/text/data.py +47 -0
  49. rawmaker/text/superfast.py +91 -0
  50. rawmaker/text/wordbox.py +95 -0
  51. rawmaker/utils.py +44 -0
  52. rawmaker-2.40.3.dist-info/METADATA +51 -0
  53. rawmaker-2.40.3.dist-info/RECORD +63 -0
  54. rawmaker-2.40.3.dist-info/WHEEL +5 -0
  55. rawmaker-2.40.3.dist-info/entry_points.txt +6 -0
  56. rawmaker-2.40.3.dist-info/licenses/LICENSE +21 -0
  57. rawmaker-2.40.3.dist-info/top_level.txt +3 -0
  58. spacestation/__init__.py +18 -0
  59. spacestation/cli.py +51 -0
  60. spacestation/features/__init__.py +8 -0
  61. spacestation/features/chardist.py +85 -0
  62. spacestation/features/worddist.py +57 -0
  63. spacestation/features/wspace.py +130 -0
@@ -0,0 +1,448 @@
1
+ # =============================================================================
2
+ # C O P Y R I G H T
3
+ # -----------------------------------------------------------------------------
4
+ # Copyright (c) 2019-2023 by Helmut Konrad Schewe. All rights reserved.
5
+ # This file is property of Helmut Konrad Schewe. Any unauthorized copy,
6
+ # use or distribution is an offensive act against international law and may
7
+ # be prosecuted under federal law. Its content is company confidential.
8
+ # =============================================================================
9
+ """Extract images out of a PDF-Page
10
+
11
+ There are 2 types of images to extract:
12
+
13
+ One the one hand, there is the image which stored in internal format
14
+ One the other hand, there is the image that is generated by a color table
15
+
16
+ Furthermore there are some images which are composed out of other
17
+ images. Sometimes one Image is splitted into two or three parts. The
18
+ maximum of this split is one image per pixel line.
19
+
20
+ NOTE Currently this feature is experimental.
21
+ """
22
+
23
+ import array
24
+ import collections
25
+ import io
26
+ import os
27
+
28
+ import pdfminer.converter
29
+ import pdfminer.image
30
+ import pdfminer.layout
31
+ import pdfminer.pdfdocument
32
+ import pdfminer.pdfinterp
33
+ import pdfminer.pdftypes
34
+ import pdfminer.psparser
35
+ import PIL.Image
36
+ import PIL.ImageDraw
37
+ import PIL.ImageDraw2
38
+ import PIL.PngImagePlugin
39
+ import utilo
40
+
41
+ import rawmaker.converter.images
42
+ import rawmaker.miner.colorspace
43
+
44
+ MergedImage = collections.namedtuple('MergedImage', 'image, ext, bounding')
45
+ WrittenImage = collections.namedtuple('WrittenImage', 'filename, bounding')
46
+ LTImages = list[pdfminer.layout.LTImage]
47
+
48
+
49
+ def extract_images(
50
+ document: pdfminer.pdfdocument.PDFDocument,
51
+ outputfolder,
52
+ pages: tuple = None,
53
+ ) -> dict:
54
+ """Extract all images of `document` of selected `pages`.
55
+
56
+ Hint: `Outputfolder` is only created if `document` contains some images.
57
+
58
+ Args:
59
+ document: source to extract images from
60
+ outputfolder(str): write extracted images to
61
+ pages(tuple): selective list to process pages
62
+ Returns:
63
+ dict with one list per page with containing images of this page
64
+ """
65
+ # ensure that page computation works correct
66
+ if pages:
67
+ pages = utilo.ensure_tuple(pages)
68
+ pages = sorted(pages)
69
+ # Processing layout
70
+ content = pdfminer.pdfpage.PDFPage.create_pages(document)
71
+ # setup collector
72
+ collect = CollectAndMerge(outputfolder)
73
+ firstpage = pages[0] if pages else 0
74
+ interpreter = rawmaker.converter.images.create_fastimageextractor(
75
+ collect.imagereciver,
76
+ firstpage=firstpage,
77
+ )
78
+ # iterate pages
79
+ with utilo.SkipCollector(pages) as collector:
80
+ for number, page in enumerate(content):
81
+ if collector.skip(number):
82
+ continue
83
+ page.pageid = number
84
+ interpreter.process_page(page)
85
+ # determine result
86
+ result = collect.merge_and_write()
87
+ return result
88
+
89
+
90
+ class CollectAndMerge:
91
+
92
+ def __init__(self, outputfolder):
93
+ self.outputfolder = outputfolder
94
+ self.to_merge = collections.defaultdict(list)
95
+ self.written = collections.defaultdict(list)
96
+
97
+ def imagereciver(self, page, image):
98
+ self.to_merge[page].append(image)
99
+
100
+ def merge_and_write(self) -> dict:
101
+ if not self.to_merge:
102
+ # no images given
103
+ return {}
104
+ os.makedirs(self.outputfolder, exist_ok=True)
105
+ merged = merge_document_images(self.to_merge)
106
+ # write merged images
107
+ for page, values in merged.items():
108
+ for index, extracted in enumerate(values):
109
+ if not extracted:
110
+ continue
111
+ written = write_image(
112
+ extracted,
113
+ write_to=self.outputfolder,
114
+ page=page,
115
+ index=index,
116
+ )
117
+ self.written[page].append(written)
118
+ self.to_merge.clear()
119
+ # convert defaultdict to normal dict, remove empty pages
120
+ result = {key: value for key, value in self.written.items() if value}
121
+ return result
122
+
123
+
124
+ IMAGE_WIDTH_MAX = 2048
125
+ IMAGE_HEIGHT_MAX = 2048
126
+
127
+
128
+ def write_image(extracted, write_to, page, index) -> WrittenImage:
129
+ """Write image `extracted` to directory `write_to`.
130
+
131
+ The file is named {page}_{index}.{extracted.ext}.
132
+ """
133
+ assert extracted
134
+ ext = extracted.ext
135
+ filename = f'{page}_{index}.{ext}'
136
+ if isinstance(extracted.image, PIL.Image.Image):
137
+ outpath = os.path.join(write_to, filename)
138
+ with open(outpath, mode='wb') as output:
139
+ ext = ext.replace('jpg', 'jpeg')
140
+ try:
141
+ extracted.image.save(output, format=ext)
142
+ except Exception: # pylint:disable=broad-except
143
+ utilo.error(f'could not use save method: {filename}')
144
+ else:
145
+ try:
146
+ # images writer add file extention bt themself
147
+ writer = pdfminer.image.ImageWriter(write_to)
148
+ rawimage = extracted.image
149
+ rawimage.name = f'{page}_{index}'
150
+ if rawimage.width < IMAGE_WIDTH_MAX and rawimage.height < IMAGE_HEIGHT_MAX:
151
+ raw_data = rawimage.stream.get_rawdata()
152
+ if not raw_data:
153
+ utilo.error(f'empty image data, {rawimage.name}')
154
+ else:
155
+ writer.export_image(rawimage)
156
+ else:
157
+ msg = f'skip image size: {rawimage.srcsize} name: {rawimage.name}'
158
+ utilo.info(msg)
159
+ except pdfminer.pdftypes.PDFNotImplementedError as error:
160
+ utilo.error(f'could not export: {error}')
161
+ except TypeError:
162
+ utilo.error(f'empty export: {extracted.image.name}')
163
+ except ValueError:
164
+ utilo.error(f'decompression error: {extracted.image.name}')
165
+ return WrittenImage(filename=filename, bounding=extracted.bounding)
166
+
167
+
168
+ def merge_document_images(items):
169
+ result = collections.defaultdict(list)
170
+ # merge pages by yposition
171
+ for page, content in items.items():
172
+ merged = merge_page(content, page)
173
+ result[page].extend(merged)
174
+ return result
175
+
176
+
177
+ def merge_page(images: LTImages, page: int):
178
+ todo = [
179
+ utilo.roundme((image.x0, image.y0, image.x1, image.y1))
180
+ for image in images
181
+ ]
182
+ # cluster image parts into mergable image
183
+ lookup = {str(item): line for item, line in zip(todo, images)}
184
+ # assert len(lookup) == len(todo), f'{len(lookup)} != {len(todo)}'
185
+ grouped = group_rectangles(todo)
186
+ # convert back
187
+ lines = [[lookup[str(item)] for item in group] for group in grouped]
188
+ result = []
189
+ try:
190
+ result = [raw_images_merge(item) for item in lines]
191
+ except ValueError as error:
192
+ utilo.error(f'could not parse images on page: {page}')
193
+ utilo.error(error)
194
+ return result
195
+
196
+
197
+ def group_rectangles(rectangles):
198
+ """Split potential images by distance in y-coordiante."""
199
+ border = range(0, 1000, 10)
200
+ bucket = utilo.Buckets(border, sorting=True)
201
+ bucket.selector = lambda x: x[1] # TODO: REMOVE THIS HACK
202
+ for item in rectangles:
203
+ bucket.add(item)
204
+ grouped = utilo.groupby_empty(bucket)
205
+ if not grouped:
206
+ return []
207
+ # merge neighbors which are huger than bucket size
208
+ result = [list(grouped[0])]
209
+ for current in grouped[1:]:
210
+ before = result[-1][-1]
211
+ if utilo.near(before[3], current[0][1], diff=5.0):
212
+ result[-1].extend(current)
213
+ elif any(utilo.rectangles_intersecting(result[-1], item) for item in current): # yapf:disable
214
+ # verify if any rectangle intersects to detect rectangles
215
+ # inside each other
216
+ result[-1].extend(current)
217
+ else:
218
+ result.append(list(current))
219
+ return result
220
+
221
+
222
+ BITMAP = '1'
223
+
224
+
225
+ # pylint:disable=R1260,R0914,R0915
226
+ def raw_images_merge(images: LTImages) -> MergedImage:
227
+ """Merge list of images to one image."""
228
+ ext = extention(images[0])
229
+ bounding = tuple(images[0].bbox)
230
+ if ext == 'jbig2':
231
+ images = [jbig2(images[0])]
232
+ ext = 'jpg'
233
+ if len(images) == 1:
234
+ # TODO: png is not supported by pdfimage exporter properly
235
+ if ext != 'png':
236
+ # no merge required
237
+ return MergedImage(images[0], ext, bounding)
238
+ utilo.debug(f'extraction not supported: {images[0]}')
239
+ # determine rectangle bounding
240
+ x00 = min(item.x0 for item in images) # pylint:disable=no-member
241
+ x11 = max(item.x1 for item in images) # pylint:disable=no-member
242
+ y00 = min(item.y0 for item in images) # pylint:disable=no-member
243
+ y11 = max(item.y1 for item in images) # pylint:disable=no-member
244
+ # create empty image to render sub images into
245
+ image_width = x11 - x00
246
+ image_height = y11 - y00
247
+ size = (int(image_width), int(image_height))
248
+ mode = 'RGB'
249
+ result = PIL.Image.new(mode, size, color=0)
250
+ renderer = PIL.ImageDraw.Draw(result, mode=mode)
251
+ # render sub-images
252
+ for image in images:
253
+ ext = extention(image)
254
+ current = image_fromlt(image)
255
+ if not current:
256
+ continue
257
+ # render to common image
258
+ current = ensure_bitmap(current)
259
+ renderer.bitmap((image.x0 - x00, image.y0 - y00), bitmap=current) # pylint:disable=no-member
260
+ # update bottom bounding of merged rectangle
261
+ multi_bounding = (x00, y00, x11, y11)
262
+ return MergedImage(result, ext, multi_bounding)
263
+
264
+
265
+ def ensure_bitmap(image):
266
+ if isinstance(image, PIL.PngImagePlugin.PngImageFile):
267
+ image = image.convert(mode=BITMAP, colors=1024, palette='1')
268
+ return image
269
+
270
+
271
+ def jbig2(image):
272
+ # convert size, cause later fill method requests int
273
+ size = (int(image.width), int(image.height))
274
+
275
+ monochrom = '1'
276
+ result = PIL.Image.new(mode=monochrom, size=size, color=1)
277
+ renderer = PIL.ImageDraw.Draw(result, mode=monochrom)
278
+
279
+ data = image.stream.get_data()
280
+ width = image.width
281
+ for cursor, item in enumerate(data):
282
+ cursor = cursor * 8
283
+ x, y = cursor % width, cursor // (width / 8)
284
+ for pos in range(8):
285
+ datum = item << pos & 0b00000001
286
+ renderer.point((x + pos, y), datum)
287
+ return result
288
+
289
+
290
+ def image_fromlt(image) -> PIL.Image: # pylint:disable=R0912
291
+ try:
292
+ colorspace = rawmaker.miner.colorspace.parse(image.colorspace)
293
+ except AttributeError as error:
294
+ utilo.print_stacktrace()
295
+ utilo.error(error)
296
+ colorspace = 'DeviceRGB'
297
+ try:
298
+ data = image.stream.get_data()
299
+ except ValueError as error:
300
+ utilo.error(error)
301
+ return None
302
+ except pdfminer.pdftypes.PDFNotImplementedError as error:
303
+ if 'JPXDecode' in str(error):
304
+ utilo.debug(error)
305
+ utilo.debug('use own png converter')
306
+ rawdata = image.stream.get_rawdata()
307
+ return png_load(rawdata)
308
+ utilo.error(error)
309
+ return None
310
+ # try to load images
311
+ mode = '1' # default mode
312
+ size = image.srcsize
313
+ bits = image.bits
314
+ if colorspace == 'DeviceGray':
315
+ mode = BITMAP
316
+ elif colorspace:
317
+ data = rgb256_decoder(data, colorspace, bits=bits)
318
+ else:
319
+ # black and white
320
+ mode = BITMAP
321
+
322
+ if bits == 4:
323
+ # TODO Do not know why this is required
324
+ size = (size[0] + 1, size[1])
325
+ mode = 'RGB'
326
+ if colorspace == 'DeviceRGB':
327
+ try:
328
+ # open jpg etc.
329
+ buffer = io.BytesIO(data)
330
+ current = PIL.Image.open(buffer)
331
+ except IOError:
332
+ try:
333
+ current = PIL.Image.frombytes(mode, size, data)
334
+ except ValueError:
335
+ # TODO: REMOVE THIS DIRTY SHIT
336
+ current = PIL.Image.frombytes('1', size, data)
337
+ else:
338
+ try:
339
+ current = PIL.Image.frombytes(mode, size, data)
340
+ except ValueError:
341
+ utilo.error(f'could not decode: {image}')
342
+ return None
343
+ # convert to bitmap
344
+ try:
345
+ current = current.convert(mode=BITMAP, colors=1024, palette='1')
346
+ loaded = io.BytesIO(current.tobitmap())
347
+ current = PIL.Image.open(loaded)
348
+ except OSError:
349
+ current = PIL.Image.new(mode, size, color=0)
350
+ return current
351
+
352
+
353
+ def png_load(rawdata) -> PIL.Image:
354
+ """Convert JPEG2000 to png data."""
355
+ # TODO: MOVE THIS CODE TO PDFMINER
356
+ buffer = io.BytesIO(rawdata)
357
+ buffer.seek(0)
358
+ with PIL.Image.open(buffer) as fp:
359
+ converted = io.BytesIO()
360
+ try:
361
+ fp.save(converted, 'png')
362
+ except OSError:
363
+ utilo.error('invalid png file, maybe an other type')
364
+ utilo.error(rawdata[0:50])
365
+ return None
366
+ converted.seek(0)
367
+ loaded = PIL.Image.open(converted)
368
+ return loaded
369
+
370
+
371
+ def rgb256_decoder(data, dataspace, bits=8):
372
+ # RGB
373
+ # TODO: FIX TABLE ERRORS
374
+ table = []
375
+ if isinstance(dataspace, pdfminer.pdftypes.PDFStream):
376
+ dataspace = dataspace.get_data()
377
+ for index in range(0, len(dataspace), 3):
378
+ try:
379
+ table.append([
380
+ dataspace[index],
381
+ dataspace[index + 1],
382
+ dataspace[index + 2],
383
+ ])
384
+ except IndexError:
385
+ utilo.debug('rgb256 decoder out of bounds')
386
+ return data
387
+ result = []
388
+ for item in data:
389
+ try:
390
+ if bits == 4:
391
+ lower = table[item & (15)]
392
+ higher = table[item & (15 >> 4 - 1)]
393
+ result.extend(lower)
394
+ result.extend(higher)
395
+ elif bits == 8:
396
+ result.extend(table[item])
397
+ else:
398
+ raise ValueError(f'{bits} bits not supported')
399
+ except IndexError:
400
+ utilo.debug('rgb256 decoder out of bounds')
401
+ return data
402
+ try:
403
+ data = array.array("B", result).tobytes()
404
+ except TypeError:
405
+ return data
406
+ return data
407
+
408
+
409
+ def extention(image) -> str:
410
+ """\
411
+ #JBIG2Decode: monochrom 1bit per pixel data
412
+ """
413
+ decoder = {
414
+ 'DCTDecode': 'jpg',
415
+ 'JPXDecode': 'png',
416
+ 'CCITTFaxDecode': 'tiff',
417
+ 'Default': 'png',
418
+ 'FlateDecode': 'png',
419
+ 'JBIG2Decode': 'jbig2',
420
+ 'RunLengthDecode': 'png',
421
+ }
422
+ try:
423
+ filters = image.stream['Filter']
424
+ if isinstance(filters, list):
425
+ # TODO: SUPPORT MULTIPLE FILTER
426
+ if len(filters) > 1:
427
+ utilo.error(f'more than one filter: {filters}')
428
+ # assert len(filter_) == 1, str(filter_)
429
+ filters = filters[0]
430
+ imagefilter = filters.name
431
+ except KeyError:
432
+ imagefilter = 'Default'
433
+ ext = decoder[imagefilter]
434
+ return ext
435
+
436
+
437
+ # def extention(image) -> str:
438
+ # stream = image.stream
439
+ # filters = stream.get_filters()
440
+ # (width, height) = image.srcsize
441
+ # if len(filters) == 1 and filters[0][0] in LITERALS_DCT_DECODE:
442
+ # ext = 'jpg'
443
+ # elif (image.bits == 1 or image.bits == 8 and
444
+ # image.colorspace in (LITERAL_DEVICE_RGB, LITERAL_DEVICE_GRAY)):
445
+ # ext = 'bmp'
446
+ # else:
447
+ # ext = 'png'
448
+ # return ext
@@ -0,0 +1,121 @@
1
+ # =============================================================================
2
+ # C O P Y R I G H T
3
+ # -----------------------------------------------------------------------------
4
+ # Copyright (c) 2019-2023 by Helmut Konrad Schewe. All rights reserved.
5
+ # This file is property of Helmut Konrad Schewe. Any unauthorized copy,
6
+ # use or distribution is an offensive act against international law and may
7
+ # be prosecuted under federal law. Its content is company confidential.
8
+ # =============================================================================
9
+ """Save position of element by object hash"""
10
+
11
+ import contextlib
12
+ import statistics
13
+
14
+ import iamraw
15
+ import utilo
16
+
17
+
18
+ class DocumentItemHasher:
19
+
20
+ # TODO: REMOVE THIS SENSELESS CLASS?
21
+ def __init__(self, page: int = -1):
22
+ self.data = {}
23
+ self.page = page
24
+
25
+ def hashitem(self, item: str, position):
26
+ hashid = hash(item)
27
+ # assert that hashid is not saved before, 'collision %s' % item
28
+ # TODO: Investigate later, how to avoid collision
29
+ assert hashid not in self.data, f'collision "{item}"'
30
+ # while hashid in self.data:
31
+ # hashid += 1
32
+ self.data[hashid] = position
33
+
34
+ def position(self, item):
35
+ hashid = hash(item)
36
+ try:
37
+ current = self.data[hashid]
38
+ return current
39
+ except KeyError as error:
40
+ # TODO: CHANGE TO KEY ERROR
41
+ raise ItemNotFound(f'not stored: {item} {hashid}') from error
42
+
43
+ def __eq__(self, value):
44
+ return value and (str(self) == str(value))
45
+
46
+ def __hash__(self):
47
+ return hash(str(self))
48
+
49
+ def __str__(self):
50
+ result = [f'DocumentItemHasher, size: {len(self.data)}']
51
+ for key, value in self.data.items():
52
+ result.append(f'{key} {value}')
53
+ return utilo.NEWLINE.join(result)
54
+
55
+
56
+ def load_hasher(content: str) -> DocumentItemHasher:
57
+ loaded = utilo.yaml_load(content)
58
+ result = []
59
+ for page in loaded:
60
+ pagenumber = int(page['page'])
61
+ hasher = DocumentItemHasher(page=pagenumber)
62
+ for item in page['content']:
63
+ key, position = item.split(maxsplit=1)
64
+ hasher.data[int(key)] = iamraw.BoundingBox.from_str(position)
65
+ result.append(hasher)
66
+ return result
67
+
68
+
69
+ def hash_positions(
70
+ document: iamraw.Document,
71
+ pages=None,
72
+ ) -> iamraw.PageContentTextPositions:
73
+ assert isinstance(document, iamraw.Document), type(document)
74
+ collected = []
75
+ with utilo.SkipCollector(pages) as collector:
76
+ for page in document:
77
+ pagenumber = page.page
78
+ if collector.skip(pagenumber):
79
+ continue
80
+ hasher = DocumentItemHasher(pagenumber)
81
+ collected.append(hasher)
82
+ index = 0
83
+ for item in page:
84
+ try:
85
+ # TODO: REMOVE?
86
+ # Not every element has text
87
+ _ = item.text
88
+ except AttributeError:
89
+ continue
90
+ # TODO: COMPUTE FOR OTHER LINES THAN ZERO
91
+ mean = mean_height(item.lines[0])
92
+ hasher.hashitem(
93
+ index,
94
+ iamraw.TextPosition(bounding=item.box, mean=mean),
95
+ )
96
+ index += 1
97
+ result = []
98
+ for page in collected:
99
+ pagenumber = page.page
100
+ content = dict(page.data)
101
+ result.append(
102
+ iamraw.PageContentTextPosition(
103
+ content=content,
104
+ page=pagenumber,
105
+ ))
106
+ return result
107
+
108
+
109
+ def mean_height(chars):
110
+ height = []
111
+ for char in chars:
112
+ with contextlib.suppress(AttributeError):
113
+ height.append(char.box.y1 - char.box.y0)
114
+ if not height:
115
+ return 0.0
116
+ mean = statistics.mean(height)
117
+ return utilo.roundme(mean)
118
+
119
+
120
+ class ItemNotFound(ValueError):
121
+ pass