python-gdb 0.3.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-gdb
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Topic :: Scientific/Engineering :: GIS
@@ -29,38 +29,61 @@ from .gdb_reader import (
29
29
  header_fields,
30
30
  iter_blobs,
31
31
  read_blob_directory,
32
+ read_blob_symbols,
32
33
  read_blob_values,
33
34
  read_channels,
34
35
  read_lines,
35
36
  )
36
37
  from .grd_reader import GrdHeader, GRDParseWarning, read_grd
37
38
  from .lzrw1 import LZRW1DecodeError
38
- from .registry import find_coordinate_systems
39
+ from .registry import (
40
+ ChannelMaker,
41
+ DisplayListEntry,
42
+ ProjectionParameters,
43
+ find_channel_makers,
44
+ find_channel_roles,
45
+ find_channel_settings,
46
+ find_coordinate_systems,
47
+ find_display_lists,
48
+ find_projection_parameters,
49
+ )
50
+ from .unseen import GDBUnseenFeatureWarning, unseen_feature_report
39
51
 
40
52
  __all__ = [
41
53
  "GDB",
42
54
  "BlobDirectory",
43
55
  "BlobHeader",
56
+ "ChannelMaker",
44
57
  "ChannelRecord",
45
58
  "CompressionInfo",
59
+ "DisplayListEntry",
46
60
  "GDBParseWarning",
61
+ "GDBUnseenFeatureWarning",
47
62
  "GRDParseWarning",
48
63
  "GrdHeader",
49
64
  "LZRW1DecodeError",
50
65
  "LineRecord",
66
+ "ProjectionParameters",
51
67
  "check_magic",
52
68
  "exact_line_table_start",
53
69
  "find_blob",
70
+ "find_channel_makers",
71
+ "find_channel_roles",
72
+ "find_channel_settings",
54
73
  "find_channel_table",
55
74
  "find_coordinate_systems",
75
+ "find_display_lists",
56
76
  "find_line_table",
77
+ "find_projection_parameters",
57
78
  "header_fields",
58
79
  "iter_blobs",
59
80
  "read_blob_directory",
81
+ "read_blob_symbols",
60
82
  "read_blob_values",
61
83
  "read_channels",
62
84
  "read_grd",
63
85
  "read_lines",
86
+ "unseen_feature_report",
64
87
  ]
65
88
 
66
89
  from importlib.metadata import PackageNotFoundError, version
@@ -27,6 +27,7 @@ from .gdb_reader import (
27
27
  GS_TYPE_DUMMY_VALUE,
28
28
  GS_TYPE_NUMPY_DTYPE,
29
29
  LineRecord,
30
+ blob_region_start,
30
31
  check_magic,
31
32
  header_fields,
32
33
  iter_blobs,
@@ -38,7 +39,18 @@ from .gdb_reader import (
38
39
  DIRECTORY_INVALID,
39
40
  DIRECTORY_LIVE,
40
41
  )
41
- from .registry import find_channel_roles, find_coordinate_systems
42
+ from .registry import (
43
+ ChannelMaker,
44
+ DisplayListEntry,
45
+ ProjectionParameters,
46
+ find_channel_makers,
47
+ find_channel_roles,
48
+ find_channel_settings,
49
+ find_coordinate_systems,
50
+ find_display_lists,
51
+ find_projection_parameters,
52
+ )
53
+ from . import unseen
42
54
 
43
55
  # docs/spec.md section 7
44
56
  _DB_COMP_NAMES = {
@@ -176,6 +188,8 @@ class GDB:
176
188
  f"not a recognized .gdb file"
177
189
  )
178
190
  self._fields = header_fields(header)
191
+ unseen.check_header(path, header)
192
+ unseen.check_admin_objects(path)
179
193
  self._channels: Optional[List[ChannelRecord]] = None
180
194
  self._lines: Optional[List[LineRecord]] = None
181
195
  self._lines_exact = False
@@ -184,6 +198,10 @@ class GDB:
184
198
  self._blob_index: Optional[Dict[Tuple[int, int], BlobHeader]] = None
185
199
  self._coordinate_systems: Optional[List[str]] = None
186
200
  self._coordinate_channels: Optional[Dict[str, Optional[str]]] = None
201
+ self._channel_settings: Optional[Dict[str, Dict[str, str]]] = None
202
+ self._projection_parameters: Optional[Dict[str, ProjectionParameters]] = None
203
+ self._channel_makers: Optional[Dict[str, ChannelMaker]] = None
204
+ self._display_lists: Optional[List[List[DisplayListEntry]]] = None
187
205
 
188
206
  def __repr__(self) -> str:
189
207
  return f"GDB({self.path!r})"
@@ -273,6 +291,68 @@ class GDB:
273
291
  )
