@amaster.ai/pi-lark 0.1.14 → 0.1.16

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 (180) hide show
  1. package/package.json +4 -4
  2. package/skills/lark-apps/SKILL.md +1 -0
  3. package/skills/lark-apps/references/lark-apps-db.md +2 -0
  4. package/skills/lark-apps/references/lark-apps-env-pull.md +1 -1
  5. package/skills/lark-apps/references/lark-apps-env.md +1 -1
  6. package/skills/lark-apps/references/lark-apps-export.md +62 -0
  7. package/skills/lark-apps/references/lark-apps-local-dev.md +1 -1
  8. package/skills/lark-apps/references/lark-apps-observability.md +1 -1
  9. package/skills/lark-base/SKILL.md +6 -5
  10. package/skills/lark-base/references/lark-base-app.md +3 -3
  11. package/skills/lark-base/references/lark-base-dashboard-block-config.md +21 -3
  12. package/skills/lark-base/references/lark-base-dashboard.md +1 -1
  13. package/skills/lark-base/references/lark-base-data-query.md +1 -1
  14. package/skills/lark-base/references/lark-base-field-create.md +1 -1
  15. package/skills/lark-base/references/lark-base-field-update.md +1 -1
  16. package/skills/lark-base/references/lark-base-form-questions-create.md +1 -1
  17. package/skills/lark-base/references/lark-base-form-questions-update.md +1 -1
  18. package/skills/lark-base/references/lark-base-form-submit.md +1 -1
  19. package/skills/lark-base/references/lark-base-view-set-filter.md +1 -1
  20. package/skills/lark-base/references/lark-base-view.md +109 -0
  21. package/skills/lark-base/references/lark-base-workflow-schema.md +90 -12
  22. package/skills/lark-base/references/lark-base-workflow.md +99 -3
  23. package/skills/lark-calendar/SKILL.md +13 -8
  24. package/skills/lark-calendar/references/lark-calendar-meeting-relation.md +99 -0
  25. package/skills/lark-calendar/references/lark-calendar-meeting.md +1 -1
  26. package/skills/lark-calendar/references/lark-calendar-recurring.md +3 -1
  27. package/skills/lark-calendar/references/lark-calendar-transfer.md +1 -1
  28. package/skills/lark-doc/SKILL.md +1 -1
  29. package/skills/lark-doc/references/lark-doc-create-workflow.md +8 -10
  30. package/skills/lark-doc/references/lark-doc-media-download.md +1 -1
  31. package/skills/lark-doc/references/lark-doc-media-insert.md +1 -1
  32. package/skills/lark-doc/references/lark-doc-media-preview.md +1 -1
  33. package/skills/lark-doc/references/lark-doc-resource-cover.md +1 -1
  34. package/skills/lark-doc/references/lark-doc-script.md +11 -17
  35. package/skills/lark-drive/references/lark-drive-add-comment.md +1 -1
  36. package/skills/lark-drive/references/lark-drive-apply-permission.md +1 -1
  37. package/skills/lark-drive/references/lark-drive-copy.md +1 -1
  38. package/skills/lark-drive/references/lark-drive-cover.md +1 -1
  39. package/skills/lark-drive/references/lark-drive-create-folder.md +1 -1
  40. package/skills/lark-drive/references/lark-drive-create-shortcut.md +1 -1
  41. package/skills/lark-drive/references/lark-drive-delete.md +1 -1
  42. package/skills/lark-drive/references/lark-drive-download.md +1 -1
  43. package/skills/lark-drive/references/lark-drive-export-download.md +1 -1
  44. package/skills/lark-drive/references/lark-drive-export.md +1 -1
  45. package/skills/lark-drive/references/lark-drive-import.md +1 -1
  46. package/skills/lark-drive/references/lark-drive-inspect.md +2 -2
  47. package/skills/lark-drive/references/lark-drive-list-comments.md +1 -1
  48. package/skills/lark-drive/references/lark-drive-move.md +4 -4
  49. package/skills/lark-drive/references/lark-drive-permission-guide.md +1 -1
  50. package/skills/lark-drive/references/lark-drive-preview.md +1 -1
  51. package/skills/lark-drive/references/lark-drive-pull.md +1 -1
  52. package/skills/lark-drive/references/lark-drive-push.md +1 -1
  53. package/skills/lark-drive/references/lark-drive-search.md +1 -1
  54. package/skills/lark-drive/references/lark-drive-status.md +1 -1
  55. package/skills/lark-drive/references/lark-drive-task-result.md +4 -4
  56. package/skills/lark-drive/references/lark-drive-update-title.md +1 -1
  57. package/skills/lark-drive/references/lark-drive-upload.md +1 -1
  58. package/skills/lark-drive/references/lark-drive-version-delete.md +1 -1
  59. package/skills/lark-drive/references/lark-drive-version-get.md +1 -1
  60. package/skills/lark-drive/references/lark-drive-version-history.md +1 -1
  61. package/skills/lark-drive/references/lark-drive-version-revert.md +1 -1
  62. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-execute.md +1 -1
  63. package/skills/lark-im/SKILL.md +7 -1
  64. package/skills/lark-im/references/lark-im-chat-create.md +1 -1
  65. package/skills/lark-im/references/lark-im-chat-list.md +1 -1
  66. package/skills/lark-im/references/lark-im-chat-members-list.md +1 -1
  67. package/skills/lark-im/references/lark-im-chat-messages-list.md +7 -2
  68. package/skills/lark-im/references/lark-im-chat-search.md +1 -1
  69. package/skills/lark-im/references/lark-im-chat-update.md +1 -1
  70. package/skills/lark-im/references/lark-im-feed-groups.md +1 -1
  71. package/skills/lark-im/references/lark-im-feed-shortcut-create.md +1 -1
  72. package/skills/lark-im/references/lark-im-feed-shortcut-list.md +1 -1
  73. package/skills/lark-im/references/lark-im-feed-shortcut-remove.md +1 -1
  74. package/skills/lark-im/references/lark-im-flag-cancel.md +1 -1
  75. package/skills/lark-im/references/lark-im-flag-create.md +1 -1
  76. package/skills/lark-im/references/lark-im-flag-list.md +1 -1
  77. package/skills/lark-im/references/lark-im-message-enrichment.md +1 -1
  78. package/skills/lark-im/references/lark-im-message-read-status.md +1 -1
  79. package/skills/lark-im/references/lark-im-messages-edit.md +1 -1
  80. package/skills/lark-im/references/lark-im-messages-mget.md +20 -3
  81. package/skills/lark-im/references/lark-im-messages-reply.md +1 -1
  82. package/skills/lark-im/references/lark-im-messages-resources-download.md +3 -1
  83. package/skills/lark-im/references/lark-im-messages-search.md +2 -2
  84. package/skills/lark-im/references/lark-im-messages-send.md +1 -1
  85. package/skills/lark-im/references/lark-im-reactions.md +1 -1
  86. package/skills/lark-im/references/lark-im-threads-messages-list.md +6 -2
  87. package/skills/lark-mail/SKILL.md +19 -8
  88. package/skills/lark-mail/references/lark-mail-draft-create.md +1 -1
  89. package/skills/lark-mail/references/lark-mail-draft-edit.md +1 -1
  90. package/skills/lark-mail/references/lark-mail-forward.md +1 -1
  91. package/skills/lark-mail/references/lark-mail-reply-all.md +1 -1
  92. package/skills/lark-mail/references/lark-mail-reply.md +1 -1
  93. package/skills/lark-mail/references/lark-mail-rules.md +87 -4
  94. package/skills/lark-mail/references/lark-mail-send.md +1 -1
  95. package/skills/lark-mail/references/lark-mail-thread-modify.md +73 -0
  96. package/skills/lark-mail/references/lark-mail-thread-trash.md +62 -0
  97. package/skills/lark-mail/references/lark-mail-triage.md +1 -1
  98. package/skills/lark-mail/references/lark-mail-watch.md +2 -2
  99. package/skills/lark-markdown/references/lark-markdown-create.md +1 -1
  100. package/skills/lark-markdown/references/lark-markdown-diff.md +1 -1
  101. package/skills/lark-markdown/references/lark-markdown-fetch.md +1 -1
  102. package/skills/lark-markdown/references/lark-markdown-overwrite.md +1 -1
  103. package/skills/lark-markdown/references/lark-markdown-patch.md +1 -1
  104. package/skills/lark-meeting/SKILL.md +2 -2
  105. package/skills/lark-meeting/references/lark-minutes-search.md +2 -2
  106. package/skills/lark-meeting/references/lark-vc-meeting-events.md +15 -15
  107. package/skills/lark-meeting/references/lark-vc-search.md +10 -7
  108. package/skills/lark-meeting/scenes/create-and-edit-minutes.md +4 -0
  109. package/skills/lark-meeting/scenes/live-meeting-attend.md +2 -2
  110. package/skills/lark-meeting/scenes/live-meeting-interact.md +1 -1
  111. package/skills/lark-meeting/scenes/query-meeting-and-artifacts.md +3 -3
  112. package/skills/lark-shared/references/lark-wiki-token-routing.md +7 -7
  113. package/skills/lark-sheets/SKILL.md +60 -173
  114. package/skills/lark-sheets/references/lark-sheets-batch-update.md +10 -10
  115. package/skills/lark-sheets/references/lark-sheets-chart.md +68 -34
  116. package/skills/lark-sheets/references/lark-sheets-conditional-format.md +4 -4
  117. package/skills/lark-sheets/references/lark-sheets-filter-view.md +1 -1
  118. package/skills/lark-sheets/references/lark-sheets-filter.md +2 -2
  119. package/skills/lark-sheets/references/lark-sheets-float-image.md +2 -2
  120. package/skills/lark-sheets/references/lark-sheets-formula-translation.md +90 -4
  121. package/skills/lark-sheets/references/lark-sheets-formula-verify.md +49 -13
  122. package/skills/lark-sheets/references/lark-sheets-pivot-table.md +13 -13
  123. package/skills/lark-sheets/references/lark-sheets-range-operations.md +12 -9
  124. package/skills/lark-sheets/references/lark-sheets-read-data.md +14 -12
  125. package/skills/lark-sheets/references/lark-sheets-search-replace.md +3 -3
  126. package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +8 -4
  127. package/skills/lark-sheets/references/lark-sheets-sparkline.md +2 -2
  128. package/skills/lark-sheets/references/lark-sheets-styles-put.md +2 -2
  129. package/skills/lark-sheets/references/lark-sheets-visual-standards.md +22 -19
  130. package/skills/lark-sheets/references/lark-sheets-workbook.md +22 -7
  131. package/skills/lark-sheets/references/lark-sheets-write-cells.md +66 -57
  132. package/skills/lark-sheets/scripts/lark_chart_quality_check.py +1540 -0
  133. package/skills/lark-sheets/scripts/lark_chart_size_advisor.py +409 -0
  134. package/skills/lark-sheets/scripts/lark_chart_size_rules.py +292 -0
  135. package/skills/lark-sheets/scripts/lark_inspect_workbook.py +37 -9
  136. package/skills/lark-sheets/scripts/lark_sheet_read_cli.py +53 -0
  137. package/skills/lark-sheets/scripts/{sheets_df.py → lark_sheets_df.py} +1 -1
  138. package/skills/lark-slides/SKILL.md +11 -18
  139. package/skills/lark-slides/references/cli/lark-slides-add-slide.md +1 -1
  140. package/skills/lark-slides/references/cli/lark-slides-create.md +3 -3
  141. package/skills/lark-slides/references/cli/lark-slides-delete-slide.md +1 -1
  142. package/skills/lark-slides/references/cli/lark-slides-history.md +1 -8
  143. package/skills/lark-slides/references/cli/lark-slides-media-upload.md +6 -11
  144. package/skills/lark-slides/references/cli/lark-slides-replace-slide.md +15 -16
  145. package/skills/lark-slides/references/cli/lark-slides-update-slide.md +2 -2
  146. package/skills/lark-slides/references/cli/lark-slides-xml-presentation-slide-get.md +3 -108
  147. package/skills/lark-slides/references/cli/lark-slides-xml-presentation-slide-replace.md +6 -183
  148. package/skills/lark-slides/references/cli/lark-slides-xml-presentations-get.md +26 -143
  149. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +3 -3
  150. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +2 -2
  151. package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +2 -2
  152. package/skills/lark-slides/references/workflow/error-handling.md +3 -3
  153. package/skills/lark-slides/references/workflow/slides-editing.md +10 -11
  154. package/skills/lark-slides/references/xml/slides_xml_schema_definition.xml +333 -20
  155. package/skills/lark-task/references/lark-task-assign.md +1 -1
  156. package/skills/lark-task/references/lark-task-comment.md +1 -1
  157. package/skills/lark-task/references/lark-task-complete.md +1 -1
  158. package/skills/lark-task/references/lark-task-create.md +1 -1
  159. package/skills/lark-task/references/lark-task-followers.md +1 -1
  160. package/skills/lark-task/references/lark-task-get-my-tasks.md +1 -1
  161. package/skills/lark-task/references/lark-task-get-related-tasks.md +1 -1
  162. package/skills/lark-task/references/lark-task-reminder.md +1 -1
  163. package/skills/lark-task/references/lark-task-reopen.md +1 -1
  164. package/skills/lark-task/references/lark-task-search.md +1 -1
  165. package/skills/lark-task/references/lark-task-set-ancestor.md +1 -1
  166. package/skills/lark-task/references/lark-task-tasklist-create.md +1 -1
  167. package/skills/lark-task/references/lark-task-tasklist-members.md +1 -1
  168. package/skills/lark-task/references/lark-task-tasklist-search.md +1 -1
  169. package/skills/lark-task/references/lark-task-tasklist-task-add.md +1 -1
  170. package/skills/lark-task/references/lark-task-update.md +1 -1
  171. package/skills/lark-task/references/lark-task-upload-attachment.md +1 -1
  172. package/skills/lark-wiki/SKILL.md +1 -2
  173. package/skills/lark-wiki/references/lark-wiki-delete-space.md +1 -1
  174. package/skills/lark-wiki/references/lark-wiki-move-to-drive.md +1 -1
  175. package/skills/lark-wiki/references/lark-wiki-move.md +4 -3
  176. package/skills/lark-wiki/references/lark-wiki-node-create.md +4 -3
  177. package/skills/lark-wiki/references/lark-wiki-node-delete.md +8 -4
  178. package/skills/lark-wiki/references/lark-wiki-node-get.md +7 -4
  179. package/skills/lark-sheets/references/lark-sheets-legacy-command-migration.md +0 -152
  180. package/skills/lark-sheets/scripts/lark_chart_layout_check.py +0 -472
