Files
lark-sheets/scripts/lark_chart_quality_check.py

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()