@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.
- package/package.json +4 -4
- package/skills/lark-apps/SKILL.md +1 -0
- package/skills/lark-apps/references/lark-apps-db.md +2 -0
- package/skills/lark-apps/references/lark-apps-env-pull.md +1 -1
- package/skills/lark-apps/references/lark-apps-env.md +1 -1
- package/skills/lark-apps/references/lark-apps-export.md +62 -0
- package/skills/lark-apps/references/lark-apps-local-dev.md +1 -1
- package/skills/lark-apps/references/lark-apps-observability.md +1 -1
- package/skills/lark-base/SKILL.md +6 -5
- package/skills/lark-base/references/lark-base-app.md +3 -3
- package/skills/lark-base/references/lark-base-dashboard-block-config.md +21 -3
- package/skills/lark-base/references/lark-base-dashboard.md +1 -1
- package/skills/lark-base/references/lark-base-data-query.md +1 -1
- package/skills/lark-base/references/lark-base-field-create.md +1 -1
- package/skills/lark-base/references/lark-base-field-update.md +1 -1
- package/skills/lark-base/references/lark-base-form-questions-create.md +1 -1
- package/skills/lark-base/references/lark-base-form-questions-update.md +1 -1
- package/skills/lark-base/references/lark-base-form-submit.md +1 -1
- package/skills/lark-base/references/lark-base-view-set-filter.md +1 -1
- package/skills/lark-base/references/lark-base-view.md +109 -0
- package/skills/lark-base/references/lark-base-workflow-schema.md +90 -12
- package/skills/lark-base/references/lark-base-workflow.md +99 -3
- package/skills/lark-calendar/SKILL.md +13 -8
- package/skills/lark-calendar/references/lark-calendar-meeting-relation.md +99 -0
- package/skills/lark-calendar/references/lark-calendar-meeting.md +1 -1
- package/skills/lark-calendar/references/lark-calendar-recurring.md +3 -1
- package/skills/lark-calendar/references/lark-calendar-transfer.md +1 -1
- package/skills/lark-doc/SKILL.md +1 -1
- package/skills/lark-doc/references/lark-doc-create-workflow.md +8 -10
- package/skills/lark-doc/references/lark-doc-media-download.md +1 -1
- package/skills/lark-doc/references/lark-doc-media-insert.md +1 -1
- package/skills/lark-doc/references/lark-doc-media-preview.md +1 -1
- package/skills/lark-doc/references/lark-doc-resource-cover.md +1 -1
- package/skills/lark-doc/references/lark-doc-script.md +11 -17
- package/skills/lark-drive/references/lark-drive-add-comment.md +1 -1
- package/skills/lark-drive/references/lark-drive-apply-permission.md +1 -1
- package/skills/lark-drive/references/lark-drive-copy.md +1 -1
- package/skills/lark-drive/references/lark-drive-cover.md +1 -1
- package/skills/lark-drive/references/lark-drive-create-folder.md +1 -1
- package/skills/lark-drive/references/lark-drive-create-shortcut.md +1 -1
- package/skills/lark-drive/references/lark-drive-delete.md +1 -1
- package/skills/lark-drive/references/lark-drive-download.md +1 -1
- package/skills/lark-drive/references/lark-drive-export-download.md +1 -1
- package/skills/lark-drive/references/lark-drive-export.md +1 -1
- package/skills/lark-drive/references/lark-drive-import.md +1 -1
- package/skills/lark-drive/references/lark-drive-inspect.md +2 -2
- package/skills/lark-drive/references/lark-drive-list-comments.md +1 -1
- package/skills/lark-drive/references/lark-drive-move.md +4 -4
- package/skills/lark-drive/references/lark-drive-permission-guide.md +1 -1
- package/skills/lark-drive/references/lark-drive-preview.md +1 -1
- package/skills/lark-drive/references/lark-drive-pull.md +1 -1
- package/skills/lark-drive/references/lark-drive-push.md +1 -1
- package/skills/lark-drive/references/lark-drive-search.md +1 -1
- package/skills/lark-drive/references/lark-drive-status.md +1 -1
- package/skills/lark-drive/references/lark-drive-task-result.md +4 -4
- package/skills/lark-drive/references/lark-drive-update-title.md +1 -1
- package/skills/lark-drive/references/lark-drive-upload.md +1 -1
- package/skills/lark-drive/references/lark-drive-version-delete.md +1 -1
- package/skills/lark-drive/references/lark-drive-version-get.md +1 -1
- package/skills/lark-drive/references/lark-drive-version-history.md +1 -1
- package/skills/lark-drive/references/lark-drive-version-revert.md +1 -1
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-execute.md +1 -1
- package/skills/lark-im/SKILL.md +7 -1
- package/skills/lark-im/references/lark-im-chat-create.md +1 -1
- package/skills/lark-im/references/lark-im-chat-list.md +1 -1
- package/skills/lark-im/references/lark-im-chat-members-list.md +1 -1
- package/skills/lark-im/references/lark-im-chat-messages-list.md +7 -2
- package/skills/lark-im/references/lark-im-chat-search.md +1 -1
- package/skills/lark-im/references/lark-im-chat-update.md +1 -1
- package/skills/lark-im/references/lark-im-feed-groups.md +1 -1
- package/skills/lark-im/references/lark-im-feed-shortcut-create.md +1 -1
- package/skills/lark-im/references/lark-im-feed-shortcut-list.md +1 -1
- package/skills/lark-im/references/lark-im-feed-shortcut-remove.md +1 -1
- package/skills/lark-im/references/lark-im-flag-cancel.md +1 -1
- package/skills/lark-im/references/lark-im-flag-create.md +1 -1
- package/skills/lark-im/references/lark-im-flag-list.md +1 -1
- package/skills/lark-im/references/lark-im-message-enrichment.md +1 -1
- package/skills/lark-im/references/lark-im-message-read-status.md +1 -1
- package/skills/lark-im/references/lark-im-messages-edit.md +1 -1
- package/skills/lark-im/references/lark-im-messages-mget.md +20 -3
- package/skills/lark-im/references/lark-im-messages-reply.md +1 -1
- package/skills/lark-im/references/lark-im-messages-resources-download.md +3 -1
- package/skills/lark-im/references/lark-im-messages-search.md +2 -2
- package/skills/lark-im/references/lark-im-messages-send.md +1 -1
- package/skills/lark-im/references/lark-im-reactions.md +1 -1
- package/skills/lark-im/references/lark-im-threads-messages-list.md +6 -2
- package/skills/lark-mail/SKILL.md +19 -8
- package/skills/lark-mail/references/lark-mail-draft-create.md +1 -1
- package/skills/lark-mail/references/lark-mail-draft-edit.md +1 -1
- package/skills/lark-mail/references/lark-mail-forward.md +1 -1
- package/skills/lark-mail/references/lark-mail-reply-all.md +1 -1
- package/skills/lark-mail/references/lark-mail-reply.md +1 -1
- package/skills/lark-mail/references/lark-mail-rules.md +87 -4
- package/skills/lark-mail/references/lark-mail-send.md +1 -1
- package/skills/lark-mail/references/lark-mail-thread-modify.md +73 -0
- package/skills/lark-mail/references/lark-mail-thread-trash.md +62 -0
- package/skills/lark-mail/references/lark-mail-triage.md +1 -1
- package/skills/lark-mail/references/lark-mail-watch.md +2 -2
- package/skills/lark-markdown/references/lark-markdown-create.md +1 -1
- package/skills/lark-markdown/references/lark-markdown-diff.md +1 -1
- package/skills/lark-markdown/references/lark-markdown-fetch.md +1 -1
- package/skills/lark-markdown/references/lark-markdown-overwrite.md +1 -1
- package/skills/lark-markdown/references/lark-markdown-patch.md +1 -1
- package/skills/lark-meeting/SKILL.md +2 -2
- package/skills/lark-meeting/references/lark-minutes-search.md +2 -2
- package/skills/lark-meeting/references/lark-vc-meeting-events.md +15 -15
- package/skills/lark-meeting/references/lark-vc-search.md +10 -7
- package/skills/lark-meeting/scenes/create-and-edit-minutes.md +4 -0
- package/skills/lark-meeting/scenes/live-meeting-attend.md +2 -2
- package/skills/lark-meeting/scenes/live-meeting-interact.md +1 -1
- package/skills/lark-meeting/scenes/query-meeting-and-artifacts.md +3 -3
- package/skills/lark-shared/references/lark-wiki-token-routing.md +7 -7
- package/skills/lark-sheets/SKILL.md +60 -173
- package/skills/lark-sheets/references/lark-sheets-batch-update.md +10 -10
- package/skills/lark-sheets/references/lark-sheets-chart.md +68 -34
- package/skills/lark-sheets/references/lark-sheets-conditional-format.md +4 -4
- package/skills/lark-sheets/references/lark-sheets-filter-view.md +1 -1
- package/skills/lark-sheets/references/lark-sheets-filter.md +2 -2
- package/skills/lark-sheets/references/lark-sheets-float-image.md +2 -2
- package/skills/lark-sheets/references/lark-sheets-formula-translation.md +90 -4
- package/skills/lark-sheets/references/lark-sheets-formula-verify.md +49 -13
- package/skills/lark-sheets/references/lark-sheets-pivot-table.md +13 -13
- package/skills/lark-sheets/references/lark-sheets-range-operations.md +12 -9
- package/skills/lark-sheets/references/lark-sheets-read-data.md +14 -12
- package/skills/lark-sheets/references/lark-sheets-search-replace.md +3 -3
- package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +8 -4
- package/skills/lark-sheets/references/lark-sheets-sparkline.md +2 -2
- package/skills/lark-sheets/references/lark-sheets-styles-put.md +2 -2
- package/skills/lark-sheets/references/lark-sheets-visual-standards.md +22 -19
- package/skills/lark-sheets/references/lark-sheets-workbook.md +22 -7
- package/skills/lark-sheets/references/lark-sheets-write-cells.md +66 -57
- package/skills/lark-sheets/scripts/lark_chart_quality_check.py +1540 -0
- package/skills/lark-sheets/scripts/lark_chart_size_advisor.py +409 -0
- package/skills/lark-sheets/scripts/lark_chart_size_rules.py +292 -0
- package/skills/lark-sheets/scripts/lark_inspect_workbook.py +37 -9
- package/skills/lark-sheets/scripts/lark_sheet_read_cli.py +53 -0
- package/skills/lark-sheets/scripts/{sheets_df.py → lark_sheets_df.py} +1 -1
- package/skills/lark-slides/SKILL.md +11 -18
- package/skills/lark-slides/references/cli/lark-slides-add-slide.md +1 -1
- package/skills/lark-slides/references/cli/lark-slides-create.md +3 -3
- package/skills/lark-slides/references/cli/lark-slides-delete-slide.md +1 -1
- package/skills/lark-slides/references/cli/lark-slides-history.md +1 -8
- package/skills/lark-slides/references/cli/lark-slides-media-upload.md +6 -11
- package/skills/lark-slides/references/cli/lark-slides-replace-slide.md +15 -16
- package/skills/lark-slides/references/cli/lark-slides-update-slide.md +2 -2
- package/skills/lark-slides/references/cli/lark-slides-xml-presentation-slide-get.md +3 -108
- package/skills/lark-slides/references/cli/lark-slides-xml-presentation-slide-replace.md +6 -183
- package/skills/lark-slides/references/cli/lark-slides-xml-presentations-get.md +26 -143
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +3 -3
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +2 -2
- package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +2 -2
- package/skills/lark-slides/references/workflow/error-handling.md +3 -3
- package/skills/lark-slides/references/workflow/slides-editing.md +10 -11
- package/skills/lark-slides/references/xml/slides_xml_schema_definition.xml +333 -20
- package/skills/lark-task/references/lark-task-assign.md +1 -1
- package/skills/lark-task/references/lark-task-comment.md +1 -1
- package/skills/lark-task/references/lark-task-complete.md +1 -1
- package/skills/lark-task/references/lark-task-create.md +1 -1
- package/skills/lark-task/references/lark-task-followers.md +1 -1
- package/skills/lark-task/references/lark-task-get-my-tasks.md +1 -1
- package/skills/lark-task/references/lark-task-get-related-tasks.md +1 -1
- package/skills/lark-task/references/lark-task-reminder.md +1 -1
- package/skills/lark-task/references/lark-task-reopen.md +1 -1
- package/skills/lark-task/references/lark-task-search.md +1 -1
- package/skills/lark-task/references/lark-task-set-ancestor.md +1 -1
- package/skills/lark-task/references/lark-task-tasklist-create.md +1 -1
- package/skills/lark-task/references/lark-task-tasklist-members.md +1 -1
- package/skills/lark-task/references/lark-task-tasklist-search.md +1 -1
- package/skills/lark-task/references/lark-task-tasklist-task-add.md +1 -1
- package/skills/lark-task/references/lark-task-update.md +1 -1
- package/skills/lark-task/references/lark-task-upload-attachment.md +1 -1
- package/skills/lark-wiki/SKILL.md +1 -2
- package/skills/lark-wiki/references/lark-wiki-delete-space.md +1 -1
- package/skills/lark-wiki/references/lark-wiki-move-to-drive.md +1 -1
- package/skills/lark-wiki/references/lark-wiki-move.md +4 -3
- package/skills/lark-wiki/references/lark-wiki-node-create.md +4 -3
- package/skills/lark-wiki/references/lark-wiki-node-delete.md +8 -4
- package/skills/lark-wiki/references/lark-wiki-node-get.md +7 -4
- package/skills/lark-sheets/references/lark-sheets-legacy-command-migration.md +0 -152
- package/skills/lark-sheets/scripts/lark_chart_layout_check.py +0 -472
|
@@ -0,0 +1,409 @@
|
|
|
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
|
+
# 海象运算符要 3.8,CI 的解释器是 3.7:先映射再滤空,语义等价。
|
|
276
|
+
"values": [n for n in (_numeric(v) for v in first_values) if n is not None],
|
|
277
|
+
"dim2_indexes": selected,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _header_values(matrices: list[list[list[Any]]], direction: str) -> list[Any]:
|
|
282
|
+
matrix = _combine(matrices, direction)
|
|
283
|
+
if direction == "column":
|
|
284
|
+
if len(matrix) != 1:
|
|
285
|
+
raise ValueError("Column-direction --header-range must contain one row")
|
|
286
|
+
return matrix[0]
|
|
287
|
+
if any(len(row) != 1 for row in matrix):
|
|
288
|
+
raise ValueError("Row-direction --header-range must contain one column")
|
|
289
|
+
return [row[0] for row in matrix]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _boolean_argument(value: str) -> bool:
|
|
293
|
+
normalized = str(value).strip().lower()
|
|
294
|
+
if normalized == "true":
|
|
295
|
+
return True
|
|
296
|
+
if normalized == "false":
|
|
297
|
+
return False
|
|
298
|
+
raise argparse.ArgumentTypeError("expected true or false")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _comma_separated_values(value: str) -> list[str]:
|
|
302
|
+
values = [item.strip() for item in str(value).split(",")]
|
|
303
|
+
if not values or any(not item for item in values):
|
|
304
|
+
raise argparse.ArgumentTypeError("expected a comma-separated list")
|
|
305
|
+
return values
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _series_y_axes_argument(value: str) -> list[str]:
|
|
309
|
+
values = _comma_separated_values(value)
|
|
310
|
+
invalid = [item for item in values if item not in {"left", "right"}]
|
|
311
|
+
if invalid:
|
|
312
|
+
raise argparse.ArgumentTypeError(
|
|
313
|
+
f"unsupported series Y axis {invalid[0]!r}; expected left or right"
|
|
314
|
+
)
|
|
315
|
+
return values
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
319
|
+
parser = argparse.ArgumentParser(description="Recommend chart width and height before +chart-create-basic")
|
|
320
|
+
parser.add_argument("target", help="Spreadsheet URL or spreadsheet token")
|
|
321
|
+
worksheet = parser.add_mutually_exclusive_group()
|
|
322
|
+
worksheet.add_argument("--worksheet-id")
|
|
323
|
+
worksheet.add_argument("--worksheet-name")
|
|
324
|
+
parser.add_argument("--chart-type", choices=sorted(SUPPORTED_CHART_TYPES), required=True)
|
|
325
|
+
parser.add_argument("--data-range", required=True)
|
|
326
|
+
parser.add_argument("--header-range")
|
|
327
|
+
parser.add_argument("--data-direction", choices=("column", "row"), default="column")
|
|
328
|
+
parser.add_argument("--dim1-index", type=int, default=1)
|
|
329
|
+
parser.add_argument("--dim2-indexes")
|
|
330
|
+
parser.add_argument("--series-types", type=_comma_separated_values)
|
|
331
|
+
parser.add_argument("--series-y-axes", type=_series_y_axes_argument)
|
|
332
|
+
parser.add_argument("--data-labels", default="none")
|
|
333
|
+
parser.add_argument("--aggregate-categories", type=_boolean_argument, default=True)
|
|
334
|
+
parser.add_argument("--legend-position", default="bottom")
|
|
335
|
+
parser.add_argument("--title", default="")
|
|
336
|
+
parser.add_argument("--timeout", type=int, default=60)
|
|
337
|
+
return parser.parse_args(argv)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main() -> None:
|
|
341
|
+
args = parse_args()
|
|
342
|
+
locator = _locator(args.target)
|
|
343
|
+
try:
|
|
344
|
+
sheets: list[dict[str, Any]] = []
|
|
345
|
+
if _needs_workbook_metadata(
|
|
346
|
+
[args.data_range, args.header_range],
|
|
347
|
+
worksheet_id=args.worksheet_id,
|
|
348
|
+
worksheet_name=args.worksheet_name,
|
|
349
|
+
):
|
|
350
|
+
workbook = envelope_data(
|
|
351
|
+
_run_read(
|
|
352
|
+
"+workbook-info",
|
|
353
|
+
stage="workbook metadata",
|
|
354
|
+
**locator,
|
|
355
|
+
timeout=args.timeout,
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
sheets = extract_sheets(workbook)
|
|
359
|
+
matrices = _read_ranges(
|
|
360
|
+
locator,
|
|
361
|
+
sheets,
|
|
362
|
+
args.data_range,
|
|
363
|
+
worksheet_id=args.worksheet_id,
|
|
364
|
+
worksheet_name=args.worksheet_name,
|
|
365
|
+
timeout=args.timeout,
|
|
366
|
+
)
|
|
367
|
+
headers = None
|
|
368
|
+
if args.header_range:
|
|
369
|
+
header_matrices = _read_ranges(
|
|
370
|
+
locator,
|
|
371
|
+
sheets,
|
|
372
|
+
args.header_range,
|
|
373
|
+
worksheet_id=args.worksheet_id,
|
|
374
|
+
worksheet_name=args.worksheet_name,
|
|
375
|
+
timeout=args.timeout,
|
|
376
|
+
stage_prefix="header range",
|
|
377
|
+
)
|
|
378
|
+
headers = _header_values(header_matrices, args.data_direction)
|
|
379
|
+
profile = profile_matrix(
|
|
380
|
+
_combine(matrices, args.data_direction),
|
|
381
|
+
direction=args.data_direction,
|
|
382
|
+
dim1_index=args.dim1_index,
|
|
383
|
+
dim2_indexes=args.dim2_indexes,
|
|
384
|
+
detached_headers=headers,
|
|
385
|
+
)
|
|
386
|
+
result = recommend_chart_size(
|
|
387
|
+
chart_type=args.chart_type,
|
|
388
|
+
categories=profile["categories"],
|
|
389
|
+
series_names=profile["series_names"],
|
|
390
|
+
data_labels=args.data_labels,
|
|
391
|
+
legend_position=args.legend_position,
|
|
392
|
+
title=args.title,
|
|
393
|
+
values=profile["values"],
|
|
394
|
+
aggregate_categories=args.aggregate_categories,
|
|
395
|
+
series_types=args.series_types,
|
|
396
|
+
series_y_axes=args.series_y_axes,
|
|
397
|
+
)
|
|
398
|
+
result["data_profile"] = {
|
|
399
|
+
"dim2_indexes": profile["dim2_indexes"],
|
|
400
|
+
**result.pop("evidence"),
|
|
401
|
+
}
|
|
402
|
+
except (LarkCliError, KeyError, TypeError, ValueError) as exc:
|
|
403
|
+
emit_error(ACTION, str(exc))
|
|
404
|
+
raise SystemExit(1) from exc
|
|
405
|
+
emit_success(ACTION, result)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
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
|
+
}
|