numbers-parser 4.14.4__py3-none-any.whl → 4.16.1__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.
numbers_parser/model.py CHANGED
@@ -4,9 +4,12 @@ import re
4
4
  from array import array
5
5
  from collections import defaultdict
6
6
  from hashlib import sha1
7
+ from itertools import chain
7
8
  from math import floor
8
9
  from pathlib import Path
9
10
  from struct import pack
11
+ from typing import TYPE_CHECKING
12
+ from warnings import warn
10
13
 
11
14
  from numbers_parser.bullets import (
12
15
  BULLET_CONVERSION,
@@ -29,8 +32,6 @@ from numbers_parser.cell import (
29
32
  PaddingType,
30
33
  Style,
31
34
  VerticalJustification,
32
- xl_col_to_name,
33
- xl_rowcol_to_cell,
34
35
  )
35
36
  from numbers_parser.constants import (
36
37
  ALLOWED_FORMATTING_PARAMETERS,
@@ -38,6 +39,7 @@ from numbers_parser.constants import (
38
39
  CUSTOM_TEXT_PLACEHOLDER,
39
40
  DEFAULT_COLUMN_WIDTH,
40
41
  DEFAULT_DOCUMENT,
42
+ DEFAULT_FONT,
41
43
  DEFAULT_PRE_BNC_BYTES,
42
44
  DEFAULT_ROW_HEIGHT,
43
45
  DEFAULT_TABLE_OFFSET,
@@ -49,11 +51,12 @@ from numbers_parser.constants import (
49
51
  MAX_TILE_SIZE,
50
52
  PACKAGE_ID,
51
53
  CellInteractionType,
54
+ CellValueType,
52
55
  FormatType,
53
56
  OwnerKind,
54
57
  )
55
58
  from numbers_parser.containers import ObjectStore
56
- from numbers_parser.exceptions import UnsupportedError
59
+ from numbers_parser.exceptions import UnsupportedError, UnsupportedWarning
57
60
  from numbers_parser.formula import TableFormulas
58
61
  from numbers_parser.generated import TNArchives_pb2 as TNArchives
59
62
  from numbers_parser.generated import TSAArchives_pb2 as TSAArchives
@@ -74,7 +77,11 @@ from numbers_parser.generated.TSWPArchives_pb2 import (
74
77
  )
75
78
  from numbers_parser.iwafile import find_extension
76
79
  from numbers_parser.numbers_cache import Cacheable, cache
77
- from numbers_parser.numbers_uuid import NumbersUUID
80
+ from numbers_parser.numbers_uuid import NumbersUUID, uuid_to_hex
81
+ from numbers_parser.xrefs import CellRange, ScopedNameRefCache
82
+
83
+ if TYPE_CHECKING:
84
+ from datetime import datetime
78
85
 
79
86
 
80
87
  def create_font_name_map(font_map: dict) -> dict:
@@ -224,6 +231,7 @@ class _NumbersModel(Cacheable):
224
231
  self._table_styles = DataLists(self, "styleTable", "reference")
225
232
  self._table_strings = DataLists(self, "stringTable", "string")
226
233
  self._control_specs = DataLists(self, "control_cell_spec_table", "cell_spec")
234
+ self._formulas = DataLists(self, "formula_table", "formula")
227
235
  self._table_data = {}
228
236
  self._styles = None
229
237
  self._images = {}
@@ -236,6 +244,9 @@ class _NumbersModel(Cacheable):
236
244
  "bottom": defaultdict(),
237
245
  "left": defaultdict(),
238
246
  }
247
+ self.name_ref_cache = ScopedNameRefCache(self)
248
+ self.missing_fonts = {}
249
+ self.calculate_table_uuid_map()
239
250
 
240
251
  def save(self, filepath: Path, package: bool) -> None:
241
252
  self.objects.save(filepath, package)
@@ -258,13 +269,16 @@ class _NumbersModel(Cacheable):
258
269
  self._table_data[table_id] = data
259
270
 
260
271
  # Don't cache: new tables can be added at runtime
261
- def table_ids(self, sheet_id: int) -> list:
262
- """Return a list of table IDs for a given sheet ID."""
272
+ def table_ids(self, sheet_id: int | None = None) -> list:
273
+ """
274
+ Return a list of table IDs for a given sheet ID or all table
275
+ IDs id the sheet ID is None
276
+ """
263
277
  table_info_ids = self.find_refs("TableInfoArchive")
264
278
  return [
265
279
  self.objects[t_id].tableModel.identifier
266
280
  for t_id in table_info_ids
267
- if self.objects[t_id].super.parent.identifier == sheet_id
281
+ if (sheet_id is None or self.objects[t_id].super.parent.identifier == sheet_id)
268
282
  ]
269
283
 
270
284
  # Don't cache: new tables can be added at runtime
@@ -282,15 +296,8 @@ class _NumbersModel(Cacheable):
282
296
  # The base data store contains a reference to rowHeaders.buckets
283
297
  # which is an ordered list that matches the storage buffers, but
284
298
  # identifies which row a storage buffer belongs to (empty rows have
285
- # no storage buffers). Each bucket is:
286
- #
287
- # {
288
- # "hiding_state": 0,
289
- # "index": 0,
290
- # "number_of_cells": 3,
291
- # "size": 0.0
292
- # },
293
- row_bucket_map = {i: None for i in range(self.objects[table_id].number_of_rows)}
299
+ # no storage buffers).
300
+ row_bucket_map = dict.fromkeys(range(self.objects[table_id].number_of_rows))
294
301
  bds = self.objects[table_id].base_data_store
295
302
  bucket_ids = [x.identifier for x in bds.rowHeaders.buckets]
296
303
  idx = 0
@@ -316,6 +323,13 @@ class _NumbersModel(Cacheable):
316
323
  self.objects[table_id].table_name = value
317
324
  return None
318
325
 
326
+ def table_names(self):
327
+ return list(
328
+ chain.from_iterable(
329
+ [[self.table_name(tid) for tid in self.table_ids(sid)] for sid in self.sheet_ids()],
330
+ ),
331
+ )
332
+
319
333
  def table_name_enabled(self, table_id: int, enabled: bool | None = None):
320
334
  if enabled is not None:
321
335
  self.objects[table_id].table_name_enabled = enabled
@@ -751,51 +765,77 @@ class _NumbersModel(Cacheable):
751
765
  owner_id_map[e.internal_owner_id] = NumbersUUID(e.owner_id).hex
752
766
  return owner_id_map
753
767
 
768
+ def calculate_table_uuid_map(self) -> None:
769
+ # Each Table Model has a UUID which is used in references to the table. See
770
+ # Numbers.md#uuid-mapping for more details.
771
+
772
+ # For haunted owner archive types, map formula_owner_uids to their base_owner_uids
773
+ haunted_owner_ids = [
774
+ obj_id
775
+ for obj_id in self.find_refs("FormulaOwnerDependenciesArchive")
776
+ if self.objects[obj_id].owner_kind == OwnerKind.HAUNTED_OWNER
777
+ ]
778
+ if len(haunted_owner_ids) == 0:
779
+ # Some older documents (see issue-18) do not use FormulaOwnerDependenciesArchive
780
+ self._table_id_to_base_id = {}
781
+ return
782
+
783
+ formula_owner_to_base_owner_map = {
784
+ uuid_to_hex(self.objects[obj_id].formula_owner_uid): uuid_to_hex(
785
+ self.objects[obj_id].base_owner_uid,
786
+ )
787
+ for obj_id in haunted_owner_ids
788
+ }
789
+
790
+ # Map table IDs to the base_owner_uids of the formula owners that match
791
+ # the table model's haunted owner
792
+ self._table_id_to_base_id = {
793
+ table_id: formula_owner_to_base_owner_map[
794
+ uuid_to_hex(self.objects[table_id].haunted_owner.owner_uid)
795
+ ]
796
+ for table_id in self.table_ids()
797
+ }
798
+ self._table_base_id_to_formula_owner_id = {
799
+ uuid_to_hex(self.objects[obj_id].base_owner_uid): obj_id for obj_id in haunted_owner_ids
800
+ }
801
+
754
802
  @cache()
755
803
  def table_base_id(self, table_id: int) -> int:
756
804
  """ "Finds the UUID of a table."""
757
- # Look for a TSCE.FormulaOwnerDependenciesArchive objects with the following at the
758
- # root level of the protobuf:
759
- #
760
- # "base_owner_uid": "6a4a5281-7b06-f5a1-904b-7f9ec784b368"",
761
- #   "formula_owner_uid": "6a4a5281-7b06-f5a1-904b-7f9ec784b36d"
762
- #
763
- # The Table UUID is the TSCE.FormulaOwnerDependenciesArchive whose formula_owner_uid
764
- # matches the UUID of the haunted_owner of the Table:
765
- #
766
- # "haunted_owner": {
767
- # "owner_uid": "6a4a5281-7b06-f5a1-904b-7f9ec784b368""
768
- # }
769
- haunted_owner = NumbersUUID(self.objects[table_id].haunted_owner.owner_uid).hex
770
- formula_owner_ids = self.find_refs("FormulaOwnerDependenciesArchive")
771
- for dependency_id in formula_owner_ids: # pragma: no branch
772
- obj = self.objects[dependency_id]
773
- # if obj.owner_kind == OwnerKind.HAUNTED_OWNER:
774
- if obj.HasField("base_owner_uid") and obj.HasField(
775
- "formula_owner_uid",
776
- ): # pragma: no branch
777
- base_owner_uid = NumbersUUID(obj.base_owner_uid).hex
778
- formula_owner_uid = NumbersUUID(obj.formula_owner_uid).hex
779
- if formula_owner_uid == haunted_owner:
780
- return base_owner_uid
781
- return None
805
+ # Table can be empty if the document does not use FormulaOwnerDependenciesArchive
806
+ return self._table_id_to_base_id.get(table_id)
782
807
 
783
- @cache()
784
- def formula_cell_ranges(self, table_id: int) -> list:
785
- """Exract all the formula cell ranges for the Table."""
786
- # https://github.com/masaccio/numbers-parser/blob/main/doc/Numbers.md#formula-ranges
808
+ def get_formula_owner(self, table_id: int) -> object:
809
+ table_uuid = self.table_base_id(table_id)
810
+ return self.objects[self._table_base_id_to_formula_owner_id[table_uuid]]
811
+
812
+ def add_formula_dependency(self, row: int, col: int, table_id: int) -> None:
787
813
  calc_engine = self.calc_engine()
814
+ calc_engine.dependency_tracker.number_of_formulas += 1
815
+ internal_formula_id = calc_engine.dependency_tracker.number_of_formulas
788
816
 
789
- table_base_id = self.table_base_id(table_id)
790
- cell_records = []
791
- for finfo in calc_engine.dependency_tracker.formula_owner_info:
792
- if finfo.HasField("cell_dependencies"): # pragma: no branch
793
- formula_owner_id = NumbersUUID(finfo.formula_owner_id).hex
794
- if formula_owner_id == table_base_id:
795
- for cell_record in finfo.cell_dependencies.cell_record:
796
- if cell_record.contains_a_formula: # pragma: no branch
797
- cell_records.append((cell_record.row, cell_record.column))
798
- return cell_records
817
+ formula_owner = self.get_formula_owner(table_id)
818
+ formula_owner.cell_dependencies.cell_record.append(
819
+ TSCEArchives.CellRecordExpandedArchive(column=col, row=row),
820
+ )
821
+ if len(formula_owner.tiled_cell_dependencies.cell_record_tiles) == 0:
822
+ cell_record_id, cell_record = self.objects.create_object_from_dict(
823
+ "CalculationEngine",
824
+ {
825
+ "internal_owner_id": internal_formula_id,
826
+ "tile_column_begin": 0,
827
+ "tile_row_begin": 0,
828
+ },
829
+ TSCEArchives.CellRecordTileArchive,
830
+ )
831
+ formula_owner.tiled_cell_dependencies.cell_record_tiles.append(
832
+ TSPMessages.Reference(identifier=cell_record_id),
833
+ )
834
+ else:
835
+ cell_record_id = formula_owner.tiled_cell_dependencies.cell_record_tiles[0].identifier
836
+ cell_record = self.objects[cell_record_id]
837
+
838
+ cell_record.cell_records.append(formula_owner.cell_dependencies.cell_record[-1])
799
839
 
800
840
  @cache(num_args=0)
801
841
  def calc_engine_id(self):
@@ -815,8 +855,8 @@ class _NumbersModel(Cacheable):
815
855
 
816
856
  @cache()
817
857
  def calculate_merge_cell_ranges(self, table_id) -> None:
818
- """Exract all the merge cell ranges for the Table."""
819
- # https://github.com/masaccio/numbers-parser/blob/main/doc/Numbers.md#merge-ranges
858
+ """Extract all the merge cell ranges for the Table."""
859
+ # See details in Numbers.md#merge-ranges.
820
860
  owner_id_map = self.owner_id_map()
821
861
  table_base_id = self.table_base_id(table_id)
822
862
 
@@ -879,6 +919,17 @@ class _NumbersModel(Cacheable):
879
919
  return sheet_id
880
920
  return None
881
921
 
922
+ def table_name_to_uuid(self, sheet_name: str, table_name: str) -> str:
923
+ table_ids = [tid for tid in self.table_ids() if table_name == self.table_name(tid)]
924
+ if len(table_ids) == 1:
925
+ return self.table_base_id(table_ids[0])
926
+
927
+ sheet_name_to_id = {self.sheet_name(x): x for x in self.sheet_ids()}
928
+ sheet_id = sheet_name_to_id[sheet_name]
929
+ table_name_to_id = {self.table_name(x): x for x in self.table_ids(sheet_id)}
930
+ table_id = table_name_to_id[table_name]
931
+ return self.table_base_id(table_id)
932
+
882
933
  @cache()
883
934
  def table_uuids_to_id(self, table_uuid) -> int | None:
884
935
  for sheet_id in self.sheet_ids(): # pragma: no branch # noqa: RET503
@@ -886,66 +937,103 @@ class _NumbersModel(Cacheable):
886
937
  if table_uuid == self.table_base_id(table_id):
887
938
  return table_id
888
939
 
889
- def node_to_ref(self, this_table_id: int, row: int, col: int, node):
940
+ def node_to_ref(self, table_id: int, row: int, col: int, node):
941
+ def resolve_range(is_absolute, absolute_list, relative_list, offset, max_val):
942
+ if is_absolute:
943
+ return absolute_list[0].range_begin
944
+ if not relative_list and absolute_list[0].range_begin == max_val:
945
+ return max_val
946
+ return offset + relative_list[0].range_begin
947
+
948
+ def resolve_range_end(is_absolute, absolute_list, relative_list, offset, max_val):
949
+ if is_absolute:
950
+ return range_end(absolute_list[0])
951
+ if not relative_list and range_end(absolute_list[0]) == max_val:
952
+ return max_val
953
+ return offset + range_end(relative_list[0])
954
+
890
955
  if node.HasField("AST_cross_table_reference_extra_info"):
891
956
  table_uuid = NumbersUUID(node.AST_cross_table_reference_extra_info.table_id).hex
892
- other_table_id = self.table_uuids_to_id(table_uuid)
893
- other_table_name = self.table_name(other_table_id)
957
+ to_table_id = self.table_uuids_to_id(table_uuid)
894
958
  else:
895
- other_table_id = None
896
- other_table_name = None
897
-
898
- if other_table_id is not None:
899
- this_sheet_id = self.table_id_to_sheet_id(this_table_id)
900
- other_sheet_id = self.table_id_to_sheet_id(other_table_id)
901
- if this_sheet_id != other_sheet_id:
902
- other_sheet_name = self.sheet_name(other_sheet_id)
903
- other_table_name = f"{other_sheet_name}::" + other_table_name
959
+ to_table_id = None
904
960
 
905
961
  if node.HasField("AST_colon_tract"):
906
- return self.tract_to_row_col_ref(node, other_table_name, row, col)
907
- if node.HasField("AST_row") and not node.HasField("AST_column"):
908
- return node_to_row_ref(node, other_table_name, row)
909
- if node.HasField("AST_column") and not node.HasField("AST_row"):
910
- return node_to_col_ref(node, other_table_name, col)
911
- return node_to_row_col_ref(node, other_table_name, row, col)
962
+ row_begin = resolve_range(
963
+ node.AST_sticky_bits.begin_row_is_absolute,
964
+ node.AST_colon_tract.absolute_row,
965
+ node.AST_colon_tract.relative_row,
966
+ row,
967
+ 0x7FFFFFFF,
968
+ )
912
969
 
913
- def tract_to_row_col_ref(self, node: object, table_name: str, row: int, col: int) -> str:
914
- if node.AST_sticky_bits.begin_row_is_absolute:
915
- row_begin = node.AST_colon_tract.absolute_row[0].range_begin
916
- else:
917
- row_begin = row + node.AST_colon_tract.relative_row[0].range_begin
970
+ row_end = resolve_range_end(
971
+ node.AST_sticky_bits.end_row_is_absolute,
972
+ node.AST_colon_tract.absolute_row,
973
+ node.AST_colon_tract.relative_row,
974
+ row,
975
+ 0x7FFFFFFF,
976
+ )
918
977
 
919
- if node.AST_sticky_bits.end_row_is_absolute:
920
- row_end = range_end(node.AST_colon_tract.absolute_row[0])
921
- else:
922
- row_end = row + range_end(node.AST_colon_tract.relative_row[0])
978
+ col_begin = resolve_range(
979
+ node.AST_sticky_bits.begin_column_is_absolute,
980
+ node.AST_colon_tract.absolute_column,
981
+ node.AST_colon_tract.relative_column,
982
+ col,
983
+ 0x7FFF,
984
+ )
923
985
 
924
- if node.AST_sticky_bits.begin_column_is_absolute:
925
- col_begin = node.AST_colon_tract.absolute_column[0].range_begin
926
- else:
927
- col_begin = col + node.AST_colon_tract.relative_column[0].range_begin
986
+ col_end = resolve_range_end(
987
+ node.AST_sticky_bits.end_column_is_absolute,
988
+ node.AST_colon_tract.absolute_column,
989
+ node.AST_colon_tract.relative_column,
990
+ col,
991
+ 0x7FFF,
992
+ )
928
993
 
929
- if node.AST_sticky_bits.end_column_is_absolute:
930
- col_end = range_end(node.AST_colon_tract.absolute_column[0])
931
- else:
932
- col_end = col + range_end(node.AST_colon_tract.relative_column[0])
994
+ return CellRange(
995
+ model=self,
996
+ row_start=None if row_begin == 0x7FFFFFFF else row_begin,
997
+ row_end=None if row_end == 0x7FFFFFFF else row_end,
998
+ col_start=None if col_begin == 0x7FFF else col_begin,
999
+ col_end=None if col_end == 0x7FFF else col_end,
1000
+ row_start_is_abs=node.AST_sticky_bits.begin_row_is_absolute,
1001
+ row_end_is_abs=node.AST_sticky_bits.end_row_is_absolute,
1002
+ col_start_is_abs=node.AST_sticky_bits.begin_column_is_absolute,
1003
+ col_end_is_abs=node.AST_sticky_bits.end_column_is_absolute,
1004
+ from_table_id=table_id,
1005
+ to_table_id=to_table_id,
1006
+ )
933
1007
 
934
- begin_ref = xl_rowcol_to_cell(
935
- row_begin,
936
- col_begin,
937
- row_abs=node.AST_sticky_bits.begin_row_is_absolute,
938
- col_abs=node.AST_sticky_bits.begin_column_is_absolute,
939
- )
940
- end_ref = xl_rowcol_to_cell(
941
- row_end,
942
- col_end,
943
- row_abs=node.AST_sticky_bits.end_row_is_absolute,
944
- col_abs=node.AST_sticky_bits.end_column_is_absolute,
1008
+ row = node.AST_row.row if node.AST_row.absolute else row + node.AST_row.row
1009
+ col = node.AST_column.column if node.AST_column.absolute else col + node.AST_column.column
1010
+ if node.HasField("AST_row") and not node.HasField("AST_column"):
1011
+ return CellRange(
1012
+ model=self,
1013
+ row_start=row,
1014
+ row_start_is_abs=node.AST_row.absolute,
1015
+ from_table_id=table_id,
1016
+ to_table_id=to_table_id,
1017
+ )
1018
+
1019
+ if node.HasField("AST_column") and not node.HasField("AST_row"):
1020
+ return CellRange(
1021
+ model=self,
1022
+ col_start=col,
1023
+ col_start_is_abs=node.AST_column.absolute,
1024
+ from_table_id=table_id,
1025
+ to_table_id=to_table_id,
1026
+ )
1027
+
1028
+ return CellRange(
1029
+ model=self,
1030
+ row_start=row,
1031
+ col_start=col,
1032
+ row_start_is_abs=node.AST_row.absolute,
1033
+ col_start_is_abs=node.AST_column.absolute,
1034
+ from_table_id=table_id,
1035
+ to_table_id=to_table_id,
945
1036
  )
946
- if table_name is not None:
947
- return f"{table_name}::{begin_ref}:{end_ref}"
948
- return f"{begin_ref}:{end_ref}"
949
1037
 
950
1038
  @cache()
951
1039
  def formula_ast(self, table_id: int):
@@ -1480,12 +1568,6 @@ class _NumbersModel(Cacheable):
1480
1568
  TSPMessages.Reference(identifier=row_headers_id),
1481
1569
  )
1482
1570
 
1483
- self._table_data[table_model_id] = [
1484
- [Cell._empty_cell(table_model_id, row, col, self) for col in range(num_cols)]
1485
- for row in range(num_rows)
1486
- ]
1487
- self.recalculate_table_data(table_model_id, self._table_data[table_model_id])
1488
-
1489
1571
  table_info_id, table_info = self.objects.create_object_from_dict(
1490
1572
  "CalculationEngine",
1491
1573
  {},
@@ -1494,6 +1576,22 @@ class _NumbersModel(Cacheable):
1494
1576
  table_info.tableModel.MergeFrom(TSPMessages.Reference(identifier=table_model_id))
1495
1577
  table_info.super.MergeFrom(self.create_drawable(sheet_id, x, y))
1496
1578
 
1579
+ haunted_owner_uuid = self.add_formula_owner(
1580
+ table_info_id,
1581
+ num_rows,
1582
+ num_cols,
1583
+ number_of_header_rows,
1584
+ number_of_header_columns,
1585
+ )
1586
+ table_model.haunted_owner.owner_uid.MergeFrom(haunted_owner_uuid.protobuf2)
1587
+ self.calculate_table_uuid_map()
1588
+
1589
+ self._table_data[table_model_id] = [
1590
+ [Cell._empty_cell(table_model_id, row, col, self) for col in range(num_cols)]
1591
+ for row in range(num_rows)
1592
+ ]
1593
+ self.recalculate_table_data(table_model_id, self._table_data[table_model_id])
1594
+
1497
1595
  self.add_component_reference(
1498
1596
  table_info_id,
1499
1597
  location="Document",
@@ -1502,17 +1600,11 @@ class _NumbersModel(Cacheable):
1502
1600
  self.create_caption_archive(table_model_id)
1503
1601
  self.caption_enabled(table_model_id, False)
1504
1602
 
1505
- self.add_formula_owner(
1506
- table_info_id,
1507
- num_rows,
1508
- num_cols,
1509
- number_of_header_rows,
1510
- number_of_header_columns,
1511
- )
1512
-
1513
1603
  self.objects[sheet_id].drawable_infos.append(
1514
1604
  TSPMessages.Reference(identifier=table_info_id),
1515
1605
  )
1606
+
1607
+ self.name_ref_cache.mark_dirty()
1516
1608
  return table_model_id
1517
1609
 
1518
1610
  def add_formula_owner(
@@ -1522,7 +1614,7 @@ class _NumbersModel(Cacheable):
1522
1614
  num_cols: int,
1523
1615
  number_of_header_rows: int,
1524
1616
  number_of_header_columns: int,
1525
- ) -> None:
1617
+ ) -> NumbersUUID:
1526
1618
  """
1527
1619
  Create a FormulaOwnerDependenciesArchive that references a TableInfoArchive
1528
1620
  so that cross-references to cells in this table will work.
@@ -1531,49 +1623,43 @@ class _NumbersModel(Cacheable):
1531
1623
  calc_engine = self.calc_engine()
1532
1624
  owner_id_map = calc_engine.dependency_tracker.owner_id_map.map_entry
1533
1625
  next_owner_id = max([x.internal_owner_id for x in owner_id_map]) + 1
1534
- formula_deps_id, formula_deps = self.objects.create_object_from_dict(
1626
+ volatile_dependencies = {
1627
+ "volatile_time_cells": {},
1628
+ "volatile_random_cells": {},
1629
+ "volatile_locale_cells": {},
1630
+ "volatile_sheet_table_name_cells": {},
1631
+ "volatile_remote_data_cells": {},
1632
+ "volatile_geometry_cell_refs": {},
1633
+ }
1634
+ total_range_for_table = {
1635
+ "top_left_column": 0,
1636
+ "top_left_row": 0,
1637
+ "bottom_right_column": num_cols - 1,
1638
+ "bottom_right_row": num_cols - 1,
1639
+ }
1640
+ body_range_for_table = {
1641
+ "top_left_column": number_of_header_columns,
1642
+ "top_left_row": number_of_header_rows,
1643
+ "bottom_right_column": num_cols - 1,
1644
+ "bottom_right_row": num_cols - 1,
1645
+ }
1646
+
1647
+ formula_deps_id, _ = self.objects.create_object_from_dict(
1535
1648
  "CalculationEngine",
1536
1649
  {
1537
1650
  "formula_owner_uid": formula_owner_uuid.dict2,
1538
1651
  "internal_formula_owner_id": next_owner_id,
1539
- "owner_kind": 1,
1652
+ "owner_kind": OwnerKind.TABLE_MODEL,
1540
1653
  "cell_dependencies": {},
1541
1654
  "range_dependencies": {},
1542
- "volatile_dependencies": {
1543
- "volatile_time_cells": {},
1544
- "volatile_random_cells": {},
1545
- "volatile_locale_cells": {},
1546
- "volatile_sheet_table_name_cells": {},
1547
- "volatile_remote_data_cells": {},
1548
- "volatile_geometry_cell_refs": {},
1549
- },
1655
+ "volatile_dependencies": volatile_dependencies,
1550
1656
  "spanning_column_dependencies": {
1551
- "total_range_for_table": {
1552
- "top_left_column": 0,
1553
- "top_left_row": 0,
1554
- "bottom_right_column": num_cols - 1,
1555
- "bottom_right_row": num_cols - 1,
1556
- },
1557
- "body_range_for_table": {
1558
- "top_left_column": number_of_header_columns,
1559
- "top_left_row": number_of_header_rows,
1560
- "bottom_right_column": num_cols - 1,
1561
- "bottom_right_row": num_cols - 1,
1562
- },
1657
+ "total_range_for_table": total_range_for_table,
1658
+ "body_range_for_table": body_range_for_table,
1563
1659
  },
1564
1660
  "spanning_row_dependencies": {
1565
- "total_range_for_table": {
1566
- "top_left_column": 0,
1567
- "top_left_row": 0,
1568
- "bottom_right_column": num_cols - 1,
1569
- "bottom_right_row": num_cols - 1,
1570
- },
1571
- "body_range_for_table": {
1572
- "top_left_column": number_of_header_columns,
1573
- "top_left_row": number_of_header_rows,
1574
- "bottom_right_column": num_cols - 1,
1575
- "bottom_right_row": num_cols - 1,
1576
- },
1661
+ "total_range_for_table": total_range_for_table,
1662
+ "body_range_for_table": body_range_for_table,
1577
1663
  },
1578
1664
  "whole_owner_dependencies": {"dependent_cells": {}},
1579
1665
  "cell_errors": {},
@@ -1594,6 +1680,52 @@ class _NumbersModel(Cacheable):
1594
1680
  ),
1595
1681
  )
1596
1682
 
1683
+ # See Numbers.md#uuid-mapping for more details on mapping table model
1684
+ # UUID to the formula owner.
1685
+ formula_owner_uuid = NumbersUUID()
1686
+ base_owner_uuid = NumbersUUID()
1687
+ next_owner_id += 1
1688
+ null_range_ref = {
1689
+ "top_left_column": 0x7FFF,
1690
+ "top_left_row": 0x7FFFFFFF,
1691
+ "bottom_right_column": 0x7FFF,
1692
+ "bottom_right_row": 0x7FFFFFFF,
1693
+ }
1694
+ spanning_depdendencies = {
1695
+ "total_range_for_table": null_range_ref,
1696
+ "body_range_for_table": null_range_ref,
1697
+ }
1698
+ formula_deps_id, formula_deps = self.objects.create_object_from_dict(
1699
+ "CalculationEngine",
1700
+ {
1701
+ "formula_owner_uid": formula_owner_uuid.dict2,
1702
+ "internal_formula_owner_id": next_owner_id,
1703
+ "owner_kind": OwnerKind.HAUNTED_OWNER,
1704
+ "cell_dependencies": {},
1705
+ "range_dependencies": {},
1706
+ "volatile_dependencies": volatile_dependencies,
1707
+ "spanning_column_dependencies": spanning_depdendencies,
1708
+ "spanning_row_dependencies": spanning_depdendencies,
1709
+ "whole_owner_dependencies": {"dependent_cells": {}},
1710
+ "cell_errors": {},
1711
+ "base_owner_uid": base_owner_uuid.dict2,
1712
+ "tiled_cell_dependencies": {},
1713
+ "uuid_references": {},
1714
+ "tiled_range_dependencies": {},
1715
+ },
1716
+ TSCEArchives.FormulaOwnerDependenciesArchive,
1717
+ )
1718
+ calc_engine.dependency_tracker.formula_owner_dependencies.append(
1719
+ TSPMessages.Reference(identifier=formula_deps_id),
1720
+ )
1721
+ owner_id_map.append(
1722
+ TSCEArchives.OwnerIDMapArchive.OwnerIDMapArchiveEntry(
1723
+ internal_owner_id=next_owner_id,
1724
+ owner_id=formula_owner_uuid.protobuf4,
1725
+ ),
1726
+ )
1727
+ return formula_owner_uuid
1728
+
1597
1729
  def add_sheet(self, sheet_name: str) -> int:
1598
1730
  """Add a new sheet with a copy of a table from another sheet."""
1599
1731
  sheet_id, _ = self.objects.create_object_from_dict(
@@ -2144,6 +2276,16 @@ class _NumbersModel(Cacheable):
2144
2276
  def cell_font_name(self, obj: Cell | object) -> str:
2145
2277
  style = self.cell_text_style(obj) if isinstance(obj, Cell) else obj
2146
2278
  font_name = self.char_property(style, "font_name")
2279
+ if font_name not in FONT_NAME_TO_FAMILY:
2280
+ if font_name not in self.missing_fonts:
2281
+ warn(
2282
+ f"Custom font '{font_name}' unsupported; falling back to {DEFAULT_FONT}",
2283
+ UnsupportedWarning,
2284
+ stacklevel=2,
2285
+ )
2286
+ self.missing_fonts[font_name] = True
2287
+ return DEFAULT_FONT
2288
+
2147
2289
  return FONT_NAME_TO_FAMILY[font_name]
2148
2290
 
2149
2291
  def cell_first_indent(self, obj: Cell | object) -> float:
@@ -2426,6 +2568,108 @@ class _NumbersModel(Cacheable):
2426
2568
  # datas never appears to be an empty list (default themes include images)
2427
2569
  return max(image_ids) + 1
2428
2570
 
2571
+ def table_category_data(self, table_id: int) -> dict | None:
2572
+ category_owner_id = self.objects[table_id].category_owner.identifier
2573
+ category_archive_id = self.objects[category_owner_id].group_by[0].identifier
2574
+ category_archive = self.objects[category_archive_id]
2575
+ if not category_archive.is_enabled:
2576
+ return None
2577
+
2578
+ table_info = self.objects[self.table_info_id(table_id)]
2579
+ category_order = self.objects[table_info.category_order.identifier]
2580
+ row_uid_map = self.objects[category_order.uid_map.identifier]
2581
+ sorted_row_uuids = [
2582
+ NumbersUUID(row_uid_map.sorted_row_uids[i]).hex for i in row_uid_map.row_uid_for_index
2583
+ ]
2584
+
2585
+ data = self._table_data[table_id]
2586
+ header = [cell.value for cell in data[0]]
2587
+
2588
+ def index_set_to_offsets(index_set: TSCEArchives.IndexSetArchive) -> list[int]:
2589
+ """Convert an IndexSetArchive to a list of offsets."""
2590
+ offsets = []
2591
+ for entry in index_set.entries:
2592
+ if entry.HasField("range_end"):
2593
+ offsets += list(range(entry.range_begin, entry.range_end + 1))
2594
+ else:
2595
+ offsets += list(range(entry.range_begin, entry.range_begin + 1))
2596
+ return offsets
2597
+
2598
+ def cell_value_to_key(
2599
+ cell_value: TSCEArchives.CellValueArchive,
2600
+ ) -> str | int | bool | datetime:
2601
+ """Convert a CellValueArchive to a key."""
2602
+ cell_value_type = cell_value.cell_value_type
2603
+ if cell_value_type == CellValueType.STRING_TYPE:
2604
+ return cell_value.string_value.value
2605
+ if cell_value_type == CellValueType.NUMBER_TYPE:
2606
+ return cell_value.number_value.value
2607
+ if cell_value_type == CellValueType.BOOLEAN_TYPE:
2608
+ return cell_value.boolean_value.value
2609
+ # Must be DATE_TYPE
2610
+ return cell_value.date_value.value
2611
+
2612
+ group_node_to_key = {
2613
+ NumbersUUID(self.objects[_id].group_uid).hex: cell_value_to_key(
2614
+ self.objects[_id].group_cell_value,
2615
+ )
2616
+ for _id in self.find_refs("GroupNodeArchive")
2617
+ }
2618
+ group_uuids = [NumbersUUID(x.group_uid).hex for x in category_archive.group_node_root.child]
2619
+ group_uuids = [uuid for uuid in sorted_row_uuids if uuid in group_uuids]
2620
+
2621
+ def group_hierarchy(parent: str, children: list):
2622
+ nodes = {}
2623
+ for child in children:
2624
+ group_uuid = NumbersUUID(child.group_uid).hex
2625
+ if len(child.child) > 0:
2626
+ nodes[group_uuid] = group_hierarchy(group_uuid, child.child)
2627
+ else:
2628
+ nodes[group_uuid] = None
2629
+ return nodes
2630
+
2631
+ def assign_rows_to_categories(parent: str, children: list, categories: dict):
2632
+ for child in children:
2633
+ group_uuid = NumbersUUID(child.group_uid).hex
2634
+ if len(child.child) == 0:
2635
+ key = cell_value_to_key(child.group_cell_value)
2636
+
2637
+ row_offsets = index_set_to_offsets(child.row_lookup_uids)
2638
+ categories[group_uuid] = {
2639
+ "key": key,
2640
+ "parent": parent,
2641
+ "rows": [
2642
+ {header[col]: cell.value for col, cell in enumerate(data[row])}
2643
+ for row in row_offsets
2644
+ ],
2645
+ }
2646
+ else:
2647
+ categories[group_uuid] = {
2648
+ "key": group_node_to_key[group_uuid],
2649
+ "parent": parent,
2650
+ "rows": None,
2651
+ }
2652
+ assign_rows_to_categories(group_uuid, child.child, categories)
2653
+
2654
+ category_tree = group_hierarchy(
2655
+ NumbersUUID(category_archive.group_node_root.group_uid).hex,
2656
+ category_archive.group_node_root.child,
2657
+ )
2658
+
2659
+ categories = {}
2660
+ assign_rows_to_categories(None, category_archive.group_node_root.child, categories)
2661
+
2662
+ def merge_trees(a: dict, b: dict):
2663
+ new_tree = {}
2664
+ for k, v in a.items():
2665
+ if v is not None:
2666
+ new_tree[b[k]["key"]] = merge_trees(v, b)
2667
+ else:
2668
+ new_tree[b[k]["key"]] = b[k]["rows"]
2669
+ return new_tree
2670
+
2671
+ return merge_trees(category_tree, categories)
2672
+
2429
2673
 
2430
2674
  def rgb(obj) -> RGB:
2431
2675
  """Convert a TSPArchives.Color into an RGB tuple."""
@@ -2448,39 +2692,6 @@ def formatted_number(number_type, index):
2448
2692
  return bullet_char
2449
2693
 
2450
2694
 
2451
- def node_to_col_ref(node: object, table_name: str, col: int) -> str:
2452
- col = node.AST_column.column if node.AST_column.absolute else col + node.AST_column.column
2453
-
2454
- col_name = xl_col_to_name(col, node.AST_column.absolute)
2455
- if table_name is not None:
2456
- return f"{table_name}::{col_name}"
2457
- return col_name
2458
-
2459
-
2460
- def node_to_row_ref(node: object, table_name: str, row: int) -> str:
2461
- row = node.AST_row.row if node.AST_row.absolute else row + node.AST_row.row
2462
-
2463
- row_name = f"${row + 1}" if node.AST_row.absolute else f"{row + 1}"
2464
- if table_name is not None:
2465
- return f"{table_name}::{row_name}:{row_name}"
2466
- return f"{row_name}:{row_name}"
2467
-
2468
-
2469
- def node_to_row_col_ref(node: object, table_name: str, row: int, col: int) -> str:
2470
- row = node.AST_row.row if node.AST_row.absolute else row + node.AST_row.row
2471
- col = node.AST_column.column if node.AST_column.absolute else col + node.AST_column.column
2472
-
2473
- ref = xl_rowcol_to_cell(
2474
- row,
2475
- col,
2476
- row_abs=node.AST_row.absolute,
2477
- col_abs=node.AST_column.absolute,
2478
- )
2479
- if table_name is not None:
2480
- return f"{table_name}::{ref}"
2481
- return ref
2482
-
2483
-
2484
2695
  def get_storage_buffers_for_row(
2485
2696
  storage_buffer: bytes,
2486
2697
  offsets: list,