274
292
  return self._coordinate_channels
275
293
 
294
+ @property
295
+ def channel_settings(self) -> Dict[str, Dict[str, str]]:
296
+ """
297
+ dict of {str : dict of {str : str}}: Real per-channel settings
298
+ recorded in this file's own internal registry
299
+ (docs/provenance/notes.md section 6.8c), e.g. `{"raw_mag":
300
+ {"UNITS": "nT"}, ...}`. Only a channel with at least one
301
+ populated registry key is present; most real files have none for
302
+ most channels -- see `pygdb.registry.find_channel_settings` for
303
+ what is and isn't decoded (the registry's key/value entries, not
304
+ its nested `MAKER` records, which are `channel_makers`).
305
+ """
306
+ if self._channel_settings is None:
307
+ self._channel_settings = find_channel_settings(self.path, channels=self.channels)
308
+ return self._channel_settings
309
+
310
+ @property
311
+ def channel_makers(self) -> Dict[str, ChannelMaker]:
312
+ """
313
+ dict of {str : ChannelMaker}: How each channel was made, from the
314
+ `MAKER` records in this file's registry (docs/spec.md section 9) --
315
+ the tool (`"newchan.gx"`, a math expression, `"newxy.gx"`, ...),
316
+ its label, and the parameters it ran with, e.g. a derived
317
+ channel's formula. Only channels with a record are present; see
318
+ `pygdb.registry.find_channel_makers`.
319
+ """
320
+ if self._channel_makers is None:
321
+ self._channel_makers = find_channel_makers(self.path, channels=self.channels)
322
+ return self._channel_makers
323
+
324
+ @property
325
+ def display_lists(self) -> List[List[DisplayListEntry]]:
326
+ """
327
+ list of list of DisplayListEntry: The file's `Display List` objects
328
+ (docs/spec.md section 9) -- **[LIKELY]** the channels shown in the
329
+ database's spreadsheet view -- each entry resolved by handle to the
330
+ channel's current name. See `pygdb.registry.find_display_lists`.
331
+ """
332
+ if self._display_lists is None:
333
+ self._display_lists = find_display_lists(self.path, channels=self.channels)
334
+ return self._display_lists
335
+
336
+ @property
337
+ def projection_parameters(self) -> Dict[str, ProjectionParameters]:
338
+ """
339
+ dict of {str : ProjectionParameters}: Real geodetic parameters for
340
+ each coordinate system named in `coordinate_systems`, decoded from
341
+ this file's own internal IPJ registry (docs/spec.md section 8,
342
+ docs/provenance/notes.md section 6.7b) -- datum, ellipsoid, and
343
+ (when this coordinate system is a projected one) central
344
+ meridian, scale factor, and false easting/northing. An empty
345
+ dict just means no `IPJ` content was found, same as
346
+ `coordinate_systems`; see `pygdb.registry.find_projection_parameters`
347
+ for exactly what is and isn't decoded.
348
+ """
349
+ if self._projection_parameters is None:
350
+ max_real_line_slot = max((line.index for line in self.lines), default=-1)
351
+ self._projection_parameters = find_projection_parameters(
352
+ self.path, max_real_line_slot=max_real_line_slot,
353
+ )
354
+ return self._projection_parameters
355
+
276
356
  # -- channels / lines ----------------------------------------------------
