@amaster.ai/pi-lark 0.1.2-beta.69 → 0.1.2-beta.71

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 (46) hide show
  1. package/package.json +2 -2
  2. package/skills/lark-apps/references/lark-apps-local-dev.md +1 -1
  3. package/skills/lark-base/references/lark-base-app.md +2 -2
  4. package/skills/lark-base/references/lark-base-workflow-schema.md +68 -0
  5. package/skills/lark-base/references/lark-base-workflow.md +99 -3
  6. package/skills/lark-calendar/SKILL.md +12 -7
  7. package/skills/lark-calendar/references/lark-calendar-meeting-relation.md +99 -0
  8. package/skills/lark-calendar/references/lark-calendar-meeting.md +1 -1
  9. package/skills/lark-calendar/references/lark-calendar-recurring.md +3 -1
  10. package/skills/lark-doc/SKILL.md +1 -1
  11. package/skills/lark-doc/references/lark-doc-create-workflow.md +8 -10
  12. package/skills/lark-doc/references/lark-doc-script.md +11 -17
  13. package/skills/lark-drive/references/lark-drive-permission-guide.md +1 -1
  14. package/skills/lark-im/references/lark-im-chat-messages-list.md +2 -1
  15. package/skills/lark-im/references/lark-im-messages-mget.md +19 -2
  16. package/skills/lark-im/references/lark-im-messages-search.md +1 -1
  17. package/skills/lark-im/references/lark-im-threads-messages-list.md +1 -1
  18. package/skills/lark-mail/SKILL.md +19 -8
  19. package/skills/lark-mail/references/lark-mail-draft-create.md +1 -1
  20. package/skills/lark-mail/references/lark-mail-draft-edit.md +1 -1
  21. package/skills/lark-mail/references/lark-mail-forward.md +1 -1
  22. package/skills/lark-mail/references/lark-mail-reply-all.md +1 -1
  23. package/skills/lark-mail/references/lark-mail-reply.md +1 -1
  24. package/skills/lark-mail/references/lark-mail-rules.md +87 -4
  25. package/skills/lark-mail/references/lark-mail-send.md +1 -1
  26. package/skills/lark-mail/references/lark-mail-thread-modify.md +73 -0
  27. package/skills/lark-mail/references/lark-mail-thread-trash.md +62 -0
  28. package/skills/lark-mail/references/lark-mail-watch.md +1 -1
  29. package/skills/lark-meeting/SKILL.md +2 -2
  30. package/skills/lark-meeting/references/lark-minutes-search.md +2 -2
  31. package/skills/lark-meeting/references/lark-vc-meeting-events.md +3 -2
  32. package/skills/lark-meeting/references/lark-vc-search.md +10 -7
  33. package/skills/lark-meeting/scenes/create-and-edit-minutes.md +4 -0
  34. package/skills/lark-meeting/scenes/query-meeting-and-artifacts.md +3 -3
  35. package/skills/lark-sheets/SKILL.md +3 -1
  36. package/skills/lark-sheets/references/lark-sheets-chart.md +66 -32
  37. package/skills/lark-sheets/references/lark-sheets-visual-standards.md +6 -3
  38. package/skills/lark-sheets/scripts/lark_chart_quality_check.py +1524 -0
  39. package/skills/lark-sheets/scripts/lark_chart_size_advisor.py +408 -0
  40. package/skills/lark-sheets/scripts/lark_chart_size_rules.py +292 -0
  41. package/skills/lark-slides/references/xml/slides_xml_schema_definition.xml +333 -20
  42. package/skills/lark-wiki/references/lark-wiki-move.md +3 -2
  43. package/skills/lark-wiki/references/lark-wiki-node-create.md +3 -2
  44. package/skills/lark-wiki/references/lark-wiki-node-delete.md +8 -4
  45. package/skills/lark-wiki/references/lark-wiki-node-get.md +7 -4
  46. package/skills/lark-sheets/scripts/lark_chart_layout_check.py +0 -472
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 Lark Technologies Pte. Ltd.
3
+ # SPDX-License-Identifier: MIT
4
+ """Recommend a Lark Sheet chart size before creating the chart object."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import re
11
+ from typing import Any
12
+
13
+ from lark_chart_size_rules import SUPPORTED_CHART_TYPES, recommend_chart_size
14
+ from lark_sheet_read_cli import (
15
+ LarkCliError,
16
+ emit_error,
17
+ emit_success,
18
+ envelope_data,
19
+ extract_sheets,
20
+ run_sheets,
21
+ sheet_identifier,
22
+ sheet_title,
23
+ )
24
+
25
+ ACTION = "chart_size_advisor"
26
+
27
+
28
+ def _locator(target: str) -> dict[str, str]:
29
+ return {"url": target} if target.startswith(("http://", "https://")) else {"spreadsheet_token": target}
30
+
31
+
32
+ def _split_range_refs(value: str) -> list[str]:
33
+ refs: list[str] = []
34
+ start = 0
35
+ quoted = False
36
+ index = 0
37
+ while index < len(value):
38
+ char = value[index]
39
+ if char == "'":
40
+ if quoted and index + 1 < len(value) and value[index + 1] == "'":
41
+ index += 2
42
+ continue
43
+ quoted = not quoted
44
+ elif char == "," and not quoted:
45
+ ref = value[start:index].strip()
46
+ if ref:
47
+ refs.append(ref)
48
+ start = index + 1
49
+ index += 1
50
+ tail = value[start:].strip()
51
+ if tail:
52
+ refs.append(tail)
53
+ if not refs or quoted:
54
+ raise ValueError(f"Invalid data range: {value!r}")
55
+ return refs
56
+
57
+
58
+ def _parse_ref(value: str) -> tuple[str | None, str]:
59
+ raw = value.strip()
60
+ sheet_name = None
61
+ cell_range = raw
62
+ if "!" in raw:
63
+ sheet_name, cell_range = raw.rsplit("!", 1)
64
+ sheet_name = sheet_name.strip()
65
+ if len(sheet_name) >= 2 and sheet_name[0] == sheet_name[-1] == "'":
66
+ sheet_name = sheet_name[1:-1].replace("''", "'")
67
+ cell_range = cell_range.replace("$", "")
68
+ if not re.fullmatch(r"[A-Za-z]+\d+:[A-Za-z]+\d+", cell_range):
69
+ raise ValueError(f"Invalid A1 range: {value!r}")
70
+ return sheet_name, cell_range
71
+
72
+
73
+ def _cell_value(cell: Any) -> Any:
74
+ if not isinstance(cell, dict):
75
+ return None
76
+ return cell.get("value", cell.get("raw_value"))
77
+
78
+
79
+ def _matrix(data: dict[str, Any]) -> list[list[Any]]:
80
+ ranges = data.get("ranges")
81
+ if not isinstance(ranges, list) or not ranges:
82
+ return []
83
+ result = ranges[0]
84
+ cells = result.get("cells") if isinstance(result, dict) else None
85
+ if not isinstance(cells, list):
86
+ return []
87
+ return [
88
+ [_cell_value(cell) for cell in row]
89
+ for row in cells
90
+ if isinstance(row, list)
91
+ ]
92
+
93
+
94
+ def _is_network_timeout(exc: LarkCliError) -> bool:
95
+ text = str(exc).strip()
96
+ try:
97
+ payload = json.loads(text)
98
+ except json.JSONDecodeError:
99
+ payload = None
100
+ if isinstance(payload, dict):
101
+ error = payload.get("error")
102
+ if isinstance(error, dict) and str(error.get("subtype") or "").lower() == "timeout":
103
+ return True
104
+ lowered = text.lower()
105
+ return "server time out" in lowered or "timed out" in lowered
106
+
107
+
108
+ def _run_read(
109
+ shortcut: str,
110
+ *,
111
+ stage: str,
112
+ timeout: int,
113
+ **kwargs: Any,
114
+ ) -> dict[str, Any]:
115
+ retried = False
116
+ while True:
117
+ try:
118
+ return run_sheets(shortcut, timeout=timeout, **kwargs)
119
+ except LarkCliError as exc:
120
+ if not retried and _is_network_timeout(exc):
121
+ retried = True
122
+ continue
123
+ suffix = " after one retry" if retried else ""
124
+ raise LarkCliError(f"{stage} failed{suffix}: {exc}", cmd=exc.cmd) from exc
125
+
126
+
127
+ def _sheet_selector(
128
+ sheets: list[dict[str, Any]],
129
+ *,
130
+ explicit_name: str | None,
131
+ worksheet_id: str | None,
132
+ worksheet_name: str | None,
133
+ ) -> dict[str, str]:
134
+ if explicit_name:
135
+ return {"sheet_name": explicit_name}
136
+ if worksheet_id:
137
+ return {"sheet_id": worksheet_id}
138
+ if worksheet_name:
139
+ return {"sheet_name": worksheet_name}
140
+ if len(sheets) != 1:
141
+ raise LarkCliError("Unqualified data ranges require --worksheet-id or --worksheet-name")
142
+ sheet_id = sheet_identifier(sheets[0])
143
+ if sheet_id:
144
+ return {"sheet_id": sheet_id}
145
+ return {"sheet_name": sheet_title(sheets[0])}
146
+
147
+
148
+ def _needs_workbook_metadata(
149
+ ranges: list[str | None],
150
+ *,
151
+ worksheet_id: str | None,
152
+ worksheet_name: str | None,
153
+ ) -> bool:
154
+ if worksheet_id or worksheet_name:
155
+ return False
156
+ return any(
157
+ _parse_ref(ref)[0] is None
158
+ for value in ranges
159
+ if value
160
+ for ref in _split_range_refs(value)
161
+ )
162
+
163
+
164
+ def _read_ranges(
165
+ locator: dict[str, str],
166
+ sheets: list[dict[str, Any]],
167
+ value: str,
168
+ *,
169
+ worksheet_id: str | None,
170
+ worksheet_name: str | None,
171
+ timeout: int,
172
+ stage_prefix: str = "data range",
173
+ ) -> list[list[list[Any]]]:
174
+ matrices: list[list[list[Any]]] = []
175
+ for ref in _split_range_refs(value):
176
+ explicit_name, cell_range = _parse_ref(ref)
177
+ selector = _sheet_selector(
178
+ sheets,
179
+ explicit_name=explicit_name,
180
+ worksheet_id=worksheet_id,
181
+ worksheet_name=worksheet_name,
182
+ )
183
+ data = envelope_data(
184
+ _run_read(
185
+ "+cells-get",
186
+ stage=f"{stage_prefix} {ref}",
187
+ **locator,
188
+ **selector,
189
+ flags={"range": cell_range, "include": "value"},
190
+ timeout=timeout,
191
+ )
192
+ )
193
+ matrix = _matrix(data)
194
+ if not matrix:
195
+ raise LarkCliError(f"No cells returned for {ref}")
196
+ matrices.append(matrix)
197
+ return matrices
198
+
199
+
200
+ def _combine(matrices: list[list[list[Any]]], direction: str) -> list[list[Any]]:
201
+ if direction == "column":
202
+ row_count = len(matrices[0])
203
+ if any(len(matrix) != row_count for matrix in matrices):
204
+ raise ValueError("Column-direction ranges must contain the same number of rows")
205
+ return [sum((matrix[row] for matrix in matrices), []) for row in range(row_count)]
206
+ column_count = max((len(row) for row in matrices[0]), default=0)
207
+ if any(max((len(row) for row in matrix), default=0) != column_count for matrix in matrices):
208
+ raise ValueError("Row-direction ranges must contain the same number of columns")
209
+ return sum(matrices, [])
210
+
211
+
212
+ def _parse_indexes(value: str | None, *, dimension_count: int, dim1_index: int) -> list[int]:
213
+ indexes = (
214
+ [int(item.strip()) for item in value.split(",") if item.strip()]
215
+ if value
216
+ else [index for index in range(1, dimension_count + 1) if index != dim1_index]
217
+ )
218
+ if not indexes or any(index < 1 or index > dimension_count for index in indexes):
219
+ raise ValueError("--dim2-indexes contains an out-of-range dimension index")
220
+ if dim1_index in indexes:
221
+ raise ValueError("--dim1-index cannot also appear in --dim2-indexes")
222
+ return indexes
223
+
224
+
225
+ def _numeric(value: Any) -> float | None:
226
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
227
+ return float(value)
228
+ if isinstance(value, str):
229
+ text = value.strip().replace(",", "")
230
+ if text.endswith("%"):
231
+ text = text[:-1]
232
+ try:
233
+ return float(text)
234
+ except ValueError:
235
+ return None
236
+ return None
237
+
238
+
239
+ def profile_matrix(
240
+ matrix: list[list[Any]],
241
+ *,
242
+ direction: str,
243
+ dim1_index: int,
244
+ dim2_indexes: str | None,
245
+ detached_headers: list[Any] | None = None,
246
+ ) -> dict[str, Any]:
247
+ if not matrix:
248
+ raise ValueError("Data range is empty")
249
+ dimension_count = max((len(row) for row in matrix), default=0) if direction == "column" else len(matrix)
250
+ if dim1_index < 1 or dim1_index > dimension_count:
251
+ raise ValueError("--dim1-index is outside the data range")
252
+ selected = _parse_indexes(
253
+ dim2_indexes,
254
+ dimension_count=dimension_count,
255
+ dim1_index=dim1_index,
256
+ )
257
+ detached = detached_headers is not None
258
+ if direction == "column":
259
+ data_rows = matrix if detached else matrix[1:]
260
+ categories = [row[dim1_index - 1] if len(row) >= dim1_index else None for row in data_rows]
261
+ headers = detached_headers or matrix[0]
262
+ series_names = [str(headers[index - 1]) if len(headers) >= index else f"Series {index}" for index in selected]
263
+ first_values = [row[selected[0] - 1] if len(row) >= selected[0] else None for row in data_rows]
264
+ else:
265
+ category_row = matrix[dim1_index - 1]
266
+ categories = category_row if detached else category_row[1:]
267
+ headers = detached_headers or [row[0] if row else None for row in matrix]
268
+ series_names = [str(headers[index - 1]) if len(headers) >= index else f"Series {index}" for index in selected]
269
+ first_row = matrix[selected[0] - 1]
270
+ first_values = first_row if detached else first_row[1:]
271
+ nonempty_categories = [value for value in categories if value not in (None, "")]
272
+ return {
273
+ "categories": nonempty_categories,
274
+ "series_names": series_names,
275
+ "values": [number for value in first_values if (number := _numeric(value)) is not None],
276
+ "dim2_indexes": selected,
277
+ }
278
+
279
+
280
+ def _header_values(matrices: list[list[list[Any]]], direction: str) -> list[Any]:
281
+ matrix = _combine(matrices, direction)
282
+ if direction == "column":
283
+ if len(matrix) != 1:
284
+ raise ValueError("Column-direction --header-range must contain one row")
285
+ return matrix[0]
286
+ if any(len(row) != 1 for row in matrix):
287
+ raise ValueError("Row-direction --header-range must contain one column")
288
+ return [row[0] for row in matrix]
289
+
290
+
291
+ def _boolean_argument(value: str) -> bool:
292
+ normalized = str(value).strip().lower()
293
+ if normalized == "true":
294
+ return True
295
+ if normalized == "false":
296
+ return False
297
+ raise argparse.ArgumentTypeError("expected true or false")
298
+
299
+
300
+ def _comma_separated_values(value: str) -> list[str]:
301
+ values = [item.strip() for item in str(value).split(",")]
302
+ if not values or any(not item for item in values):
303
+ raise argparse.ArgumentTypeError("expected a comma-separated list")
304
+ return values
305
+
306
+
307
+ def _series_y_axes_argument(value: str) -> list[str]:
308
+ values = _comma_separated_values(value)
309
+ invalid = [item for item in values if item not in {"left", "right"}]
310
+ if invalid:
311
+ raise argparse.ArgumentTypeError(
312
+ f"unsupported series Y axis {invalid[0]!r}; expected left or right"
313
+ )
314
+ return values
315
+
316
+
317
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
318
+ parser = argparse.ArgumentParser(description="Recommend chart width and height before +chart-create-basic")
319
+ parser.add_argument("target", help="Spreadsheet URL or spreadsheet token")
320
+ worksheet = parser.add_mutually_exclusive_group()
321
+ worksheet.add_argument("--worksheet-id")
322
+ worksheet.add_argument("--worksheet-name")
323
+ parser.add_argument("--chart-type", choices=sorted(SUPPORTED_CHART_TYPES), required=True)
324
+ parser.add_argument("--data-range", required=True)
325
+ parser.add_argument("--header-range")
326
+ parser.add_argument("--data-direction", choices=("column", "row"), default="column")
327
+ parser.add_argument("--dim1-index", type=int, default=1)
328
+ parser.add_argument("--dim2-indexes")
329
+ parser.add_argument("--series-types", type=_comma_separated_values)
330
+ parser.add_argument("--series-y-axes", type=_series_y_axes_argument)
331
+ parser.add_argument("--data-labels", default="none")
332
+ parser.add_argument("--aggregate-categories", type=_boolean_argument, default=True)
333
+ parser.add_argument("--legend-position", default="bottom")
334
+ parser.add_argument("--title", default="")
335
+ parser.add_argument("--timeout", type=int, default=60)
336
+ return parser.parse_args(argv)
337
+
338
+
339
+ def main() -> None:
340
+ args = parse_args()
341
+ locator = _locator(args.target)
342
+ try:
343
+ sheets: list[dict[str, Any]] = []
344
+ if _needs_workbook_metadata(
345
+ [args.data_range, args.header_range],
346
+ worksheet_id=args.worksheet_id,
347
+ worksheet_name=args.worksheet_name,
348
+ ):
349
+ workbook = envelope_data(
350
+ _run_read(
351
+ "+workbook-info",
352
+ stage="workbook metadata",
353
+ **locator,
354
+ timeout=args.timeout,
355
+ )
356
+ )
357
+ sheets = extract_sheets(workbook)
358
+ matrices = _read_ranges(
359
+ locator,
360
+ sheets,
361
+ args.data_range,
362
+ worksheet_id=args.worksheet_id,
363
+ worksheet_name=args.worksheet_name,
364
+ timeout=args.timeout,
365
+ )
366
+ headers = None
367
+ if args.header_range:
368
+ header_matrices = _read_ranges(
369
+ locator,
370
+ sheets,
371
+ args.header_range,
372
+ worksheet_id=args.worksheet_id,
373
+ worksheet_name=args.worksheet_name,
374
+ timeout=args.timeout,
375
+ stage_prefix="header range",
376
+ )
377
+ headers = _header_values(header_matrices, args.data_direction)
378
+ profile = profile_matrix(
379
+ _combine(matrices, args.data_direction),
380
+ direction=args.data_direction,
381
+ dim1_index=args.dim1_index,
382
+ dim2_indexes=args.dim2_indexes,
383
+ detached_headers=headers,
384
+ )
385
+ result = recommend_chart_size(
386
+ chart_type=args.chart_type,
387
+ categories=profile["categories"],
388
+ series_names=profile["series_names"],
389
+ data_labels=args.data_labels,
390
+ legend_position=args.legend_position,
391
+ title=args.title,
392
+ values=profile["values"],
393
+ aggregate_categories=args.aggregate_categories,
394
+ series_types=args.series_types,
395
+ series_y_axes=args.series_y_axes,
396
+ )
397
+ result["data_profile"] = {
398
+ "dim2_indexes": profile["dim2_indexes"],
399
+ **result.pop("evidence"),
400
+ }
401
+ except (LarkCliError, KeyError, TypeError, ValueError) as exc:
402
+ emit_error(ACTION, str(exc))
403
+ raise SystemExit(1) from exc
404
+ emit_success(ACTION, result)
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 Lark Technologies Pte. Ltd.
3
+ # SPDX-License-Identifier: MIT
4
+ """Pure sizing heuristics shared by Lark chart helper scripts."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import unicodedata
10
+ from typing import Any
11
+
12
+
13
+ MINIMUM_SIZES = {
14
+ "column": (640, 400),
15
+ "line": (640, 400),
16
+ "area": (640, 400),
17
+ "bar": (720, 420),
18
+ "combo": (720, 420),
19
+ "pie": (720, 440),
20
+ }
21
+ SUPPORTED_CHART_TYPES = {
22
+ "column",
23
+ "bar",
24
+ "line",
25
+ "area",
26
+ "pie",
27
+ "scatter",
28
+ "combo",
29
+ "radar",
30
+ "bubble",
31
+ "waterfall",
32
+ "pareto",
33
+ }
34
+ DEFAULT_MINIMUM_SIZE = (640, 400)
35
+ MAX_CHART_WIDTH = 1600
36
+ MAX_CHART_HEIGHT = 720
37
+ MAX_ASPECT_RATIO = 2.6
38
+ COMBO_SERIES_TYPES = {"column", "line", "area", "scatter"}
39
+ COMBO_SERIES_Y_AXES = {"left", "right"}
40
+
41
+
42
+ def display_units(value: Any) -> int:
43
+ """Estimate visible text width; CJK/full-width characters count double."""
44
+ lines = str(value if value is not None else "").splitlines() or [""]
45
+ return max(
46
+ sum(2 if unicodedata.east_asian_width(char) in {"W", "F", "A"} else 1 for char in line)
47
+ for line in lines
48
+ )
49
+
50
+
51
+ def _round_up(value: float, step: int = 40) -> int:
52
+ return int(math.ceil(value / step) * step)
53
+
54
+
55
+ def _round_down(value: float, step: int = 40) -> int:
56
+ return int(math.floor(value / step) * step)
57
+
58
+
59
+ def _percentile(values: list[int], ratio: float) -> int:
60
+ if not values:
61
+ return 0
62
+ ordered = sorted(values)
63
+ return ordered[max(0, math.ceil(len(ordered) * ratio) - 1)]
64
+
65
+
66
+ def _has_clustered_small_slices(values: list[float]) -> bool:
67
+ positive = [value for value in values if value > 0]
68
+ total = sum(positive)
69
+ if not total:
70
+ return False
71
+ shares = [value / total for value in positive]
72
+ return max(shares, default=0) >= 0.75 and sum(share < 0.05 for share in shares) >= 3
73
+
74
+
75
+ def minimum_chart_size(chart_type: str) -> dict[str, int]:
76
+ width, height = MINIMUM_SIZES.get(str(chart_type).lower(), DEFAULT_MINIMUM_SIZE)
77
+ return {"width": width, "height": height}
78
+
79
+
80
+ def estimate_legend_rows(items: list[str], width: int) -> int:
81
+ if not items:
82
+ return 0
83
+ available = max(240, width - 80)
84
+ used = 0
85
+ rows = 1
86
+ for item in items:
87
+ item_width = min(320, 34 + display_units(item) * 7)
88
+ if used and used + item_width > available:
89
+ rows += 1
90
+ used = 0
91
+ used += item_width
92
+ return rows
93
+
94
+
95
+ def effective_category_labels(
96
+ categories: list[Any], *, aggregate_categories: bool = True
97
+ ) -> list[str]:
98
+ labels = [str(value if value is not None else "") for value in categories]
99
+ if aggregate_categories:
100
+ return list(dict.fromkeys(labels))
101
+ return labels
102
+
103
+
104
+ def effective_series_types(
105
+ chart_type: str,
106
+ series_count: int,
107
+ series_types: list[str] | None = None,
108
+ ) -> list[str]:
109
+ chart_type = str(chart_type).lower()
110
+ if chart_type != "combo":
111
+ if series_types:
112
+ raise ValueError("series_types is only valid for combo charts")
113
+ return [chart_type] * series_count
114
+ if series_types is None:
115
+ return ["column", *(["line"] * max(0, series_count - 1))]
116
+ normalized = [str(value).strip().lower() for value in series_types]
117
+ if len(normalized) != series_count:
118
+ raise ValueError("series_types length must match series_names")
119
+ invalid = [value for value in normalized if value not in COMBO_SERIES_TYPES]
120
+ if invalid:
121
+ raise ValueError(f"unsupported combo series type: {invalid[0]}")
122
+ return normalized
123
+
124
+
125
+ def effective_series_y_axes(
126
+ chart_type: str,
127
+ series_count: int,
128
+ series_y_axes: list[str] | None = None,
129
+ ) -> list[str]:
130
+ chart_type = str(chart_type).lower()
131
+ if chart_type != "combo":
132
+ if series_y_axes:
133
+ raise ValueError("series_y_axes is only valid for combo charts")
134
+ return ["left"] * series_count
135
+ if series_y_axes is None:
136
+ return ["left", *(["right"] * max(0, series_count - 1))]
137
+ normalized = [str(value).strip().lower() for value in series_y_axes]
138
+ if len(normalized) != series_count:
139
+ raise ValueError("series_y_axes length must match series_names")
140
+ invalid = [value for value in normalized if value not in COMBO_SERIES_Y_AXES]
141
+ if invalid:
142
+ raise ValueError(f"unsupported combo series Y axis: {invalid[0]}")
143
+ return normalized
144
+
145
+
146
+ def recommend_chart_size(
147
+ *,
148
+ chart_type: str,
149
+ categories: list[Any],
150
+ series_names: list[str],
151
+ data_labels: str = "none",
152
+ legend_position: str = "bottom",
153
+ title: str = "",
154
+ values: list[float] | None = None,
155
+ aggregate_categories: bool = True,
156
+ series_types: list[str] | None = None,
157
+ series_y_axes: list[str] | None = None,
158
+ ) -> dict[str, Any]:
159
+ chart_type = str(chart_type).lower()
160
+ if chart_type not in SUPPORTED_CHART_TYPES:
161
+ raise ValueError(f"unsupported chart type: {chart_type}")
162
+ category_text = effective_category_labels(
163
+ categories,
164
+ aggregate_categories=aggregate_categories,
165
+ )
166
+ category_count = len(category_text)
167
+ series_count = max(1, len(series_names))
168
+ normalized_series_types = effective_series_types(
169
+ chart_type,
170
+ series_count,
171
+ series_types,
172
+ )
173
+ normalized_series_y_axes = effective_series_y_axes(
174
+ chart_type,
175
+ series_count,
176
+ series_y_axes,
177
+ )
178
+ column_series_count = sum(value == "column" for value in normalized_series_types)
179
+ line_like_series_count = series_count - column_series_count
180
+ label_units = [display_units(value) for value in category_text]
181
+ max_units = max(label_units, default=0)
182
+ p75_units = _percentile(label_units, 0.75)
183
+ max_lines = max((len(value.splitlines()) for value in category_text), default=1)
184
+ labels_enabled = str(data_labels or "").lower() not in {"", "none"}
185
+ minimum = minimum_chart_size(chart_type)
186
+ width = float(minimum["width"])
187
+ height = float(minimum["height"])
188
+ reasons: list[str] = []
189
+ advice: list[str] = []
190
+
191
+ if chart_type == "pie":
192
+ label_reserve = max(150, min(360, max_units * 7 + 60))
193
+ width = max(width, 420 + 2 * label_reserve)
194
+ if labels_enabled:
195
+ reasons.append("outside_slice_labels")
196
+ if values and _has_clustered_small_slices(values):
197
+ height += 40
198
+ reasons.append("clustered_small_slices")
199
+ if category_count > 8:
200
+ advice.append("prefer_bar_or_top_n")
201
+ size_alone_is_insufficient = category_count > 12
202
+ elif chart_type == "bar":
203
+ width = max(width, 420 + max_units * 7)
204
+ height = max(height, 190 + category_count * 36)
205
+ height_limited = _round_up(height) > MAX_CHART_HEIGHT
206
+ size_alone_is_insufficient = category_count > 24
207
+ if height_limited:
208
+ reasons.append("maximum_height_limited")
209
+ if size_alone_is_insufficient:
210
+ advice.extend(["use_top_n", "split_chart"])
211
+ else:
212
+ reserve = (
213
+ 230
214
+ if chart_type == "combo" and "right" in normalized_series_y_axes
215
+ else 170
216
+ )
217
+ line_dominant_combo = chart_type == "combo" and column_series_count == 0
218
+ base_slot = 44 if chart_type in {"line", "area"} or line_dominant_combo else 52
219
+ text_slot = 20 + p75_units * 7 * 0.72
220
+ slot = max(base_slot, min(180, text_slot))
221
+ if column_series_count <= 1 and category_count >= 10:
222
+ # With many categories, Sheet rotates X-axis labels. Reserving each
223
+ # label's full horizontal text width makes single-series charts
224
+ # disproportionately wide; density checks below still expand when
225
+ # data labels would actually collide.
226
+ slot = min(slot, 68)
227
+ if column_series_count > 1:
228
+ slot = max(slot, 44 + 12 * min(column_series_count - 1, 4))
229
+ if chart_type == "combo" and line_like_series_count > 1:
230
+ slot += min(12, 4 * (line_like_series_count - 1))
231
+ if labels_enabled:
232
+ slot += min(24, 4 * series_count)
233
+ width = max(width, reserve + category_count * slot)
234
+ if p75_units > 12:
235
+ height += 40
236
+ reasons.append("long_category_labels")
237
+ if max_lines > 1:
238
+ height += min(120, 40 * (max_lines - 1))
239
+ reasons.append("multiline_category_labels")
240
+ size_alone_is_insufficient = (
241
+ category_count > 20
242
+ and (p75_units > 12 or series_count > 3 or labels_enabled)
243
+ )
244
+ if size_alone_is_insufficient:
245
+ advice.extend(["prefer_bar_or_top_n", "split_chart"])
246
+
247
+ width = min(MAX_CHART_WIDTH, _round_up(width))
248
+ aspect_width_limit = max(minimum["width"], _round_down(height * MAX_ASPECT_RATIO))
249
+ if width > aspect_width_limit:
250
+ width = aspect_width_limit
251
+ reasons.append("aspect_ratio_limited")
252
+ legend_items = category_text if chart_type == "pie" else series_names
253
+ legend_rows = 0
254
+ if str(legend_position).lower() != "hidden":
255
+ legend_rows = estimate_legend_rows(legend_items, width)
256
+ if legend_rows > 1:
257
+ height += (legend_rows - 1) * 32
258
+ reasons.append("multi_row_legend")
259
+
260
+ if title:
261
+ reasons.append("chart_title")
262
+
263
+ height = min(MAX_CHART_HEIGHT, _round_up(height))
264
+ if size_alone_is_insufficient:
265
+ if chart_type == "pie":
266
+ height = max(height, 520)
267
+ elif chart_type != "bar":
268
+ width = max(width, 1200)
269
+ height = max(height, 520)
270
+
271
+ return {
272
+ "minimum_size": minimum,
273
+ "recommended_size": {"width": width, "height": height},
274
+ "create_flags": {"width": width, "height": height},
275
+ "evidence": {
276
+ "chart_type": chart_type,
277
+ "series_count": series_count,
278
+ "series_types": normalized_series_types,
279
+ "series_y_axes": normalized_series_y_axes,
280
+ "column_series_count": column_series_count,
281
+ "max_category_display_units": max_units,
282
+ "p75_category_display_units": p75_units,
283
+ "max_category_line_count": max_lines,
284
+ "legend_rows": legend_rows,
285
+ "data_labels": data_labels,
286
+ "aggregate_categories": aggregate_categories,
287
+ "recommended_aspect_ratio": round(width / height, 2),
288
+ },
289
+ "reasons": list(dict.fromkeys(reasons)),
290
+ "layout_advice": list(dict.fromkeys(advice)),
291
+ "size_alone_is_insufficient": size_alone_is_insufficient,
292
+ }