@@ -0,0 +1,1540 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 Lark Technologies Pte. Ltd.
3
+ # SPDX-License-Identifier: MIT
4
+ """Check Lark Sheet chart quality, placement, and numeric source-data issues.
5
+
6
+ The single required argument is a spreadsheet URL or spreadsheet token. By
7
+ default every worksheet is checked; pass --worksheet-id to restrict the check
8
+ to one worksheet reference_id.
9
+
10
+ Numeric source checks sample at most 50 data points per series and request at
11
+ most 2000 source cells per chart across the style and typed-value reads,
12
+ including headers and gaps between series.
13
+ Sampled zero/constant values do not establish a whole-series issue.
14
+
15
+ Exit codes:
16
+ 0: check completed and no issue was found
17
+ 1: the check could not be completed (CLI/read/response error)
18
+ 2: check completed and at least one chart-quality issue was found
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import re
26
+ from typing import Any, Dict, Tuple
27
+
28
+ from lark_sheet_read_cli import (
29
+ LarkCliError,
30
+ emit_error,
31
+ envelope_data,
32
+ resolve_target_sheets,
33
+ run_sheets,
34
+ sheet_identifier,
35
+ sheet_title,
36
+ )
37
+ from lark_chart_size_rules import MAX_ASPECT_RATIO, MAX_CHART_WIDTH, minimum_chart_size
38
+
39
+ ACTION = "chart_quality_check"
40
+ DEFAULT_COLUMN_WIDTH = 105.0
41
+ DEFAULT_ROW_HEIGHT = 27.0
42
+ MAX_CELL_READ_SIZE = 2_000
43
+ MAX_SOURCE_SAMPLE_POINTS = 50
44
+
45
+
46
+ # 类型别名是运行期求值的,__future__ annotations 豁免不了它们;
47
+ # 内置泛型下标要 3.9,CI 的解释器是 3.7,只能走 typing 里的等价物。
48
+ CellBounds = Tuple[int, int, int, int]
49
+ CellCache = Dict[Tuple[str, str, str, bool], Dict[str, Any]]
50
+ SeriesProfile = Dict[str, Any]
51
+
52
+
53
+ def _parse_a1_bounds(cell_range: str) -> CellBounds:
54
+ value = str(cell_range).rsplit("!", 1)[-1].replace("$", "")
55
+ match = re.fullmatch(r"([A-Za-z]+)(\d+)(?::([A-Za-z]+)(\d+))?", value)
56
+ if not match:
57
+ raise ValueError(f"Invalid A1 range: {cell_range!r}")
58
+ start_column = column_to_index(match.group(1))
59
+ start_row = int(match.group(2))
60
+ end_column = column_to_index(match.group(3) or match.group(1))
61
+ end_row = int(match.group(4) or match.group(2))
62
+ if end_row < start_row or end_column < start_column:
63
+ raise ValueError(f"Invalid A1 range: {cell_range!r}")
64
+ return start_row, end_row, start_column, end_column
65
+
66
+
67
+ def _format_a1_bounds(bounds: CellBounds) -> str:
68
+ start_row, end_row, start_column, end_column = bounds
69
+ return (
70
+ f"{index_to_column(start_column)}{start_row}:"
71
+ f"{index_to_column(end_column)}{end_row}"
72
+ )
73
+
74
+
75
+ def _bounds_area(bounds: CellBounds) -> int:
76
+ start_row, end_row, start_column, end_column = bounds
77
+ return (end_row - start_row + 1) * (end_column - start_column + 1)
78
+
79
+
80
+ def _merge_bounds(first: CellBounds, second: CellBounds) -> CellBounds:
81
+ return (
82
+ min(first[0], second[0]),
83
+ max(first[1], second[1]),
84
+ min(first[2], second[2]),
85
+ max(first[3], second[3]),
86
+ )
87
+
88
+
89
+ def _cluster_cell_reads(
90
+ items: list[tuple[dict[str, Any], CellBounds]],
91
+ ) -> list[dict[str, Any]]:
92
+ clusters: list[dict[str, Any]] = []
93
+ for rectangle, bounds in sorted(items, key=lambda item: (item[1][0], item[1][2])):
94
+ if clusters:
95
+ merged = _merge_bounds(clusters[-1]["bounds"], bounds)
96
+ if _bounds_area(merged) <= MAX_CELL_READ_SIZE:
97
+ clusters[-1]["bounds"] = merged
98
+ clusters[-1]["members"].append((rectangle, bounds))
99
+ continue
100
+ clusters.append({"bounds": bounds, "members": [(rectangle, bounds)]})
101
+ return clusters
102
+
103
+
104
+ def column_to_index(column: str) -> int:
105
+ value = 0
106
+ text = str(column).strip().upper()
107
+ if not text or not text.isalpha():
108
+ raise ValueError(f"Invalid column: {column!r}")
109
+ for char in text:
110
+ value = value * 26 + ord(char) - ord("A") + 1
111
+ return value - 1
112
+
113
+
114
+ def index_to_column(index: int) -> str:
115
+ if index < 0:
116
+ raise ValueError(f"Invalid column index: {index}")
117
+ chars: list[str] = []
118
+ value = index + 1
119
+ while value:
120
+ value, remainder = divmod(value - 1, 26)
121
+ chars.append(chr(ord("A") + remainder))
122
+ return "".join(reversed(chars))
123
+
124
+
125
+ def _span_bounds(span: str, *, columns: bool) -> tuple[int, int]:
126
+ start, separator, end = str(span).partition(":")
127
+ end = end if separator else start
128
+ if columns:
129
+ return column_to_index(start), column_to_index(end)
130
+ return int(start) - 1, int(end) - 1
131
+
132
+
133
+ def _size_edges(
134
+ groups: Any,
135
+ *,
136
+ count: int,
137
+ span_key: str,
138
+ size_key: str,
139
+ columns: bool,
140
+ default_size: float,
141
+ ) -> tuple[list[float], bool]:
142
+ sizes: list[float | None] = [None] * count
143
+ if isinstance(groups, list):
144
+ for group in groups:
145
+ if not isinstance(group, dict) or group.get(span_key) is None:
146
+ continue
147
+ start, end = _span_bounds(str(group[span_key]), columns=columns)
148
+ size = float(group.get(size_key, default_size))
149
+ for index in range(max(0, start), min(count - 1, end) + 1):
150
+ sizes[index] = max(0.0, size)
151
+
152
+ used_default = any(size is None for size in sizes)
153
+ resolved = [default_size if size is None else size for size in sizes]
154
+ edges = [0.0]
155
+ for size in resolved:
156
+ edges.append(edges[-1] + size)
157
+ return edges, used_default
158
+
159
+
160
+ def build_layout(
161
+ structure: dict[str, Any], row_count: int, column_count: int
162
+ ) -> tuple[list[float], list[float], list[str]]:
163
+ row_groups = structure.get("row_heights")
164
+ column_groups = structure.get("col_widths", structure.get("column_widths"))
165
+ row_edges, row_defaulted = _size_edges(
166
+ row_groups,
167
+ count=row_count,
168
+ span_key="rows",
169
+ size_key="height",
170
+ columns=False,
171
+ default_size=DEFAULT_ROW_HEIGHT,
172
+ )
173
+ column_edges, column_defaulted = _size_edges(
174
+ column_groups,
175
+ count=column_count,
176
+ span_key="cols",
177
+ size_key="width",
178
+ columns=True,
179
+ default_size=DEFAULT_COLUMN_WIDTH,
180
+ )
181
+ warnings: list[str] = []
182
+ if row_defaulted:
183
+ warnings.append("部分行缺少高度信息,按 27 px 估算")
184
+ if column_defaulted:
185
+ warnings.append("部分列缺少宽度信息,按 105 px 估算")
186
+ return row_edges, column_edges, warnings
187
+
188
+
189
+ def _first_dict(value: Any) -> dict[str, Any] | None:
190
+ if isinstance(value, dict):
191
+ return value
192
+ if isinstance(value, list):
193
+ return next((item for item in value if isinstance(item, dict)), None)
194
+ return None
195
+
196
+
197
+ def extract_sheet_structure(data: dict[str, Any]) -> dict[str, Any]:
198
+ sheet = _first_dict(data.get("sheets")) or _first_dict(data.get("sheet"))
199
+ return sheet or data
200
+
201
+
202
+ def extract_charts(data: dict[str, Any], sheet_id: str, title: str) -> list[dict[str, Any]]:
203
+ sheets = data.get("sheets")
204
+ if isinstance(sheets, list):
205
+ for sheet in sheets:
206
+ if not isinstance(sheet, dict):
207
+ continue
208
+ if sheet_identifier(sheet) == sheet_id or sheet_title(sheet) == title:
209
+ charts = sheet.get("charts")
210
+ return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []
211
+ charts = data.get("charts")
212
+ return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []
213
+
214
+
215
+ def chart_rectangle(
216
+ chart: dict[str, Any], row_edges: list[float], column_edges: list[float]
217
+ ) -> dict[str, Any]:
218
+ details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
219
+ position = details.get("position") if isinstance(details.get("position"), dict) else {}
220
+ offset = details.get("offset") if isinstance(details.get("offset"), dict) else {}
221
+ size = details.get("size") if isinstance(details.get("size"), dict) else {}
222
+
223
+ row = int(position["row"])
224
+ column = column_to_index(str(position["col"]))
225
+ if row < 0 or column < 0 or row >= len(row_edges) - 1 or column >= len(column_edges) - 1:
226
+ raise ValueError(f"anchor outside sheet: {position!r}")
227
+
228
+ width = float(size["width"])
229
+ height = float(size["height"])
230
+ if width <= 0 or height <= 0:
231
+ raise ValueError(f"invalid chart size: {size!r}")
232
+
233
+ left = column_edges[column] + float(offset.get("col_offset", 0) or 0)
234
+ top = row_edges[row] + float(offset.get("row_offset", 0) or 0)
235
+ return {
236
+ "chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
237
+ "anchor_cell": f"{index_to_column(column)}{row + 1}",
238
+ "left": left,
239
+ "top": top,
240
+ "right": left + width,
241
+ "bottom": top + height,
242
+ "width": width,
243
+ "height": height,
244
+ }
245
+
246
+
247
+ def intersection(first: dict[str, Any], second: dict[str, Any]) -> dict[str, float] | None:
248
+ left = max(float(first["left"]), float(second["left"]))
249
+ top = max(float(first["top"]), float(second["top"]))
250
+ right = min(float(first["right"]), float(second["right"]))
251
+ bottom = min(float(first["bottom"]), float(second["bottom"]))
252
+ if right <= left or bottom <= top:
253
+ return None
254
+ return {
255
+ "width": round(right - left, 2),
256
+ "height": round(bottom - top, 2),
257
+ "area": round((right - left) * (bottom - top), 2),
258
+ }
259
+
260
+
261
+ def chart_context(rectangle: dict[str, Any]) -> dict[str, Any]:
262
+ return {
263
+ "chart_id": rectangle["chart_id"],
264
+ "anchor_cell": rectangle["anchor_cell"],
265
+ "rectangle_px": {
266
+ "left": round(rectangle["left"], 2),
267
+ "top": round(rectangle["top"], 2),
268
+ "right": round(rectangle["right"], 2),
269
+ "bottom": round(rectangle["bottom"], 2),
270
+ "width": round(rectangle["width"], 2),
271
+ "height": round(rectangle["height"], 2),
272
+ },
273
+ }
274
+
275
+
276
+ def _covered_indexes(edges: list[float], start: float, end: float) -> list[int]:
277
+ return [
278
+ index
279
+ for index in range(len(edges) - 1)
280
+ if edges[index + 1] > start and edges[index] < end
281
+ ]
282
+
283
+
284
+ def rectangle_cell_range(
285
+ rectangle: dict[str, Any], row_edges: list[float], column_edges: list[float]
286
+ ) -> str | None:
287
+ rows = _covered_indexes(row_edges, max(0.0, rectangle["top"]), rectangle["bottom"])
288
+ columns = _covered_indexes(column_edges, max(0.0, rectangle["left"]), rectangle["right"])
289
+ if not rows or not columns:
290
+ return None
291
+ return f"{index_to_column(columns[0])}{rows[0] + 1}:{index_to_column(columns[-1])}{rows[-1] + 1}"
292
+
293
+
294
+ def _has_content(cell: Any) -> bool:
295
+ if not isinstance(cell, dict):
296
+ return False
297
+ for key in ("value", "formula", "note"):
298
+ value = cell.get(key)
299
+ if value not in (None, ""):
300
+ return True
301
+ return bool(cell.get("rich_text") or cell.get("multiple_values"))
302
+
303
+
304
+ def _iter_cells(data: dict[str, Any]):
305
+ ranges = data.get("ranges")
306
+ if not isinstance(ranges, list):
307
+ return
308
+ for result_range in ranges:
309
+ if not isinstance(result_range, dict):
310
+ continue
311
+ cells = result_range.get("cells")
312
+ rows = result_range.get("row_indices")
313
+ columns = result_range.get("col_indices")
314
+ if not isinstance(cells, list):
315
+ continue
316
+ for row_offset, row in enumerate(cells):
317
+ if not isinstance(row, list):
318
+ continue
319
+ row_number = int(rows[row_offset]) if isinstance(rows, list) and row_offset < len(rows) else row_offset + 1
320
+ for column_offset, cell in enumerate(row):
321
+ column = columns[column_offset] if isinstance(columns, list) and column_offset < len(columns) else index_to_column(column_offset)
322
+ yield row_number, column_to_index(str(column)), cell
323
+
324
+
325
+ def non_empty_cells(
326
+ data: dict[str, Any], sample_limit: int, bounds: CellBounds | None = None
327
+ ) -> tuple[int, list[str], bool]:
328
+ count = 0
329
+ samples: list[str] = []
330
+ truncated = bool(data.get("has_more"))
331
+ ranges = data.get("ranges")
332
+ if not isinstance(ranges, list):
333
+ return 0, [], truncated
334
+ for result_range in ranges:
335
+ if not isinstance(result_range, dict):
336
+ continue
337
+ truncated = truncated or bool(result_range.get("truncated"))
338
+ for row_number, column_index, cell in _iter_cells(data):
339
+ if bounds and not (
340
+ bounds[0] <= row_number <= bounds[1]
341
+ and bounds[2] <= column_index <= bounds[3]
342
+ ):
343
+ continue
344
+ if not _has_content(cell):
345
+ continue
346
+ count += 1
347
+ if len(samples) < sample_limit:
348
+ samples.append(f"{index_to_column(column_index)}{row_number}")
349
+ return count, samples, truncated
350
+
351
+
352
+ def _read_cells(
353
+ cache: CellCache,
354
+ locator: dict[str, str],
355
+ *,
356
+ sheet_id: str | None,
357
+ sheet_name: str | None,
358
+ cell_range: str,
359
+ include: str,
360
+ skip_hidden: bool = False,
361
+ timeout: int,
362
+ ) -> dict[str, Any]:
363
+ selector = f"id:{sheet_id}" if sheet_id else f"name:{sheet_name}"
364
+ key = (selector, cell_range, include, skip_hidden)
365
+ if key not in cache:
366
+ flags: dict[str, Any] = {"range": cell_range, "include": include}
367
+ if skip_hidden:
368
+ flags["skip-hidden"] = True
369
+ cache[key] = envelope_data(
370
+ run_sheets(
371
+ "+cells-get",
372
+ **locator,
373
+ **({"sheet_id": sheet_id} if sheet_id else {"sheet_name": sheet_name}),
374
+ flags=flags,
375
+ timeout=timeout,
376
+ )
377
+ )
378
+ return cache[key]
379
+
380
+
381
+ def _read_typed_table(
382
+ cache: CellCache,
383
+ locator: dict[str, str],
384
+ *,
385
+ sheet_id: str | None,
386
+ sheet_name: str | None,
387
+ cell_range: str,
388
+ timeout: int,
389
+ ) -> tuple[list[list[Any]], list[str], bool]:
390
+ selector = f"id:{sheet_id}" if sheet_id else f"name:{sheet_name}"
391
+ key = (selector, cell_range, "typed_table", False)
392
+ if key not in cache:
393
+ cache[key] = envelope_data(
394
+ run_sheets(
395
+ "+table-get",
396
+ **locator,
397
+ **({"sheet_id": sheet_id} if sheet_id else {"sheet_name": sheet_name}),
398
+ flags={"range": cell_range, "no-header": True},
399
+ timeout=timeout,
400
+ )
401
+ )
402
+ data = cache[key]
403
+ sheets = data.get("sheets")
404
+ if not isinstance(sheets, list) or len(sheets) != 1 or not isinstance(sheets[0], dict):
405
+ return [], [], True
406
+ table = sheets[0]
407
+ rows = table.get("data")
408
+ columns = table.get("columns")
409
+ dtypes = table.get("dtypes")
410
+ if not isinstance(rows, list) or not isinstance(columns, list) or not isinstance(dtypes, dict):
411
+ return [], [], True
412
+ def value_kind(dtype: Any) -> str:
413
+ value = str(dtype or "").lower()
414
+ if value in {"number", "int64", "float64"}:
415
+ return "number"
416
+ if value in {"bool", "boolean"}:
417
+ return "bool"
418
+ if value.startswith("datetime"):
419
+ return "date"
420
+ return "string"
421
+
422
+ types = [value_kind(dtypes.get(str(column))) for column in columns]
423
+ truncated = bool(data.get("truncated")) or bool(table.get("truncated"))
424
+ return rows, types, truncated
425
+
426
+
427
+ def _typed_cell(
428
+ rows: list[list[Any]],
429
+ column_types: list[str],
430
+ row_offset: int,
431
+ column_offset: int,
432
+ visible_offsets: set[tuple[int, int]],
433
+ ) -> tuple[Any, str]:
434
+ if (
435
+ row_offset < 0
436
+ or row_offset >= len(rows)
437
+ or not isinstance(rows[row_offset], list)
438
+ or column_offset < 0
439
+ or column_offset >= len(rows[row_offset])
440
+ ):
441
+ return None, ""
442
+ value = rows[row_offset][column_offset]
443
+ if isinstance(value, bool):
444
+ return value, "bool"
445
+ if isinstance(value, (int, float)):
446
+ return value, "number"
447
+ kind = column_types[column_offset] if column_offset < len(column_types) else ""
448
+ if kind != "string" or not isinstance(value, str) or not _looks_numeric(value):
449
+ return value, kind
450
+
451
+ # table-get infers one dtype per physical column. A hidden cell or another
452
+ # row-series can widen that dtype and stringify otherwise numeric cells.
453
+ # Treat such cells as unknown instead of reporting a false storage issue.
454
+ numeric_string_count = 0
455
+ for other_row_offset, row in enumerate(rows):
456
+ if not isinstance(row, list) or column_offset >= len(row):
457
+ continue
458
+ other = row[column_offset]
459
+ if other not in (None, "") and (other_row_offset, column_offset) not in visible_offsets:
460
+ return value, "unknown"
461
+ if other in (None, "") or (
462
+ isinstance(other, (int, float)) and not isinstance(other, bool)
463
+ ):
464
+ continue
465
+ if isinstance(other, str) and _looks_numeric(other):
466
+ numeric_string_count += 1
467
+ continue
468
+ return value, "unknown"
469
+ return value, "string" if numeric_string_count == 1 else "ambiguous_string"
470
+
471
+
472
+ def _numeric_source_format_reason(
473
+ value: Any,
474
+ raw_type: str,
475
+ number_format: Any,
476
+ ) -> tuple[str, bool]:
477
+ if not _looks_numeric(str(value or "")):
478
+ return "", False
479
+ if str(number_format or "").strip() == "@":
480
+ return "numeric_value_uses_text_format", False
481
+ if raw_type == "string":
482
+ return "numeric_value_stored_as_text", False
483
+ return "", raw_type in {"ambiguous_string", "unknown"}
484
+
485
+
486
+ def _chart_snapshot(chart: dict[str, Any]) -> dict[str, Any]:
487
+ details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
488
+ snapshot = details.get("snapshot")
489
+ return snapshot if isinstance(snapshot, dict) else {}
490
+
491
+
492
+ def _chart_type(snapshot: dict[str, Any]) -> str:
493
+ plot_area = snapshot.get("plotArea")
494
+ plot = plot_area.get("plot") if isinstance(plot_area, dict) else None
495
+ return str(plot.get("type") or "").lower() if isinstance(plot, dict) else ""
496
+
497
+
498
+ def _plot(snapshot: dict[str, Any]) -> dict[str, Any]:
499
+ plot_area = snapshot.get("plotArea")
500
+ plot = plot_area.get("plot") if isinstance(plot_area, dict) else None
501
+ return plot if isinstance(plot, dict) else {}
502
+
503
+
504
+ def _static_series_profiles(snapshot: dict[str, Any]) -> list[SeriesProfile]:
505
+ data = snapshot.get("data")
506
+ dim2 = data.get("dim2") if isinstance(data, dict) else None
507
+ fields = dim2.get("fields") if isinstance(dim2, dict) else None
508
+ if not isinstance(fields, list):
509
+ return []
510
+ profiles: list[SeriesProfile] = []
511
+ for offset, field in enumerate(fields, start=1):
512
+ if not isinstance(field, dict):
513
+ continue
514
+ values = []
515
+ for value in field.get("parsedValues") or []:
516
+ numeric = (
517
+ float(value)
518
+ if isinstance(value, (int, float)) and not isinstance(value, bool)
519
+ else _numeric_text_value(value) if isinstance(value, str) else None
520
+ )
521
+ if numeric is not None:
522
+ values.append(numeric)
523
+ profiles.append(
524
+ {
525
+ "dimension_index": offset,
526
+ "series_name": str(field.get("name") or f"Series {offset}"),
527
+ "point_count": len(field.get("parsedValues") or []),
528
+ "numeric_value_count": len(values),
529
+ "unique_numeric_values": list(dict.fromkeys(values))[:2],
530
+ "source_sheet": "",
531
+ "source_range": "",
532
+ "series_range": "",
533
+ }
534
+ )
535
+ return profiles
536
+
537
+
538
+ def _labeled_series_indexes(
539
+ snapshot: dict[str, Any], profiles: list[SeriesProfile]
540
+ ) -> set[int]:
541
+ plot = _plot(snapshot)
542
+ available = {
543
+ int(profile["dimension_index"])
544
+ for profile in profiles
545
+ if profile.get("dimension_index") is not None
546
+ }
547
+ labeled = set(available) if isinstance(plot.get("labels"), dict) else set()
548
+ series = plot.get("series")
549
+ if isinstance(series, list):
550
+ labeled.update(
551
+ int(item["index"])
552
+ for item in series
553
+ if isinstance(item, dict)
554
+ and item.get("index") is not None
555
+ and isinstance(item.get("labels"), dict)
556
+ )
557
+ return labeled & available
558
+
559
+
560
+ def _constant_labeled_series(
561
+ chart: dict[str, Any], profiles: list[SeriesProfile]
562
+ ) -> list[dict[str, Any]]:
563
+ chart_id = str(chart.get("chart_id") or chart.get("id") or "")
564
+ snapshot = _chart_snapshot(chart)
565
+ labeled = _labeled_series_indexes(snapshot, profiles)
566
+ return [
567
+ {
568
+ "chart_id": chart_id,
569
+ "dimension_index": profile["dimension_index"],
570
+ "series_name": profile["series_name"],
571
+ "source_sheet": profile["source_sheet"],
572
+ "source_range": profile["source_range"],
573
+ "series_range": profile["series_range"],
574
+ "reason": "constant_labeled_series",
575
+ "data_point_count": profile["point_count"],
576
+ "constant_value": profile["unique_numeric_values"][0],
577
+ "suggested_fix": "remove_series_labels_or_use_one_sparse_marker",
578
+ }
579
+ for profile in profiles
580
+ if int(profile.get("dimension_index", -1)) in labeled
581
+ and profile.get("constant_check_unverifiable") is not True
582
+ and int(profile.get("numeric_value_count", 0)) >= 2
583
+ and len(profile.get("unique_numeric_values") or []) == 1
584
+ ]
585
+
586
+
587
+ def _unbound_secondary_axis(chart: dict[str, Any]) -> dict[str, Any] | None:
588
+ snapshot = _chart_snapshot(chart)
589
+ plot_area = snapshot.get("plotArea")
590
+ if not isinstance(plot_area, dict):
591
+ return None
592
+ plot = plot_area.get("plot")
593
+ if not isinstance(plot, dict) or str(plot.get("type") or "").lower() != "combo":
594
+ return None
595
+
596
+ axes = plot_area.get("axes")
597
+ has_right_axis = isinstance(axes, list) and any(
598
+ isinstance(axis, dict)
599
+ and str(axis.get("type") or "").lower() == "y"
600
+ and str(axis.get("position") or axis.get("axisPosition") or "").lower() == "right"
601
+ for axis in axes
602
+ )
603
+ series = plot.get("series")
604
+ configured_series = (
605
+ [item for item in series if isinstance(item, dict)]
606
+ if isinstance(series, list)
607
+ else []
608
+ )
609
+ if not has_right_axis or not configured_series:
610
+ return None
611
+ if str(plot.get("yAxisPosition") or "").lower() == "right" or any(
612
+ str(item.get("yAxisPosition") or "").lower() == "right"
613
+ for item in configured_series
614
+ ):
615
+ return None
616
+
617
+ return {
618
+ "chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
619
+ "reason": "secondary_axis_has_no_bound_series",
620
+ "series_indexes": [
621
+ int(item["index"])
622
+ for item in configured_series
623
+ if isinstance(item.get("index"), (int, float))
624
+ ],
625
+ "suggested_fix": "bind_the_intended_combo_series_to_the_right_axis",
626
+ }
627
+
628
+
629
+ def _undersized_chart(chart: dict[str, Any]) -> dict[str, Any] | None:
630
+ details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
631
+ snapshot = _chart_snapshot(chart)
632
+ chart_type = _chart_type(snapshot)
633
+ size = details.get("size")
634
+ if not isinstance(size, dict):
635
+ return None
636
+ width = size.get("width")
637
+ height = size.get("height")
638
+ if (
639
+ not isinstance(width, (int, float))
640
+ or isinstance(width, bool)
641
+ or width <= 0
642
+ or not isinstance(height, (int, float))
643
+ or isinstance(height, bool)
644
+ or height <= 0
645
+ ):
646
+ return None
647
+ actual = {
648
+ "width": float(width),
649
+ "height": float(height),
650
+ }
651
+ minimum = minimum_chart_size(chart_type)
652
+ if actual["width"] >= minimum["width"] and actual["height"] >= minimum["height"]:
653
+ return None
654
+ return {
655
+ "chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
656
+ "reason": "chart_below_minimum_size",
657
+ "chart_type": chart_type,
658
+ "actual_size": actual,
659
+ "minimum_size": minimum,
660
+ "suggested_fix": "run_lark_chart_size_advisor_before_resizing",
661
+ }
662
+
663
+
664
+ def _overwide_chart(chart: dict[str, Any]) -> dict[str, Any] | None:
665
+ details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
666
+ snapshot = _chart_snapshot(chart)
667
+ chart_type = _chart_type(snapshot)
668
+ size = details.get("size") if isinstance(details.get("size"), dict) else {}
669
+ width = size.get("width")
670
+ height = size.get("height")
671
+ if (
672
+ not isinstance(width, (int, float))
673
+ or isinstance(width, bool)
674
+ or width <= 0
675
+ or not isinstance(height, (int, float))
676
+ or isinstance(height, bool)
677
+ or height <= 0
678
+ ):
679
+ return None
680
+ width = float(width)
681
+ height = float(height)
682
+ minimum = minimum_chart_size(chart_type)
683
+ aspect_ratio = width / height if height > 0 else 0
684
+ width_exceeded = width > MAX_CHART_WIDTH
685
+ aspect_ratio_exceeded = (
686
+ width >= minimum["width"]
687
+ and height >= minimum["height"]
688
+ and aspect_ratio > MAX_ASPECT_RATIO
689
+ )
690
+ if not width_exceeded and not aspect_ratio_exceeded:
691
+ return None
692
+ return {
693
+ "chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
694
+ "reason": "chart_too_wide",
695
+ "chart_type": chart_type,
696
+ "actual_size": {"width": width, "height": height},
697
+ "actual_aspect_ratio": round(aspect_ratio, 2),
698
+ "maximum_width": MAX_CHART_WIDTH,
699
+ "maximum_aspect_ratio": MAX_ASPECT_RATIO,
700
+ "suggested_fix": "use_size_advisor_create_flags_or_change_chart_structure",
701
+ }
702
+
703
+
704
+ def _numeric_dimensions(snapshot: dict[str, Any]) -> list[tuple[int, str]]:
705
+ data = snapshot.get("data")
706
+ if not isinstance(data, dict):
707
+ return []
708
+ dim2 = data.get("dim2")
709
+ series = dim2.get("series") if isinstance(dim2, dict) else None
710
+ chart_type = _chart_type(snapshot)
711
+ dimensions: list[tuple[int, str]] = []
712
+ if isinstance(series, list):
713
+ for offset, serie in enumerate(series):
714
+ if not isinstance(serie, dict) or serie.get("index") is None:
715
+ continue
716
+ if str(serie.get("aggregateType") or "").lower() == "counta":
717
+ continue
718
+ role = str(serie.get("role") or "").lower()
719
+ if chart_type == "bubble":
720
+ role = role or ("x", "y", "group", "size")[min(offset, 3)]
721
+ if role not in {"x", "y", "size"}:
722
+ continue
723
+ dimensions.append((int(serie["index"]), role or "value"))
724
+
725
+ plot_area = snapshot.get("plotArea")
726
+ axes = plot_area.get("axes") if isinstance(plot_area, dict) else None
727
+ continuous_x = chart_type == "scatter"
728
+ if isinstance(axes, list):
729
+ for axis in axes:
730
+ if not isinstance(axis, dict) or str(axis.get("type") or "").lower() != "x":
731
+ continue
732
+ position = axis.get("position")
733
+ if (
734
+ (position is None or str(position).lower() in {"bottom", "x"})
735
+ and str(axis.get("valueType") or "").lower() == "linear"
736
+ ):
737
+ continuous_x = True
738
+ break
739
+ dim1 = data.get("dim1")
740
+ serie = dim1.get("serie") if isinstance(dim1, dict) else None
741
+ if continuous_x and chart_type != "bubble" and isinstance(serie, dict) and serie.get("index") is not None:
742
+ dimensions.append((int(serie["index"]), "x"))
743
+
744
+ return list(dict.fromkeys(dimensions))
745
+
746
+
747
+ def _series_aggregate_type(data: dict[str, Any], dimension_index: int) -> str:
748
+ dim2 = data.get("dim2")
749
+ value_series = dim2.get("series") if isinstance(dim2, dict) else None
750
+ source_series = next(
751
+ (
752
+ item
753
+ for item in (value_series if isinstance(value_series, list) else [])
754
+ if isinstance(item, dict)
755
+ and item.get("index") is not None
756
+ and int(item["index"]) == dimension_index
757
+ ),
758
+ {},
759
+ )
760
+ return str(source_series.get("aggregateType") or "sum").lower()
761
+
762
+
763
+ def _aggregation_can_change_constant(data: dict[str, Any], dimension_index: int) -> bool:
764
+ dim1 = data.get("dim1")
765
+ category_series = dim1.get("serie") if isinstance(dim1, dict) else None
766
+ if not isinstance(category_series, dict) or category_series.get("aggregate") is False:
767
+ return False
768
+ return _series_aggregate_type(data, dimension_index) in {"sum", "count", "counta"}
769
+
770
+
771
+ def _parse_chart_ref(value: str, default_sheet: str) -> tuple[str, str, CellBounds]:
772
+ raw = str(value).strip()
773
+ sheet_name = default_sheet
774
+ cell_range = raw
775
+ if "!" in raw:
776
+ sheet_name, cell_range = raw.rsplit("!", 1)
777
+ sheet_name = sheet_name.strip()
778
+ if len(sheet_name) >= 2 and sheet_name[0] == sheet_name[-1] == "'":
779
+ sheet_name = sheet_name[1:-1].replace("''", "'")
780
+ return sheet_name, cell_range.replace("$", ""), _parse_a1_bounds(cell_range)
781
+
782
+
783
+ def _looks_numeric(value: str) -> bool:
784
+ return _numeric_text_value(value) is not None
785
+
786
+
787
+ def _numeric_text_value(value: str) -> float | None:
788
+ text = value.strip()
789
+ if not text:
790
+ return None
791
+ text = re.sub(r"^([+-]?)[\$\u00a5\uffe5\u20ac\u00a3]", r"\1", text)
792
+ if text.endswith("%"):
793
+ text = text[:-1]
794
+ # Group separators must be consistent and separate groups of three digits.
795
+ if not re.fullmatch(
796
+ r"[+-]?(?:(?:\d+|\d{1,3}([, ])\d{3}(?:\1\d{3})*)(?:\.\d*)?|\.\d+)"
797
+ r"(?:[eE][+-]?\d+)?",
798
+ text,
799
+ ):
800
+ return None
801
+ return float(text.replace(" ", "").replace(",", ""))
802
+
803
+
804
+ def _zero_state(value: Any) -> str:
805
+ if value in (None, ""):
806
+ return "empty"
807
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
808
+ return "zero" if value == 0 else "nonzero"
809
+ if isinstance(value, str):
810
+ numeric_value = _numeric_text_value(value)
811
+ if numeric_value is not None:
812
+ return "zero" if numeric_value == 0 else "nonzero"
813
+ return "nonzero"
814
+
815
+
816
+ def _update_series_state(state: dict[str, Any], value: Any) -> None:
817
+ state[_zero_state(value)] = True
818
+ numeric = (
819
+ float(value)
820
+ if isinstance(value, (int, float)) and not isinstance(value, bool)
821
+ else _numeric_text_value(value) if isinstance(value, str) else None
822
+ )
823
+ if numeric is None:
824
+ return
825
+ state["numeric_value_count"] += 1
826
+ if len(state["unique_numeric_values"]) < 2:
827
+ state["unique_numeric_values"].add(numeric)
828
+
829
+
830
+ def _cells_truncated(data: dict[str, Any]) -> bool:
831
+ ranges = data.get("ranges")
832
+ return bool(data.get("has_more")) or any(
833
+ isinstance(item, dict) and item.get("truncated")
834
+ for item in (ranges if isinstance(ranges, list) else [])
835
+ )
836
+
837
+
838
+ def _numeric_source_issues(
839
+ chart: dict[str, Any],
840
+ *,
841
+ owner_sheet_id: str,
842
+ owner_sheet_name: str,
843
+ cache: CellCache,
844
+ locator: dict[str, str],
845
+ timeout: int,
846
+ sample_limit: int,
847
+ ) -> tuple[
848
+ list[dict[str, Any]],
849
+ list[dict[str, Any]],
850
+ list[dict[str, str]],
851
+ list[SeriesProfile],
852
+ ]:
853
+ chart_id = str(chart.get("chart_id") or chart.get("id") or "")
854
+ snapshot = _chart_snapshot(chart)
855
+ data = snapshot.get("data")
856
+ if not isinstance(data, dict):
857
+ return [], [], [{"chart_id": chart_id, "reason": "chart snapshot.data is missing"}], []
858
+ if data.get("isStaticData") is True:
859
+ return [], [], [], _static_series_profiles(snapshot)
860
+ dimensions = _numeric_dimensions(snapshot)
861
+ if not dimensions:
862
+ return [], [], [], []
863
+ refs = data.get("refs")
864
+ if not isinstance(refs, list) or not refs:
865
+ return [], [], [{"chart_id": chart_id, "reason": "chart data.refs is missing"}], []
866
+
867
+ parsed_refs: list[tuple[str, str, CellBounds]] = []
868
+ unverifiable: list[dict[str, str]] = []
869
+ for ref in refs:
870
+ raw_ref = ref.get("value") if isinstance(ref, dict) else ref
871
+ try:
872
+ parsed_refs.append(_parse_chart_ref(str(raw_ref), owner_sheet_name))
873
+ except (TypeError, ValueError) as exc:
874
+ unverifiable.append({"chart_id": chart_id, "reason": str(exc)})
875
+ return [], [], unverifiable, []
876
+
877
+ direction = str(data.get("direction") or "column").lower()
878
+ skip_hidden = data.get("includeHiddenOrFilter") is not True
879
+ mapped: list[tuple[int, str, int, str, str, CellBounds]] = []
880
+ for dimension_index, role in dimensions:
881
+ offset = 0
882
+ for source_sheet, source_range, bounds in parsed_refs:
883
+ dimension_count = (
884
+ bounds[3] - bounds[2] + 1 if direction == "column" else bounds[1] - bounds[0] + 1
885
+ )
886
+ if offset < dimension_index <= offset + dimension_count:
887
+ mapped.append(
888
+ (
889
+ dimension_index,
890
+ role,
891
+ dimension_index - offset,
892
+ source_sheet,
893
+ source_range,
894
+ bounds,
895
+ )
896
+ )
897
+ break
898
+ offset += dimension_count
899
+ else:
900
+ unverifiable.append(
901
+ {
902
+ "chart_id": chart_id,
903
+ "reason": f"numeric dimension index {dimension_index} is outside data.refs",
904
+ }
905
+ )
906
+
907
+ detached = str(data.get("headerMode") or "").lower() == "detached"
908
+ mapped_by_ref: dict[
909
+ tuple[str, str, CellBounds], list[tuple[int, str, int]]
910
+ ] = {}
911
+ for dimension_index, role, local_index, source_sheet, source_range, bounds in mapped:
912
+ mapped_by_ref.setdefault((source_sheet, source_range, bounds), []).append(
913
+ (dimension_index, role, local_index)
914
+ )
915
+
916
+ issue_groups: dict[tuple[int, str, str, str, str, str], list[str]] = {}
917
+ issue_counts: dict[tuple[int, str, str, str, str, str], int] = {}
918
+ degenerate_series: list[dict[str, Any]] = []
919
+ series_profiles: list[SeriesProfile] = []
920
+ remaining_source_cells = MAX_CELL_READ_SIZE
921
+ remaining_dimension_spans = sum(
922
+ max(local_index for _, _, local_index in ref_dimensions)
923
+ - min(local_index for _, _, local_index in ref_dimensions)
924
+ + 1
925
+ for ref_dimensions in mapped_by_ref.values()
926
+ )
927
+ for (source_sheet, source_range, bounds), ref_dimensions in mapped_by_ref.items():
928
+ dimension_start = bounds[2] if direction == "column" else bounds[0]
929
+ selected = {
930
+ dimension_start + local_index - 1: (dimension_index, role)
931
+ for dimension_index, role, local_index in ref_dimensions
932
+ }
933
+ dimension_span = max(selected) - min(selected) + 1
934
+ header_points = 0 if detached else 1
935
+ # Each sampled rectangle is read twice: cells-get supplies coordinates,
936
+ # styles, and hidden/filter semantics; table-get supplies typed values.
937
+ cells_per_dimension = remaining_source_cells // remaining_dimension_spans
938
+ remaining_dimension_spans -= dimension_span
939
+ point_axis_size = (
940
+ bounds[1] - bounds[0] + 1
941
+ if direction == "column"
942
+ else bounds[3] - bounds[2] + 1
943
+ )
944
+ if point_axis_size <= header_points:
945
+ unverifiable.append(
946
+ {
947
+ "chart_id": chart_id,
948
+ "reason": f"source has no data points: {source_sheet}!{source_range}",
949
+ }
950
+ )
951
+ continue
952
+ sample_points = min(MAX_SOURCE_SAMPLE_POINTS, max(0, (cells_per_dimension - header_points) // 2))
953
+ if sample_points == 0:
954
+ unverifiable.append({
955
+ "chart_id": chart_id,
956
+ "reason": f"source sampling 2000-cell budget cannot cover {source_sheet}!{source_range}",
957
+ })
958
+ continue
959
+ point_count = sample_points + header_points
960
+ if direction == "column":
961
+ checked_bounds = (
962
+ bounds[0],
963
+ min(bounds[1], bounds[0] + point_count - 1),
964
+ min(selected),
965
+ max(selected),
966
+ )
967
+ else:
968
+ checked_bounds = (
969
+ min(selected),
970
+ max(selected),
971
+ bounds[2],
972
+ min(bounds[3], bounds[2] + point_count - 1),
973
+ )
974
+ typed_bounds = (
975
+ (checked_bounds[0] + header_points, checked_bounds[1], checked_bounds[2], checked_bounds[3])
976
+ if direction == "column"
977
+ else (checked_bounds[0], checked_bounds[1], checked_bounds[2] + header_points, checked_bounds[3])
978
+ )
979
+ remaining_source_cells -= _bounds_area(checked_bounds) + _bounds_area(typed_bounds)
980
+ checked_range = _format_a1_bounds(checked_bounds)
981
+ same_sheet = source_sheet == owner_sheet_name
982
+ cells_data = _read_cells(
983
+ cache,
984
+ locator,
985
+ sheet_id=owner_sheet_id if same_sheet else None,
986
+ sheet_name=None if same_sheet else source_sheet,
987
+ cell_range=checked_range,
988
+ include="value,style",
989
+ skip_hidden=skip_hidden,
990
+ timeout=timeout,
991
+ )
992
+ cells_truncated = _cells_truncated(cells_data)
993
+ typed_rows, typed_columns, table_truncated = _read_typed_table(
994
+ cache,
995
+ locator,
996
+ sheet_id=owner_sheet_id if same_sheet else None,
997
+ sheet_name=None if same_sheet else source_sheet,
998
+ cell_range=_format_a1_bounds(typed_bounds),
999
+ timeout=timeout,
1000
+ )
1001
+ if cells_truncated:
1002
+ unverifiable.append(
1003
+ {"chart_id": chart_id, "reason": f"cells-get truncated for {source_sheet}!{checked_range}"}
1004
+ )
1005
+ if table_truncated:
1006
+ unverifiable.append(
1007
+ {
1008
+ "chart_id": chart_id,
1009
+ "reason": (
1010
+ "table-get truncated or returned invalid typed data for "
1011
+ f"{source_sheet}!{_format_a1_bounds(typed_bounds)}"
1012
+ ),
1013
+ }
1014
+ )
1015
+ truncated = cells_truncated or table_truncated
1016
+ states = {
1017
+ coordinate: {
1018
+ "zero": False,
1019
+ "empty": False,
1020
+ "nonzero": False,
1021
+ "sample_point_count": 0,
1022
+ "numeric_value_count": 0,
1023
+ "unique_numeric_values": set(),
1024
+ }
1025
+ for coordinate in selected
1026
+ }
1027
+ type_unverifiable_dimensions: set[int] = set()
1028
+ visible_offsets = {
1029
+ (row_number - typed_bounds[0], column_index - typed_bounds[2])
1030
+ for row_number, column_index, _ in _iter_cells(cells_data)
1031
+ if typed_bounds[0] <= row_number <= typed_bounds[1]
1032
+ and typed_bounds[2] <= column_index <= typed_bounds[3]
1033
+ }
1034
+ for row_number, column_index, cell in _iter_cells(cells_data):
1035
+ coordinate = column_index if direction == "column" else row_number
1036
+ dimension = selected.get(coordinate)
1037
+ if dimension is None:
1038
+ continue
1039
+ if not detached and (
1040
+ (direction == "column" and row_number == bounds[0])
1041
+ or (direction != "column" and column_index == bounds[2])
1042
+ ):
1043
+ continue
1044
+ states[coordinate]["sample_point_count"] += 1
1045
+ row_offset = row_number - typed_bounds[0]
1046
+ column_offset = column_index - typed_bounds[2]
1047
+ value, raw_type = _typed_cell(
1048
+ typed_rows,
1049
+ typed_columns,
1050
+ row_offset,
1051
+ column_offset,
1052
+ visible_offsets,
1053
+ )
1054
+ _update_series_state(states[coordinate], value)
1055
+ number_format = (
1056
+ cell.get("cell_styles", {}).get("number_format")
1057
+ if isinstance(cell, dict) and isinstance(cell.get("cell_styles"), dict)
1058
+ else None
1059
+ )
1060
+ reason, type_unverifiable = _numeric_source_format_reason(
1061
+ value,
1062
+ raw_type,
1063
+ number_format,
1064
+ )
1065
+ if type_unverifiable:
1066
+ type_unverifiable_dimensions.add(dimension[0])
1067
+ if not reason:
1068
+ continue
1069
+ dimension_index, role = dimension
1070
+ key = (
1071
+ dimension_index,
1072
+ role,
1073
+ source_sheet,
1074
+ source_range,
1075
+ checked_range,
1076
+ reason,
1077
+ )
1078
+ issue_counts[key] = issue_counts.get(key, 0) + 1
1079
+ samples = issue_groups.setdefault(key, [])
1080
+ if len(samples) < sample_limit:
1081
+ samples.append(f"{index_to_column(column_index)}{row_number}")
1082
+
1083
+ if truncated:
1084
+ continue
1085
+ for dimension_index in sorted(type_unverifiable_dimensions):
1086
+ unverifiable.append(
1087
+ {
1088
+ "chart_id": chart_id,
1089
+ "reason": (
1090
+ "numeric storage type cannot be attributed to individual cells after "
1091
+ f"typed column coercion for {source_sheet}!{checked_range}, "
1092
+ f"dimension {dimension_index}"
1093
+ ),
1094
+ }
1095
+ )
1096
+ zero_candidates = {
1097
+ coordinate
1098
+ for coordinate, state in states.items()
1099
+ if not state["nonzero"]
1100
+ and _series_aggregate_type(data, selected[coordinate][0]) != "count"
1101
+ }
1102
+ data_start = bounds[0] + (0 if detached else 1)
1103
+ data_column = bounds[2] + (0 if detached else 1)
1104
+ dim2 = data.get("dim2")
1105
+ value_series = dim2.get("series") if isinstance(dim2, dict) else None
1106
+ for coordinate, state in states.items():
1107
+ dimension_index, role = selected[coordinate]
1108
+ if direction == "column":
1109
+ point_total = max(0, bounds[1] - data_start + 1)
1110
+ series_range = (
1111
+ f"{index_to_column(coordinate)}{data_start}:"
1112
+ f"{index_to_column(coordinate)}{bounds[1]}"
1113
+ if point_total
1114
+ else ""
1115
+ )
1116
+ else:
1117
+ point_total = max(0, bounds[3] - data_column + 1)
1118
+ series_range = (
1119
+ f"{index_to_column(data_column)}{coordinate}:"
1120
+ f"{index_to_column(bounds[3])}{coordinate}"
1121
+ if point_total
1122
+ else ""
1123
+ )
1124
+ source_series = next(
1125
+ (
1126
+ item
1127
+ for item in (value_series if isinstance(value_series, list) else [])
1128
+ if isinstance(item, dict)
1129
+ and item.get("index") is not None
1130
+ and int(item["index"]) == dimension_index
1131
+ ),
1132
+ {},
1133
+ )
1134
+ profile = {
1135
+ "dimension_index": dimension_index,
1136
+ "series_name": str(
1137
+ source_series.get("name")
1138
+ or source_series.get("nameRef")
1139
+ or f"Series {dimension_index}"
1140
+ ),
1141
+ "point_count": point_total,
1142
+ "sample_point_count": state["sample_point_count"],
1143
+ "checked_range": checked_range,
1144
+ "sampled": (
1145
+ checked_bounds[1] < bounds[1]
1146
+ if direction == "column"
1147
+ else checked_bounds[3] < bounds[3]
1148
+ ),
1149
+ "numeric_value_count": state["numeric_value_count"],
1150
+ "unique_numeric_values": list(state["unique_numeric_values"]),
1151
+ "source_sheet": source_sheet,
1152
+ "source_range": source_range,
1153
+ "series_range": series_range,
1154
+ }
1155
+ aggregate_type = _series_aggregate_type(data, dimension_index)
1156
+ if aggregate_type == "count":
1157
+ profile["constant_check_unverifiable"] = True
1158
+ constant_labeled = (
1159
+ aggregate_type != "count"
1160
+ and state["numeric_value_count"] >= 2
1161
+ and len(state["unique_numeric_values"]) == 1
1162
+ and dimension_index
1163
+ in _labeled_series_indexes(snapshot, [{"dimension_index": dimension_index}])
1164
+ )
1165
+ if profile["sampled"]:
1166
+ profile["constant_check_unverifiable"] = True
1167
+ if coordinate in zero_candidates or constant_labeled:
1168
+ unverifiable.append({
1169
+ "chart_id": chart_id,
1170
+ "reason": (
1171
+ "zero/constant-series check is unverifiable outside sampled source "
1172
+ f"{source_sheet}!{checked_range} for dimension {dimension_index}"
1173
+ ),
1174
+ })
1175
+ elif constant_labeled and _aggregation_can_change_constant(data, dimension_index):
1176
+ profile["constant_check_unverifiable"] = True
1177
+ unverifiable.append(
1178
+ {
1179
+ "chart_id": chart_id,
1180
+ "reason": (
1181
+ "constant-series check is unverifiable after category "
1182
+ f"aggregation for dimension {dimension_index}"
1183
+ ),
1184
+ }
1185
+ )
1186
+ series_profiles.append(profile)
1187
+ if coordinate not in zero_candidates or profile["sampled"]:
1188
+ continue
1189
+ degenerate_series.append(
1190
+ {
1191
+ "chart_id": chart_id,
1192
+ "dimension_index": dimension_index,
1193
+ "role": role,
1194
+ "source_sheet": source_sheet,
1195
+ "source_range": source_range,
1196
+ "series_range": series_range,
1197
+ "reason": (
1198
+ "numeric_series_all_zero_or_empty"
1199
+ if states[coordinate]["zero"]
1200
+ else "numeric_series_all_empty"
1201
+ ),
1202
+ "data_point_count": point_total,
1203
+ }
1204
+ )
1205
+
1206
+ issues = [
1207
+ {
1208
+ "chart_id": chart_id,
1209
+ "dimension_index": key[0],
1210
+ "role": key[1],
1211
+ "source_sheet": key[2],
1212
+ "source_range": key[3],
1213
+ "checked_range": key[4],
1214
+ "reason": key[5],
1215
+ "suggested_fix": (
1216
+ "set_numeric_number_format"
1217
+ if key[5] == "numeric_value_uses_text_format"
1218
+ else "rewrite_as_number_and_set_numeric_number_format"
1219
+ ),
1220
+ "affected_sample_cell_count": issue_counts[key],
1221
+ "sample_cells": samples,
1222
+ }
1223
+ for key, samples in issue_groups.items()
1224
+ ]
1225
+ return issues, degenerate_series, unverifiable, series_profiles
1226
+
1227
+
1228
+ def _locator(target: str) -> dict[str, str]:
1229
+ return {"url": target} if target.startswith(("http://", "https://")) else {"spreadsheet_token": target}
1230
+
1231
+
1232
+ def _sheet_counts(sheet: dict[str, Any]) -> tuple[int, int]:
1233
+ row_count = int(sheet.get("row_count") or sheet.get("rowCount") or 0)
1234
+ column_count = int(sheet.get("column_count") or sheet.get("columnCount") or 0)
1235
+ if row_count <= 0 or column_count <= 0:
1236
+ raise LarkCliError(f"Missing row_count/column_count for sheet {sheet_title(sheet)!r}")
1237
+ return row_count, column_count
1238
+
1239
+
1240
+ def check_sheet(
1241
+ locator: dict[str, str],
1242
+ sheet: dict[str, Any],
1243
+ *,
1244
+ timeout: int,
1245
+ sample_limit: int,
1246
+ cell_cache: CellCache | None = None,
1247
+ ) -> dict[str, Any]:
1248
+ cell_cache = cell_cache if cell_cache is not None else {}
1249
+ sheet_id = sheet_identifier(sheet)
1250
+ title = sheet_title(sheet)
1251
+ if not sheet_id:
1252
+ raise LarkCliError(f"Missing sheet_id for sheet {title!r}")
1253
+
1254
+ chart_data = envelope_data(
1255
+ run_sheets("+chart-list", **locator, sheet_id=sheet_id, timeout=timeout)
1256
+ )
1257
+ charts = extract_charts(chart_data, sheet_id, title)
1258
+ unverifiable: list[dict[str, str]] = []
1259
+ expected_chart_count = sheet.get("chart_count")
1260
+ if expected_chart_count is not None and int(expected_chart_count) != len(charts):
1261
+ unverifiable.append(
1262
+ {
1263
+ "chart_id": "",
1264
+ "reason": (
1265
+ f"chart-list returned {len(charts)} charts, "
1266
+ f"but workbook-info reported {int(expected_chart_count)}"
1267
+ ),
1268
+ }
1269
+ )
1270
+ if not charts:
1271
+ return {
1272
+ "sheet_id": sheet_id,
1273
+ "sheet_name": title,
1274
+ "chart_count": 0,
1275
+ "sheet_size_px": None,
1276
+ "chart_overlaps": [],
1277
+ "cell_content_overlaps": [],
1278
+ "numeric_source_format_issues": [],
1279
+ "numeric_source_samples": [],
1280
+ "degenerate_numeric_series": [],
1281
+ "constant_labeled_series": [],
1282
+ "unbound_secondary_axes": [],
1283
+ "undersized_charts": [],
1284
+ "overwide_charts": [],
1285
+ "out_of_visible_range": [],
1286
+ "unverifiable_charts": unverifiable,
1287
+ "issue_count": 0,
1288
+ "unverifiable_count": len(unverifiable),
1289
+ "warnings": [],
1290
+ }
1291
+
1292
+ row_count, column_count = _sheet_counts(sheet)
1293
+ structure_data = envelope_data(
1294
+ run_sheets(
1295
+ "+sheet-info",
1296
+ **locator,
1297
+ sheet_id=sheet_id,
1298
+ flags={"include": "row_heights,col_widths"},
1299
+ timeout=timeout,
1300
+ )
1301
+ )
1302
+ row_edges, column_edges, warnings = build_layout(
1303
+ extract_sheet_structure(structure_data), row_count, column_count
1304
+ )
1305
+
1306
+ rectangles: list[dict[str, Any]] = []
1307
+ for chart in charts:
1308
+ chart_id = str(chart.get("chart_id") or chart.get("id") or "")
1309
+ if not chart_id:
1310
+ unverifiable.append({"chart_id": "", "reason": "chart is missing chart_id"})
1311
+ continue
1312
+ try:
1313
+ rectangles.append(chart_rectangle(chart, row_edges, column_edges))
1314
+ except (KeyError, TypeError, ValueError) as exc:
1315
+ unverifiable.append({"chart_id": chart_id, "reason": str(exc)})
1316
+
1317
+ overlaps: list[dict[str, Any]] = []
1318
+ for index, first in enumerate(rectangles):
1319
+ for second in rectangles[index + 1 :]:
1320
+ overlap = intersection(first, second)
1321
+ if overlap:
1322
+ overlaps.append(
1323
+ {
1324
+ "chart_ids": [first["chart_id"], second["chart_id"]],
1325
+ "charts": [chart_context(first), chart_context(second)],
1326
+ "intersection": overlap,
1327
+ }
1328
+ )
1329
+
1330
+ sheet_width = column_edges[-1]
1331
+ sheet_height = row_edges[-1]
1332
+ out_of_bounds: list[dict[str, Any]] = []
1333
+ content_overlaps: list[dict[str, Any]] = []
1334
+ covered_items: list[tuple[dict[str, Any], CellBounds]] = []
1335
+ for rectangle in rectangles:
1336
+ overflow = {
1337
+ "left": round(max(0.0, -rectangle["left"]), 2),
1338
+ "top": round(max(0.0, -rectangle["top"]), 2),
1339
+ "right": round(max(0.0, rectangle["right"] - sheet_width), 2),
1340
+ "bottom": round(max(0.0, rectangle["bottom"] - sheet_height), 2),
1341
+ }
1342
+ if any(overflow.values()):
1343
+ out_of_bounds.append({**chart_context(rectangle), "overflow_px": overflow})
1344
+
1345
+ covered_range = rectangle_cell_range(rectangle, row_edges, column_edges)
1346
+ if not covered_range:
1347
+ continue
1348
+ covered_items.append((rectangle, _parse_a1_bounds(covered_range)))
1349
+
1350
+ for cluster in _cluster_cell_reads(covered_items):
1351
+ read_range = _format_a1_bounds(cluster["bounds"])
1352
+ cells_data = _read_cells(
1353
+ cell_cache,
1354
+ locator,
1355
+ sheet_id=sheet_id,
1356
+ sheet_name=None,
1357
+ cell_range=read_range,
1358
+ include="value,formula,comment",
1359
+ timeout=timeout,
1360
+ )
1361
+ for rectangle, bounds in cluster["members"]:
1362
+ covered_range = _format_a1_bounds(bounds)
1363
+ count, samples, truncated = non_empty_cells(cells_data, sample_limit, bounds)
1364
+ if truncated:
1365
+ unverifiable.append(
1366
+ {
1367
+ "chart_id": rectangle["chart_id"],
1368
+ "reason": f"cells-get truncated for {read_range}",
1369
+ }
1370
+ )
1371
+ if count:
1372
+ content_overlaps.append(
1373
+ {
1374
+ **chart_context(rectangle),
1375
+ "covered_range": covered_range,
1376
+ "non_empty_cell_count": count,
1377
+ "sample_cells": samples,
1378
+ }
1379
+ )
1380
+
1381
+ numeric_source_issues: list[dict[str, Any]] = []
1382
+ numeric_source_samples: list[dict[str, Any]] = []
1383
+ degenerate_numeric_series: list[dict[str, Any]] = []
1384
+ constant_series_issues: list[dict[str, Any]] = []
1385
+ unbound_secondary_axes: list[dict[str, Any]] = []
1386
+ undersized_charts: list[dict[str, Any]] = []
1387
+ overwide_charts: list[dict[str, Any]] = []
1388
+ for chart in charts:
1389
+ issues, degenerate, source_unverifiable, profiles = _numeric_source_issues(
1390
+ chart,
1391
+ owner_sheet_id=sheet_id,
1392
+ owner_sheet_name=title,
1393
+ cache=cell_cache,
1394
+ locator=locator,
1395
+ timeout=timeout,
1396
+ sample_limit=sample_limit,
1397
+ )
1398
+ numeric_source_issues.extend(issues)
1399
+ numeric_source_samples.extend(
1400
+ {"chart_id": str(chart.get("chart_id") or chart.get("id") or ""), **profile}
1401
+ for profile in profiles
1402
+ if "checked_range" in profile
1403
+ )
1404
+ degenerate_numeric_series.extend(degenerate)
1405
+ unverifiable.extend(source_unverifiable)
1406
+ constant_series_issues.extend(_constant_labeled_series(chart, profiles))
1407
+ unbound_secondary_axis = _unbound_secondary_axis(chart)
1408
+ if unbound_secondary_axis:
1409
+ unbound_secondary_axes.append(unbound_secondary_axis)
1410
+ undersized = _undersized_chart(chart)
1411
+ if undersized:
1412
+ undersized_charts.append(undersized)
1413
+ overwide = _overwide_chart(chart)
1414
+ if overwide:
1415
+ overwide_charts.append(overwide)
1416
+
1417
+ issue_count = (
1418
+ len(overlaps)
1419
+ + len(out_of_bounds)
1420
+ + len(content_overlaps)
1421
+ + len(numeric_source_issues)
1422
+ + len(degenerate_numeric_series)
1423
+ + len(constant_series_issues)
1424
+ + len(unbound_secondary_axes)
1425
+ + len(undersized_charts)
1426
+ + len(overwide_charts)
1427
+ )
1428
+ return {
1429
+ "sheet_id": sheet_id,
1430
+ "sheet_name": title,
1431
+ "chart_count": len(charts),
1432
+ "sheet_size_px": {"width": round(sheet_width, 2), "height": round(sheet_height, 2)},
1433
+ "chart_overlaps": overlaps,
1434
+ "cell_content_overlaps": content_overlaps,
1435
+ "numeric_source_format_issues": numeric_source_issues,
1436
+ "numeric_source_samples": numeric_source_samples,
1437
+ "degenerate_numeric_series": degenerate_numeric_series,
1438
+ "constant_labeled_series": constant_series_issues,
1439
+ "unbound_secondary_axes": unbound_secondary_axes,
1440
+ "undersized_charts": undersized_charts,
1441
+ "overwide_charts": overwide_charts,
1442
+ "out_of_visible_range": out_of_bounds,
1443
+ "unverifiable_charts": unverifiable,
1444
+ "issue_count": issue_count,
1445
+ "unverifiable_count": len(unverifiable),
1446
+ "warnings": warnings,
1447
+ }
1448
+
1449
+
1450
+ def parse_args() -> argparse.Namespace:
1451
+ parser = argparse.ArgumentParser(
1452
+ description=(
1453
+ "Check chart overlap, covered cell content, worksheet boundary overflow, "
1454
+ "minimum size, excessive width, constant labeled series, numeric source-cell "
1455
+ "formats, all-zero/empty numeric series, and unbound combo-chart secondary axes."
1456
+ )
1457
+ )
1458
+ parser.add_argument("sheet_id", help="Spreadsheet URL or spreadsheet token")
1459
+ parser.add_argument("--worksheet-id", help="Only check this worksheet reference_id")
1460
+ parser.add_argument("--timeout", type=int, default=60)
1461
+ parser.add_argument("--sample-limit", type=int, default=10)
1462
+ return parser.parse_args()
1463
+
1464
+
1465
+ def success_envelope(results: list[dict[str, Any]]) -> dict[str, Any]:
1466
+ issue_count = sum(result["issue_count"] for result in results)
1467
+ unverifiable_count = sum(result["unverifiable_count"] for result in results)
1468
+ warnings = [
1469
+ f"{result['sheet_name'] or result['sheet_id']}: {warning}"
1470
+ for result in results
1471
+ for warning in result["warnings"]
1472
+ ]
1473
+ return {
1474
+ "ok": True,
1475
+ "engine": "lark",
1476
+ "action": ACTION,
1477
+ "data": {
1478
+ "passed": issue_count == 0 and unverifiable_count == 0,
1479
+ "scope_note": (
1480
+ "out_of_visible_range checks worksheet drawable bounds, not a device-specific browser viewport; "
1481
+ "numeric source checks sample at most the first 50 data points of each chart value dimension "
1482
+ "for formats, all-zero/empty, and constant-value checks; zero/constant results are conclusive "
1483
+ "only when that sample covers the complete series, otherwise they are marked unverifiable"
1484
+ ),
1485
+ "summary": {
1486
+ "worksheet_count": len(results),
1487
+ "chart_count": sum(result["chart_count"] for result in results),
1488
+ "issue_count": issue_count,
1489
+ "unverifiable_count": unverifiable_count,
1490
+ },
1491
+ "sheets": results,
1492
+ },
1493
+ "warnings": warnings,
1494
+ }
1495
+
1496
+
1497
+ def report_exit_code(report: dict[str, Any]) -> int:
1498
+ if report["data"]["passed"]:
1499
+ return 0
1500
+ if report["data"]["summary"]["issue_count"] > 0:
1501
+ return 2
1502
+ return 1
1503
+
1504
+
1505
+ def main() -> None:
1506
+ args = parse_args()
1507
+ locator = _locator(args.sheet_id)
1508
+ cell_cache: CellCache = {}
1509
+ try:
1510
+ workbook_data = envelope_data(
1511
+ run_sheets("+workbook-info", **locator, timeout=args.timeout)
1512
+ )
1513
+ sheets = resolve_target_sheets(workbook_data, sheet_id=args.worksheet_id)
1514
+ if not args.worksheet_id:
1515
+ sheets = [sheet for sheet in sheets if not bool(sheet.get("is_hidden"))]
1516
+ if not sheets:
1517
+ raise LarkCliError("No visible worksheet matched")
1518
+ results = [
1519
+ check_sheet(
1520
+ locator,
1521
+ sheet,
1522
+ timeout=args.timeout,
1523
+ sample_limit=args.sample_limit,
1524
+ cell_cache=cell_cache,
1525
+ )
1526
+ for sheet in sheets
1527
+ ]
1528
+ except (LarkCliError, KeyError, TypeError, ValueError) as exc:
1529
+ emit_error(ACTION, str(exc))
1530
+ raise SystemExit(1) from exc
1531
+
1532
+ report = success_envelope(results)
1533
+ print(json.dumps(report, ensure_ascii=False, indent=2))
1534
+ exit_code = report_exit_code(report)
1535
+ if exit_code:
1536
+ raise SystemExit(exit_code)
1537
+
1538
+
1539
+ if __name__ == "__main__":
1540
+ main()