277
357
 
278
358
  @property
@@ -532,7 +612,13 @@ class GDB:
532
612
  f"against the line's other channels, this is the likely cause (see issue #2)"
533
613
  )
534
614
  return
535
- first_offset = min(by_offset)
615
+ # Directory start pages count from the blob region's start (header
616
+ # word 108), not from the first blob walked: a real file can hold a
617
+ # page there that is not a blob (docs/spec.md section 6.2).
618
+ with open(self.path, "rb") as f:
619
+ first_offset = blob_region_start(f.read(128))
620
+ if first_offset is None:
621
+ first_offset = min(by_offset)
536
622
  skipped: List[Tuple[int, int]] = []
537
623
  invalid: List[Tuple[int, int]] = []
538
624
  for key in copies:
@@ -50,11 +50,12 @@ import warnings
50
50
  import zlib
51
51
  from dataclasses import dataclass
52
52
 
53
- from typing import BinaryIO, List, Optional, Tuple
53
+ from typing import BinaryIO, Dict, List, Optional, Tuple
54
54
 
55
55
  import numpy as np
56
56
 
57
57
  from . import lzrw1 as _lzrw1
58
+ from . import unseen
58
59
 
59
60
  try:
60
61
  from . import _native as _native_ext
@@ -330,12 +331,24 @@ class ChannelRecord:
330
331
  printable name (observed for real in DB_Mag_833.gdb -- see
331
332
  docs/provenance/notes.md). Real records seen so far always
332
333
  have dtype either a known GS_* code (0-13) or a small
