1523 lines
55 KiB
Python
1523 lines
55 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
PPT Master - SVG Position Calculation and Validation Tool
|
||
|
||
Provides pre-calculation and post-validation of chart coordinates,
|
||
outputting clear coordinate tables.
|
||
|
||
======================================================================
|
||
Common Commands (can be copied and used directly)
|
||
======================================================================
|
||
|
||
1. Analyze all coordinates in an SVG file:
|
||
python scripts/svg_position_calculator.py analyze <svg_file>
|
||
|
||
2. Interactive calculation mode:
|
||
python scripts/svg_position_calculator.py interactive
|
||
|
||
3. Calculate from JSON config file:
|
||
python scripts/svg_position_calculator.py from-json <config.json>
|
||
|
||
4. Quick calculation:
|
||
python scripts/svg_position_calculator.py calc bar --data "East:185,South:142"
|
||
python scripts/svg_position_calculator.py calc pie --data "A:35,B:25,C:20"
|
||
python scripts/svg_position_calculator.py calc line --data "0:50,10:80,20:120"
|
||
python scripts/svg_position_calculator.py calc grid --rows 2 --cols 3
|
||
|
||
======================================================================
|
||
"""
|
||
|
||
import sys
|
||
import re
|
||
import math
|
||
import argparse
|
||
from pathlib import Path
|
||
from typing import Dict, List, Tuple, Optional, Any
|
||
from dataclasses import dataclass
|
||
|
||
from console_encoding import configure_utf8_stdio
|
||
|
||
configure_utf8_stdio()
|
||
|
||
# Import canvas format configuration
|
||
try:
|
||
from project_utils import CANVAS_FORMATS
|
||
except ImportError:
|
||
# Use built-in definitions if import fails
|
||
CANVAS_FORMATS = {
|
||
'ppt169': {'name': 'PPT 16:9', 'dimensions': '1280×720', 'viewbox': '0 0 1280 720'},
|
||
'ppt43': {'name': 'PPT 4:3', 'dimensions': '1024×768', 'viewbox': '0 0 1024 768'},
|
||
'xiaohongshu': {'name': 'Xiaohongshu (RED)', 'dimensions': '1242×1660', 'viewbox': '0 0 1242 1660'},
|
||
'moments': {'name': 'WeChat Moments', 'dimensions': '1080×1080', 'viewbox': '0 0 1080 1080'},
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# Coordinate System Base Classes
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class ChartArea:
|
||
"""Chart area definition"""
|
||
x_min: float
|
||
y_min: float
|
||
x_max: float
|
||
y_max: float
|
||
|
||
@property
|
||
def width(self) -> float:
|
||
return self.x_max - self.x_min
|
||
|
||
@property
|
||
def height(self) -> float:
|
||
return self.y_max - self.y_min
|
||
|
||
@property
|
||
def center(self) -> Tuple[float, float]:
|
||
return ((self.x_min + self.x_max) / 2, (self.y_min + self.y_max) / 2)
|
||
|
||
|
||
class CoordinateSystem:
|
||
"""Coordinate system - maps data domain to SVG canvas coordinates"""
|
||
|
||
def __init__(self, canvas_format: str = 'ppt169', chart_area: Optional[ChartArea] = None):
|
||
"""
|
||
Initialize the coordinate system
|
||
|
||
Args:
|
||
canvas_format: Canvas format (ppt169, ppt43, xiaohongshu, moments, etc.)
|
||
chart_area: Chart area; uses default values if not specified
|
||
"""
|
||
self.canvas_format = canvas_format
|
||
|
||
# Parse canvas dimensions
|
||
if canvas_format in CANVAS_FORMATS:
|
||
viewbox = CANVAS_FORMATS[canvas_format]['viewbox']
|
||
parts = viewbox.split()
|
||
self.canvas_width = int(parts[2])
|
||
self.canvas_height = int(parts[3])
|
||
else:
|
||
self.canvas_width = 1280
|
||
self.canvas_height = 720
|
||
|
||
# Set chart area (default with margins)
|
||
if chart_area:
|
||
self.chart_area = chart_area
|
||
else:
|
||
# Default chart area: left/right margin 140px, top/bottom margin 150px
|
||
self.chart_area = ChartArea(
|
||
x_min=140,
|
||
y_min=150,
|
||
x_max=self.canvas_width - 120,
|
||
y_max=self.canvas_height - 120
|
||
)
|
||
|
||
def data_to_svg_x(self, data_x: float, x_range: Tuple[float, float]) -> float:
|
||
"""
|
||
Map data X value to SVG X coordinate
|
||
|
||
Args:
|
||
data_x: Data X value
|
||
x_range: X axis data range (min, max)
|
||
"""
|
||
x_min, x_max = x_range
|
||
if x_max == x_min:
|
||
return self.chart_area.x_min
|
||
|
||
ratio = (data_x - x_min) / (x_max - x_min)
|
||
return self.chart_area.x_min + ratio * self.chart_area.width
|
||
|
||
def data_to_svg_y(self, data_y: float, y_range: Tuple[float, float]) -> float:
|
||
"""
|
||
Map data Y value to SVG Y coordinate (note: SVG Y axis points downward)
|
||
|
||
Args:
|
||
data_y: Data Y value
|
||
y_range: Y axis data range (min, max)
|
||
"""
|
||
y_min, y_max = y_range
|
||
if y_max == y_min:
|
||
return self.chart_area.y_max
|
||
|
||
ratio = (data_y - y_min) / (y_max - y_min)
|
||
# SVG Y axis points downward, so invert
|
||
return self.chart_area.y_max - ratio * self.chart_area.height
|
||
|
||
def data_to_svg(self, data_x: float, data_y: float,
|
||
x_range: Tuple[float, float], y_range: Tuple[float, float]) -> Tuple[float, float]:
|
||
"""Map data point to SVG coordinates"""
|
||
return (self.data_to_svg_x(data_x, x_range), self.data_to_svg_y(data_y, y_range))
|
||
|
||
|
||
# =============================================================================
|
||
# Bar Chart Calculator
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class BarPosition:
|
||
"""Bar position information"""
|
||
index: int
|
||
label: str
|
||
value: float
|
||
x: float
|
||
y: float
|
||
width: float
|
||
height: float
|
||
label_x: float # Label X position
|
||
label_y: float # Label Y position (below bar)
|
||
value_x: float # Value X position
|
||
value_y: float # Value Y position (above bar)
|
||
|
||
|
||
class BarChartCalculator:
|
||
"""Bar chart coordinate calculator"""
|
||
|
||
def __init__(self, coord_system: CoordinateSystem):
|
||
self.coord = coord_system
|
||
|
||
def calculate(self, data: Dict[str, float],
|
||
bar_width: float = 50,
|
||
gap_ratio: float = 0.3,
|
||
y_min: float = 0,
|
||
y_max: Optional[float] = None,
|
||
horizontal: bool = False) -> List[BarPosition]:
|
||
"""
|
||
Calculate bar chart positions
|
||
|
||
Args:
|
||
data: Data dictionary {label: value}
|
||
bar_width: Bar width (auto-calculated if None)
|
||
gap_ratio: Gap ratio between bars (relative to bar width)
|
||
y_min: Y axis minimum value
|
||
y_max: Y axis maximum value (uses data maximum if None)
|
||
horizontal: Whether to use horizontal bar chart
|
||
"""
|
||
labels = list(data.keys())
|
||
values = list(data.values())
|
||
n = len(labels)
|
||
|
||
if n == 0:
|
||
return []
|
||
|
||
# Calculate Y axis range
|
||
if y_max is None:
|
||
y_max = max(values) * 1.1 # Leave 10% headroom
|
||
|
||
area = self.coord.chart_area
|
||
|
||
if horizontal:
|
||
# Horizontal bar chart
|
||
return self._calculate_horizontal(labels, values, bar_width, gap_ratio, y_min, y_max)
|
||
|
||
# Calculate bar layout
|
||
total_width = area.width
|
||
if bar_width is None:
|
||
# Auto-calculate bar width: total width / (bar count * (1 + gap ratio))
|
||
bar_width = total_width / (n * (1 + gap_ratio))
|
||
|
||
gap = bar_width * gap_ratio
|
||
total_bars_width = n * bar_width + (n - 1) * gap
|
||
start_x = area.x_min + (area.width - total_bars_width) / 2
|
||
|
||
results = []
|
||
for i, (label, value) in enumerate(zip(labels, values)):
|
||
# Bar X position
|
||
x = start_x + i * (bar_width + gap)
|
||
|
||
# Bar height and Y position
|
||
ratio = (value - y_min) / (y_max - y_min) if y_max > y_min else 0
|
||
height = ratio * area.height
|
||
y = area.y_max - height # SVG Y axis points downward
|
||
|
||
# Label and value positions
|
||
center_x = x + bar_width / 2
|
||
|
||
results.append(BarPosition(
|
||
index=i + 1,
|
||
label=label,
|
||
value=value,
|
||
x=round(x, 1),
|
||
y=round(y, 1),
|
||
width=round(bar_width, 1),
|
||
height=round(height, 1),
|
||
label_x=round(center_x, 1),
|
||
label_y=round(area.y_max + 30, 1),
|
||
value_x=round(center_x, 1),
|
||
value_y=round(y - 15, 1)
|
||
))
|
||
|
||
return results
|
||
|
||
def _calculate_horizontal(self, labels: List[str], values: List[float],
|
||
bar_height: float, gap_ratio: float,
|
||
x_min: float, x_max: float) -> List[BarPosition]:
|
||
"""Calculate horizontal bar chart"""
|
||
n = len(labels)
|
||
area = self.coord.chart_area
|
||
|
||
if bar_height is None:
|
||
bar_height = area.height / (n * (1 + gap_ratio))
|
||
|
||
gap = bar_height * gap_ratio
|
||
total_bars_height = n * bar_height + (n - 1) * gap
|
||
start_y = area.y_min + (area.height - total_bars_height) / 2
|
||
|
||
results = []
|
||
for i, (label, value) in enumerate(zip(labels, values)):
|
||
y = start_y + i * (bar_height + gap)
|
||
|
||
ratio = (value - x_min) / (x_max - x_min) if x_max > x_min else 0
|
||
width = ratio * area.width
|
||
x = area.x_min
|
||
|
||
center_y = y + bar_height / 2
|
||
|
||
results.append(BarPosition(
|
||
index=i + 1,
|
||
label=label,
|
||
value=value,
|
||
x=round(x, 1),
|
||
y=round(y, 1),
|
||
width=round(width, 1),
|
||
height=round(bar_height, 1),
|
||
label_x=round(area.x_min - 10, 1),
|
||
label_y=round(center_y, 1),
|
||
value_x=round(x + width + 10, 1),
|
||
value_y=round(center_y, 1)
|
||
))
|
||
|
||
return results
|
||
|
||
def format_table(self, positions: List[BarPosition]) -> str:
|
||
"""Format as table output"""
|
||
lines = []
|
||
lines.append("Index Label Value X Y Width Height")
|
||
lines.append("---- ---------- -------- ------- ------- ------- -------")
|
||
|
||
for p in positions:
|
||
lines.append(f"{p.index:4d} {p.label:<10s} {p.value:>8.1f} {p.x:>7.1f} {p.y:>7.1f} {p.width:>7.1f} {p.height:>7.1f}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# =============================================================================
|
||
# Pie / Donut Chart Calculator
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class PieSlice:
|
||
"""Pie chart slice information"""
|
||
index: int
|
||
label: str
|
||
value: float
|
||
percentage: float
|
||
start_angle: float # Start angle (degrees)
|
||
end_angle: float # End angle (degrees)
|
||
path_d: str # SVG path d attribute
|
||
label_x: float # Label X position
|
||
label_y: float # Label Y position
|
||
# Arc endpoint coordinates (relative to center)
|
||
start_x: float
|
||
start_y: float
|
||
end_x: float
|
||
end_y: float
|
||
|
||
|
||
class PieChartCalculator:
|
||
"""Pie / donut chart calculator"""
|
||
|
||
def __init__(self, center: Tuple[float, float] = (420, 400), radius: float = 200):
|
||
self.cx, self.cy = center
|
||
self.radius = radius
|
||
|
||
def calculate(self, data: Dict[str, float],
|
||
start_angle: float = -90,
|
||
inner_radius: float = 0) -> List[PieSlice]:
|
||
"""
|
||
Calculate pie chart slices
|
||
|
||
Args:
|
||
data: Data dictionary {label: value}
|
||
start_angle: Start angle (degrees, -90 means starting from 12 o'clock)
|
||
inner_radius: Inner radius (0 for pie chart, > 0 for donut chart)
|
||
"""
|
||
labels = list(data.keys())
|
||
values = list(data.values())
|
||
total = sum(values)
|
||
|
||
if total == 0:
|
||
return []
|
||
|
||
results = []
|
||
current_angle = start_angle
|
||
|
||
for i, (label, value) in enumerate(zip(labels, values)):
|
||
percentage = value / total * 100
|
||
angle_span = value / total * 360
|
||
end_angle = current_angle + angle_span
|
||
|
||
# Calculate arc endpoints
|
||
start_rad = math.radians(current_angle)
|
||
end_rad = math.radians(end_angle)
|
||
|
||
start_x = self.radius * math.cos(start_rad)
|
||
start_y = self.radius * math.sin(start_rad)
|
||
end_x = self.radius * math.cos(end_rad)
|
||
end_y = self.radius * math.sin(end_rad)
|
||
|
||
# Generate path
|
||
large_arc = 1 if angle_span > 180 else 0
|
||
|
||
if inner_radius > 0:
|
||
# Donut chart
|
||
inner_start_x = inner_radius * math.cos(start_rad)
|
||
inner_start_y = inner_radius * math.sin(start_rad)
|
||
inner_end_x = inner_radius * math.cos(end_rad)
|
||
inner_end_y = inner_radius * math.sin(end_rad)
|
||
|
||
path_d = (
|
||
f"M {inner_start_x:.2f},{inner_start_y:.2f} "
|
||
f"L {start_x:.2f},{start_y:.2f} "
|
||
f"A {self.radius},{self.radius} 0 {large_arc},1 {end_x:.2f},{end_y:.2f} "
|
||
f"L {inner_end_x:.2f},{inner_end_y:.2f} "
|
||
f"A {inner_radius},{inner_radius} 0 {large_arc},0 {inner_start_x:.2f},{inner_start_y:.2f} Z"
|
||
)
|
||
else:
|
||
# Pie chart
|
||
path_d = (
|
||
f"M 0,0 "
|
||
f"L {start_x:.2f},{start_y:.2f} "
|
||
f"A {self.radius},{self.radius} 0 {large_arc},1 {end_x:.2f},{end_y:.2f} Z"
|
||
)
|
||
|
||
# Label position (70% of radius in the direction of slice center)
|
||
mid_angle = (current_angle + end_angle) / 2
|
||
mid_rad = math.radians(mid_angle)
|
||
label_distance = self.radius * 0.7
|
||
label_x = self.cx + label_distance * math.cos(mid_rad)
|
||
label_y = self.cy + label_distance * math.sin(mid_rad)
|
||
|
||
results.append(PieSlice(
|
||
index=i + 1,
|
||
label=label,
|
||
value=value,
|
||
percentage=round(percentage, 1),
|
||
start_angle=round(current_angle, 1),
|
||
end_angle=round(end_angle, 1),
|
||
path_d=path_d,
|
||
label_x=round(label_x, 1),
|
||
label_y=round(label_y, 1),
|
||
start_x=round(start_x, 2),
|
||
start_y=round(start_y, 2),
|
||
end_x=round(end_x, 2),
|
||
end_y=round(end_y, 2)
|
||
))
|
||
|
||
current_angle = end_angle
|
||
|
||
return results
|
||
|
||
def format_table(self, slices: List[PieSlice]) -> str:
|
||
"""Format as table output"""
|
||
lines = []
|
||
lines.append(f"Center: ({self.cx}, {self.cy}) | Radius: {self.radius}")
|
||
lines.append("")
|
||
lines.append("Index Label Pct Start End LabelX LabelY")
|
||
lines.append("---- ---------- -------- -------- -------- ------- -------")
|
||
|
||
for s in slices:
|
||
lines.append(
|
||
f"{s.index:4d} {s.label:<10s} {s.percentage:>6.1f}% {s.start_angle:>8.1f} "
|
||
f"{s.end_angle:>8.1f} {s.label_x:>7.1f} {s.label_y:>7.1f}"
|
||
)
|
||
|
||
lines.append("")
|
||
lines.append("=== Arc Endpoint Coordinates (relative to center) ===")
|
||
lines.append("Index StartX StartY EndX EndY")
|
||
lines.append("---- --------- --------- --------- ---------")
|
||
|
||
for s in slices:
|
||
lines.append(
|
||
f"{s.index:4d} {s.start_x:>9.2f} {s.start_y:>9.2f} {s.end_x:>9.2f} {s.end_y:>9.2f}"
|
||
)
|
||
|
||
lines.append("")
|
||
lines.append("=== Path d Attribute ===")
|
||
for s in slices:
|
||
lines.append(f"{s.index}. {s.label}: {s.path_d}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# =============================================================================
|
||
# Radar Chart Calculator
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class RadarPoint:
|
||
"""Radar chart data point"""
|
||
index: int
|
||
label: str
|
||
value: float
|
||
percentage: float # Percentage relative to max value
|
||
angle: float # Angle (degrees)
|
||
x: float # X relative to center
|
||
y: float # Y relative to center
|
||
abs_x: float # Absolute X coordinate
|
||
abs_y: float # Absolute Y coordinate
|
||
label_x: float # Label X position
|
||
label_y: float # Label Y position
|
||
|
||
|
||
class RadarChartCalculator:
|
||
"""Radar chart calculator"""
|
||
|
||
def __init__(self, center: Tuple[float, float] = (640, 400), radius: float = 200):
|
||
self.cx, self.cy = center
|
||
self.radius = radius
|
||
|
||
def calculate(self, data: Dict[str, float],
|
||
max_value: Optional[float] = None,
|
||
start_angle: float = -90) -> List[RadarPoint]:
|
||
"""
|
||
Calculate radar chart vertex coordinates
|
||
|
||
Args:
|
||
data: Data dictionary {dimension_name: value}
|
||
max_value: Maximum value (for normalization); uses data maximum if None
|
||
start_angle: Start angle (degrees, -90 means starting from 12 o'clock)
|
||
"""
|
||
labels = list(data.keys())
|
||
values = list(data.values())
|
||
n = len(labels)
|
||
|
||
if n == 0:
|
||
return []
|
||
|
||
if max_value is None:
|
||
max_value = max(values)
|
||
|
||
angle_step = 360 / n
|
||
results = []
|
||
|
||
for i, (label, value) in enumerate(zip(labels, values)):
|
||
angle = start_angle + i * angle_step
|
||
rad = math.radians(angle)
|
||
|
||
# Calculate normalized radius
|
||
percentage = (value / max_value * 100) if max_value > 0 else 0
|
||
point_radius = self.radius * (value / max_value) if max_value > 0 else 0
|
||
|
||
# Calculate coordinates
|
||
x = point_radius * math.cos(rad)
|
||
y = point_radius * math.sin(rad)
|
||
|
||
# Label position (outside the outermost ring)
|
||
label_distance = self.radius + 30
|
||
label_x = self.cx + label_distance * math.cos(rad)
|
||
label_y = self.cy + label_distance * math.sin(rad)
|
||
|
||
results.append(RadarPoint(
|
||
index=i + 1,
|
||
label=label,
|
||
value=value,
|
||
percentage=round(percentage, 1),
|
||
angle=round(angle, 1),
|
||
x=round(x, 2),
|
||
y=round(y, 2),
|
||
abs_x=round(self.cx + x, 2),
|
||
abs_y=round(self.cy + y, 2),
|
||
label_x=round(label_x, 1),
|
||
label_y=round(label_y, 1)
|
||
))
|
||
|
||
return results
|
||
|
||
def calculate_grid(self, levels: int = 5) -> List[List[Tuple[float, float]]]:
|
||
"""Calculate grid layer coordinates (for drawing background polygons)"""
|
||
n = 6 # Assume 6 dimensions
|
||
grids = []
|
||
|
||
for level in range(1, levels + 1):
|
||
level_radius = self.radius * level / levels
|
||
points = []
|
||
|
||
angle_step = 360 / n
|
||
for i in range(n):
|
||
angle = -90 + i * angle_step
|
||
rad = math.radians(angle)
|
||
x = level_radius * math.cos(rad)
|
||
y = level_radius * math.sin(rad)
|
||
points.append((round(x, 2), round(y, 2)))
|
||
|
||
grids.append(points)
|
||
|
||
return grids
|
||
|
||
def format_table(self, points: List[RadarPoint]) -> str:
|
||
"""Format as table output"""
|
||
lines = []
|
||
lines.append(f"Center: ({self.cx}, {self.cy}) | Radius: {self.radius}")
|
||
lines.append("")
|
||
lines.append("Index Dimension Value Pct Angle X Y Abs_X Abs_Y")
|
||
lines.append("---- ---------- ------ -------- ------ ------- ------- ------- -------")
|
||
|
||
for p in points:
|
||
lines.append(
|
||
f"{p.index:4d} {p.label:<10s} {p.value:>6.1f} {p.percentage:>6.1f}% "
|
||
f"{p.angle:>6.1f} {p.x:>7.2f} {p.y:>7.2f} {p.abs_x:>7.1f} {p.abs_y:>7.1f}"
|
||
)
|
||
|
||
# Generate polygon points attribute
|
||
lines.append("")
|
||
lines.append("=== SVG Polygon Points ===")
|
||
points_str = " ".join([f"{p.x},{p.y}" for p in points])
|
||
lines.append(f'points="{points_str}"')
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# =============================================================================
|
||
# Line / Scatter Chart Calculator
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class DataPoint:
|
||
"""Data point"""
|
||
index: int
|
||
x_value: float
|
||
y_value: float
|
||
svg_x: float
|
||
svg_y: float
|
||
label: Optional[str] = None
|
||
|
||
|
||
class LineChartCalculator:
|
||
"""Line / scatter chart calculator"""
|
||
|
||
def __init__(self, coord_system: CoordinateSystem):
|
||
self.coord = coord_system
|
||
|
||
def calculate(self, data: List[Tuple[float, float]],
|
||
x_range: Optional[Tuple[float, float]] = None,
|
||
y_range: Optional[Tuple[float, float]] = None,
|
||
labels: Optional[List[str]] = None) -> List[DataPoint]:
|
||
"""
|
||
Calculate data point coordinates
|
||
|
||
Args:
|
||
data: Data point list [(x1, y1), (x2, y2), ...]
|
||
x_range: X axis range; auto-calculated if None
|
||
y_range: Y axis range; auto-calculated if None
|
||
labels: Point label list
|
||
"""
|
||
if not data:
|
||
return []
|
||
|
||
x_values = [p[0] for p in data]
|
||
y_values = [p[1] for p in data]
|
||
|
||
if x_range is None:
|
||
x_range = (min(x_values), max(x_values))
|
||
if y_range is None:
|
||
y_min = 0
|
||
y_max = max(y_values) * 1.1
|
||
y_range = (y_min, y_max)
|
||
|
||
results = []
|
||
for i, (x, y) in enumerate(data):
|
||
svg_x, svg_y = self.coord.data_to_svg(x, y, x_range, y_range)
|
||
|
||
results.append(DataPoint(
|
||
index=i + 1,
|
||
x_value=x,
|
||
y_value=y,
|
||
svg_x=round(svg_x, 1),
|
||
svg_y=round(svg_y, 1),
|
||
label=labels[i] if labels and i < len(labels) else None
|
||
))
|
||
|
||
return results
|
||
|
||
def generate_path(self, points: List[DataPoint], closed: bool = False) -> str:
|
||
"""Generate SVG path d attribute"""
|
||
if not points:
|
||
return ""
|
||
|
||
parts = [f"M {points[0].svg_x},{points[0].svg_y}"]
|
||
for p in points[1:]:
|
||
parts.append(f"L {p.svg_x},{p.svg_y}")
|
||
|
||
if closed:
|
||
parts.append("Z")
|
||
|
||
return " ".join(parts)
|
||
|
||
def format_table(self, points: List[DataPoint]) -> str:
|
||
"""Format as table output"""
|
||
lines = []
|
||
area = self.coord.chart_area
|
||
lines.append(f"Chart area: ({area.x_min}, {area.y_min}) - ({area.x_max}, {area.y_max})")
|
||
lines.append("")
|
||
lines.append("Index X_Value Y_Value SVG_X SVG_Y")
|
||
lines.append("---- --------- --------- -------- --------")
|
||
|
||
for p in points:
|
||
label_part = f" ({p.label})" if p.label else ""
|
||
lines.append(
|
||
f"{p.index:4d} {p.x_value:>9.2f} {p.y_value:>9.2f} {p.svg_x:>8.1f} {p.svg_y:>8.1f}{label_part}"
|
||
)
|
||
|
||
lines.append("")
|
||
lines.append("=== SVG Path ===")
|
||
lines.append(self.generate_path(points))
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# =============================================================================
|
||
# Grid Layout Calculator
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class GridCell:
|
||
"""Grid cell"""
|
||
row: int
|
||
col: int
|
||
index: int # 1-based index
|
||
x: float
|
||
y: float
|
||
width: float
|
||
height: float
|
||
center_x: float
|
||
center_y: float
|
||
|
||
|
||
class GridLayoutCalculator:
|
||
"""Grid layout calculator"""
|
||
|
||
def __init__(self, coord_system: CoordinateSystem):
|
||
self.coord = coord_system
|
||
|
||
def calculate(self, rows: int, cols: int,
|
||
padding: float = 20,
|
||
gap: float = 20) -> List[GridCell]:
|
||
"""
|
||
Calculate grid layout
|
||
|
||
Args:
|
||
rows: Number of rows
|
||
cols: Number of columns
|
||
padding: Chart area inner padding
|
||
gap: Cell spacing
|
||
"""
|
||
area = self.coord.chart_area
|
||
|
||
# Calculate available area
|
||
available_width = area.width - 2 * padding - (cols - 1) * gap
|
||
available_height = area.height - 2 * padding - (rows - 1) * gap
|
||
|
||
cell_width = available_width / cols
|
||
cell_height = available_height / rows
|
||
|
||
results = []
|
||
index = 1
|
||
|
||
for row in range(rows):
|
||
for col in range(cols):
|
||
x = area.x_min + padding + col * (cell_width + gap)
|
||
y = area.y_min + padding + row * (cell_height + gap)
|
||
|
||
results.append(GridCell(
|
||
row=row + 1,
|
||
col=col + 1,
|
||
index=index,
|
||
x=round(x, 1),
|
||
y=round(y, 1),
|
||
width=round(cell_width, 1),
|
||
height=round(cell_height, 1),
|
||
center_x=round(x + cell_width / 2, 1),
|
||
center_y=round(y + cell_height / 2, 1)
|
||
))
|
||
index += 1
|
||
|
||
return results
|
||
|
||
def format_table(self, cells: List[GridCell]) -> str:
|
||
"""Format as table output"""
|
||
lines = []
|
||
area = self.coord.chart_area
|
||
lines.append(f"Chart area: ({area.x_min}, {area.y_min}) - ({area.x_max}, {area.y_max})")
|
||
lines.append("")
|
||
lines.append("Index Row Col X Y Width Height CenterX CenterY")
|
||
lines.append("---- ---- ---- ------- ------- ------- ------- ------- -------")
|
||
|
||
for c in cells:
|
||
lines.append(
|
||
f"{c.index:4d} {c.row:4d} {c.col:4d} {c.x:>7.1f} {c.y:>7.1f} "
|
||
f"{c.width:>7.1f} {c.height:>7.1f} {c.center_x:>7.1f} {c.center_y:>7.1f}"
|
||
)
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# =============================================================================
|
||
# SVG Validator
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class ValidationResult:
|
||
"""Validation result"""
|
||
element_type: str
|
||
element_id: str
|
||
attribute: str
|
||
expected: float
|
||
actual: float
|
||
deviation: float
|
||
passed: bool
|
||
|
||
|
||
class SVGPositionValidator:
|
||
"""SVG position validator"""
|
||
|
||
def __init__(self, tolerance: float = 1.0):
|
||
"""
|
||
Initialize the validator
|
||
|
||
Args:
|
||
tolerance: Allowed deviation (pixels)
|
||
"""
|
||
self.tolerance = tolerance
|
||
|
||
def validate_from_file(self, svg_file: str,
|
||
expected_coords: Dict[str, Dict[str, float]]) -> List[ValidationResult]:
|
||
"""
|
||
Validate coordinates from file
|
||
|
||
Args:
|
||
svg_file: SVG file path
|
||
expected_coords: Expected coordinates {element_ID: {attribute: value}}
|
||
"""
|
||
svg_path = Path(svg_file)
|
||
if not svg_path.exists():
|
||
raise FileNotFoundError(f"SVG file does not exist: {svg_file}")
|
||
|
||
with open(svg_path, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
|
||
return self.validate_content(content, expected_coords)
|
||
|
||
def validate_content(self, svg_content: str,
|
||
expected_coords: Dict[str, Dict[str, float]]) -> List[ValidationResult]:
|
||
"""Validate coordinates in SVG content"""
|
||
results = []
|
||
|
||
for element_id, attrs in expected_coords.items():
|
||
for attr, expected in attrs.items():
|
||
actual = self._extract_attribute(svg_content, element_id, attr)
|
||
|
||
if actual is not None:
|
||
deviation = abs(actual - expected)
|
||
passed = deviation <= self.tolerance
|
||
|
||
results.append(ValidationResult(
|
||
element_type=self._guess_element_type(element_id),
|
||
element_id=element_id,
|
||
attribute=attr,
|
||
expected=expected,
|
||
actual=actual,
|
||
deviation=round(deviation, 2),
|
||
passed=passed
|
||
))
|
||
else:
|
||
results.append(ValidationResult(
|
||
element_type=self._guess_element_type(element_id),
|
||
element_id=element_id,
|
||
attribute=attr,
|
||
expected=expected,
|
||
actual=float('nan'),
|
||
deviation=float('inf'),
|
||
passed=False
|
||
))
|
||
|
||
return results
|
||
|
||
def _extract_attribute(self, content: str, element_id: str, attr: str) -> Optional[float]:
|
||
"""Extract attribute value from SVG content"""
|
||
pattern = rf'<[^>]*(?<![\w:-])id\s*=\s*([\'"]){re.escape(element_id)}\1[^>]*>'
|
||
match = re.search(pattern, content)
|
||
if match:
|
||
value = extract_attr(match.group(0), attr)
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return float(value)
|
||
except ValueError:
|
||
return None
|
||
|
||
return None
|
||
|
||
def _guess_element_type(self, element_id: str) -> str:
|
||
"""Guess element type based on ID"""
|
||
id_lower = element_id.lower()
|
||
if 'bar' in id_lower or 'rect' in id_lower:
|
||
return 'rect'
|
||
elif 'circle' in id_lower or 'dot' in id_lower:
|
||
return 'circle'
|
||
elif 'path' in id_lower or 'slice' in id_lower:
|
||
return 'path'
|
||
elif 'line' in id_lower:
|
||
return 'line'
|
||
elif 'text' in id_lower or 'label' in id_lower:
|
||
return 'text'
|
||
return 'unknown'
|
||
|
||
def extract_all_positions(self, svg_content: str) -> Dict[str, Dict[str, float]]:
|
||
"""Extract position information of all elements in SVG"""
|
||
positions = {}
|
||
|
||
# Extract rect elements
|
||
for match in re.finditer(r'<rect[^>]*/?>', svg_content):
|
||
elem = match.group(0)
|
||
x = extract_attr(elem, 'x')
|
||
y = extract_attr(elem, 'y')
|
||
if x is None or y is None:
|
||
continue
|
||
id_val = extract_attr(elem, 'id') or f"rect_{len(positions)}"
|
||
try:
|
||
positions[id_val] = {'x': float(x), 'y': float(y)}
|
||
width = extract_attr(elem, 'width')
|
||
height = extract_attr(elem, 'height')
|
||
if width is not None:
|
||
positions[id_val]['width'] = float(width)
|
||
if height is not None:
|
||
positions[id_val]['height'] = float(height)
|
||
except ValueError:
|
||
continue
|
||
|
||
# Extract circle elements
|
||
for match in re.finditer(r'<circle[^>]*/?>', svg_content):
|
||
elem = match.group(0)
|
||
cx = extract_attr(elem, 'cx')
|
||
cy = extract_attr(elem, 'cy')
|
||
if cx is None or cy is None:
|
||
continue
|
||
id_val = extract_attr(elem, 'id') or f"circle_{len(positions)}"
|
||
try:
|
||
positions[id_val] = {'cx': float(cx), 'cy': float(cy)}
|
||
except ValueError:
|
||
continue
|
||
|
||
return positions
|
||
|
||
def format_results(self, results: List[ValidationResult]) -> str:
|
||
"""Format validation results"""
|
||
lines = []
|
||
lines.append("=== SVG Position Validation Results ===")
|
||
lines.append(f"Tolerance: {self.tolerance}px")
|
||
lines.append("")
|
||
lines.append("Status Element_ID Attr Expected Actual Deviation")
|
||
lines.append("---- -------------- ------ -------- -------- ------")
|
||
|
||
passed_count = 0
|
||
for r in results:
|
||
status = "[OK]" if r.passed else "[X]"
|
||
if r.passed:
|
||
passed_count += 1
|
||
|
||
actual_str = f"{r.actual:.1f}" if not math.isnan(r.actual) else "N/A"
|
||
deviation_str = f"{r.deviation:.2f}" if not math.isinf(r.deviation) else "N/A"
|
||
|
||
lines.append(
|
||
f"{status} {r.element_id:<14s} {r.attribute:<6s} "
|
||
f"{r.expected:>8.1f} {actual_str:>8s} {deviation_str:>6s}"
|
||
)
|
||
|
||
lines.append("")
|
||
pct = passed_count / len(results) * 100 if results else 0
|
||
lines.append(f"Passed: {passed_count}/{len(results)} ({pct:.1f}%)")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# =============================================================================
|
||
# Command Line Interface
|
||
# =============================================================================
|
||
|
||
def parse_data_string(data_str: str) -> Dict[str, float]:
|
||
"""Parse data string in 'label1:value1,label2:value2' format"""
|
||
result = {}
|
||
for item in data_str.split(','):
|
||
item = item.strip()
|
||
if not item:
|
||
continue
|
||
if ':' in item:
|
||
label, value = item.split(':', 1)
|
||
try:
|
||
result[label.strip()] = float(value.strip())
|
||
except ValueError:
|
||
print(f"[Warning] Unable to parse value: '{value.strip()}', skipped")
|
||
else:
|
||
print(f"[Warning] Invalid format (expected 'label:value'): '{item}'")
|
||
return result
|
||
|
||
|
||
def parse_xy_data_string(data_str: str) -> List[Tuple[float, float]]:
|
||
"""Parse XY data string in 'x1:y1,x2:y2' format"""
|
||
result = []
|
||
for item in data_str.split(','):
|
||
item = item.strip()
|
||
if not item:
|
||
continue
|
||
if ':' in item:
|
||
x, y = item.split(':', 1)
|
||
try:
|
||
result.append((float(x.strip()), float(y.strip())))
|
||
except ValueError:
|
||
print(f"[Warning] Unable to parse coordinates: '{item}', skipped")
|
||
else:
|
||
print(f"[Warning] Invalid format (expected 'x:y'): '{item}'")
|
||
return result
|
||
|
||
|
||
def parse_tuple(s: str) -> Tuple[float, ...]:
|
||
"""Parse comma-separated numeric tuple"""
|
||
return tuple(float(x.strip()) for x in s.split(','))
|
||
|
||
|
||
def extract_attr(element: str, attr_name: str) -> Optional[str]:
|
||
"""Extract attribute value from element string (attribute order independent)"""
|
||
pattern = rf'(?<![\w:-]){re.escape(attr_name)}\s*=\s*([\'"])(.*?)\1'
|
||
match = re.search(pattern, element)
|
||
return match.group(2) if match else None
|
||
|
||
|
||
def analyze_svg_file(svg_file: str) -> None:
|
||
"""Analyze all chart elements in an SVG file"""
|
||
svg_path = Path(svg_file)
|
||
if not svg_path.exists():
|
||
print(f"[Error] File does not exist: {svg_file}")
|
||
return
|
||
|
||
with open(svg_path, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
|
||
print(f"\n{'='*70}")
|
||
print(f"SVG File Analysis: {svg_path.name}")
|
||
print(f"{'='*70}")
|
||
|
||
# Extract viewBox
|
||
viewbox_match = re.search(r'viewBox\s*=\s*["\']([^"\']+)["\']', content)
|
||
if viewbox_match:
|
||
print(f"Canvas viewBox: {viewbox_match.group(1)}")
|
||
|
||
# Use more robust element extraction (attribute order independent)
|
||
# Extract all rect elements
|
||
rect_elements = re.findall(r'<rect[^>]*/?>', content)
|
||
rects = []
|
||
for elem in rect_elements:
|
||
x = extract_attr(elem, 'x')
|
||
y = extract_attr(elem, 'y')
|
||
w = extract_attr(elem, 'width')
|
||
h = extract_attr(elem, 'height')
|
||
if x is not None and y is not None:
|
||
rects.append((x, y, w, h))
|
||
|
||
# Extract all circle elements
|
||
circle_elements = re.findall(r'<circle[^>]*/?>', content)
|
||
circles = []
|
||
for elem in circle_elements:
|
||
cx = extract_attr(elem, 'cx')
|
||
cy = extract_attr(elem, 'cy')
|
||
r = extract_attr(elem, 'r')
|
||
if cx is not None and cy is not None:
|
||
circles.append((cx, cy, r))
|
||
|
||
# Extract all polyline/polygon elements
|
||
polylines = re.findall(r'<(?:polyline|polygon)[^>]*points="([^"]*)"', content)
|
||
|
||
# Extract path elements
|
||
paths = re.findall(r'<path[^>]*d="([^"]*)"', content)
|
||
|
||
print(f"\nElement statistics:")
|
||
print(f" - rect (rectangle): {len(rects)}")
|
||
print(f" - circle: {len(circles)}")
|
||
print(f" - polyline/polygon: {len(polylines)}")
|
||
print(f" - path: {len(paths)}")
|
||
|
||
# List rect elements in detail
|
||
if rects:
|
||
print(f"\n=== Rectangle Elements (rect) ===")
|
||
print(f"{'Index':<6}{'X':<8} {'Y':<8} {'Width':<8} {'Height':<8}")
|
||
print("-" * 45)
|
||
for i, (x, y, w, h) in enumerate(rects[:20], 1): # Only show first 20
|
||
w_str = w if w else '-'
|
||
h_str = h if h else '-'
|
||
print(f"{i:<6}{x:<8} {y:<8} {w_str:<8} {h_str:<8}")
|
||
if len(rects) > 20:
|
||
print(f"... and {len(rects) - 20} more rectangle(s)")
|
||
|
||
# List circle elements in detail
|
||
if circles:
|
||
print(f"\n=== Circle Elements (circle) ===")
|
||
print(f"{'Index':<6}{'CX':<10} {'CY':<10} {'Radius':<8}")
|
||
print("-" * 40)
|
||
for i, (cx, cy, r) in enumerate(circles[:20], 1):
|
||
r_str = r if r else '-'
|
||
print(f"{i:<6}{cx:<10} {cy:<10} {r_str:<8}")
|
||
if len(circles) > 20:
|
||
print(f"... and {len(circles) - 20} more circle(s)")
|
||
|
||
# List polyline points
|
||
if polylines:
|
||
print(f"\n=== Polyline/Polygon (polyline/polygon) ===")
|
||
for i, points in enumerate(polylines, 1):
|
||
point_list = points.strip().split()
|
||
print(f"\nPolyline {i} ({len(point_list)} points):")
|
||
# Parse and show first few points
|
||
parsed_points = []
|
||
for p in point_list[:5]:
|
||
if ',' in p:
|
||
x, y = p.split(',')
|
||
parsed_points.append(f"({x},{y})")
|
||
print(f" Start points: {' -> '.join(parsed_points)}")
|
||
if len(point_list) > 5:
|
||
print(f" ... {len(point_list)} points total")
|
||
|
||
print(f"\n{'='*70}")
|
||
|
||
|
||
def interactive_mode() -> None:
|
||
"""Interactive calculation mode"""
|
||
print("\n" + "="*60)
|
||
print("SVG Position Calculator - Interactive Mode")
|
||
print("="*60)
|
||
print("\nSelect chart type:")
|
||
print(" 1. Bar chart (bar)")
|
||
print(" 2. Pie chart (pie)")
|
||
print(" 3. Radar chart (radar)")
|
||
print(" 4. Line chart (line)")
|
||
print(" 5. Grid layout (grid)")
|
||
print(" 6. Custom line (custom)")
|
||
print(" 0. Exit")
|
||
|
||
while True:
|
||
try:
|
||
choice = input("\nSelect [1-6, 0 to exit]: ").strip()
|
||
|
||
if choice == '0':
|
||
print("Exiting interactive mode")
|
||
break
|
||
|
||
elif choice == '1':
|
||
print("\n=== Bar Chart Calculation ===")
|
||
data_str = input("Enter data (format: label1:value1,label2:value2): ").strip()
|
||
if not data_str:
|
||
print("Example: East:185,South:142,North:128")
|
||
continue
|
||
|
||
canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169'
|
||
coord = CoordinateSystem(canvas)
|
||
calc = BarChartCalculator(coord)
|
||
data = parse_data_string(data_str)
|
||
positions = calc.calculate(data)
|
||
print()
|
||
print(calc.format_table(positions))
|
||
|
||
elif choice == '2':
|
||
print("\n=== Pie Chart Calculation ===")
|
||
data_str = input("Enter data (format: label1:value1,label2:value2): ").strip()
|
||
if not data_str:
|
||
print("Example: A:35,B:25,C:20,D:12,Other:8")
|
||
continue
|
||
|
||
center_str = input("Center coordinates [420,400]: ").strip() or '420,400'
|
||
radius = float(input("Radius [200]: ").strip() or '200')
|
||
|
||
center = parse_tuple(center_str)
|
||
calc = PieChartCalculator(center, radius)
|
||
data = parse_data_string(data_str)
|
||
slices = calc.calculate(data)
|
||
print()
|
||
print(calc.format_table(slices))
|
||
|
||
elif choice == '3':
|
||
print("\n=== Radar Chart Calculation ===")
|
||
data_str = input("Enter data (format: dim1:value1,dim2:value2): ").strip()
|
||
if not data_str:
|
||
print("Example: Performance:90,Security:85,Usability:75,Price:70")
|
||
continue
|
||
|
||
center_str = input("Center coordinates [640,400]: ").strip() or '640,400'
|
||
radius = float(input("Radius [200]: ").strip() or '200')
|
||
|
||
center = parse_tuple(center_str)
|
||
calc = RadarChartCalculator(center, radius)
|
||
data = parse_data_string(data_str)
|
||
points = calc.calculate(data)
|
||
print()
|
||
print(calc.format_table(points))
|
||
|
||
elif choice == '4':
|
||
print("\n=== Line Chart Calculation ===")
|
||
data_str = input("Enter data (format: x1:y1,x2:y2): ").strip()
|
||
if not data_str:
|
||
print("Example: 0:50,10:80,20:120,30:95")
|
||
continue
|
||
|
||
canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169'
|
||
coord = CoordinateSystem(canvas)
|
||
calc = LineChartCalculator(coord)
|
||
data = parse_xy_data_string(data_str)
|
||
points = calc.calculate(data)
|
||
print()
|
||
print(calc.format_table(points))
|
||
|
||
elif choice == '5':
|
||
print("\n=== Grid Layout Calculation ===")
|
||
rows = int(input("Rows: ").strip() or '2')
|
||
cols = int(input("Columns: ").strip() or '3')
|
||
canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169'
|
||
|
||
coord = CoordinateSystem(canvas)
|
||
calc = GridLayoutCalculator(coord)
|
||
cells = calc.calculate(rows, cols)
|
||
print()
|
||
print(calc.format_table(cells))
|
||
|
||
elif choice == '6':
|
||
print("\n=== Custom Line Calculation ===")
|
||
print("For custom formula line charts, such as price index charts")
|
||
|
||
base_x = float(input("X start value [170]: ").strip() or '170')
|
||
step_x = float(input("X step [40]: ").strip() or '40')
|
||
base_y = float(input("Y baseline [595]: ").strip() or '595')
|
||
scale_y = float(input("Y scale factor [20]: ").strip() or '20')
|
||
ref_value = float(input("Reference baseline value [100]: ").strip() or '100')
|
||
|
||
print(f"\nFormula: X = {base_x} + index * {step_x}")
|
||
print(f" Y = {base_y} - (value - {ref_value}) * {scale_y}")
|
||
|
||
data_str = input("\nEnter data (comma-separated values): ").strip()
|
||
if data_str:
|
||
values = [float(v.strip()) for v in data_str.split(',')]
|
||
print(f"\n{'Index':<6}{'Value':<10} {'X':<8} {'Y':<8}")
|
||
print("-" * 35)
|
||
for i, v in enumerate(values, 1):
|
||
x = base_x + i * step_x
|
||
y = base_y - (v - ref_value) * scale_y
|
||
print(f"{i:<6}{v:<10.1f} {x:<8.0f} {y:<8.0f}")
|
||
|
||
# Generate polyline points
|
||
points_list = []
|
||
for i, v in enumerate(values, 1):
|
||
x = base_x + i * step_x
|
||
y = base_y - (v - ref_value) * scale_y
|
||
points_list.append(f"{int(x)},{int(y)}")
|
||
print(f"\npolyline points:")
|
||
print(" ".join(points_list))
|
||
|
||
else:
|
||
print("Invalid selection, please enter 1-6 or 0")
|
||
|
||
except KeyboardInterrupt:
|
||
print("\nExiting interactive mode")
|
||
break
|
||
except Exception as e:
|
||
print(f"Error: {e}")
|
||
|
||
|
||
def from_json_config(config_file: str) -> None:
|
||
"""Read and calculate from JSON config file"""
|
||
import json
|
||
|
||
config_path = Path(config_file)
|
||
if not config_path.exists():
|
||
print(f"[Error] Config file does not exist: {config_file}")
|
||
return
|
||
|
||
with open(config_path, 'r', encoding='utf-8') as f:
|
||
config = json.load(f)
|
||
|
||
chart_type = config.get('type', 'bar')
|
||
data = config.get('data', {})
|
||
|
||
print(f"\nLoaded from config file: {config_path.name}")
|
||
print(f"Chart type: {chart_type}")
|
||
|
||
if chart_type == 'bar':
|
||
canvas = config.get('canvas', 'ppt169')
|
||
coord = CoordinateSystem(canvas)
|
||
calc = BarChartCalculator(coord)
|
||
positions = calc.calculate(data)
|
||
print(calc.format_table(positions))
|
||
|
||
elif chart_type == 'pie':
|
||
center = tuple(config.get('center', [420, 400]))
|
||
radius = config.get('radius', 200)
|
||
calc = PieChartCalculator(center, radius)
|
||
slices = calc.calculate(data)
|
||
print(calc.format_table(slices))
|
||
|
||
elif chart_type == 'line':
|
||
canvas = config.get('canvas', 'ppt169')
|
||
coord = CoordinateSystem(canvas)
|
||
calc = LineChartCalculator(coord)
|
||
# data should be list of [x, y] pairs
|
||
points_data = [(p[0], p[1]) for p in data]
|
||
points = calc.calculate(points_data)
|
||
print(calc.format_table(points))
|
||
|
||
elif chart_type == 'custom_line':
|
||
# Custom line chart
|
||
base_x = config.get('base_x', 170)
|
||
step_x = config.get('step_x', 40)
|
||
base_y = config.get('base_y', 595)
|
||
scale_y = config.get('scale_y', 20)
|
||
ref_value = config.get('ref_value', 100)
|
||
values = config.get('values', [])
|
||
|
||
print(f"\nFormula: X = {base_x} + index * {step_x}")
|
||
print(f" Y = {base_y} - (value - {ref_value}) * {scale_y}")
|
||
print(f"\n{'Index':<6}{'Value':<10} {'X':<8} {'Y':<8}")
|
||
print("-" * 35)
|
||
|
||
points_list = []
|
||
for i, v in enumerate(values, 1):
|
||
x = base_x + i * step_x
|
||
y = base_y - (v - ref_value) * scale_y
|
||
print(f"{i:<6}{v:<10.1f} {x:<8.0f} {y:<8.0f}")
|
||
points_list.append(f"{int(x)},{int(y)}")
|
||
|
||
print(f"\npolyline points:")
|
||
print(" ".join(points_list))
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
"""Run the CLI entry point."""
|
||
parser = argparse.ArgumentParser(
|
||
description='SVG Position Calculation and Validation Tool',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Common commands:
|
||
# Analyze SVG file
|
||
python svg_position_calculator.py analyze example.svg
|
||
|
||
# Interactive mode
|
||
python svg_position_calculator.py interactive
|
||
|
||
# Calculate from JSON config
|
||
python svg_position_calculator.py from-json config.json
|
||
|
||
# Quick calculation
|
||
python svg_position_calculator.py calc bar --data "East:185,South:142"
|
||
python svg_position_calculator.py calc pie --data "A:35,B:25,C:20"
|
||
python svg_position_calculator.py calc line --data "0:50,10:80,20:120"
|
||
"""
|
||
)
|
||
|
||
subparsers = parser.add_subparsers(dest='command', help='Command', required=True)
|
||
|
||
# calc subcommand
|
||
calc_parser = subparsers.add_parser('calc', help='Calculate coordinates')
|
||
calc_subparsers = calc_parser.add_subparsers(dest='chart_type', help='Chart type', required=True)
|
||
|
||
# Bar chart
|
||
bar_parser = calc_subparsers.add_parser('bar', help='Bar chart')
|
||
bar_parser.add_argument('--data', required=True, help='Data "label1:value1,label2:value2"')
|
||
bar_parser.add_argument('--canvas', default='ppt169', help='Canvas format')
|
||
bar_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"')
|
||
bar_parser.add_argument('--bar-width', type=float, default=50, help='Bar width')
|
||
bar_parser.add_argument('--horizontal', action='store_true', help='Horizontal bar chart')
|
||
bar_parser.add_argument('--value-range', help='Value axis range "min,max" (from axis tick labels; omit to auto-normalize)')
|
||
|
||
# Pie chart
|
||
pie_parser = calc_subparsers.add_parser('pie', help='Pie / donut chart')
|
||
pie_parser.add_argument('--data', required=True, help='Data "label1:value1,label2:value2"')
|
||
pie_parser.add_argument('--center', default='420,400', help='Center "x,y"')
|
||
pie_parser.add_argument('--radius', type=float, default=200, help='Radius')
|
||
pie_parser.add_argument('--inner-radius', type=float, default=0, help='Inner radius (donut chart)')
|
||
pie_parser.add_argument('--start-angle', type=float, default=-90, help='Start angle')
|
||
|
||
# Radar chart
|
||
radar_parser = calc_subparsers.add_parser('radar', help='Radar chart')
|
||
radar_parser.add_argument('--data', required=True, help='Data "dim1:value1,dim2:value2"')
|
||
radar_parser.add_argument('--center', default='640,400', help='Center "x,y"')
|
||
radar_parser.add_argument('--radius', type=float, default=200, help='Radius')
|
||
radar_parser.add_argument('--max-value', type=float, help='Maximum value')
|
||
|
||
# Line / scatter chart
|
||
line_parser = calc_subparsers.add_parser('line', help='Line / scatter chart')
|
||
line_parser.add_argument('--data', required=True, help='Data "x1:y1,x2:y2"')
|
||
line_parser.add_argument('--canvas', default='ppt169', help='Canvas format')
|
||
line_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"')
|
||
line_parser.add_argument('--x-range', help='X axis range "min,max"')
|
||
line_parser.add_argument('--y-range', help='Y axis range "min,max"')
|
||
|
||
# Grid layout
|
||
grid_parser = calc_subparsers.add_parser('grid', help='Grid layout')
|
||
grid_parser.add_argument('--rows', type=int, required=True, help='Number of rows')
|
||
grid_parser.add_argument('--cols', type=int, required=True, help='Number of columns')
|
||
grid_parser.add_argument('--canvas', default='ppt169', help='Canvas format')
|
||
grid_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"')
|
||
grid_parser.add_argument('--padding', type=float, default=20, help='Inner padding')
|
||
grid_parser.add_argument('--gap', type=float, default=20, help='Spacing')
|
||
|
||
# validate subcommand
|
||
validate_parser = subparsers.add_parser('validate', help='Validate SVG')
|
||
validate_parser.add_argument('svg_file', help='SVG file path')
|
||
validate_parser.add_argument('--expected', help='Expected coordinates JSON file')
|
||
validate_parser.add_argument('--extract', action='store_true', help='Extract all position information')
|
||
validate_parser.add_argument('--tolerance', type=float, default=1.0, help='Tolerance (pixels)')
|
||
|
||
# analyze subcommand - analyze SVG file
|
||
analyze_parser = subparsers.add_parser('analyze', help='Analyze chart elements in SVG file')
|
||
analyze_parser.add_argument('svg_file', help='SVG file path')
|
||
|
||
# interactive subcommand - interactive mode
|
||
subparsers.add_parser('interactive', help='Interactive calculation mode')
|
||
|
||
# from-json subcommand - read from config file
|
||
json_parser = subparsers.add_parser('from-json', help='Calculate from JSON config file')
|
||
json_parser.add_argument('config_file', help='JSON config file path')
|
||
|
||
args = parser.parse_args(argv)
|
||
|
||
if args.command == 'calc':
|
||
# Parse chart area
|
||
chart_area = None
|
||
if hasattr(args, 'area') and args.area:
|
||
parts = parse_tuple(args.area)
|
||
chart_area = ChartArea(parts[0], parts[1], parts[2], parts[3])
|
||
|
||
if args.chart_type == 'bar':
|
||
canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169'
|
||
coord = CoordinateSystem(canvas, chart_area)
|
||
calc = BarChartCalculator(coord)
|
||
data = parse_data_string(args.data)
|
||
|
||
# Parse value-range from axis tick labels (if provided)
|
||
v_min, v_max = 0, None
|
||
scale_source = 'auto (max*1.1)'
|
||
if hasattr(args, 'value_range') and args.value_range:
|
||
try:
|
||
vr = parse_tuple(args.value_range)
|
||
except ValueError:
|
||
parser.error('calc bar --value-range must be numeric "min,max"')
|
||
if len(vr) != 2:
|
||
parser.error('calc bar --value-range must contain exactly two values: "min,max"')
|
||
v_min, v_max = vr[0], vr[1]
|
||
if v_max <= v_min:
|
||
parser.error('calc bar --value-range max must be greater than min')
|
||
scale_source = f'axis ticks ({v_min}-{v_max})'
|
||
|
||
positions = calc.calculate(data, bar_width=args.bar_width,
|
||
horizontal=args.horizontal,
|
||
y_min=v_min, y_max=v_max)
|
||
|
||
print(f"\n=== Bar Chart Coordinate Calculation ===")
|
||
print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}")
|
||
print(f"Chart area: ({coord.chart_area.x_min}, {coord.chart_area.y_min}) - "
|
||
f"({coord.chart_area.x_max}, {coord.chart_area.y_max})")
|
||
print(f"Value scale: {scale_source}")
|
||
print()
|
||
print(calc.format_table(positions))
|
||
|
||
elif args.chart_type == 'pie':
|
||
center = parse_tuple(args.center)
|
||
calc = PieChartCalculator(center, args.radius)
|
||
data = parse_data_string(args.data)
|
||
slices = calc.calculate(data, start_angle=args.start_angle, inner_radius=args.inner_radius)
|
||
|
||
print(f"\n=== Pie Chart Slice Calculation ===")
|
||
print(calc.format_table(slices))
|
||
|
||
elif args.chart_type == 'radar':
|
||
center = parse_tuple(args.center)
|
||
calc = RadarChartCalculator(center, args.radius)
|
||
data = parse_data_string(args.data)
|
||
points = calc.calculate(data, max_value=args.max_value)
|
||
|
||
print(f"\n=== Radar Chart Vertex Calculation ===")
|
||
print(calc.format_table(points))
|
||
|
||
elif args.chart_type == 'line':
|
||
canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169'
|
||
coord = CoordinateSystem(canvas, chart_area)
|
||
calc = LineChartCalculator(coord)
|
||
data = parse_xy_data_string(args.data)
|
||
|
||
x_range = parse_tuple(args.x_range) if args.x_range else None
|
||
y_range = parse_tuple(args.y_range) if args.y_range else None
|
||
|
||
points = calc.calculate(data, x_range, y_range)
|
||
|
||
print(f"\n=== Line / Scatter Chart Coordinate Calculation ===")
|
||
print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}")
|
||
print(calc.format_table(points))
|
||
|
||
elif args.chart_type == 'grid':
|
||
canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169'
|
||
coord = CoordinateSystem(canvas, chart_area)
|
||
calc = GridLayoutCalculator(coord)
|
||
cells = calc.calculate(args.rows, args.cols, args.padding, args.gap)
|
||
|
||
print(f"\n=== Grid Layout Calculation ({args.rows}x{args.cols}) ===")
|
||
print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}")
|
||
print(calc.format_table(cells))
|
||
|
||
else:
|
||
parser.print_help()
|
||
return 1
|
||
|
||
elif args.command == 'validate':
|
||
validator = SVGPositionValidator(tolerance=args.tolerance)
|
||
|
||
if args.extract:
|
||
# Extract mode
|
||
with open(args.svg_file, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
|
||
positions = validator.extract_all_positions(content)
|
||
|
||
print(f"\n=== Extracted Element Positions ===")
|
||
print(f"File: {args.svg_file}")
|
||
print()
|
||
|
||
for element_id, attrs in positions.items():
|
||
print(f"{element_id}:")
|
||
for attr, value in attrs.items():
|
||
print(f" {attr}: {value}")
|
||
elif args.expected:
|
||
import json
|
||
expected_path = Path(args.expected)
|
||
if not expected_path.exists():
|
||
print(f"[Error] Expected coordinates file does not exist: {args.expected}")
|
||
return 1
|
||
with open(expected_path, 'r', encoding='utf-8') as f:
|
||
expected_coords = json.load(f)
|
||
results = validator.validate_from_file(args.svg_file, expected_coords)
|
||
print(validator.format_results(results))
|
||
else:
|
||
print("Validation mode requires --expected <json_file>; use --extract to extract coordinates first")
|
||
return 1
|
||
|
||
elif args.command == 'analyze':
|
||
analyze_svg_file(args.svg_file)
|
||
|
||
elif args.command == 'interactive':
|
||
interactive_mode()
|
||
|
||
elif args.command == 'from-json':
|
||
from_json_config(args.config_file)
|
||
|
||
else:
|
||
parser.print_help()
|
||
return 1
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|