1525 lines
57 KiB
Python
1525 lines
57 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
# SPDX-License-Identifier: MIT
|
|
"""Check Lark Sheet chart quality, placement, and numeric source-data issues.
|
|
|
|
The single required argument is a spreadsheet URL or spreadsheet token. By
|
|
default every worksheet is checked; pass --worksheet-id to restrict the check
|
|
to one worksheet reference_id.
|
|
|
|
Numeric source checks sample at most 50 data points per series and request at
|
|
most 2000 source cells per chart across the style and typed-value reads,
|
|
including headers and gaps between series.
|
|
Sampled zero/constant values do not establish a whole-series issue.
|
|
|
|
Exit codes:
|
|
0: check completed and no issue was found
|
|
1: the check could not be completed (CLI/read/response error)
|
|
2: check completed and at least one chart-quality issue was found
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from typing import Any
|
|
|
|
from lark_sheet_read_cli import (
|
|
LarkCliError,
|
|
emit_error,
|
|
envelope_data,
|
|
resolve_target_sheets,
|
|
run_sheets,
|
|
sheet_identifier,
|
|
sheet_title,
|
|
)
|
|
from lark_chart_size_rules import MAX_ASPECT_RATIO, MAX_CHART_WIDTH, minimum_chart_size
|
|
|
|
ACTION = "chart_quality_check"
|
|
DEFAULT_COLUMN_WIDTH = 105.0
|
|
DEFAULT_ROW_HEIGHT = 27.0
|
|
MAX_CELL_READ_SIZE = 2_000
|
|
MAX_SOURCE_SAMPLE_POINTS = 50
|
|
|
|
|
|
CellBounds = tuple[int, int, int, int]
|
|
CellCache = dict[tuple[str, str, str, bool], dict[str, Any]]
|
|
SeriesProfile = dict[str, Any]
|
|
|
|
|
|
def _parse_a1_bounds(cell_range: str) -> CellBounds:
|
|
value = str(cell_range).rsplit("!", 1)[-1].replace("$", "")
|
|
match = re.fullmatch(r"([A-Za-z]+)(\d+)(?::([A-Za-z]+)(\d+))?", value)
|
|
if not match:
|
|
raise ValueError(f"Invalid A1 range: {cell_range!r}")
|
|
start_column = column_to_index(match.group(1))
|
|
start_row = int(match.group(2))
|
|
end_column = column_to_index(match.group(3) or match.group(1))
|
|
end_row = int(match.group(4) or match.group(2))
|
|
if end_row < start_row or end_column < start_column:
|
|
raise ValueError(f"Invalid A1 range: {cell_range!r}")
|
|
return start_row, end_row, start_column, end_column
|
|
|
|
|
|
def _format_a1_bounds(bounds: CellBounds) -> str:
|
|
start_row, end_row, start_column, end_column = bounds
|
|
return (
|
|
f"{index_to_column(start_column)}{start_row}:"
|
|
f"{index_to_column(end_column)}{end_row}"
|
|
)
|
|
|
|
|
|
def _bounds_area(bounds: CellBounds) -> int:
|
|
start_row, end_row, start_column, end_column = bounds
|
|
return (end_row - start_row + 1) * (end_column - start_column + 1)
|
|
|
|
|
|
def _merge_bounds(first: CellBounds, second: CellBounds) -> CellBounds:
|
|
return (
|
|
min(first[0], second[0]),
|
|
max(first[1], second[1]),
|
|
min(first[2], second[2]),
|
|
max(first[3], second[3]),
|
|
)
|
|
|
|
|
|
def _cluster_cell_reads(
|
|
items: list[tuple[dict[str, Any], CellBounds]],
|
|
) -> list[dict[str, Any]]:
|
|
clusters: list[dict[str, Any]] = []
|
|
for rectangle, bounds in sorted(items, key=lambda item: (item[1][0], item[1][2])):
|
|
if clusters:
|
|
merged = _merge_bounds(clusters[-1]["bounds"], bounds)
|
|
if _bounds_area(merged) <= MAX_CELL_READ_SIZE:
|
|
clusters[-1]["bounds"] = merged
|
|
clusters[-1]["members"].append((rectangle, bounds))
|
|
continue
|
|
clusters.append({"bounds": bounds, "members": [(rectangle, bounds)]})
|
|
return clusters
|
|
|
|
|
|
def column_to_index(column: str) -> int:
|
|
value = 0
|
|
text = str(column).strip().upper()
|
|
if not text or not text.isalpha():
|
|
raise ValueError(f"Invalid column: {column!r}")
|
|
for char in text:
|
|
value = value * 26 + ord(char) - ord("A") + 1
|
|
return value - 1
|
|
|
|
|
|
def index_to_column(index: int) -> str:
|
|
if index < 0:
|
|
raise ValueError(f"Invalid column index: {index}")
|
|
chars: list[str] = []
|
|
value = index + 1
|
|
while value:
|
|
value, remainder = divmod(value - 1, 26)
|
|
chars.append(chr(ord("A") + remainder))
|
|
return "".join(reversed(chars))
|
|
|
|
|
|
def _span_bounds(span: str, *, columns: bool) -> tuple[int, int]:
|
|
start, separator, end = str(span).partition(":")
|
|
end = end if separator else start
|
|
if columns:
|
|
return column_to_index(start), column_to_index(end)
|
|
return int(start) - 1, int(end) - 1
|
|
|
|
|
|
def _size_edges(
|
|
groups: Any,
|
|
*,
|
|
count: int,
|
|
span_key: str,
|
|
size_key: str,
|
|
columns: bool,
|
|
default_size: float,
|
|
) -> tuple[list[float], bool]:
|
|
sizes: list[float | None] = [None] * count
|
|
if isinstance(groups, list):
|
|
for group in groups:
|
|
if not isinstance(group, dict) or group.get(span_key) is None:
|
|
continue
|
|
start, end = _span_bounds(str(group[span_key]), columns=columns)
|
|
size = float(group.get(size_key, default_size))
|
|
for index in range(max(0, start), min(count - 1, end) + 1):
|
|
sizes[index] = max(0.0, size)
|
|
|
|
used_default = any(size is None for size in sizes)
|
|
resolved = [default_size if size is None else size for size in sizes]
|
|
edges = [0.0]
|
|
for size in resolved:
|
|
edges.append(edges[-1] + size)
|
|
return edges, used_default
|
|
|
|
|
|
def build_layout(
|
|
structure: dict[str, Any], row_count: int, column_count: int
|
|
) -> tuple[list[float], list[float], list[str]]:
|
|
row_groups = structure.get("row_heights")
|
|
column_groups = structure.get("col_widths", structure.get("column_widths"))
|
|
row_edges, row_defaulted = _size_edges(
|
|
row_groups,
|
|
count=row_count,
|
|
span_key="rows",
|
|
size_key="height",
|
|
columns=False,
|
|
default_size=DEFAULT_ROW_HEIGHT,
|
|
)
|
|
column_edges, column_defaulted = _size_edges(
|
|
column_groups,
|
|
count=column_count,
|
|
span_key="cols",
|
|
size_key="width",
|
|
columns=True,
|
|
default_size=DEFAULT_COLUMN_WIDTH,
|
|
)
|
|
warnings: list[str] = []
|
|
if row_defaulted:
|
|
warnings.append("部分行缺少高度信息,按 27 px 估算")
|
|
if column_defaulted:
|
|
warnings.append("部分列缺少宽度信息,按 105 px 估算")
|
|
return row_edges, column_edges, warnings
|
|
|
|
|
|
def _first_dict(value: Any) -> dict[str, Any] | None:
|
|
if isinstance(value, dict):
|
|
return value
|
|
if isinstance(value, list):
|
|
return next((item for item in value if isinstance(item, dict)), None)
|
|
return None
|
|
|
|
|
|
def extract_sheet_structure(data: dict[str, Any]) -> dict[str, Any]:
|
|
sheet = _first_dict(data.get("sheets")) or _first_dict(data.get("sheet"))
|
|
return sheet or data
|
|
|
|
|
|
def extract_charts(data: dict[str, Any], sheet_id: str, title: str) -> list[dict[str, Any]]:
|
|
sheets = data.get("sheets")
|
|
if isinstance(sheets, list):
|
|
for sheet in sheets:
|
|
if not isinstance(sheet, dict):
|
|
continue
|
|
if sheet_identifier(sheet) == sheet_id or sheet_title(sheet) == title:
|
|
charts = sheet.get("charts")
|
|
return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []
|
|
charts = data.get("charts")
|
|
return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []
|
|
|
|
|
|
def chart_rectangle(
|
|
chart: dict[str, Any], row_edges: list[float], column_edges: list[float]
|
|
) -> dict[str, Any]:
|
|
details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
|
|
position = details.get("position") if isinstance(details.get("position"), dict) else {}
|
|
offset = details.get("offset") if isinstance(details.get("offset"), dict) else {}
|
|
size = details.get("size") if isinstance(details.get("size"), dict) else {}
|
|
|
|
row = int(position["row"])
|
|
column = column_to_index(str(position["col"]))
|
|
if row < 0 or column < 0 or row >= len(row_edges) - 1 or column >= len(column_edges) - 1:
|
|
raise ValueError(f"anchor outside sheet: {position!r}")
|
|
|
|
width = float(size["width"])
|
|
height = float(size["height"])
|
|
if width <= 0 or height <= 0:
|
|
raise ValueError(f"invalid chart size: {size!r}")
|
|
|
|
left = column_edges[column] + float(offset.get("col_offset", 0) or 0)
|
|
top = row_edges[row] + float(offset.get("row_offset", 0) or 0)
|
|
return {
|
|
"chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
|
|
"anchor_cell": f"{index_to_column(column)}{row + 1}",
|
|
"left": left,
|
|
"top": top,
|
|
"right": left + width,
|
|
"bottom": top + height,
|
|
"width": width,
|
|
"height": height,
|
|
}
|
|
|
|
|
|
def intersection(first: dict[str, Any], second: dict[str, Any]) -> dict[str, float] | None:
|
|
left = max(float(first["left"]), float(second["left"]))
|
|
top = max(float(first["top"]), float(second["top"]))
|
|
right = min(float(first["right"]), float(second["right"]))
|
|
bottom = min(float(first["bottom"]), float(second["bottom"]))
|
|
if right <= left or bottom <= top:
|
|
return None
|
|
return {
|
|
"width": round(right - left, 2),
|
|
"height": round(bottom - top, 2),
|
|
"area": round((right - left) * (bottom - top), 2),
|
|
}
|
|
|
|
|
|
def chart_context(rectangle: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"chart_id": rectangle["chart_id"],
|
|
"anchor_cell": rectangle["anchor_cell"],
|
|
"rectangle_px": {
|
|
"left": round(rectangle["left"], 2),
|
|
"top": round(rectangle["top"], 2),
|
|
"right": round(rectangle["right"], 2),
|
|
"bottom": round(rectangle["bottom"], 2),
|
|
"width": round(rectangle["width"], 2),
|
|
"height": round(rectangle["height"], 2),
|
|
},
|
|
}
|
|
|
|
|
|
def _covered_indexes(edges: list[float], start: float, end: float) -> list[int]:
|
|
return [
|
|
index
|
|
for index in range(len(edges) - 1)
|
|
if edges[index + 1] > start and edges[index] < end
|
|
]
|
|
|
|
|
|
def rectangle_cell_range(
|
|
rectangle: dict[str, Any], row_edges: list[float], column_edges: list[float]
|
|
) -> str | None:
|
|
rows = _covered_indexes(row_edges, max(0.0, rectangle["top"]), rectangle["bottom"])
|
|
columns = _covered_indexes(column_edges, max(0.0, rectangle["left"]), rectangle["right"])
|
|
if not rows or not columns:
|
|
return None
|
|
return f"{index_to_column(columns[0])}{rows[0] + 1}:{index_to_column(columns[-1])}{rows[-1] + 1}"
|
|
|
|
|
|
def _has_content(cell: Any) -> bool:
|
|
if not isinstance(cell, dict):
|
|
return False
|
|
for key in ("value", "formula", "note"):
|
|
value = cell.get(key)
|
|
if value not in (None, ""):
|
|
return True
|
|
return bool(cell.get("rich_text") or cell.get("multiple_values"))
|
|
|
|
|
|
def _iter_cells(data: dict[str, Any]):
|
|
ranges = data.get("ranges")
|
|
if not isinstance(ranges, list):
|
|
return
|
|
for result_range in ranges:
|
|
if not isinstance(result_range, dict):
|
|
continue
|
|
cells = result_range.get("cells")
|
|
rows = result_range.get("row_indices")
|
|
columns = result_range.get("col_indices")
|
|
if not isinstance(cells, list):
|
|
continue
|
|
for row_offset, row in enumerate(cells):
|
|
if not isinstance(row, list):
|
|
continue
|
|
row_number = int(rows[row_offset]) if isinstance(rows, list) and row_offset < len(rows) else row_offset + 1
|
|
for column_offset, cell in enumerate(row):
|
|
column = columns[column_offset] if isinstance(columns, list) and column_offset < len(columns) else index_to_column(column_offset)
|
|
yield row_number, column_to_index(str(column)), cell
|
|
|
|
|
|
def non_empty_cells(
|
|
data: dict[str, Any], sample_limit: int, bounds: CellBounds | None = None
|
|
) -> tuple[int, list[str], bool]:
|
|
count = 0
|
|
samples: list[str] = []
|
|
truncated = bool(data.get("has_more"))
|
|
ranges = data.get("ranges")
|
|
if not isinstance(ranges, list):
|
|
return 0, [], truncated
|
|
for result_range in ranges:
|
|
if not isinstance(result_range, dict):
|
|
continue
|
|
truncated = truncated or bool(result_range.get("truncated"))
|
|
for row_number, column_index, cell in _iter_cells(data):
|
|
if bounds and not (
|
|
bounds[0] <= row_number <= bounds[1]
|
|
and bounds[2] <= column_index <= bounds[3]
|
|
):
|
|
continue
|
|
if not _has_content(cell):
|
|
continue
|
|
count += 1
|
|
if len(samples) < sample_limit:
|
|
samples.append(f"{index_to_column(column_index)}{row_number}")
|
|
return count, samples, truncated
|
|
|
|
|
|
def _read_cells(
|
|
cache: CellCache,
|
|
locator: dict[str, str],
|
|
*,
|
|
sheet_id: str | None,
|
|
sheet_name: str | None,
|
|
cell_range: str,
|
|
include: str,
|
|
skip_hidden: bool = False,
|
|
timeout: int,
|
|
) -> dict[str, Any]:
|
|
selector = f"id:{sheet_id}" if sheet_id else f"name:{sheet_name}"
|
|
key = (selector, cell_range, include, skip_hidden)
|
|
if key not in cache:
|
|
flags: dict[str, Any] = {"range": cell_range, "include": include}
|
|
if skip_hidden:
|
|
flags["skip-hidden"] = True
|
|
cache[key] = envelope_data(
|
|
run_sheets(
|
|
"+cells-get",
|
|
**locator,
|
|
**({"sheet_id": sheet_id} if sheet_id else {"sheet_name": sheet_name}),
|
|
flags=flags,
|
|
timeout=timeout,
|
|
)
|
|
)
|
|
return cache[key]
|
|
|
|
|
|
def _read_typed_table(
|
|
cache: CellCache,
|
|
locator: dict[str, str],
|
|
*,
|
|
sheet_id: str | None,
|
|
sheet_name: str | None,
|
|
cell_range: str,
|
|
timeout: int,
|
|
) -> tuple[list[list[Any]], list[str], bool]:
|
|
selector = f"id:{sheet_id}" if sheet_id else f"name:{sheet_name}"
|
|
key = (selector, cell_range, "typed_table", False)
|
|
if key not in cache:
|
|
cache[key] = envelope_data(
|
|
run_sheets(
|
|
"+table-get",
|
|
**locator,
|
|
**({"sheet_id": sheet_id} if sheet_id else {"sheet_name": sheet_name}),
|
|
flags={"range": cell_range, "no-header": True},
|
|
timeout=timeout,
|
|
)
|
|
)
|
|
data = cache[key]
|
|
sheets = data.get("sheets")
|
|
if not isinstance(sheets, list) or len(sheets) != 1 or not isinstance(sheets[0], dict):
|
|
return [], [], True
|
|
table = sheets[0]
|
|
rows = table.get("data")
|
|
columns = table.get("columns")
|
|
dtypes = table.get("dtypes")
|
|
if not isinstance(rows, list) or not isinstance(columns, list) or not isinstance(dtypes, dict):
|
|
return [], [], True
|
|
def value_kind(dtype: Any) -> str:
|
|
value = str(dtype or "").lower()
|
|
if value in {"number", "int64", "float64"}:
|
|
return "number"
|
|
if value in {"bool", "boolean"}:
|
|
return "bool"
|
|
if value.startswith("datetime"):
|
|
return "date"
|
|
return "string"
|
|
|
|
types = [value_kind(dtypes.get(str(column))) for column in columns]
|
|
truncated = bool(data.get("truncated")) or bool(table.get("truncated"))
|
|
return rows, types, truncated
|
|
|
|
|
|
def _typed_cell(
|
|
rows: list[list[Any]],
|
|
column_types: list[str],
|
|
row_offset: int,
|
|
column_offset: int,
|
|
visible_offsets: set[tuple[int, int]],
|
|
) -> tuple[Any, str]:
|
|
if (
|
|
row_offset < 0
|
|
or row_offset >= len(rows)
|
|
or not isinstance(rows[row_offset], list)
|
|
or column_offset < 0
|
|
or column_offset >= len(rows[row_offset])
|
|
):
|
|
return None, ""
|
|
value = rows[row_offset][column_offset]
|
|
if isinstance(value, bool):
|
|
return value, "bool"
|
|
if isinstance(value, (int, float)):
|
|
return value, "number"
|
|
kind = column_types[column_offset] if column_offset < len(column_types) else ""
|
|
if kind != "string" or not isinstance(value, str) or not _looks_numeric(value):
|
|
return value, kind
|
|
|
|
# table-get infers one dtype per physical column. A hidden cell or another
|
|
# row-series can widen that dtype and stringify otherwise numeric cells.
|
|
# Treat such cells as unknown instead of reporting a false storage issue.
|
|
numeric_string_count = 0
|
|
for other_row_offset, row in enumerate(rows):
|
|
if not isinstance(row, list) or column_offset >= len(row):
|
|
continue
|
|
other = row[column_offset]
|
|
if other not in (None, "") and (other_row_offset, column_offset) not in visible_offsets:
|
|
return value, "unknown"
|
|
if other in (None, "") or (
|
|
isinstance(other, (int, float)) and not isinstance(other, bool)
|
|
):
|
|
continue
|
|
if isinstance(other, str) and _looks_numeric(other):
|
|
numeric_string_count += 1
|
|
continue
|
|
return value, "unknown"
|
|
return value, "string" if numeric_string_count == 1 else "ambiguous_string"
|
|
|
|
|
|
def _chart_snapshot(chart: dict[str, Any]) -> dict[str, Any]:
|
|
details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
|
|
snapshot = details.get("snapshot")
|
|
return snapshot if isinstance(snapshot, dict) else {}
|
|
|
|
|
|
def _chart_type(snapshot: dict[str, Any]) -> str:
|
|
plot_area = snapshot.get("plotArea")
|
|
plot = plot_area.get("plot") if isinstance(plot_area, dict) else None
|
|
return str(plot.get("type") or "").lower() if isinstance(plot, dict) else ""
|
|
|
|
|
|
def _plot(snapshot: dict[str, Any]) -> dict[str, Any]:
|
|
plot_area = snapshot.get("plotArea")
|
|
plot = plot_area.get("plot") if isinstance(plot_area, dict) else None
|
|
return plot if isinstance(plot, dict) else {}
|
|
|
|
|
|
def _static_series_profiles(snapshot: dict[str, Any]) -> list[SeriesProfile]:
|
|
data = snapshot.get("data")
|
|
dim2 = data.get("dim2") if isinstance(data, dict) else None
|
|
fields = dim2.get("fields") if isinstance(dim2, dict) else None
|
|
if not isinstance(fields, list):
|
|
return []
|
|
profiles: list[SeriesProfile] = []
|
|
for offset, field in enumerate(fields, start=1):
|
|
if not isinstance(field, dict):
|
|
continue
|
|
values = []
|
|
for value in field.get("parsedValues") or []:
|
|
numeric = (
|
|
float(value)
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
else _numeric_text_value(value) if isinstance(value, str) else None
|
|
)
|
|
if numeric is not None:
|
|
values.append(numeric)
|
|
profiles.append(
|
|
{
|
|
"dimension_index": offset,
|
|
"series_name": str(field.get("name") or f"Series {offset}"),
|
|
"point_count": len(field.get("parsedValues") or []),
|
|
"numeric_value_count": len(values),
|
|
"unique_numeric_values": list(dict.fromkeys(values))[:2],
|
|
"source_sheet": "",
|
|
"source_range": "",
|
|
"series_range": "",
|
|
}
|
|
)
|
|
return profiles
|
|
|
|
|
|
def _labeled_series_indexes(
|
|
snapshot: dict[str, Any], profiles: list[SeriesProfile]
|
|
) -> set[int]:
|
|
plot = _plot(snapshot)
|
|
available = {
|
|
int(profile["dimension_index"])
|
|
for profile in profiles
|
|
if profile.get("dimension_index") is not None
|
|
}
|
|
labeled = set(available) if isinstance(plot.get("labels"), dict) else set()
|
|
series = plot.get("series")
|
|
if isinstance(series, list):
|
|
labeled.update(
|
|
int(item["index"])
|
|
for item in series
|
|
if isinstance(item, dict)
|
|
and item.get("index") is not None
|
|
and isinstance(item.get("labels"), dict)
|
|
)
|
|
return labeled & available
|
|
|
|
|
|
def _constant_labeled_series(
|
|
chart: dict[str, Any], profiles: list[SeriesProfile]
|
|
) -> list[dict[str, Any]]:
|
|
chart_id = str(chart.get("chart_id") or chart.get("id") or "")
|
|
snapshot = _chart_snapshot(chart)
|
|
labeled = _labeled_series_indexes(snapshot, profiles)
|
|
return [
|
|
{
|
|
"chart_id": chart_id,
|
|
"dimension_index": profile["dimension_index"],
|
|
"series_name": profile["series_name"],
|
|
"source_sheet": profile["source_sheet"],
|
|
"source_range": profile["source_range"],
|
|
"series_range": profile["series_range"],
|
|
"reason": "constant_labeled_series",
|
|
"data_point_count": profile["point_count"],
|
|
"constant_value": profile["unique_numeric_values"][0],
|
|
"suggested_fix": "remove_series_labels_or_use_one_sparse_marker",
|
|
}
|
|
for profile in profiles
|
|
if int(profile.get("dimension_index", -1)) in labeled
|
|
and profile.get("constant_check_unverifiable") is not True
|
|
and int(profile.get("numeric_value_count", 0)) >= 2
|
|
and len(profile.get("unique_numeric_values") or []) == 1
|
|
]
|
|
|
|
|
|
def _unbound_secondary_axis(chart: dict[str, Any]) -> dict[str, Any] | None:
|
|
snapshot = _chart_snapshot(chart)
|
|
plot_area = snapshot.get("plotArea")
|
|
if not isinstance(plot_area, dict):
|
|
return None
|
|
plot = plot_area.get("plot")
|
|
if not isinstance(plot, dict) or str(plot.get("type") or "").lower() != "combo":
|
|
return None
|
|
|
|
axes = plot_area.get("axes")
|
|
has_right_axis = isinstance(axes, list) and any(
|
|
isinstance(axis, dict)
|
|
and str(axis.get("type") or "").lower() == "y"
|
|
and str(axis.get("position") or axis.get("axisPosition") or "").lower() == "right"
|
|
for axis in axes
|
|
)
|
|
series = plot.get("series")
|
|
configured_series = (
|
|
[item for item in series if isinstance(item, dict)]
|
|
if isinstance(series, list)
|
|
else []
|
|
)
|
|
if not has_right_axis or not configured_series:
|
|
return None
|
|
if str(plot.get("yAxisPosition") or "").lower() == "right" or any(
|
|
str(item.get("yAxisPosition") or "").lower() == "right"
|
|
for item in configured_series
|
|
):
|
|
return None
|
|
|
|
return {
|
|
"chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
|
|
"reason": "secondary_axis_has_no_bound_series",
|
|
"series_indexes": [
|
|
int(item["index"])
|
|
for item in configured_series
|
|
if isinstance(item.get("index"), (int, float))
|
|
],
|
|
"suggested_fix": "bind_the_intended_combo_series_to_the_right_axis",
|
|
}
|
|
|
|
|
|
def _undersized_chart(chart: dict[str, Any]) -> dict[str, Any] | None:
|
|
details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
|
|
snapshot = _chart_snapshot(chart)
|
|
chart_type = _chart_type(snapshot)
|
|
size = details.get("size")
|
|
if not isinstance(size, dict):
|
|
return None
|
|
width = size.get("width")
|
|
height = size.get("height")
|
|
if (
|
|
not isinstance(width, (int, float))
|
|
or isinstance(width, bool)
|
|
or width <= 0
|
|
or not isinstance(height, (int, float))
|
|
or isinstance(height, bool)
|
|
or height <= 0
|
|
):
|
|
return None
|
|
actual = {
|
|
"width": float(width),
|
|
"height": float(height),
|
|
}
|
|
minimum = minimum_chart_size(chart_type)
|
|
if actual["width"] >= minimum["width"] and actual["height"] >= minimum["height"]:
|
|
return None
|
|
return {
|
|
"chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
|
|
"reason": "chart_below_minimum_size",
|
|
"chart_type": chart_type,
|
|
"actual_size": actual,
|
|
"minimum_size": minimum,
|
|
"suggested_fix": "run_lark_chart_size_advisor_before_resizing",
|
|
}
|
|
|
|
|
|
def _overwide_chart(chart: dict[str, Any]) -> dict[str, Any] | None:
|
|
details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
|
|
snapshot = _chart_snapshot(chart)
|
|
chart_type = _chart_type(snapshot)
|
|
size = details.get("size") if isinstance(details.get("size"), dict) else {}
|
|
width = size.get("width")
|
|
height = size.get("height")
|
|
if (
|
|
not isinstance(width, (int, float))
|
|
or isinstance(width, bool)
|
|
or width <= 0
|
|
or not isinstance(height, (int, float))
|
|
or isinstance(height, bool)
|
|
or height <= 0
|
|
):
|
|
return None
|
|
width = float(width)
|
|
height = float(height)
|
|
minimum = minimum_chart_size(chart_type)
|
|
aspect_ratio = width / height if height > 0 else 0
|
|
width_exceeded = width > MAX_CHART_WIDTH
|
|
aspect_ratio_exceeded = (
|
|
width >= minimum["width"]
|
|
and height >= minimum["height"]
|
|
and aspect_ratio > MAX_ASPECT_RATIO
|
|
)
|
|
if not width_exceeded and not aspect_ratio_exceeded:
|
|
return None
|
|
return {
|
|
"chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
|
|
"reason": "chart_too_wide",
|
|
"chart_type": chart_type,
|
|
"actual_size": {"width": width, "height": height},
|
|
"actual_aspect_ratio": round(aspect_ratio, 2),
|
|
"maximum_width": MAX_CHART_WIDTH,
|
|
"maximum_aspect_ratio": MAX_ASPECT_RATIO,
|
|
"suggested_fix": "use_size_advisor_create_flags_or_change_chart_structure",
|
|
}
|
|
|
|
|
|
def _numeric_dimensions(snapshot: dict[str, Any]) -> list[tuple[int, str]]:
|
|
data = snapshot.get("data")
|
|
if not isinstance(data, dict):
|
|
return []
|
|
dim2 = data.get("dim2")
|
|
series = dim2.get("series") if isinstance(dim2, dict) else None
|
|
chart_type = _chart_type(snapshot)
|
|
dimensions: list[tuple[int, str]] = []
|
|
if isinstance(series, list):
|
|
for offset, serie in enumerate(series):
|
|
if not isinstance(serie, dict) or serie.get("index") is None:
|
|
continue
|
|
if str(serie.get("aggregateType") or "").lower() == "counta":
|
|
continue
|
|
role = str(serie.get("role") or "").lower()
|
|
if chart_type == "bubble":
|
|
role = role or ("x", "y", "group", "size")[min(offset, 3)]
|
|
if role not in {"x", "y", "size"}:
|
|
continue
|
|
dimensions.append((int(serie["index"]), role or "value"))
|
|
|
|
plot_area = snapshot.get("plotArea")
|
|
axes = plot_area.get("axes") if isinstance(plot_area, dict) else None
|
|
continuous_x = chart_type == "scatter"
|
|
if isinstance(axes, list):
|
|
for axis in axes:
|
|
if not isinstance(axis, dict) or str(axis.get("type") or "").lower() != "x":
|
|
continue
|
|
position = axis.get("position")
|
|
if (
|
|
(position is None or str(position).lower() in {"bottom", "x"})
|
|
and str(axis.get("valueType") or "").lower() == "linear"
|
|
):
|
|
continuous_x = True
|
|
break
|
|
dim1 = data.get("dim1")
|
|
serie = dim1.get("serie") if isinstance(dim1, dict) else None
|
|
if continuous_x and chart_type != "bubble" and isinstance(serie, dict) and serie.get("index") is not None:
|
|
dimensions.append((int(serie["index"]), "x"))
|
|
|
|
return list(dict.fromkeys(dimensions))
|
|
|
|
|
|
def _series_aggregate_type(data: dict[str, Any], dimension_index: int) -> str:
|
|
dim2 = data.get("dim2")
|
|
value_series = dim2.get("series") if isinstance(dim2, dict) else None
|
|
source_series = next(
|
|
(
|
|
item
|
|
for item in (value_series if isinstance(value_series, list) else [])
|
|
if isinstance(item, dict)
|
|
and item.get("index") is not None
|
|
and int(item["index"]) == dimension_index
|
|
),
|
|
{},
|
|
)
|
|
return str(source_series.get("aggregateType") or "sum").lower()
|
|
|
|
|
|
def _aggregation_can_change_constant(data: dict[str, Any], dimension_index: int) -> bool:
|
|
dim1 = data.get("dim1")
|
|
category_series = dim1.get("serie") if isinstance(dim1, dict) else None
|
|
if not isinstance(category_series, dict) or category_series.get("aggregate") is False:
|
|
return False
|
|
return _series_aggregate_type(data, dimension_index) in {"sum", "count", "counta"}
|
|
|
|
|
|
def _parse_chart_ref(value: str, default_sheet: str) -> tuple[str, str, CellBounds]:
|
|
raw = str(value).strip()
|
|
sheet_name = default_sheet
|
|
cell_range = raw
|
|
if "!" in raw:
|
|
sheet_name, cell_range = raw.rsplit("!", 1)
|
|
sheet_name = sheet_name.strip()
|
|
if len(sheet_name) >= 2 and sheet_name[0] == sheet_name[-1] == "'":
|
|
sheet_name = sheet_name[1:-1].replace("''", "'")
|
|
return sheet_name, cell_range.replace("$", ""), _parse_a1_bounds(cell_range)
|
|
|
|
|
|
def _looks_numeric(value: str) -> bool:
|
|
return _numeric_text_value(value) is not None
|
|
|
|
|
|
def _numeric_text_value(value: str) -> float | None:
|
|
text = value.strip()
|
|
if not text:
|
|
return None
|
|
text = re.sub(r"^([+-]?)[\$\u00a5\uffe5\u20ac\u00a3]", r"\1", text)
|
|
if text.endswith("%"):
|
|
text = text[:-1]
|
|
# Group separators must be consistent and separate groups of three digits.
|
|
if not re.fullmatch(
|
|
r"[+-]?(?:(?:\d+|\d{1,3}([, ])\d{3}(?:\1\d{3})*)(?:\.\d*)?|\.\d+)"
|
|
r"(?:[eE][+-]?\d+)?",
|
|
text,
|
|
):
|
|
return None
|
|
return float(text.replace(" ", "").replace(",", ""))
|
|
|
|
|
|
def _zero_state(value: Any) -> str:
|
|
if value in (None, ""):
|
|
return "empty"
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
return "zero" if value == 0 else "nonzero"
|
|
if isinstance(value, str):
|
|
numeric_value = _numeric_text_value(value)
|
|
if numeric_value is not None:
|
|
return "zero" if numeric_value == 0 else "nonzero"
|
|
return "nonzero"
|
|
|
|
|
|
def _update_series_state(state: dict[str, Any], value: Any) -> None:
|
|
state[_zero_state(value)] = True
|
|
numeric = (
|
|
float(value)
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
else _numeric_text_value(value) if isinstance(value, str) else None
|
|
)
|
|
if numeric is None:
|
|
return
|
|
state["numeric_value_count"] += 1
|
|
if len(state["unique_numeric_values"]) < 2:
|
|
state["unique_numeric_values"].add(numeric)
|
|
|
|
|
|
def _cells_truncated(data: dict[str, Any]) -> bool:
|
|
ranges = data.get("ranges")
|
|
return bool(data.get("has_more")) or any(
|
|
isinstance(item, dict) and item.get("truncated")
|
|
for item in (ranges if isinstance(ranges, list) else [])
|
|
)
|
|
|
|
|
|
def _numeric_source_issues(
|
|
chart: dict[str, Any],
|
|
*,
|
|
owner_sheet_id: str,
|
|
owner_sheet_name: str,
|
|
cache: CellCache,
|
|
locator: dict[str, str],
|
|
timeout: int,
|
|
sample_limit: int,
|
|
) -> tuple[
|
|
list[dict[str, Any]],
|
|
list[dict[str, Any]],
|
|
list[dict[str, str]],
|
|
list[SeriesProfile],
|
|
]:
|
|
chart_id = str(chart.get("chart_id") or chart.get("id") or "")
|
|
snapshot = _chart_snapshot(chart)
|
|
data = snapshot.get("data")
|
|
if not isinstance(data, dict):
|
|
return [], [], [{"chart_id": chart_id, "reason": "chart snapshot.data is missing"}], []
|
|
if data.get("isStaticData") is True:
|
|
return [], [], [], _static_series_profiles(snapshot)
|
|
dimensions = _numeric_dimensions(snapshot)
|
|
if not dimensions:
|
|
return [], [], [], []
|
|
refs = data.get("refs")
|
|
if not isinstance(refs, list) or not refs:
|
|
return [], [], [{"chart_id": chart_id, "reason": "chart data.refs is missing"}], []
|
|
|
|
parsed_refs: list[tuple[str, str, CellBounds]] = []
|
|
unverifiable: list[dict[str, str]] = []
|
|
for ref in refs:
|
|
raw_ref = ref.get("value") if isinstance(ref, dict) else ref
|
|
try:
|
|
parsed_refs.append(_parse_chart_ref(str(raw_ref), owner_sheet_name))
|
|
except (TypeError, ValueError) as exc:
|
|
unverifiable.append({"chart_id": chart_id, "reason": str(exc)})
|
|
return [], [], unverifiable, []
|
|
|
|
direction = str(data.get("direction") or "column").lower()
|
|
skip_hidden = data.get("includeHiddenOrFilter") is not True
|
|
mapped: list[tuple[int, str, int, str, str, CellBounds]] = []
|
|
for dimension_index, role in dimensions:
|
|
offset = 0
|
|
for source_sheet, source_range, bounds in parsed_refs:
|
|
dimension_count = (
|
|
bounds[3] - bounds[2] + 1 if direction == "column" else bounds[1] - bounds[0] + 1
|
|
)
|
|
if offset < dimension_index <= offset + dimension_count:
|
|
mapped.append(
|
|
(
|
|
dimension_index,
|
|
role,
|
|
dimension_index - offset,
|
|
source_sheet,
|
|
source_range,
|
|
bounds,
|
|
)
|
|
)
|
|
break
|
|
offset += dimension_count
|
|
else:
|
|
unverifiable.append(
|
|
{
|
|
"chart_id": chart_id,
|
|
"reason": f"numeric dimension index {dimension_index} is outside data.refs",
|
|
}
|
|
)
|
|
|
|
detached = str(data.get("headerMode") or "").lower() == "detached"
|
|
mapped_by_ref: dict[
|
|
tuple[str, str, CellBounds], list[tuple[int, str, int]]
|
|
] = {}
|
|
for dimension_index, role, local_index, source_sheet, source_range, bounds in mapped:
|
|
mapped_by_ref.setdefault((source_sheet, source_range, bounds), []).append(
|
|
(dimension_index, role, local_index)
|
|
)
|
|
|
|
issue_groups: dict[tuple[int, str, str, str, str, str], list[str]] = {}
|
|
issue_counts: dict[tuple[int, str, str, str, str, str], int] = {}
|
|
degenerate_series: list[dict[str, Any]] = []
|
|
series_profiles: list[SeriesProfile] = []
|
|
remaining_source_cells = MAX_CELL_READ_SIZE
|
|
remaining_dimension_spans = sum(
|
|
max(local_index for _, _, local_index in ref_dimensions)
|
|
- min(local_index for _, _, local_index in ref_dimensions)
|
|
+ 1
|
|
for ref_dimensions in mapped_by_ref.values()
|
|
)
|
|
for (source_sheet, source_range, bounds), ref_dimensions in mapped_by_ref.items():
|
|
dimension_start = bounds[2] if direction == "column" else bounds[0]
|
|
selected = {
|
|
dimension_start + local_index - 1: (dimension_index, role)
|
|
for dimension_index, role, local_index in ref_dimensions
|
|
}
|
|
dimension_span = max(selected) - min(selected) + 1
|
|
header_points = 0 if detached else 1
|
|
# Each sampled rectangle is read twice: cells-get supplies coordinates,
|
|
# styles, and hidden/filter semantics; table-get supplies typed values.
|
|
cells_per_dimension = remaining_source_cells // remaining_dimension_spans
|
|
remaining_dimension_spans -= dimension_span
|
|
point_axis_size = (
|
|
bounds[1] - bounds[0] + 1
|
|
if direction == "column"
|
|
else bounds[3] - bounds[2] + 1
|
|
)
|
|
if point_axis_size <= header_points:
|
|
unverifiable.append(
|
|
{
|
|
"chart_id": chart_id,
|
|
"reason": f"source has no data points: {source_sheet}!{source_range}",
|
|
}
|
|
)
|
|
continue
|
|
sample_points = min(MAX_SOURCE_SAMPLE_POINTS, max(0, (cells_per_dimension - header_points) // 2))
|
|
if sample_points == 0:
|
|
unverifiable.append({
|
|
"chart_id": chart_id,
|
|
"reason": f"source sampling 2000-cell budget cannot cover {source_sheet}!{source_range}",
|
|
})
|
|
continue
|
|
point_count = sample_points + header_points
|
|
if direction == "column":
|
|
checked_bounds = (
|
|
bounds[0],
|
|
min(bounds[1], bounds[0] + point_count - 1),
|
|
min(selected),
|
|
max(selected),
|
|
)
|
|
else:
|
|
checked_bounds = (
|
|
min(selected),
|
|
max(selected),
|
|
bounds[2],
|
|
min(bounds[3], bounds[2] + point_count - 1),
|
|
)
|
|
typed_bounds = (
|
|
(checked_bounds[0] + header_points, checked_bounds[1], checked_bounds[2], checked_bounds[3])
|
|
if direction == "column"
|
|
else (checked_bounds[0], checked_bounds[1], checked_bounds[2] + header_points, checked_bounds[3])
|
|
)
|
|
remaining_source_cells -= _bounds_area(checked_bounds) + _bounds_area(typed_bounds)
|
|
checked_range = _format_a1_bounds(checked_bounds)
|
|
same_sheet = source_sheet == owner_sheet_name
|
|
cells_data = _read_cells(
|
|
cache,
|
|
locator,
|
|
sheet_id=owner_sheet_id if same_sheet else None,
|
|
sheet_name=None if same_sheet else source_sheet,
|
|
cell_range=checked_range,
|
|
include="value,style",
|
|
skip_hidden=skip_hidden,
|
|
timeout=timeout,
|
|
)
|
|
cells_truncated = _cells_truncated(cells_data)
|
|
typed_rows, typed_columns, table_truncated = _read_typed_table(
|
|
cache,
|
|
locator,
|
|
sheet_id=owner_sheet_id if same_sheet else None,
|
|
sheet_name=None if same_sheet else source_sheet,
|
|
cell_range=_format_a1_bounds(typed_bounds),
|
|
timeout=timeout,
|
|
)
|
|
if cells_truncated:
|
|
unverifiable.append(
|
|
{"chart_id": chart_id, "reason": f"cells-get truncated for {source_sheet}!{checked_range}"}
|
|
)
|
|
if table_truncated:
|
|
unverifiable.append(
|
|
{
|
|
"chart_id": chart_id,
|
|
"reason": (
|
|
"table-get truncated or returned invalid typed data for "
|
|
f"{source_sheet}!{_format_a1_bounds(typed_bounds)}"
|
|
),
|
|
}
|
|
)
|
|
truncated = cells_truncated or table_truncated
|
|
states = {
|
|
coordinate: {
|
|
"zero": False,
|
|
"empty": False,
|
|
"nonzero": False,
|
|
"sample_point_count": 0,
|
|
"numeric_value_count": 0,
|
|
"unique_numeric_values": set(),
|
|
}
|
|
for coordinate in selected
|
|
}
|
|
type_unverifiable_dimensions: set[int] = set()
|
|
visible_offsets = {
|
|
(row_number - typed_bounds[0], column_index - typed_bounds[2])
|
|
for row_number, column_index, _ in _iter_cells(cells_data)
|
|
if typed_bounds[0] <= row_number <= typed_bounds[1]
|
|
and typed_bounds[2] <= column_index <= typed_bounds[3]
|
|
}
|
|
for row_number, column_index, cell in _iter_cells(cells_data):
|
|
coordinate = column_index if direction == "column" else row_number
|
|
dimension = selected.get(coordinate)
|
|
if dimension is None:
|
|
continue
|
|
if not detached and (
|
|
(direction == "column" and row_number == bounds[0])
|
|
or (direction != "column" and column_index == bounds[2])
|
|
):
|
|
continue
|
|
states[coordinate]["sample_point_count"] += 1
|
|
row_offset = row_number - typed_bounds[0]
|
|
column_offset = column_index - typed_bounds[2]
|
|
value, raw_type = _typed_cell(
|
|
typed_rows,
|
|
typed_columns,
|
|
row_offset,
|
|
column_offset,
|
|
visible_offsets,
|
|
)
|
|
_update_series_state(states[coordinate], value)
|
|
number_format = (
|
|
cell.get("cell_styles", {}).get("number_format")
|
|
if isinstance(cell, dict) and isinstance(cell.get("cell_styles"), dict)
|
|
else None
|
|
)
|
|
reason = ""
|
|
if _looks_numeric(str(value or "")):
|
|
if str(number_format or "").strip() == "@":
|
|
reason = "numeric_value_uses_text_format"
|
|
elif raw_type == "string":
|
|
reason = "numeric_value_stored_as_text"
|
|
elif raw_type in {"ambiguous_string", "unknown"}:
|
|
type_unverifiable_dimensions.add(dimension[0])
|
|
if reason:
|
|
dimension_index, role = dimension
|
|
key = (
|
|
dimension_index,
|
|
role,
|
|
source_sheet,
|
|
source_range,
|
|
checked_range,
|
|
reason,
|
|
)
|
|
issue_counts[key] = issue_counts.get(key, 0) + 1
|
|
samples = issue_groups.setdefault(key, [])
|
|
if len(samples) < sample_limit:
|
|
samples.append(f"{index_to_column(column_index)}{row_number}")
|
|
|
|
if truncated:
|
|
continue
|
|
for dimension_index in sorted(type_unverifiable_dimensions):
|
|
unverifiable.append(
|
|
{
|
|
"chart_id": chart_id,
|
|
"reason": (
|
|
"numeric storage type cannot be attributed to individual cells after "
|
|
f"typed column coercion for {source_sheet}!{checked_range}, "
|
|
f"dimension {dimension_index}"
|
|
),
|
|
}
|
|
)
|
|
zero_candidates = {
|
|
coordinate
|
|
for coordinate, state in states.items()
|
|
if not state["nonzero"]
|
|
and _series_aggregate_type(data, selected[coordinate][0]) != "count"
|
|
}
|
|
data_start = bounds[0] + (0 if detached else 1)
|
|
data_column = bounds[2] + (0 if detached else 1)
|
|
dim2 = data.get("dim2")
|
|
value_series = dim2.get("series") if isinstance(dim2, dict) else None
|
|
for coordinate, state in states.items():
|
|
dimension_index, role = selected[coordinate]
|
|
if direction == "column":
|
|
point_total = max(0, bounds[1] - data_start + 1)
|
|
series_range = (
|
|
f"{index_to_column(coordinate)}{data_start}:"
|
|
f"{index_to_column(coordinate)}{bounds[1]}"
|
|
if point_total
|
|
else ""
|
|
)
|
|
else:
|
|
point_total = max(0, bounds[3] - data_column + 1)
|
|
series_range = (
|
|
f"{index_to_column(data_column)}{coordinate}:"
|
|
f"{index_to_column(bounds[3])}{coordinate}"
|
|
if point_total
|
|
else ""
|
|
)
|
|
source_series = next(
|
|
(
|
|
item
|
|
for item in (value_series if isinstance(value_series, list) else [])
|
|
if isinstance(item, dict)
|
|
and item.get("index") is not None
|
|
and int(item["index"]) == dimension_index
|
|
),
|
|
{},
|
|
)
|
|
profile = {
|
|
"dimension_index": dimension_index,
|
|
"series_name": str(
|
|
source_series.get("name")
|
|
or source_series.get("nameRef")
|
|
or f"Series {dimension_index}"
|
|
),
|
|
"point_count": point_total,
|
|
"sample_point_count": state["sample_point_count"],
|
|
"checked_range": checked_range,
|
|
"sampled": (
|
|
checked_bounds[1] < bounds[1]
|
|
if direction == "column"
|
|
else checked_bounds[3] < bounds[3]
|
|
),
|
|
"numeric_value_count": state["numeric_value_count"],
|
|
"unique_numeric_values": list(state["unique_numeric_values"]),
|
|
"source_sheet": source_sheet,
|
|
"source_range": source_range,
|
|
"series_range": series_range,
|
|
}
|
|
aggregate_type = _series_aggregate_type(data, dimension_index)
|
|
if aggregate_type == "count":
|
|
profile["constant_check_unverifiable"] = True
|
|
constant_labeled = (
|
|
aggregate_type != "count"
|
|
and state["numeric_value_count"] >= 2
|
|
and len(state["unique_numeric_values"]) == 1
|
|
and dimension_index
|
|
in _labeled_series_indexes(snapshot, [{"dimension_index": dimension_index}])
|
|
)
|
|
if profile["sampled"]:
|
|
profile["constant_check_unverifiable"] = True
|
|
if coordinate in zero_candidates or constant_labeled:
|
|
unverifiable.append({
|
|
"chart_id": chart_id,
|
|
"reason": (
|
|
"zero/constant-series check is unverifiable outside sampled source "
|
|
f"{source_sheet}!{checked_range} for dimension {dimension_index}"
|
|
),
|
|
})
|
|
elif constant_labeled and _aggregation_can_change_constant(data, dimension_index):
|
|
profile["constant_check_unverifiable"] = True
|
|
unverifiable.append(
|
|
{
|
|
"chart_id": chart_id,
|
|
"reason": (
|
|
"constant-series check is unverifiable after category "
|
|
f"aggregation for dimension {dimension_index}"
|
|
),
|
|
}
|
|
)
|
|
series_profiles.append(profile)
|
|
if coordinate not in zero_candidates or profile["sampled"]:
|
|
continue
|
|
degenerate_series.append(
|
|
{
|
|
"chart_id": chart_id,
|
|
"dimension_index": dimension_index,
|
|
"role": role,
|
|
"source_sheet": source_sheet,
|
|
"source_range": source_range,
|
|
"series_range": series_range,
|
|
"reason": (
|
|
"numeric_series_all_zero_or_empty"
|
|
if states[coordinate]["zero"]
|
|
else "numeric_series_all_empty"
|
|
),
|
|
"data_point_count": point_total,
|
|
}
|
|
)
|
|
|
|
issues = [
|
|
{
|
|
"chart_id": chart_id,
|
|
"dimension_index": key[0],
|
|
"role": key[1],
|
|
"source_sheet": key[2],
|
|
"source_range": key[3],
|
|
"checked_range": key[4],
|
|
"reason": key[5],
|
|
"suggested_fix": (
|
|
"set_numeric_number_format"
|
|
if key[5] == "numeric_value_uses_text_format"
|
|
else "rewrite_as_number_and_set_numeric_number_format"
|
|
),
|
|
"affected_sample_cell_count": issue_counts[key],
|
|
"sample_cells": samples,
|
|
}
|
|
for key, samples in issue_groups.items()
|
|
]
|
|
return issues, degenerate_series, unverifiable, series_profiles
|
|
|
|
|
|
def _locator(target: str) -> dict[str, str]:
|
|
return {"url": target} if target.startswith(("http://", "https://")) else {"spreadsheet_token": target}
|
|
|
|
|
|
def _sheet_counts(sheet: dict[str, Any]) -> tuple[int, int]:
|
|
row_count = int(sheet.get("row_count") or sheet.get("rowCount") or 0)
|
|
column_count = int(sheet.get("column_count") or sheet.get("columnCount") or 0)
|
|
if row_count <= 0 or column_count <= 0:
|
|
raise LarkCliError(f"Missing row_count/column_count for sheet {sheet_title(sheet)!r}")
|
|
return row_count, column_count
|
|
|
|
|
|
def check_sheet(
|
|
locator: dict[str, str],
|
|
sheet: dict[str, Any],
|
|
*,
|
|
timeout: int,
|
|
sample_limit: int,
|
|
cell_cache: CellCache | None = None,
|
|
) -> dict[str, Any]:
|
|
cell_cache = cell_cache if cell_cache is not None else {}
|
|
sheet_id = sheet_identifier(sheet)
|
|
title = sheet_title(sheet)
|
|
if not sheet_id:
|
|
raise LarkCliError(f"Missing sheet_id for sheet {title!r}")
|
|
|
|
chart_data = envelope_data(
|
|
run_sheets("+chart-list", **locator, sheet_id=sheet_id, timeout=timeout)
|
|
)
|
|
charts = extract_charts(chart_data, sheet_id, title)
|
|
unverifiable: list[dict[str, str]] = []
|
|
expected_chart_count = sheet.get("chart_count")
|
|
if expected_chart_count is not None and int(expected_chart_count) != len(charts):
|
|
unverifiable.append(
|
|
{
|
|
"chart_id": "",
|
|
"reason": (
|
|
f"chart-list returned {len(charts)} charts, "
|
|
f"but workbook-info reported {int(expected_chart_count)}"
|
|
),
|
|
}
|
|
)
|
|
if not charts:
|
|
return {
|
|
"sheet_id": sheet_id,
|
|
"sheet_name": title,
|
|
"chart_count": 0,
|
|
"sheet_size_px": None,
|
|
"chart_overlaps": [],
|
|
"cell_content_overlaps": [],
|
|
"numeric_source_format_issues": [],
|
|
"numeric_source_samples": [],
|
|
"degenerate_numeric_series": [],
|
|
"constant_labeled_series": [],
|
|
"unbound_secondary_axes": [],
|
|
"undersized_charts": [],
|
|
"overwide_charts": [],
|
|
"out_of_visible_range": [],
|
|
"unverifiable_charts": unverifiable,
|
|
"issue_count": 0,
|
|
"unverifiable_count": len(unverifiable),
|
|
"warnings": [],
|
|
}
|
|
|
|
row_count, column_count = _sheet_counts(sheet)
|
|
structure_data = envelope_data(
|
|
run_sheets(
|
|
"+sheet-info",
|
|
**locator,
|
|
sheet_id=sheet_id,
|
|
flags={"include": "row_heights,col_widths"},
|
|
timeout=timeout,
|
|
)
|
|
)
|
|
row_edges, column_edges, warnings = build_layout(
|
|
extract_sheet_structure(structure_data), row_count, column_count
|
|
)
|
|
|
|
rectangles: list[dict[str, Any]] = []
|
|
for chart in charts:
|
|
chart_id = str(chart.get("chart_id") or chart.get("id") or "")
|
|
if not chart_id:
|
|
unverifiable.append({"chart_id": "", "reason": "chart is missing chart_id"})
|
|
continue
|
|
try:
|
|
rectangles.append(chart_rectangle(chart, row_edges, column_edges))
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
unverifiable.append({"chart_id": chart_id, "reason": str(exc)})
|
|
|
|
overlaps: list[dict[str, Any]] = []
|
|
for index, first in enumerate(rectangles):
|
|
for second in rectangles[index + 1 :]:
|
|
overlap = intersection(first, second)
|
|
if overlap:
|
|
overlaps.append(
|
|
{
|
|
"chart_ids": [first["chart_id"], second["chart_id"]],
|
|
"charts": [chart_context(first), chart_context(second)],
|
|
"intersection": overlap,
|
|
}
|
|
)
|
|
|
|
sheet_width = column_edges[-1]
|
|
sheet_height = row_edges[-1]
|
|
out_of_bounds: list[dict[str, Any]] = []
|
|
content_overlaps: list[dict[str, Any]] = []
|
|
covered_items: list[tuple[dict[str, Any], CellBounds]] = []
|
|
for rectangle in rectangles:
|
|
overflow = {
|
|
"left": round(max(0.0, -rectangle["left"]), 2),
|
|
"top": round(max(0.0, -rectangle["top"]), 2),
|
|
"right": round(max(0.0, rectangle["right"] - sheet_width), 2),
|
|
"bottom": round(max(0.0, rectangle["bottom"] - sheet_height), 2),
|
|
}
|
|
if any(overflow.values()):
|
|
out_of_bounds.append({**chart_context(rectangle), "overflow_px": overflow})
|
|
|
|
covered_range = rectangle_cell_range(rectangle, row_edges, column_edges)
|
|
if not covered_range:
|
|
continue
|
|
covered_items.append((rectangle, _parse_a1_bounds(covered_range)))
|
|
|
|
for cluster in _cluster_cell_reads(covered_items):
|
|
read_range = _format_a1_bounds(cluster["bounds"])
|
|
cells_data = _read_cells(
|
|
cell_cache,
|
|
locator,
|
|
sheet_id=sheet_id,
|
|
sheet_name=None,
|
|
cell_range=read_range,
|
|
include="value,formula,comment",
|
|
timeout=timeout,
|
|
)
|
|
for rectangle, bounds in cluster["members"]:
|
|
covered_range = _format_a1_bounds(bounds)
|
|
count, samples, truncated = non_empty_cells(cells_data, sample_limit, bounds)
|
|
if truncated:
|
|
unverifiable.append(
|
|
{
|
|
"chart_id": rectangle["chart_id"],
|
|
"reason": f"cells-get truncated for {read_range}",
|
|
}
|
|
)
|
|
if count:
|
|
content_overlaps.append(
|
|
{
|
|
**chart_context(rectangle),
|
|
"covered_range": covered_range,
|
|
"non_empty_cell_count": count,
|
|
"sample_cells": samples,
|
|
}
|
|
)
|
|
|
|
numeric_source_issues: list[dict[str, Any]] = []
|
|
numeric_source_samples: list[dict[str, Any]] = []
|
|
degenerate_numeric_series: list[dict[str, Any]] = []
|
|
constant_series_issues: list[dict[str, Any]] = []
|
|
unbound_secondary_axes: list[dict[str, Any]] = []
|
|
undersized_charts: list[dict[str, Any]] = []
|
|
overwide_charts: list[dict[str, Any]] = []
|
|
for chart in charts:
|
|
issues, degenerate, source_unverifiable, profiles = _numeric_source_issues(
|
|
chart,
|
|
owner_sheet_id=sheet_id,
|
|
owner_sheet_name=title,
|
|
cache=cell_cache,
|
|
locator=locator,
|
|
timeout=timeout,
|
|
sample_limit=sample_limit,
|
|
)
|
|
numeric_source_issues.extend(issues)
|
|
numeric_source_samples.extend(
|
|
{"chart_id": str(chart.get("chart_id") or chart.get("id") or ""), **profile}
|
|
for profile in profiles
|
|
if "checked_range" in profile
|
|
)
|
|
degenerate_numeric_series.extend(degenerate)
|
|
unverifiable.extend(source_unverifiable)
|
|
constant_series_issues.extend(_constant_labeled_series(chart, profiles))
|
|
unbound_secondary_axis = _unbound_secondary_axis(chart)
|
|
if unbound_secondary_axis:
|
|
unbound_secondary_axes.append(unbound_secondary_axis)
|
|
undersized = _undersized_chart(chart)
|
|
if undersized:
|
|
undersized_charts.append(undersized)
|
|
overwide = _overwide_chart(chart)
|
|
if overwide:
|
|
overwide_charts.append(overwide)
|
|
|
|
issue_count = (
|
|
len(overlaps)
|
|
+ len(out_of_bounds)
|
|
+ len(content_overlaps)
|
|
+ len(numeric_source_issues)
|
|
+ len(degenerate_numeric_series)
|
|
+ len(constant_series_issues)
|
|
+ len(unbound_secondary_axes)
|
|
+ len(undersized_charts)
|
|
+ len(overwide_charts)
|
|
)
|
|
return {
|
|
"sheet_id": sheet_id,
|
|
"sheet_name": title,
|
|
"chart_count": len(charts),
|
|
"sheet_size_px": {"width": round(sheet_width, 2), "height": round(sheet_height, 2)},
|
|
"chart_overlaps": overlaps,
|
|
"cell_content_overlaps": content_overlaps,
|
|
"numeric_source_format_issues": numeric_source_issues,
|
|
"numeric_source_samples": numeric_source_samples,
|
|
"degenerate_numeric_series": degenerate_numeric_series,
|
|
"constant_labeled_series": constant_series_issues,
|
|
"unbound_secondary_axes": unbound_secondary_axes,
|
|
"undersized_charts": undersized_charts,
|
|
"overwide_charts": overwide_charts,
|
|
"out_of_visible_range": out_of_bounds,
|
|
"unverifiable_charts": unverifiable,
|
|
"issue_count": issue_count,
|
|
"unverifiable_count": len(unverifiable),
|
|
"warnings": warnings,
|
|
}
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Check chart overlap, covered cell content, worksheet boundary overflow, "
|
|
"minimum size, excessive width, constant labeled series, numeric source-cell "
|
|
"formats, all-zero/empty numeric series, and unbound combo-chart secondary axes."
|
|
)
|
|
)
|
|
parser.add_argument("sheet_id", help="Spreadsheet URL or spreadsheet token")
|
|
parser.add_argument("--worksheet-id", help="Only check this worksheet reference_id")
|
|
parser.add_argument("--timeout", type=int, default=60)
|
|
parser.add_argument("--sample-limit", type=int, default=10)
|
|
return parser.parse_args()
|
|
|
|
|
|
def success_envelope(results: list[dict[str, Any]]) -> dict[str, Any]:
|
|
issue_count = sum(result["issue_count"] for result in results)
|
|
unverifiable_count = sum(result["unverifiable_count"] for result in results)
|
|
warnings = [
|
|
f"{result['sheet_name'] or result['sheet_id']}: {warning}"
|
|
for result in results
|
|
for warning in result["warnings"]
|
|
]
|
|
return {
|
|
"ok": True,
|
|
"engine": "lark",
|
|
"action": ACTION,
|
|
"data": {
|
|
"passed": issue_count == 0 and unverifiable_count == 0,
|
|
"scope_note": (
|
|
"out_of_visible_range checks worksheet drawable bounds, not a device-specific browser viewport; "
|
|
"numeric source checks sample at most the first 50 data points of each chart value dimension "
|
|
"for formats, all-zero/empty, and constant-value checks; zero/constant results are conclusive "
|
|
"only when that sample covers the complete series, otherwise they are marked unverifiable"
|
|
),
|
|
"summary": {
|
|
"worksheet_count": len(results),
|
|
"chart_count": sum(result["chart_count"] for result in results),
|
|
"issue_count": issue_count,
|
|
"unverifiable_count": unverifiable_count,
|
|
},
|
|
"sheets": results,
|
|
},
|
|
"warnings": warnings,
|
|
}
|
|
|
|
|
|
def report_exit_code(report: dict[str, Any]) -> int:
|
|
if report["data"]["passed"]:
|
|
return 0
|
|
if report["data"]["summary"]["issue_count"] > 0:
|
|
return 2
|
|
return 1
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
locator = _locator(args.sheet_id)
|
|
cell_cache: CellCache = {}
|
|
try:
|
|
workbook_data = envelope_data(
|
|
run_sheets("+workbook-info", **locator, timeout=args.timeout)
|
|
)
|
|
sheets = resolve_target_sheets(workbook_data, sheet_id=args.worksheet_id)
|
|
if not args.worksheet_id:
|
|
sheets = [sheet for sheet in sheets if not bool(sheet.get("is_hidden"))]
|
|
if not sheets:
|
|
raise LarkCliError("No visible worksheet matched")
|
|
results = [
|
|
check_sheet(
|
|
locator,
|
|
sheet,
|
|
timeout=args.timeout,
|
|
sample_limit=args.sample_limit,
|
|
cell_cache=cell_cache,
|
|
)
|
|
for sheet in sheets
|
|
]
|
|
except (LarkCliError, KeyError, TypeError, ValueError) as exc:
|
|
emit_error(ACTION, str(exc))
|
|
raise SystemExit(1) from exc
|
|
|
|
report = success_envelope(results)
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
exit_code = report_exit_code(report)
|
|
if exit_code:
|
|
raise SystemExit(exit_code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|