333
- negative string width, and a format code in the known
334
- DB_CHAN_FORMAT_* range (0-6).
334
+ negative string width, a format code in the known
335
+ DB_CHAN_FORMAT_* range (0-6), and an array width of at least
336
+ 1 (elements per fiducial).
337
+
338
+ Notes
339
+ -----
340
+ The array-width test catches leftover records that pass the
341
+ other two: 16 records in three real GSQ files (`DB_AGG_1213`,
342
+ `DB_Mag_1213`, `DB_Mag_1212`) hold clean names -- fill-pattern
343
+ names, projection-catalog names, second copies of real channel
344
+ names -- with dtype 0 and array width 0 (or garbage), and own no
345
+ data blob at all. Every genuine channel in the corpus has width
346
+ 1 or more (docs/provenance/notes.md section 6.2d).
335
347
  """
336
348
  dtype_ok = self.dtype_code in GS_TYPE_NAMES or -256 <= self.dtype_code < 0
337
349
  format_ok = self.format_code in DB_CHAN_FORMAT_NAMES
338
- return dtype_ok and format_ok
350
+ width_ok = self.array_width >= 1
351
+ return dtype_ok and format_ok and width_ok
339
352
 
340
353
 
341
354
  def _read_name(raw: bytes, offset: int, max_len: int = 64):
@@ -400,7 +413,7 @@ def check_magic(data: bytes) -> bool:
400
413
  The full 16-byte `HEADER_SIGNATURE` is only **[LIKELY]** -- it
401
414
  matched exactly in 8/9 real files, but one real file
402
415
  (DB_Mag_Elaine_1003.gdb, from GSQ's Mount Gordon delivery) has
403
- `f0 f0 f0 f0` at bytes 8-11 instead of the usual `00 00 00 00`.
416
+ `f0 f0 f0 f0` at bytes 4-7 instead of the usual `00 00 00 00`.
404
417
  That file is otherwise structurally normal
405
418
  (chans_max/users_max/page_size all decode sanely), so this looks
406
419
  like a real, if rare, variation in that sub-block rather than a
@@ -679,6 +692,10 @@ def read_channels(path: str) -> List[ChannelRecord]:
679
692
  # DB_CHAN_FORMAT_* range -- garbage doesn't. See docs/provenance/notes.md.
680
693
  continue
681
694
  channels.append(rec)
695
+ unseen.check_channels(path, channels)
696
+ if fields["users_max"] is not None:
697
+ # The user table directly follows the channel table (docs/spec.md section 2.1).
698
+ unseen.check_users(path, data, table_start + chans_max * SYMBOL_RECORD_SIZE, fields["users_max"])
682
699
  return channels
683
700
 
684
701
 
@@ -998,6 +1015,7 @@ def _read_lines(path: str) -> Tuple[List[LineRecord], bool]:
998
1015
  rec = _parse_line_record(data, exact_start + i * SYMBOL_RECORD_SIZE, i)
999
1016
  if rec.name and rec.name_is_clean and rec.category_code in DB_CATEGORY_LINE_NAMES:
1000
1017
  lines.append(rec)
1018
+ unseen.check_lines(path, data, lines)
1001
1019
  return lines, True
1002
1020
 
1003
1021
  try:
@@ -1189,12 +1207,32 @@ def blob_region_start(data: bytes) -> Optional[int]:
1189
1207
  return start_page * page_size
1190
1208
 
1191
1209
 
1210
+ def _next_blob_page(f: BinaryIO, start: int, page_size: int, size: int) -> Optional[int]:
1211
+ """Offset of the first page boundary at or after `start` whose first
1212
+ bytes are the blob magic, or `None` if there is none before `size`
1213
+ minus one header. Reads in large chunks rather than seeking per page."""
1214
+ chunk_pages = max(1, (1 << 20) // page_size)
1215
+ pos = start
1216
+ while pos + BLOB_HEADER_SIZE <= size:
1217
+ f.seek(pos)
1218
+ buf = f.read(chunk_pages * page_size)
1219
+ if not buf:
1220
+ return None
1221
+ for i in range(0, len(buf), page_size):
1222
+ if buf[i:i + 4] == BLOB_MAGIC and pos + i + BLOB_HEADER_SIZE <= size:
1223
+ return pos + i
1224
+ pos += len(buf)
1225
+ return None
1226
+
1227
+
1192
1228
  def iter_blobs(path: str, max_blobs: Optional[int] = None):
1193
1229
  """
1194
1230
  Walk the self-describing blob chain.
1195
1231
 
1196
1232
  Walks from the start of the blob region to end of file (or
1197
- `max_blobs`, or the first framing anomaly).
1233
+ `max_blobs`, or a framing anomaly it cannot step past). A page that
1234
+ does not start a blob is skipped: the walk resumes at the next page
1235
+ that does.
1198
1236
 
1199
1237
  Parameters
1200
1238
  ----------
@@ -1212,7 +1250,9 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
1212
1250
  -----
1213
1251
  GDBParseWarning
1214
1252
  Reaching the file's true end cleanly is silent (the expected,
1215
- common case), but a magic mismatch, a non-positive `n_pages`,
1253
+ common case). Pages skipped because they do not start a blob get
1254
+ one summary warning (count and offsets). A magic mismatch with no
1255
+ later blob page, a non-positive `n_pages`,
1216
1256
  a file that ends mid-header, or landing short of true EOF by
1217
1257
  less than one full header (i.e. real leftover bytes, not
1218
1258
  enough to be read at all) are all real anomalies and each gets
@@ -1230,7 +1270,12 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
1230
1270
  on the true file size) on 20 real files spanning all 3 agencies
1231
1271
  this project has files from and all three `DB_COMP_*` compression
1232
1272
  modes, 2MB to 1.93GB -- see docs/provenance/notes.md section
1233
- 6.6b/6.6d/6.9.
1273
+ 6.6b/6.6d/6.9. One later real file (OpenEI BRIDGE
1274
+ `GP_Master_Gravity_11082023.gdb`, 2023) has pages between blobs
1275
+ that are not blobs: one page of leftover data at the region start
1276
+ and a run of 96 all-zero pages. Its blobs are still contiguous
1277
+ around those gaps, and the resynchronizing walk lands exactly on
1278
+ the file's end.
1234
1279
 
1235
1280
  As a generator, this already "returns partial results" in the most
1236
1281
  natural way possible: whatever's been yielded before a problem is
@@ -1261,6 +1306,7 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
1261
1306
  return
1262
1307
  f.seek(off)
1263
1308
  n = 0
1309
+ skipped_runs = [] # [(first offset, page count)] of pages that are not blobs
1264
1310
  while off + BLOB_HEADER_SIZE <= size:
1265
1311
  if max_blobs is not None and n >= max_blobs:
1266
1312
  return
@@ -1275,17 +1321,25 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
1275
1321
  f"walked -- file is likely truncated; returning the "
1276
1322
  f"{n} blob(s) already yielded"
1277
1323
  )
1278
- else:
1324
+ return
1325
+ # A page that is not a blob header. Real files can hold
1326
+ # such pages between blobs -- leftover data or never-written
1327
+ # zero pages (docs/spec.md section 6.2) -- so resynchronize on
1328
+ # the next page that starts with the blob magic.
1329
+ resume = _next_blob_page(f, off + page_size, page_size, size)
1330
+ if resume is None:
1279
1331
  _warn(
1280
1332
  f"{path}: blob magic mismatch at offset {off} "
1281
1333
  f"(got {raw[:4].hex()}, expected {BLOB_MAGIC.hex()}) "
1282
- f"after {n} blob(s) successfully walked -- stopping "
1283
- f"the chain walk here and returning the {n} blob(s) "
1284
- f"already yielded; this may be a real structural "
1285
- f"anomaly or an administrative-blob variant not yet "
1286
- f"understood (docs/provenance/notes.md section 6.4/6.9)"
1334
+ f"after {n} blob(s) successfully walked, and no later "
1335
+ f"page starts a blob -- stopping the chain walk here "
1336
+ f"and returning the {n} blob(s) already yielded"
1287
1337
  )
1288
- return
1338
+ return
1339
+ skipped_runs.append((off, (resume - off) // page_size))
1340
+ off = resume
1341
+ f.seek(off)
1342
+ continue
1289
1343
  if blob.n_pages <= 0:
1290
1344
  _warn(
1291
1345
  f"{path}: blob at offset {off} (blob_index={blob.blob_index}) "
@@ -1312,6 +1366,15 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
1312
1366
  f.seek(skip, 1)
1313
1367
  off += blob.n_pages * page_size
1314
1368
  n += 1
1369
+ if skipped_runs:
1370
+ pages = sum(count for _start, count in skipped_runs)
1371
+ runs = ", ".join(f"{count} at offset {start}" for start, count in skipped_runs[:5])
1372
+ more = f" and {len(skipped_runs) - 5} more run(s)" if len(skipped_runs) > 5 else ""
1373
+ _warn(
1374
+ f"{path}: skipped {pages} page(s) of the blob region that do not "
1375
+ f"start a blob ({runs}{more}) and resumed the chain walk after "
1376
+ f"each -- leftover or never-written pages between blobs"
1377
+ )
1315
1378
  if n > 0 and off > size:
1316
1379
  # The last blob successfully parsed claimed a page count that
1317
1380
  # implies more data than the file actually contains -- off
@@ -1403,7 +1466,11 @@ def find_blob(path: str, line_slot: int, channel_slot: int, chans_max: Optional[
1403
1466
 
1404
1467
  DIRECTORY_OFFSET = 280 # [CONFIRMED] first blob-directory slot -- docs/spec.md section 2.2
1405
1468
  _DIRECTORY_SLOT_DTYPE = np.dtype([("word", "<u4"), ("n_pages", "<u2")]) # 6 bytes, unaligned
1406
- _DIRECTORY_LIVE_FLAG = 0x8 # top nibble of a live (line, channel) entry's 32-bit word
1469
+ # A live entry's 32-bit word has the 0x80000000 bit set; the 0x40000000
1470
+ # bit flips each time the blob is rewritten (docs/spec.md section 2.2), so
1471
+ # the top nibble is 0x8 or 0xC. The remaining 30 bits are the start page.
1472
+ _DIRECTORY_LIVE_BIT = 0x80000000
1473
+ _DIRECTORY_PAGE_MASK = 0x3FFFFFFF
1407
1474
 
1408
1475
  DIRECTORY_LIVE = "live"
1409
1476
  DIRECTORY_ABSENT = "absent"
@@ -1433,7 +1500,11 @@ class BlobDirectory:
1433
1500
  The directory is an array of 6-byte slots starting at file offset
1434
1501
  280. A live entry is `(0x80000000 | start page, n_pages)` where
1435
1502
  the start page is relative to the first blob (`(offset - blob
1436
- region start) / page_size`). Across the real corpus it addressed
1503
+ region start) / page_size`), optionally with the `0x40000000` bit,
1504
+ which flips each time the blob is rewritten. (Every data entry in
1505
+ the first 22 corpus files read `0x8`; the first data entry with
1506
+ `0xC`, in USGS OFR 2011-1270 `Kalay_nk.gdb`, is the live copy -- the
1507
+ other copy is on the free list.) Across the real corpus it addressed
1437
1508
  100% of the real (line, channel) blobs of 20 of 22 files, and for
1438
1509
  every duplicated pair with an independent oracle (13 of 13) it
1439
1510
  pointed at the correct copy. A slot that is all-zero belongs to a
@@ -1477,9 +1548,9 @@ class BlobDirectory:
1477
1548
  if entry is None:
1478
1549
  return DIRECTORY_ABSENT, None
1479
1550
  word, n_pages = entry
1480
- if word >> 28 != _DIRECTORY_LIVE_FLAG:
1551
+ if not word & _DIRECTORY_LIVE_BIT:
1481
1552
  return DIRECTORY_INVALID, None
1482
- blob = blobs_by_offset.get(first_offset + (word & 0x7FFFFFFF) * page_size)
1553
+ blob = blobs_by_offset.get(first_offset + (word & _DIRECTORY_PAGE_MASK) * page_size)
1483
1554
  if blob is None or blob.blob_index != blob_index or blob.n_pages != n_pages:
1484
1555
  return DIRECTORY_INVALID, None
1485
1556
  return DIRECTORY_LIVE, blob
@@ -1540,6 +1611,77 @@ def read_blob_directory(path: str) -> Optional[BlobDirectory]:
1540
1611
  return BlobDirectory(data_slots=data_slots, entries=entries)
1541
1612
 
1542
1613
 
1614
+ # Blob-symbol record fields, measured from the record's first byte (its
1615
+ # name) -- docs/spec.md section 2.1, docs/provenance/notes.md section 6.2d.
1616
+ _BLOB_SYMBOL_CATEGORY_OFFSET = 76
1617
+ _DB_CATEGORY_BLOB_NORMAL = 0 # vendor constant; the live-symbol category
1618
+
1619
+
1620
+ def read_blob_symbols(path: str) -> Optional[Dict[int, str]]:
1621
+ """
1622
+ Read the names of the file's live administrative objects.
1623
+
1624
+ Parameters
1625
+ ----------
1626
+ path : str
1627
+ Path to the `.gdb` file.
1628
+
1629
+ Returns
1630
+ -------
1631
+ dict of {int : str} or None
1632
+ `{symbol_slot: name}` for every live blob symbol. The object
1633
+ named by slot `k` is the administrative blob whose `blob_index`
1634
+ is `data_slots + k` (`header_fields()["data_slots"]`). `None`
1635
+ when the file has no table to trust: a bad magic or truncated
1636
+ header, or header words that would put the table outside the
1637
+ region before the first blob. Absence is not an anomaly, so
1638
+ nothing is warned.
1639
+
1640
+ Notes
1641
+ -----
1642
+ **[CONFIRMED]** layout (docs/spec.md section 2.1,
1643
+ docs/provenance/notes.md section 6.2d): `blobs_max` 128-byte records
1644
+ starting right after the blob directory, at `280 + 6 * index_slots`,
1645
+ each beginning with its NUL-terminated name. A record is live when
1646
+ its category (record `+76`) is `DB_CATEGORY_BLOB_NORMAL` (0), which
1647
+ held on exactly the 1,267 corpus records that own an administrative
1648
+ blob. A freed slot has bit `0x10000` set and may still hold its old
1649
+ name, so the category, not the name, decides.
1650
+
1651
+ Names seen on real files: four fixed objects (`"__dbreg"`,
1652
+ `"Display List"`, `"Line Selection"`, `"Database Extension
1653
+ Objects"`), `"?|IPJ_<X>:<Y>"` projection objects, and `"__<n>"` REG
1654
+ objects where `n` is a global symbol handle (`blobs_max + lines_max
1655
+ + channel_slot` for a channel).
1656
+ """
1657
+ with open(path, "rb") as f:
1658
+ header = f.read(4096)
1659
+ if not check_magic(header):
1660
+ return None
1661
+ fields = header_fields(header)
1662
+ blobs_max, index_slots = fields["blobs_max"], fields["index_slots"]
1663
+ if blobs_max is None or index_slots is None or blobs_max <= 0 or index_slots <= 0:
1664
+ return None
1665
+ blob_start = blob_region_start(header)
1666
+ start = DIRECTORY_OFFSET + index_slots * _DIRECTORY_SLOT_DTYPE.itemsize
1667
+ size = blobs_max * SYMBOL_RECORD_SIZE
1668
+ if blob_start is None or start + size > blob_start:
1669
+ return None
1670
+ f.seek(start)
1671
+ raw = f.read(size)
1672
+ if len(raw) != size:
1673
+ return None
1674
+ symbols = {}
1675
+ for slot in range(blobs_max):
1676
+ rec = raw[slot * SYMBOL_RECORD_SIZE:(slot + 1) * SYMBOL_RECORD_SIZE]
1677
+ if struct.unpack_from("<i", rec, _BLOB_SYMBOL_CATEGORY_OFFSET)[0] != _DB_CATEGORY_BLOB_NORMAL:
1678
+ continue
1679
+ name, is_clean = _read_name(rec, 0)
1680
+ if name and is_clean:
1681
+ symbols[slot] = name
1682
+ return symbols
1683
+
1684
+
1543
1685
  def _element_width(channel: ChannelRecord) -> Optional[int]:
1544
1686
  """
1545
1687
  Byte width of one element of `channel`'s data.
@@ -1737,7 +1879,13 @@ def _decode_numeric_or_string(raw: bytes, channel: ChannelRecord, row_count: Opt
1737
1879
  if channel.is_string:
1738
1880
  if _native_ext is not None:
1739
1881
  max_len, ucs4 = _native_ext.decode_fixed_width_strings_ucs4(raw, width, n)
1740
- arr = np.frombuffer(ucs4, dtype=f"<U{max_len}")
1882
+ if max_len == 0:
1883
+ # Every record is empty: there is no zero-width numpy string
1884
+ # dtype to view the (empty) buffer as. `np.array(..., "<U0")`
1885
+ # in the fallback below becomes `<U1` the same way.
1886
+ arr = np.zeros(n, dtype="<U1")
1887
+ else:
1888
+ arr = np.frombuffer(ucs4, dtype=f"<U{max_len}")
1741
1889
  else:
1742
1890
  values = [
1743
1891
  raw[i * width : (i + 1) * width].split(b"\x00")[0].decode("ascii", errors="replace")