| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - SVG Position Calculation and Validation Tool |
| 4 | |
| 5 | Provides pre-calculation and post-validation of chart coordinates, |
| 6 | outputting clear coordinate tables. |
| 7 | |
| 8 | ====================================================================== |
| 9 | Common Commands (can be copied and used directly) |
| 10 | ====================================================================== |
| 11 | |
| 12 | 1. Analyze all coordinates in an SVG file: |
| 13 | python scripts/svg_position_calculator.py analyze <svg_file> |
| 14 | |
| 15 | 2. Interactive calculation mode: |
| 16 | python scripts/svg_position_calculator.py interactive |
| 17 | |
| 18 | 3. Calculate from JSON config file: |
| 19 | python scripts/svg_position_calculator.py from-json <config.json> |
| 20 | |
| 21 | 4. Quick calculation: |
| 22 | python scripts/svg_position_calculator.py calc bar --data "East:185,South:142" |
| 23 | python scripts/svg_position_calculator.py calc pie --data "A:35,B:25,C:20" |
| 24 | python scripts/svg_position_calculator.py calc line --data "0:50,10:80,20:120" |
| 25 | python scripts/svg_position_calculator.py calc grid --rows 2 --cols 3 |
| 26 | |
| 27 | ====================================================================== |
| 28 | """ |
| 29 | |
| 30 | import sys |
| 31 | import re |
| 32 | import math |
| 33 | import argparse |
| 34 | from pathlib import Path |
| 35 | from typing import Dict, List, Tuple, Optional, Any |
| 36 | from dataclasses import dataclass |
| 37 | |
| 38 | from console_encoding import configure_utf8_stdio |
| 39 | |
| 40 | configure_utf8_stdio() |
| 41 | |
| 42 | # Import canvas format configuration |
| 43 | try: |
| 44 | from project_utils import CANVAS_FORMATS |
| 45 | except ImportError: |
| 46 | # Use built-in definitions if import fails |
| 47 | CANVAS_FORMATS = { |
| 48 | 'ppt169': {'name': 'PPT 16:9', 'dimensions': '1280×720', 'viewbox': '0 0 1280 720'}, |
| 49 | 'ppt43': {'name': 'PPT 4:3', 'dimensions': '1024×768', 'viewbox': '0 0 1024 768'}, |
| 50 | 'xiaohongshu': {'name': 'Xiaohongshu (RED)', 'dimensions': '1242×1660', 'viewbox': '0 0 1242 1660'}, |
| 51 | 'moments': {'name': 'WeChat Moments', 'dimensions': '1080×1080', 'viewbox': '0 0 1080 1080'}, |
| 52 | } |
| 53 | |
| 54 | |
| 55 | # ============================================================================= |
| 56 | # Coordinate System Base Classes |
| 57 | # ============================================================================= |
| 58 | |
| 59 | @dataclass |
| 60 | class ChartArea: |
| 61 | """Chart area definition""" |
| 62 | x_min: float |
| 63 | y_min: float |
| 64 | x_max: float |
| 65 | y_max: float |
| 66 | |
| 67 | @property |
| 68 | def width(self) -> float: |
| 69 | return self.x_max - self.x_min |
| 70 | |
| 71 | @property |
| 72 | def height(self) -> float: |
| 73 | return self.y_max - self.y_min |
| 74 | |
| 75 | @property |
| 76 | def center(self) -> Tuple[float, float]: |
| 77 | return ((self.x_min + self.x_max) / 2, (self.y_min + self.y_max) / 2) |
| 78 | |
| 79 | |
| 80 | class CoordinateSystem: |
| 81 | """Coordinate system - maps data domain to SVG canvas coordinates""" |
| 82 | |
| 83 | def __init__(self, canvas_format: str = 'ppt169', chart_area: Optional[ChartArea] = None): |
| 84 | """ |
| 85 | Initialize the coordinate system |
| 86 | |
| 87 | Args: |
| 88 | canvas_format: Canvas format (ppt169, ppt43, xiaohongshu, moments, etc.) |
| 89 | chart_area: Chart area; uses default values if not specified |
| 90 | """ |
| 91 | self.canvas_format = canvas_format |
| 92 | |
| 93 | # Parse canvas dimensions |
| 94 | if canvas_format in CANVAS_FORMATS: |
| 95 | viewbox = CANVAS_FORMATS[canvas_format]['viewbox'] |
| 96 | parts = viewbox.split() |
| 97 | self.canvas_width = int(parts[2]) |
| 98 | self.canvas_height = int(parts[3]) |
| 99 | else: |
| 100 | self.canvas_width = 1280 |
| 101 | self.canvas_height = 720 |
| 102 | |
| 103 | # Set chart area (default with margins) |
| 104 | if chart_area: |
| 105 | self.chart_area = chart_area |
| 106 | else: |
| 107 | # Default chart area: left/right margin 140px, top/bottom margin 150px |
| 108 | self.chart_area = ChartArea( |
| 109 | x_min=140, |
| 110 | y_min=150, |
| 111 | x_max=self.canvas_width - 120, |
| 112 | y_max=self.canvas_height - 120 |
| 113 | ) |
| 114 | |
| 115 | def data_to_svg_x(self, data_x: float, x_range: Tuple[float, float]) -> float: |
| 116 | """ |
| 117 | Map data X value to SVG X coordinate |
| 118 | |
| 119 | Args: |
| 120 | data_x: Data X value |
| 121 | x_range: X axis data range (min, max) |
| 122 | """ |
| 123 | x_min, x_max = x_range |
| 124 | if x_max == x_min: |
| 125 | return self.chart_area.x_min |
| 126 | |
| 127 | ratio = (data_x - x_min) / (x_max - x_min) |
| 128 | return self.chart_area.x_min + ratio * self.chart_area.width |
| 129 | |
| 130 | def data_to_svg_y(self, data_y: float, y_range: Tuple[float, float]) -> float: |
| 131 | """ |
| 132 | Map data Y value to SVG Y coordinate (note: SVG Y axis points downward) |
| 133 | |
| 134 | Args: |
| 135 | data_y: Data Y value |
| 136 | y_range: Y axis data range (min, max) |
| 137 | """ |
| 138 | y_min, y_max = y_range |
| 139 | if y_max == y_min: |
| 140 | return self.chart_area.y_max |
| 141 | |
| 142 | ratio = (data_y - y_min) / (y_max - y_min) |
| 143 | # SVG Y axis points downward, so invert |
| 144 | return self.chart_area.y_max - ratio * self.chart_area.height |
| 145 | |
| 146 | def data_to_svg(self, data_x: float, data_y: float, |
| 147 | x_range: Tuple[float, float], y_range: Tuple[float, float]) -> Tuple[float, float]: |
| 148 | """Map data point to SVG coordinates""" |
| 149 | return (self.data_to_svg_x(data_x, x_range), self.data_to_svg_y(data_y, y_range)) |
| 150 | |
| 151 | |
| 152 | # ============================================================================= |
| 153 | # Bar Chart Calculator |
| 154 | # ============================================================================= |
| 155 | |
| 156 | @dataclass |
| 157 | class BarPosition: |
| 158 | """Bar position information""" |
| 159 | index: int |
| 160 | label: str |
| 161 | value: float |
| 162 | x: float |
| 163 | y: float |
| 164 | width: float |
| 165 | height: float |
| 166 | label_x: float # Label X position |
| 167 | label_y: float # Label Y position (below bar) |
| 168 | value_x: float # Value X position |
| 169 | value_y: float # Value Y position (above bar) |
| 170 | |
| 171 | |
| 172 | class BarChartCalculator: |
| 173 | """Bar chart coordinate calculator""" |
| 174 | |
| 175 | def __init__(self, coord_system: CoordinateSystem): |
| 176 | self.coord = coord_system |
| 177 | |
| 178 | def calculate(self, data: Dict[str, float], |
| 179 | bar_width: float = 50, |
| 180 | gap_ratio: float = 0.3, |
| 181 | y_min: float = 0, |
| 182 | y_max: Optional[float] = None, |
| 183 | horizontal: bool = False) -> List[BarPosition]: |
| 184 | """ |
| 185 | Calculate bar chart positions |
| 186 | |
| 187 | Args: |
| 188 | data: Data dictionary {label: value} |
| 189 | bar_width: Bar width (auto-calculated if None) |
| 190 | gap_ratio: Gap ratio between bars (relative to bar width) |
| 191 | y_min: Y axis minimum value |
| 192 | y_max: Y axis maximum value (uses data maximum if None) |
| 193 | horizontal: Whether to use horizontal bar chart |
| 194 | """ |
| 195 | labels = list(data.keys()) |
| 196 | values = list(data.values()) |
| 197 | n = len(labels) |
| 198 | |
| 199 | if n == 0: |
| 200 | return [] |
| 201 | |
| 202 | # Calculate Y axis range |
| 203 | if y_max is None: |
| 204 | y_max = max(values) * 1.1 # Leave 10% headroom |
| 205 | |
| 206 | area = self.coord.chart_area |
| 207 | |
| 208 | if horizontal: |
| 209 | # Horizontal bar chart |
| 210 | return self._calculate_horizontal(labels, values, bar_width, gap_ratio, y_min, y_max) |
| 211 | |
| 212 | # Calculate bar layout |
| 213 | total_width = area.width |
| 214 | if bar_width is None: |
| 215 | # Auto-calculate bar width: total width / (bar count * (1 + gap ratio)) |
| 216 | bar_width = total_width / (n * (1 + gap_ratio)) |
| 217 | |
| 218 | gap = bar_width * gap_ratio |
| 219 | total_bars_width = n * bar_width + (n - 1) * gap |
| 220 | start_x = area.x_min + (area.width - total_bars_width) / 2 |
| 221 | |
| 222 | results = [] |
| 223 | for i, (label, value) in enumerate(zip(labels, values)): |
| 224 | # Bar X position |
| 225 | x = start_x + i * (bar_width + gap) |
| 226 | |
| 227 | # Bar height and Y position |
| 228 | ratio = (value - y_min) / (y_max - y_min) if y_max > y_min else 0 |
| 229 | height = ratio * area.height |
| 230 | y = area.y_max - height # SVG Y axis points downward |
| 231 | |
| 232 | # Label and value positions |
| 233 | center_x = x + bar_width / 2 |
| 234 | |
| 235 | results.append(BarPosition( |
| 236 | index=i + 1, |
| 237 | label=label, |
| 238 | value=value, |
| 239 | x=round(x, 1), |
| 240 | y=round(y, 1), |
| 241 | width=round(bar_width, 1), |
| 242 | height=round(height, 1), |
| 243 | label_x=round(center_x, 1), |
| 244 | label_y=round(area.y_max + 30, 1), |
| 245 | value_x=round(center_x, 1), |
| 246 | value_y=round(y - 15, 1) |
| 247 | )) |
| 248 | |
| 249 | return results |
| 250 | |
| 251 | def _calculate_horizontal(self, labels: List[str], values: List[float], |
| 252 | bar_height: float, gap_ratio: float, |
| 253 | x_min: float, x_max: float) -> List[BarPosition]: |
| 254 | """Calculate horizontal bar chart""" |
| 255 | n = len(labels) |
| 256 | area = self.coord.chart_area |
| 257 | |
| 258 | if bar_height is None: |
| 259 | bar_height = area.height / (n * (1 + gap_ratio)) |
| 260 | |
| 261 | gap = bar_height * gap_ratio |
| 262 | total_bars_height = n * bar_height + (n - 1) * gap |
| 263 | start_y = area.y_min + (area.height - total_bars_height) / 2 |
| 264 | |
| 265 | results = [] |
| 266 | for i, (label, value) in enumerate(zip(labels, values)): |
| 267 | y = start_y + i * (bar_height + gap) |
| 268 | |
| 269 | ratio = (value - x_min) / (x_max - x_min) if x_max > x_min else 0 |
| 270 | width = ratio * area.width |
| 271 | x = area.x_min |
| 272 | |
| 273 | center_y = y + bar_height / 2 |
| 274 | |
| 275 | results.append(BarPosition( |
| 276 | index=i + 1, |
| 277 | label=label, |
| 278 | value=value, |
| 279 | x=round(x, 1), |
| 280 | y=round(y, 1), |
| 281 | width=round(width, 1), |
| 282 | height=round(bar_height, 1), |
| 283 | label_x=round(area.x_min - 10, 1), |
| 284 | label_y=round(center_y, 1), |
| 285 | value_x=round(x + width + 10, 1), |
| 286 | value_y=round(center_y, 1) |
| 287 | )) |
| 288 | |
| 289 | return results |
| 290 | |
| 291 | def format_table(self, positions: List[BarPosition]) -> str: |
| 292 | """Format as table output""" |
| 293 | lines = [] |
| 294 | lines.append("Index Label Value X Y Width Height") |
| 295 | lines.append("---- ---------- -------- ------- ------- ------- -------") |
| 296 | |
| 297 | for p in positions: |
| 298 | 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}") |
| 299 | |
| 300 | return "\n".join(lines) |
| 301 | |
| 302 | |
| 303 | # ============================================================================= |
| 304 | # Pie / Donut Chart Calculator |
| 305 | # ============================================================================= |
| 306 | |
| 307 | @dataclass |
| 308 | class PieSlice: |
| 309 | """Pie chart slice information""" |
| 310 | index: int |
| 311 | label: str |
| 312 | value: float |
| 313 | percentage: float |
| 314 | start_angle: float # Start angle (degrees) |
| 315 | end_angle: float # End angle (degrees) |
| 316 | path_d: str # SVG path d attribute |
| 317 | label_x: float # Label X position |
| 318 | label_y: float # Label Y position |
| 319 | # Arc endpoint coordinates (relative to center) |
| 320 | start_x: float |
| 321 | start_y: float |
| 322 | end_x: float |
| 323 | end_y: float |
| 324 | |
| 325 | |
| 326 | class PieChartCalculator: |
| 327 | """Pie / donut chart calculator""" |
| 328 | |
| 329 | def __init__(self, center: Tuple[float, float] = (420, 400), radius: float = 200): |
| 330 | self.cx, self.cy = center |
| 331 | self.radius = radius |
| 332 | |
| 333 | def calculate(self, data: Dict[str, float], |
| 334 | start_angle: float = -90, |
| 335 | inner_radius: float = 0) -> List[PieSlice]: |
| 336 | """ |
| 337 | Calculate pie chart slices |
| 338 | |
| 339 | Args: |
| 340 | data: Data dictionary {label: value} |
| 341 | start_angle: Start angle (degrees, -90 means starting from 12 o'clock) |
| 342 | inner_radius: Inner radius (0 for pie chart, > 0 for donut chart) |
| 343 | """ |
| 344 | labels = list(data.keys()) |
| 345 | values = list(data.values()) |
| 346 | total = sum(values) |
| 347 | |
| 348 | if total == 0: |
| 349 | return [] |
| 350 | |
| 351 | results = [] |
| 352 | current_angle = start_angle |
| 353 | |
| 354 | for i, (label, value) in enumerate(zip(labels, values)): |
| 355 | percentage = value / total * 100 |
| 356 | angle_span = value / total * 360 |
| 357 | end_angle = current_angle + angle_span |
| 358 | |
| 359 | # Calculate arc endpoints |
| 360 | start_rad = math.radians(current_angle) |
| 361 | end_rad = math.radians(end_angle) |
| 362 | |
| 363 | start_x = self.radius * math.cos(start_rad) |
| 364 | start_y = self.radius * math.sin(start_rad) |
| 365 | end_x = self.radius * math.cos(end_rad) |
| 366 | end_y = self.radius * math.sin(end_rad) |
| 367 | |
| 368 | # Generate path |
| 369 | large_arc = 1 if angle_span > 180 else 0 |
| 370 | |
| 371 | if inner_radius > 0: |
| 372 | # Donut chart |
| 373 | inner_start_x = inner_radius * math.cos(start_rad) |
| 374 | inner_start_y = inner_radius * math.sin(start_rad) |
| 375 | inner_end_x = inner_radius * math.cos(end_rad) |
| 376 | inner_end_y = inner_radius * math.sin(end_rad) |
| 377 | |
| 378 | path_d = ( |
| 379 | f"M {inner_start_x:.2f},{inner_start_y:.2f} " |
| 380 | f"L {start_x:.2f},{start_y:.2f} " |
| 381 | f"A {self.radius},{self.radius} 0 {large_arc},1 {end_x:.2f},{end_y:.2f} " |
| 382 | f"L {inner_end_x:.2f},{inner_end_y:.2f} " |
| 383 | f"A {inner_radius},{inner_radius} 0 {large_arc},0 {inner_start_x:.2f},{inner_start_y:.2f} Z" |
| 384 | ) |
| 385 | else: |
| 386 | # Pie chart |
| 387 | path_d = ( |
| 388 | f"M 0,0 " |
| 389 | f"L {start_x:.2f},{start_y:.2f} " |
| 390 | f"A {self.radius},{self.radius} 0 {large_arc},1 {end_x:.2f},{end_y:.2f} Z" |
| 391 | ) |
| 392 | |
| 393 | # Label position (70% of radius in the direction of slice center) |
| 394 | mid_angle = (current_angle + end_angle) / 2 |
| 395 | mid_rad = math.radians(mid_angle) |
| 396 | label_distance = self.radius * 0.7 |
| 397 | label_x = self.cx + label_distance * math.cos(mid_rad) |
| 398 | label_y = self.cy + label_distance * math.sin(mid_rad) |
| 399 | |
| 400 | results.append(PieSlice( |
| 401 | index=i + 1, |
| 402 | label=label, |
| 403 | value=value, |
| 404 | percentage=round(percentage, 1), |
| 405 | start_angle=round(current_angle, 1), |
| 406 | end_angle=round(end_angle, 1), |
| 407 | path_d=path_d, |
| 408 | label_x=round(label_x, 1), |
| 409 | label_y=round(label_y, 1), |
| 410 | start_x=round(start_x, 2), |
| 411 | start_y=round(start_y, 2), |
| 412 | end_x=round(end_x, 2), |
| 413 | end_y=round(end_y, 2) |
| 414 | )) |
| 415 | |
| 416 | current_angle = end_angle |
| 417 | |
| 418 | return results |
| 419 | |
| 420 | def format_table(self, slices: List[PieSlice]) -> str: |
| 421 | """Format as table output""" |
| 422 | lines = [] |
| 423 | lines.append(f"Center: ({self.cx}, {self.cy}) | Radius: {self.radius}") |
| 424 | lines.append("") |
| 425 | lines.append("Index Label Pct Start End LabelX LabelY") |
| 426 | lines.append("---- ---------- -------- -------- -------- ------- -------") |
| 427 | |
| 428 | for s in slices: |
| 429 | lines.append( |
| 430 | f"{s.index:4d} {s.label:<10s} {s.percentage:>6.1f}% {s.start_angle:>8.1f} " |
| 431 | f"{s.end_angle:>8.1f} {s.label_x:>7.1f} {s.label_y:>7.1f}" |
| 432 | ) |
| 433 | |
| 434 | lines.append("") |
| 435 | lines.append("=== Arc Endpoint Coordinates (relative to center) ===") |
| 436 | lines.append("Index StartX StartY EndX EndY") |
| 437 | lines.append("---- --------- --------- --------- ---------") |
| 438 | |
| 439 | for s in slices: |
| 440 | lines.append( |
| 441 | f"{s.index:4d} {s.start_x:>9.2f} {s.start_y:>9.2f} {s.end_x:>9.2f} {s.end_y:>9.2f}" |
| 442 | ) |
| 443 | |
| 444 | lines.append("") |
| 445 | lines.append("=== Path d Attribute ===") |
| 446 | for s in slices: |
| 447 | lines.append(f"{s.index}. {s.label}: {s.path_d}") |
| 448 | |
| 449 | return "\n".join(lines) |
| 450 | |
| 451 | |
| 452 | # ============================================================================= |
| 453 | # Radar Chart Calculator |
| 454 | # ============================================================================= |
| 455 | |
| 456 | @dataclass |
| 457 | class RadarPoint: |
| 458 | """Radar chart data point""" |
| 459 | index: int |
| 460 | label: str |
| 461 | value: float |
| 462 | percentage: float # Percentage relative to max value |
| 463 | angle: float # Angle (degrees) |
| 464 | x: float # X relative to center |
| 465 | y: float # Y relative to center |
| 466 | abs_x: float # Absolute X coordinate |
| 467 | abs_y: float # Absolute Y coordinate |
| 468 | label_x: float # Label X position |
| 469 | label_y: float # Label Y position |
| 470 | |
| 471 | |
| 472 | class RadarChartCalculator: |
| 473 | """Radar chart calculator""" |
| 474 | |
| 475 | def __init__(self, center: Tuple[float, float] = (640, 400), radius: float = 200): |
| 476 | self.cx, self.cy = center |
| 477 | self.radius = radius |
| 478 | |
| 479 | def calculate(self, data: Dict[str, float], |
| 480 | max_value: Optional[float] = None, |
| 481 | start_angle: float = -90) -> List[RadarPoint]: |
| 482 | """ |
| 483 | Calculate radar chart vertex coordinates |
| 484 | |
| 485 | Args: |
| 486 | data: Data dictionary {dimension_name: value} |
| 487 | max_value: Maximum value (for normalization); uses data maximum if None |
| 488 | start_angle: Start angle (degrees, -90 means starting from 12 o'clock) |
| 489 | """ |
| 490 | labels = list(data.keys()) |
| 491 | values = list(data.values()) |
| 492 | n = len(labels) |
| 493 | |
| 494 | if n == 0: |
| 495 | return [] |
| 496 | |
| 497 | if max_value is None: |
| 498 | max_value = max(values) |
| 499 | |
| 500 | angle_step = 360 / n |
| 501 | results = [] |
| 502 | |
| 503 | for i, (label, value) in enumerate(zip(labels, values)): |
| 504 | angle = start_angle + i * angle_step |
| 505 | rad = math.radians(angle) |
| 506 | |
| 507 | # Calculate normalized radius |
| 508 | percentage = (value / max_value * 100) if max_value > 0 else 0 |
| 509 | point_radius = self.radius * (value / max_value) if max_value > 0 else 0 |
| 510 | |
| 511 | # Calculate coordinates |
| 512 | x = point_radius * math.cos(rad) |
| 513 | y = point_radius * math.sin(rad) |
| 514 | |
| 515 | # Label position (outside the outermost ring) |
| 516 | label_distance = self.radius + 30 |
| 517 | label_x = self.cx + label_distance * math.cos(rad) |
| 518 | label_y = self.cy + label_distance * math.sin(rad) |
| 519 | |
| 520 | results.append(RadarPoint( |
| 521 | index=i + 1, |
| 522 | label=label, |
| 523 | value=value, |
| 524 | percentage=round(percentage, 1), |
| 525 | angle=round(angle, 1), |
| 526 | x=round(x, 2), |
| 527 | y=round(y, 2), |
| 528 | abs_x=round(self.cx + x, 2), |
| 529 | abs_y=round(self.cy + y, 2), |
| 530 | label_x=round(label_x, 1), |
| 531 | label_y=round(label_y, 1) |
| 532 | )) |
| 533 | |
| 534 | return results |
| 535 | |
| 536 | def calculate_grid(self, levels: int = 5) -> List[List[Tuple[float, float]]]: |
| 537 | """Calculate grid layer coordinates (for drawing background polygons)""" |
| 538 | n = 6 # Assume 6 dimensions |
| 539 | grids = [] |
| 540 | |
| 541 | for level in range(1, levels + 1): |
| 542 | level_radius = self.radius * level / levels |
| 543 | points = [] |
| 544 | |
| 545 | angle_step = 360 / n |
| 546 | for i in range(n): |
| 547 | angle = -90 + i * angle_step |
| 548 | rad = math.radians(angle) |
| 549 | x = level_radius * math.cos(rad) |
| 550 | y = level_radius * math.sin(rad) |
| 551 | points.append((round(x, 2), round(y, 2))) |
| 552 | |
| 553 | grids.append(points) |
| 554 | |
| 555 | return grids |
| 556 | |
| 557 | def format_table(self, points: List[RadarPoint]) -> str: |
| 558 | """Format as table output""" |
| 559 | lines = [] |
| 560 | lines.append(f"Center: ({self.cx}, {self.cy}) | Radius: {self.radius}") |
| 561 | lines.append("") |
| 562 | lines.append("Index Dimension Value Pct Angle X Y Abs_X Abs_Y") |
| 563 | lines.append("---- ---------- ------ -------- ------ ------- ------- ------- -------") |
| 564 | |
| 565 | for p in points: |
| 566 | lines.append( |
| 567 | f"{p.index:4d} {p.label:<10s} {p.value:>6.1f} {p.percentage:>6.1f}% " |
| 568 | f"{p.angle:>6.1f} {p.x:>7.2f} {p.y:>7.2f} {p.abs_x:>7.1f} {p.abs_y:>7.1f}" |
| 569 | ) |
| 570 | |
| 571 | # Generate polygon points attribute |
| 572 | lines.append("") |
| 573 | lines.append("=== SVG Polygon Points ===") |
| 574 | points_str = " ".join([f"{p.x},{p.y}" for p in points]) |
| 575 | lines.append(f'points="{points_str}"') |
| 576 | |
| 577 | return "\n".join(lines) |
| 578 | |
| 579 | |
| 580 | # ============================================================================= |
| 581 | # Line / Scatter Chart Calculator |
| 582 | # ============================================================================= |
| 583 | |
| 584 | @dataclass |
| 585 | class DataPoint: |
| 586 | """Data point""" |
| 587 | index: int |
| 588 | x_value: float |
| 589 | y_value: float |
| 590 | svg_x: float |
| 591 | svg_y: float |
| 592 | label: Optional[str] = None |
| 593 | |
| 594 | |
| 595 | class LineChartCalculator: |
| 596 | """Line / scatter chart calculator""" |
| 597 | |
| 598 | def __init__(self, coord_system: CoordinateSystem): |
| 599 | self.coord = coord_system |
| 600 | |
| 601 | def calculate(self, data: List[Tuple[float, float]], |
| 602 | x_range: Optional[Tuple[float, float]] = None, |
| 603 | y_range: Optional[Tuple[float, float]] = None, |
| 604 | labels: Optional[List[str]] = None) -> List[DataPoint]: |
| 605 | """ |
| 606 | Calculate data point coordinates |
| 607 | |
| 608 | Args: |
| 609 | data: Data point list [(x1, y1), (x2, y2), ...] |
| 610 | x_range: X axis range; auto-calculated if None |
| 611 | y_range: Y axis range; auto-calculated if None |
| 612 | labels: Point label list |
| 613 | """ |
| 614 | if not data: |
| 615 | return [] |
| 616 | |
| 617 | x_values = [p[0] for p in data] |
| 618 | y_values = [p[1] for p in data] |
| 619 | |
| 620 | if x_range is None: |
| 621 | x_range = (min(x_values), max(x_values)) |
| 622 | if y_range is None: |
| 623 | y_min = 0 |
| 624 | y_max = max(y_values) * 1.1 |
| 625 | y_range = (y_min, y_max) |
| 626 | |
| 627 | results = [] |
| 628 | for i, (x, y) in enumerate(data): |
| 629 | svg_x, svg_y = self.coord.data_to_svg(x, y, x_range, y_range) |
| 630 | |
| 631 | results.append(DataPoint( |
| 632 | index=i + 1, |
| 633 | x_value=x, |
| 634 | y_value=y, |
| 635 | svg_x=round(svg_x, 1), |
| 636 | svg_y=round(svg_y, 1), |
| 637 | label=labels[i] if labels and i < len(labels) else None |
| 638 | )) |
| 639 | |
| 640 | return results |
| 641 | |
| 642 | def generate_path(self, points: List[DataPoint], closed: bool = False) -> str: |
| 643 | """Generate SVG path d attribute""" |
| 644 | if not points: |
| 645 | return "" |
| 646 | |
| 647 | parts = [f"M {points[0].svg_x},{points[0].svg_y}"] |
| 648 | for p in points[1:]: |
| 649 | parts.append(f"L {p.svg_x},{p.svg_y}") |
| 650 | |
| 651 | if closed: |
| 652 | parts.append("Z") |
| 653 | |
| 654 | return " ".join(parts) |
| 655 | |
| 656 | def format_table(self, points: List[DataPoint]) -> str: |
| 657 | """Format as table output""" |
| 658 | lines = [] |
| 659 | area = self.coord.chart_area |
| 660 | lines.append(f"Chart area: ({area.x_min}, {area.y_min}) - ({area.x_max}, {area.y_max})") |
| 661 | lines.append("") |
| 662 | lines.append("Index X_Value Y_Value SVG_X SVG_Y") |
| 663 | lines.append("---- --------- --------- -------- --------") |
| 664 | |
| 665 | for p in points: |
| 666 | label_part = f" ({p.label})" if p.label else "" |
| 667 | lines.append( |
| 668 | 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}" |
| 669 | ) |
| 670 | |
| 671 | lines.append("") |
| 672 | lines.append("=== SVG Path ===") |
| 673 | lines.append(self.generate_path(points)) |
| 674 | |
| 675 | return "\n".join(lines) |
| 676 | |
| 677 | |
| 678 | # ============================================================================= |
| 679 | # Grid Layout Calculator |
| 680 | # ============================================================================= |
| 681 | |
| 682 | @dataclass |
| 683 | class GridCell: |
| 684 | """Grid cell""" |
| 685 | row: int |
| 686 | col: int |
| 687 | index: int # 1-based index |
| 688 | x: float |
| 689 | y: float |
| 690 | width: float |
| 691 | height: float |
| 692 | center_x: float |
| 693 | center_y: float |
| 694 | |
| 695 | |
| 696 | class GridLayoutCalculator: |
| 697 | """Grid layout calculator""" |
| 698 | |
| 699 | def __init__(self, coord_system: CoordinateSystem): |
| 700 | self.coord = coord_system |
| 701 | |
| 702 | def calculate(self, rows: int, cols: int, |
| 703 | padding: float = 20, |
| 704 | gap: float = 20) -> List[GridCell]: |
| 705 | """ |
| 706 | Calculate grid layout |
| 707 | |
| 708 | Args: |
| 709 | rows: Number of rows |
| 710 | cols: Number of columns |
| 711 | padding: Chart area inner padding |
| 712 | gap: Cell spacing |
| 713 | """ |
| 714 | area = self.coord.chart_area |
| 715 | |
| 716 | # Calculate available area |
| 717 | available_width = area.width - 2 * padding - (cols - 1) * gap |
| 718 | available_height = area.height - 2 * padding - (rows - 1) * gap |
| 719 | |
| 720 | cell_width = available_width / cols |
| 721 | cell_height = available_height / rows |
| 722 | |
| 723 | results = [] |
| 724 | index = 1 |
| 725 | |
| 726 | for row in range(rows): |
| 727 | for col in range(cols): |
| 728 | x = area.x_min + padding + col * (cell_width + gap) |
| 729 | y = area.y_min + padding + row * (cell_height + gap) |
| 730 | |
| 731 | results.append(GridCell( |
| 732 | row=row + 1, |
| 733 | col=col + 1, |
| 734 | index=index, |
| 735 | x=round(x, 1), |
| 736 | y=round(y, 1), |
| 737 | width=round(cell_width, 1), |
| 738 | height=round(cell_height, 1), |
| 739 | center_x=round(x + cell_width / 2, 1), |
| 740 | center_y=round(y + cell_height / 2, 1) |
| 741 | )) |
| 742 | index += 1 |
| 743 | |
| 744 | return results |
| 745 | |
| 746 | def format_table(self, cells: List[GridCell]) -> str: |
| 747 | """Format as table output""" |
| 748 | lines = [] |
| 749 | area = self.coord.chart_area |
| 750 | lines.append(f"Chart area: ({area.x_min}, {area.y_min}) - ({area.x_max}, {area.y_max})") |
| 751 | lines.append("") |
| 752 | lines.append("Index Row Col X Y Width Height CenterX CenterY") |
| 753 | lines.append("---- ---- ---- ------- ------- ------- ------- ------- -------") |
| 754 | |
| 755 | for c in cells: |
| 756 | lines.append( |
| 757 | f"{c.index:4d} {c.row:4d} {c.col:4d} {c.x:>7.1f} {c.y:>7.1f} " |
| 758 | f"{c.width:>7.1f} {c.height:>7.1f} {c.center_x:>7.1f} {c.center_y:>7.1f}" |
| 759 | ) |
| 760 | |
| 761 | return "\n".join(lines) |
| 762 | |
| 763 | |
| 764 | # ============================================================================= |
| 765 | # SVG Validator |
| 766 | # ============================================================================= |
| 767 | |
| 768 | @dataclass |
| 769 | class ValidationResult: |
| 770 | """Validation result""" |
| 771 | element_type: str |
| 772 | element_id: str |
| 773 | attribute: str |
| 774 | expected: float |
| 775 | actual: float |
| 776 | deviation: float |
| 777 | passed: bool |
| 778 | |
| 779 | |
| 780 | class SVGPositionValidator: |
| 781 | """SVG position validator""" |
| 782 | |
| 783 | def __init__(self, tolerance: float = 1.0): |
| 784 | """ |
| 785 | Initialize the validator |
| 786 | |
| 787 | Args: |
| 788 | tolerance: Allowed deviation (pixels) |
| 789 | """ |
| 790 | self.tolerance = tolerance |
| 791 | |
| 792 | def validate_from_file(self, svg_file: str, |
| 793 | expected_coords: Dict[str, Dict[str, float]]) -> List[ValidationResult]: |
| 794 | """ |
| 795 | Validate coordinates from file |
| 796 | |
| 797 | Args: |
| 798 | svg_file: SVG file path |
| 799 | expected_coords: Expected coordinates {element_ID: {attribute: value}} |
| 800 | """ |
| 801 | svg_path = Path(svg_file) |
| 802 | if not svg_path.exists(): |
| 803 | raise FileNotFoundError(f"SVG file does not exist: {svg_file}") |
| 804 | |
| 805 | with open(svg_path, 'r', encoding='utf-8') as f: |
| 806 | content = f.read() |
| 807 | |
| 808 | return self.validate_content(content, expected_coords) |
| 809 | |
| 810 | def validate_content(self, svg_content: str, |
| 811 | expected_coords: Dict[str, Dict[str, float]]) -> List[ValidationResult]: |
| 812 | """Validate coordinates in SVG content""" |
| 813 | results = [] |
| 814 | |
| 815 | for element_id, attrs in expected_coords.items(): |
| 816 | for attr, expected in attrs.items(): |
| 817 | actual = self._extract_attribute(svg_content, element_id, attr) |
| 818 | |
| 819 | if actual is not None: |
| 820 | deviation = abs(actual - expected) |
| 821 | passed = deviation <= self.tolerance |
| 822 | |
| 823 | results.append(ValidationResult( |
| 824 | element_type=self._guess_element_type(element_id), |
| 825 | element_id=element_id, |
| 826 | attribute=attr, |
| 827 | expected=expected, |
| 828 | actual=actual, |
| 829 | deviation=round(deviation, 2), |
| 830 | passed=passed |
| 831 | )) |
| 832 | else: |
| 833 | results.append(ValidationResult( |
| 834 | element_type=self._guess_element_type(element_id), |
| 835 | element_id=element_id, |
| 836 | attribute=attr, |
| 837 | expected=expected, |
| 838 | actual=float('nan'), |
| 839 | deviation=float('inf'), |
| 840 | passed=False |
| 841 | )) |
| 842 | |
| 843 | return results |
| 844 | |
| 845 | def _extract_attribute(self, content: str, element_id: str, attr: str) -> Optional[float]: |
| 846 | """Extract attribute value from SVG content""" |
| 847 | pattern = rf'<[^>]*(?<![\w:-])id\s*=\s*([\'"]){re.escape(element_id)}\1[^>]*>' |
| 848 | match = re.search(pattern, content) |
| 849 | if match: |
| 850 | value = extract_attr(match.group(0), attr) |
| 851 | if value is None: |
| 852 | return None |
| 853 | try: |
| 854 | return float(value) |
| 855 | except ValueError: |
| 856 | return None |
| 857 | |
| 858 | return None |
| 859 | |
| 860 | def _guess_element_type(self, element_id: str) -> str: |
| 861 | """Guess element type based on ID""" |
| 862 | id_lower = element_id.lower() |
| 863 | if 'bar' in id_lower or 'rect' in id_lower: |
| 864 | return 'rect' |
| 865 | elif 'circle' in id_lower or 'dot' in id_lower: |
| 866 | return 'circle' |
| 867 | elif 'path' in id_lower or 'slice' in id_lower: |
| 868 | return 'path' |
| 869 | elif 'line' in id_lower: |
| 870 | return 'line' |
| 871 | elif 'text' in id_lower or 'label' in id_lower: |
| 872 | return 'text' |
| 873 | return 'unknown' |
| 874 | |
| 875 | def extract_all_positions(self, svg_content: str) -> Dict[str, Dict[str, float]]: |
| 876 | """Extract position information of all elements in SVG""" |
| 877 | positions = {} |
| 878 | |
| 879 | # Extract rect elements |
| 880 | for match in re.finditer(r'<rect[^>]*/?>', svg_content): |
| 881 | elem = match.group(0) |
| 882 | x = extract_attr(elem, 'x') |
| 883 | y = extract_attr(elem, 'y') |
| 884 | if x is None or y is None: |
| 885 | continue |
| 886 | id_val = extract_attr(elem, 'id') or f"rect_{len(positions)}" |
| 887 | try: |
| 888 | positions[id_val] = {'x': float(x), 'y': float(y)} |
| 889 | width = extract_attr(elem, 'width') |
| 890 | height = extract_attr(elem, 'height') |
| 891 | if width is not None: |
| 892 | positions[id_val]['width'] = float(width) |
| 893 | if height is not None: |
| 894 | positions[id_val]['height'] = float(height) |
| 895 | except ValueError: |
| 896 | continue |
| 897 | |
| 898 | # Extract circle elements |
| 899 | for match in re.finditer(r'<circle[^>]*/?>', svg_content): |
| 900 | elem = match.group(0) |
| 901 | cx = extract_attr(elem, 'cx') |
| 902 | cy = extract_attr(elem, 'cy') |
| 903 | if cx is None or cy is None: |
| 904 | continue |
| 905 | id_val = extract_attr(elem, 'id') or f"circle_{len(positions)}" |
| 906 | try: |
| 907 | positions[id_val] = {'cx': float(cx), 'cy': float(cy)} |
| 908 | except ValueError: |
| 909 | continue |
| 910 | |
| 911 | return positions |
| 912 | |
| 913 | def format_results(self, results: List[ValidationResult]) -> str: |
| 914 | """Format validation results""" |
| 915 | lines = [] |
| 916 | lines.append("=== SVG Position Validation Results ===") |
| 917 | lines.append(f"Tolerance: {self.tolerance}px") |
| 918 | lines.append("") |
| 919 | lines.append("Status Element_ID Attr Expected Actual Deviation") |
| 920 | lines.append("---- -------------- ------ -------- -------- ------") |
| 921 | |
| 922 | passed_count = 0 |
| 923 | for r in results: |
| 924 | status = "[OK]" if r.passed else "[X]" |
| 925 | if r.passed: |
| 926 | passed_count += 1 |
| 927 | |
| 928 | actual_str = f"{r.actual:.1f}" if not math.isnan(r.actual) else "N/A" |
| 929 | deviation_str = f"{r.deviation:.2f}" if not math.isinf(r.deviation) else "N/A" |
| 930 | |
| 931 | lines.append( |
| 932 | f"{status} {r.element_id:<14s} {r.attribute:<6s} " |
| 933 | f"{r.expected:>8.1f} {actual_str:>8s} {deviation_str:>6s}" |
| 934 | ) |
| 935 | |
| 936 | lines.append("") |
| 937 | pct = passed_count / len(results) * 100 if results else 0 |
| 938 | lines.append(f"Passed: {passed_count}/{len(results)} ({pct:.1f}%)") |
| 939 | |
| 940 | return "\n".join(lines) |
| 941 | |
| 942 | |
| 943 | # ============================================================================= |
| 944 | # Command Line Interface |
| 945 | # ============================================================================= |
| 946 | |
| 947 | def parse_data_string(data_str: str) -> Dict[str, float]: |
| 948 | """Parse data string in 'label1:value1,label2:value2' format""" |
| 949 | result = {} |
| 950 | for item in data_str.split(','): |
| 951 | item = item.strip() |
| 952 | if not item: |
| 953 | continue |
| 954 | if ':' in item: |
| 955 | label, value = item.split(':', 1) |
| 956 | try: |
| 957 | result[label.strip()] = float(value.strip()) |
| 958 | except ValueError: |
| 959 | print(f"[Warning] Unable to parse value: '{value.strip()}', skipped") |
| 960 | else: |
| 961 | print(f"[Warning] Invalid format (expected 'label:value'): '{item}'") |
| 962 | return result |
| 963 | |
| 964 | |
| 965 | def parse_xy_data_string(data_str: str) -> List[Tuple[float, float]]: |
| 966 | """Parse XY data string in 'x1:y1,x2:y2' format""" |
| 967 | result = [] |
| 968 | for item in data_str.split(','): |
| 969 | item = item.strip() |
| 970 | if not item: |
| 971 | continue |
| 972 | if ':' in item: |
| 973 | x, y = item.split(':', 1) |
| 974 | try: |
| 975 | result.append((float(x.strip()), float(y.strip()))) |
| 976 | except ValueError: |
| 977 | print(f"[Warning] Unable to parse coordinates: '{item}', skipped") |
| 978 | else: |
| 979 | print(f"[Warning] Invalid format (expected 'x:y'): '{item}'") |
| 980 | return result |
| 981 | |
| 982 | |
| 983 | def parse_tuple(s: str) -> Tuple[float, ...]: |
| 984 | """Parse comma-separated numeric tuple""" |
| 985 | return tuple(float(x.strip()) for x in s.split(',')) |
| 986 | |
| 987 | |
| 988 | def extract_attr(element: str, attr_name: str) -> Optional[str]: |
| 989 | """Extract attribute value from element string (attribute order independent)""" |
| 990 | pattern = rf'(?<![\w:-]){re.escape(attr_name)}\s*=\s*([\'"])(.*?)\1' |
| 991 | match = re.search(pattern, element) |
| 992 | return match.group(2) if match else None |
| 993 | |
| 994 | |
| 995 | def analyze_svg_file(svg_file: str) -> None: |
| 996 | """Analyze all chart elements in an SVG file""" |
| 997 | svg_path = Path(svg_file) |
| 998 | if not svg_path.exists(): |
| 999 | print(f"[Error] File does not exist: {svg_file}") |
| 1000 | return |
| 1001 | |
| 1002 | with open(svg_path, 'r', encoding='utf-8') as f: |
| 1003 | content = f.read() |
| 1004 | |
| 1005 | print(f"\n{'='*70}") |
| 1006 | print(f"SVG File Analysis: {svg_path.name}") |
| 1007 | print(f"{'='*70}") |
| 1008 | |
| 1009 | # Extract viewBox |
| 1010 | viewbox_match = re.search(r'viewBox\s*=\s*["\']([^"\']+)["\']', content) |
| 1011 | if viewbox_match: |
| 1012 | print(f"Canvas viewBox: {viewbox_match.group(1)}") |
| 1013 | |
| 1014 | # Use more robust element extraction (attribute order independent) |
| 1015 | # Extract all rect elements |
| 1016 | rect_elements = re.findall(r'<rect[^>]*/?>', content) |
| 1017 | rects = [] |
| 1018 | for elem in rect_elements: |
| 1019 | x = extract_attr(elem, 'x') |
| 1020 | y = extract_attr(elem, 'y') |
| 1021 | w = extract_attr(elem, 'width') |
| 1022 | h = extract_attr(elem, 'height') |
| 1023 | if x is not None and y is not None: |
| 1024 | rects.append((x, y, w, h)) |
| 1025 | |
| 1026 | # Extract all circle elements |
| 1027 | circle_elements = re.findall(r'<circle[^>]*/?>', content) |
| 1028 | circles = [] |
| 1029 | for elem in circle_elements: |
| 1030 | cx = extract_attr(elem, 'cx') |
| 1031 | cy = extract_attr(elem, 'cy') |
| 1032 | r = extract_attr(elem, 'r') |
| 1033 | if cx is not None and cy is not None: |
| 1034 | circles.append((cx, cy, r)) |
| 1035 | |
| 1036 | # Extract all polyline/polygon elements |
| 1037 | polylines = re.findall(r'<(?:polyline|polygon)[^>]*points="([^"]*)"', content) |
| 1038 | |
| 1039 | # Extract path elements |
| 1040 | paths = re.findall(r'<path[^>]*d="([^"]*)"', content) |
| 1041 | |
| 1042 | print(f"\nElement statistics:") |
| 1043 | print(f" - rect (rectangle): {len(rects)}") |
| 1044 | print(f" - circle: {len(circles)}") |
| 1045 | print(f" - polyline/polygon: {len(polylines)}") |
| 1046 | print(f" - path: {len(paths)}") |
| 1047 | |
| 1048 | # List rect elements in detail |
| 1049 | if rects: |
| 1050 | print(f"\n=== Rectangle Elements (rect) ===") |
| 1051 | print(f"{'Index':<6}{'X':<8} {'Y':<8} {'Width':<8} {'Height':<8}") |
| 1052 | print("-" * 45) |
| 1053 | for i, (x, y, w, h) in enumerate(rects[:20], 1): # Only show first 20 |
| 1054 | w_str = w if w else '-' |
| 1055 | h_str = h if h else '-' |
| 1056 | print(f"{i:<6}{x:<8} {y:<8} {w_str:<8} {h_str:<8}") |
| 1057 | if len(rects) > 20: |
| 1058 | print(f"... and {len(rects) - 20} more rectangle(s)") |
| 1059 | |
| 1060 | # List circle elements in detail |
| 1061 | if circles: |
| 1062 | print(f"\n=== Circle Elements (circle) ===") |
| 1063 | print(f"{'Index':<6}{'CX':<10} {'CY':<10} {'Radius':<8}") |
| 1064 | print("-" * 40) |
| 1065 | for i, (cx, cy, r) in enumerate(circles[:20], 1): |
| 1066 | r_str = r if r else '-' |
| 1067 | print(f"{i:<6}{cx:<10} {cy:<10} {r_str:<8}") |
| 1068 | if len(circles) > 20: |
| 1069 | print(f"... and {len(circles) - 20} more circle(s)") |
| 1070 | |
| 1071 | # List polyline points |
| 1072 | if polylines: |
| 1073 | print(f"\n=== Polyline/Polygon (polyline/polygon) ===") |
| 1074 | for i, points in enumerate(polylines, 1): |
| 1075 | point_list = points.strip().split() |
| 1076 | print(f"\nPolyline {i} ({len(point_list)} points):") |
| 1077 | # Parse and show first few points |
| 1078 | parsed_points = [] |
| 1079 | for p in point_list[:5]: |
| 1080 | if ',' in p: |
| 1081 | x, y = p.split(',') |
| 1082 | parsed_points.append(f"({x},{y})") |
| 1083 | print(f" Start points: {' -> '.join(parsed_points)}") |
| 1084 | if len(point_list) > 5: |
| 1085 | print(f" ... {len(point_list)} points total") |
| 1086 | |
| 1087 | print(f"\n{'='*70}") |
| 1088 | |
| 1089 | |
| 1090 | def interactive_mode() -> None: |
| 1091 | """Interactive calculation mode""" |
| 1092 | print("\n" + "="*60) |
| 1093 | print("SVG Position Calculator - Interactive Mode") |
| 1094 | print("="*60) |
| 1095 | print("\nSelect chart type:") |
| 1096 | print(" 1. Bar chart (bar)") |
| 1097 | print(" 2. Pie chart (pie)") |
| 1098 | print(" 3. Radar chart (radar)") |
| 1099 | print(" 4. Line chart (line)") |
| 1100 | print(" 5. Grid layout (grid)") |
| 1101 | print(" 6. Custom line (custom)") |
| 1102 | print(" 0. Exit") |
| 1103 | |
| 1104 | while True: |
| 1105 | try: |
| 1106 | choice = input("\nSelect [1-6, 0 to exit]: ").strip() |
| 1107 | |
| 1108 | if choice == '0': |
| 1109 | print("Exiting interactive mode") |
| 1110 | break |
| 1111 | |
| 1112 | elif choice == '1': |
| 1113 | print("\n=== Bar Chart Calculation ===") |
| 1114 | data_str = input("Enter data (format: label1:value1,label2:value2): ").strip() |
| 1115 | if not data_str: |
| 1116 | print("Example: East:185,South:142,North:128") |
| 1117 | continue |
| 1118 | |
| 1119 | canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169' |
| 1120 | coord = CoordinateSystem(canvas) |
| 1121 | calc = BarChartCalculator(coord) |
| 1122 | data = parse_data_string(data_str) |
| 1123 | positions = calc.calculate(data) |
| 1124 | print() |
| 1125 | print(calc.format_table(positions)) |
| 1126 | |
| 1127 | elif choice == '2': |
| 1128 | print("\n=== Pie Chart Calculation ===") |
| 1129 | data_str = input("Enter data (format: label1:value1,label2:value2): ").strip() |
| 1130 | if not data_str: |
| 1131 | print("Example: A:35,B:25,C:20,D:12,Other:8") |
| 1132 | continue |
| 1133 | |
| 1134 | center_str = input("Center coordinates [420,400]: ").strip() or '420,400' |
| 1135 | radius = float(input("Radius [200]: ").strip() or '200') |
| 1136 | |
| 1137 | center = parse_tuple(center_str) |
| 1138 | calc = PieChartCalculator(center, radius) |
| 1139 | data = parse_data_string(data_str) |
| 1140 | slices = calc.calculate(data) |
| 1141 | print() |
| 1142 | print(calc.format_table(slices)) |
| 1143 | |
| 1144 | elif choice == '3': |
| 1145 | print("\n=== Radar Chart Calculation ===") |
| 1146 | data_str = input("Enter data (format: dim1:value1,dim2:value2): ").strip() |
| 1147 | if not data_str: |
| 1148 | print("Example: Performance:90,Security:85,Usability:75,Price:70") |
| 1149 | continue |
| 1150 | |
| 1151 | center_str = input("Center coordinates [640,400]: ").strip() or '640,400' |
| 1152 | radius = float(input("Radius [200]: ").strip() or '200') |
| 1153 | |
| 1154 | center = parse_tuple(center_str) |
| 1155 | calc = RadarChartCalculator(center, radius) |
| 1156 | data = parse_data_string(data_str) |
| 1157 | points = calc.calculate(data) |
| 1158 | print() |
| 1159 | print(calc.format_table(points)) |
| 1160 | |
| 1161 | elif choice == '4': |
| 1162 | print("\n=== Line Chart Calculation ===") |
| 1163 | data_str = input("Enter data (format: x1:y1,x2:y2): ").strip() |
| 1164 | if not data_str: |
| 1165 | print("Example: 0:50,10:80,20:120,30:95") |
| 1166 | continue |
| 1167 | |
| 1168 | canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169' |
| 1169 | coord = CoordinateSystem(canvas) |
| 1170 | calc = LineChartCalculator(coord) |
| 1171 | data = parse_xy_data_string(data_str) |
| 1172 | points = calc.calculate(data) |
| 1173 | print() |
| 1174 | print(calc.format_table(points)) |
| 1175 | |
| 1176 | elif choice == '5': |
| 1177 | print("\n=== Grid Layout Calculation ===") |
| 1178 | rows = int(input("Rows: ").strip() or '2') |
| 1179 | cols = int(input("Columns: ").strip() or '3') |
| 1180 | canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169' |
| 1181 | |
| 1182 | coord = CoordinateSystem(canvas) |
| 1183 | calc = GridLayoutCalculator(coord) |
| 1184 | cells = calc.calculate(rows, cols) |
| 1185 | print() |
| 1186 | print(calc.format_table(cells)) |
| 1187 | |
| 1188 | elif choice == '6': |
| 1189 | print("\n=== Custom Line Calculation ===") |
| 1190 | print("For custom formula line charts, such as price index charts") |
| 1191 | |
| 1192 | base_x = float(input("X start value [170]: ").strip() or '170') |
| 1193 | step_x = float(input("X step [40]: ").strip() or '40') |
| 1194 | base_y = float(input("Y baseline [595]: ").strip() or '595') |
| 1195 | scale_y = float(input("Y scale factor [20]: ").strip() or '20') |
| 1196 | ref_value = float(input("Reference baseline value [100]: ").strip() or '100') |
| 1197 | |
| 1198 | print(f"\nFormula: X = {base_x} + index * {step_x}") |
| 1199 | print(f" Y = {base_y} - (value - {ref_value}) * {scale_y}") |
| 1200 | |
| 1201 | data_str = input("\nEnter data (comma-separated values): ").strip() |
| 1202 | if data_str: |
| 1203 | values = [float(v.strip()) for v in data_str.split(',')] |
| 1204 | print(f"\n{'Index':<6}{'Value':<10} {'X':<8} {'Y':<8}") |
| 1205 | print("-" * 35) |
| 1206 | for i, v in enumerate(values, 1): |
| 1207 | x = base_x + i * step_x |
| 1208 | y = base_y - (v - ref_value) * scale_y |
| 1209 | print(f"{i:<6}{v:<10.1f} {x:<8.0f} {y:<8.0f}") |
| 1210 | |
| 1211 | # Generate polyline points |
| 1212 | points_list = [] |
| 1213 | for i, v in enumerate(values, 1): |
| 1214 | x = base_x + i * step_x |
| 1215 | y = base_y - (v - ref_value) * scale_y |
| 1216 | points_list.append(f"{int(x)},{int(y)}") |
| 1217 | print(f"\npolyline points:") |
| 1218 | print(" ".join(points_list)) |
| 1219 | |
| 1220 | else: |
| 1221 | print("Invalid selection, please enter 1-6 or 0") |
| 1222 | |
| 1223 | except KeyboardInterrupt: |
| 1224 | print("\nExiting interactive mode") |
| 1225 | break |
| 1226 | except Exception as e: |
| 1227 | print(f"Error: {e}") |
| 1228 | |
| 1229 | |
| 1230 | def from_json_config(config_file: str) -> None: |
| 1231 | """Read and calculate from JSON config file""" |
| 1232 | import json |
| 1233 | |
| 1234 | config_path = Path(config_file) |
| 1235 | if not config_path.exists(): |
| 1236 | print(f"[Error] Config file does not exist: {config_file}") |
| 1237 | return |
| 1238 | |
| 1239 | with open(config_path, 'r', encoding='utf-8') as f: |
| 1240 | config = json.load(f) |
| 1241 | |
| 1242 | chart_type = config.get('type', 'bar') |
| 1243 | data = config.get('data', {}) |
| 1244 | |
| 1245 | print(f"\nLoaded from config file: {config_path.name}") |
| 1246 | print(f"Chart type: {chart_type}") |
| 1247 | |
| 1248 | if chart_type == 'bar': |
| 1249 | canvas = config.get('canvas', 'ppt169') |
| 1250 | coord = CoordinateSystem(canvas) |
| 1251 | calc = BarChartCalculator(coord) |
| 1252 | positions = calc.calculate(data) |
| 1253 | print(calc.format_table(positions)) |
| 1254 | |
| 1255 | elif chart_type == 'pie': |
| 1256 | center = tuple(config.get('center', [420, 400])) |
| 1257 | radius = config.get('radius', 200) |
| 1258 | calc = PieChartCalculator(center, radius) |
| 1259 | slices = calc.calculate(data) |
| 1260 | print(calc.format_table(slices)) |
| 1261 | |
| 1262 | elif chart_type == 'line': |
| 1263 | canvas = config.get('canvas', 'ppt169') |
| 1264 | coord = CoordinateSystem(canvas) |
| 1265 | calc = LineChartCalculator(coord) |
| 1266 | # data should be list of [x, y] pairs |
| 1267 | points_data = [(p[0], p[1]) for p in data] |
| 1268 | points = calc.calculate(points_data) |
| 1269 | print(calc.format_table(points)) |
| 1270 | |
| 1271 | elif chart_type == 'custom_line': |
| 1272 | # Custom line chart |
| 1273 | base_x = config.get('base_x', 170) |
| 1274 | step_x = config.get('step_x', 40) |
| 1275 | base_y = config.get('base_y', 595) |
| 1276 | scale_y = config.get('scale_y', 20) |
| 1277 | ref_value = config.get('ref_value', 100) |
| 1278 | values = config.get('values', []) |
| 1279 | |
| 1280 | print(f"\nFormula: X = {base_x} + index * {step_x}") |
| 1281 | print(f" Y = {base_y} - (value - {ref_value}) * {scale_y}") |
| 1282 | print(f"\n{'Index':<6}{'Value':<10} {'X':<8} {'Y':<8}") |
| 1283 | print("-" * 35) |
| 1284 | |
| 1285 | points_list = [] |
| 1286 | for i, v in enumerate(values, 1): |
| 1287 | x = base_x + i * step_x |
| 1288 | y = base_y - (v - ref_value) * scale_y |
| 1289 | print(f"{i:<6}{v:<10.1f} {x:<8.0f} {y:<8.0f}") |
| 1290 | points_list.append(f"{int(x)},{int(y)}") |
| 1291 | |
| 1292 | print(f"\npolyline points:") |
| 1293 | print(" ".join(points_list)) |
| 1294 | |
| 1295 | |
| 1296 | def main(argv: list[str] | None = None) -> int: |
| 1297 | """Run the CLI entry point.""" |
| 1298 | parser = argparse.ArgumentParser( |
| 1299 | description='SVG Position Calculation and Validation Tool', |
| 1300 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1301 | epilog=""" |
| 1302 | Common commands: |
| 1303 | # Analyze SVG file |
| 1304 | python svg_position_calculator.py analyze example.svg |
| 1305 | |
| 1306 | # Interactive mode |
| 1307 | python svg_position_calculator.py interactive |
| 1308 | |
| 1309 | # Calculate from JSON config |
| 1310 | python svg_position_calculator.py from-json config.json |
| 1311 | |
| 1312 | # Quick calculation |
| 1313 | python svg_position_calculator.py calc bar --data "East:185,South:142" |
| 1314 | python svg_position_calculator.py calc pie --data "A:35,B:25,C:20" |
| 1315 | python svg_position_calculator.py calc line --data "0:50,10:80,20:120" |
| 1316 | """ |
| 1317 | ) |
| 1318 | |
| 1319 | subparsers = parser.add_subparsers(dest='command', help='Command', required=True) |
| 1320 | |
| 1321 | # calc subcommand |
| 1322 | calc_parser = subparsers.add_parser('calc', help='Calculate coordinates') |
| 1323 | calc_subparsers = calc_parser.add_subparsers(dest='chart_type', help='Chart type', required=True) |
| 1324 | |
| 1325 | # Bar chart |
| 1326 | bar_parser = calc_subparsers.add_parser('bar', help='Bar chart') |
| 1327 | bar_parser.add_argument('--data', required=True, help='Data "label1:value1,label2:value2"') |
| 1328 | bar_parser.add_argument('--canvas', default='ppt169', help='Canvas format') |
| 1329 | bar_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"') |
| 1330 | bar_parser.add_argument('--bar-width', type=float, default=50, help='Bar width') |
| 1331 | bar_parser.add_argument('--horizontal', action='store_true', help='Horizontal bar chart') |
| 1332 | bar_parser.add_argument('--value-range', help='Value axis range "min,max" (from axis tick labels; omit to auto-normalize)') |
| 1333 | |
| 1334 | # Pie chart |
| 1335 | pie_parser = calc_subparsers.add_parser('pie', help='Pie / donut chart') |
| 1336 | pie_parser.add_argument('--data', required=True, help='Data "label1:value1,label2:value2"') |
| 1337 | pie_parser.add_argument('--center', default='420,400', help='Center "x,y"') |
| 1338 | pie_parser.add_argument('--radius', type=float, default=200, help='Radius') |
| 1339 | pie_parser.add_argument('--inner-radius', type=float, default=0, help='Inner radius (donut chart)') |
| 1340 | pie_parser.add_argument('--start-angle', type=float, default=-90, help='Start angle') |
| 1341 | |
| 1342 | # Radar chart |
| 1343 | radar_parser = calc_subparsers.add_parser('radar', help='Radar chart') |
| 1344 | radar_parser.add_argument('--data', required=True, help='Data "dim1:value1,dim2:value2"') |
| 1345 | radar_parser.add_argument('--center', default='640,400', help='Center "x,y"') |
| 1346 | radar_parser.add_argument('--radius', type=float, default=200, help='Radius') |
| 1347 | radar_parser.add_argument('--max-value', type=float, help='Maximum value') |
| 1348 | |
| 1349 | # Line / scatter chart |
| 1350 | line_parser = calc_subparsers.add_parser('line', help='Line / scatter chart') |
| 1351 | line_parser.add_argument('--data', required=True, help='Data "x1:y1,x2:y2"') |
| 1352 | line_parser.add_argument('--canvas', default='ppt169', help='Canvas format') |
| 1353 | line_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"') |
| 1354 | line_parser.add_argument('--x-range', help='X axis range "min,max"') |
| 1355 | line_parser.add_argument('--y-range', help='Y axis range "min,max"') |
| 1356 | |
| 1357 | # Grid layout |
| 1358 | grid_parser = calc_subparsers.add_parser('grid', help='Grid layout') |
| 1359 | grid_parser.add_argument('--rows', type=int, required=True, help='Number of rows') |
| 1360 | grid_parser.add_argument('--cols', type=int, required=True, help='Number of columns') |
| 1361 | grid_parser.add_argument('--canvas', default='ppt169', help='Canvas format') |
| 1362 | grid_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"') |
| 1363 | grid_parser.add_argument('--padding', type=float, default=20, help='Inner padding') |
| 1364 | grid_parser.add_argument('--gap', type=float, default=20, help='Spacing') |
| 1365 | |
| 1366 | # validate subcommand |
| 1367 | validate_parser = subparsers.add_parser('validate', help='Validate SVG') |
| 1368 | validate_parser.add_argument('svg_file', help='SVG file path') |
| 1369 | validate_parser.add_argument('--expected', help='Expected coordinates JSON file') |
| 1370 | validate_parser.add_argument('--extract', action='store_true', help='Extract all position information') |
| 1371 | validate_parser.add_argument('--tolerance', type=float, default=1.0, help='Tolerance (pixels)') |
| 1372 | |
| 1373 | # analyze subcommand - analyze SVG file |
| 1374 | analyze_parser = subparsers.add_parser('analyze', help='Analyze chart elements in SVG file') |
| 1375 | analyze_parser.add_argument('svg_file', help='SVG file path') |
| 1376 | |
| 1377 | # interactive subcommand - interactive mode |
| 1378 | subparsers.add_parser('interactive', help='Interactive calculation mode') |
| 1379 | |
| 1380 | # from-json subcommand - read from config file |
| 1381 | json_parser = subparsers.add_parser('from-json', help='Calculate from JSON config file') |
| 1382 | json_parser.add_argument('config_file', help='JSON config file path') |
| 1383 | |
| 1384 | args = parser.parse_args(argv) |
| 1385 | |
| 1386 | if args.command == 'calc': |
| 1387 | # Parse chart area |
| 1388 | chart_area = None |
| 1389 | if hasattr(args, 'area') and args.area: |
| 1390 | parts = parse_tuple(args.area) |
| 1391 | chart_area = ChartArea(parts[0], parts[1], parts[2], parts[3]) |
| 1392 | |
| 1393 | if args.chart_type == 'bar': |
| 1394 | canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169' |
| 1395 | coord = CoordinateSystem(canvas, chart_area) |
| 1396 | calc = BarChartCalculator(coord) |
| 1397 | data = parse_data_string(args.data) |
| 1398 | |
| 1399 | # Parse value-range from axis tick labels (if provided) |
| 1400 | v_min, v_max = 0, None |
| 1401 | scale_source = 'auto (max*1.1)' |
| 1402 | if hasattr(args, 'value_range') and args.value_range: |
| 1403 | try: |
| 1404 | vr = parse_tuple(args.value_range) |
| 1405 | except ValueError: |
| 1406 | parser.error('calc bar --value-range must be numeric "min,max"') |
| 1407 | if len(vr) != 2: |
| 1408 | parser.error('calc bar --value-range must contain exactly two values: "min,max"') |
| 1409 | v_min, v_max = vr[0], vr[1] |
| 1410 | if v_max <= v_min: |
| 1411 | parser.error('calc bar --value-range max must be greater than min') |
| 1412 | scale_source = f'axis ticks ({v_min}-{v_max})' |
| 1413 | |
| 1414 | positions = calc.calculate(data, bar_width=args.bar_width, |
| 1415 | horizontal=args.horizontal, |
| 1416 | y_min=v_min, y_max=v_max) |
| 1417 | |
| 1418 | print(f"\n=== Bar Chart Coordinate Calculation ===") |
| 1419 | print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}") |
| 1420 | print(f"Chart area: ({coord.chart_area.x_min}, {coord.chart_area.y_min}) - " |
| 1421 | f"({coord.chart_area.x_max}, {coord.chart_area.y_max})") |
| 1422 | print(f"Value scale: {scale_source}") |
| 1423 | print() |
| 1424 | print(calc.format_table(positions)) |
| 1425 | |
| 1426 | elif args.chart_type == 'pie': |
| 1427 | center = parse_tuple(args.center) |
| 1428 | calc = PieChartCalculator(center, args.radius) |
| 1429 | data = parse_data_string(args.data) |
| 1430 | slices = calc.calculate(data, start_angle=args.start_angle, inner_radius=args.inner_radius) |
| 1431 | |
| 1432 | print(f"\n=== Pie Chart Slice Calculation ===") |
| 1433 | print(calc.format_table(slices)) |
| 1434 | |
| 1435 | elif args.chart_type == 'radar': |
| 1436 | center = parse_tuple(args.center) |
| 1437 | calc = RadarChartCalculator(center, args.radius) |
| 1438 | data = parse_data_string(args.data) |
| 1439 | points = calc.calculate(data, max_value=args.max_value) |
| 1440 | |
| 1441 | print(f"\n=== Radar Chart Vertex Calculation ===") |
| 1442 | print(calc.format_table(points)) |
| 1443 | |
| 1444 | elif args.chart_type == 'line': |
| 1445 | canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169' |
| 1446 | coord = CoordinateSystem(canvas, chart_area) |
| 1447 | calc = LineChartCalculator(coord) |
| 1448 | data = parse_xy_data_string(args.data) |
| 1449 | |
| 1450 | x_range = parse_tuple(args.x_range) if args.x_range else None |
| 1451 | y_range = parse_tuple(args.y_range) if args.y_range else None |
| 1452 | |
| 1453 | points = calc.calculate(data, x_range, y_range) |
| 1454 | |
| 1455 | print(f"\n=== Line / Scatter Chart Coordinate Calculation ===") |
| 1456 | print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}") |
| 1457 | print(calc.format_table(points)) |
| 1458 | |
| 1459 | elif args.chart_type == 'grid': |
| 1460 | canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169' |
| 1461 | coord = CoordinateSystem(canvas, chart_area) |
| 1462 | calc = GridLayoutCalculator(coord) |
| 1463 | cells = calc.calculate(args.rows, args.cols, args.padding, args.gap) |
| 1464 | |
| 1465 | print(f"\n=== Grid Layout Calculation ({args.rows}x{args.cols}) ===") |
| 1466 | print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}") |
| 1467 | print(calc.format_table(cells)) |
| 1468 | |
| 1469 | else: |
| 1470 | parser.print_help() |
| 1471 | return 1 |
| 1472 | |
| 1473 | elif args.command == 'validate': |
| 1474 | validator = SVGPositionValidator(tolerance=args.tolerance) |
| 1475 | |
| 1476 | if args.extract: |
| 1477 | # Extract mode |
| 1478 | with open(args.svg_file, 'r', encoding='utf-8') as f: |
| 1479 | content = f.read() |
| 1480 | |
| 1481 | positions = validator.extract_all_positions(content) |
| 1482 | |
| 1483 | print(f"\n=== Extracted Element Positions ===") |
| 1484 | print(f"File: {args.svg_file}") |
| 1485 | print() |
| 1486 | |
| 1487 | for element_id, attrs in positions.items(): |
| 1488 | print(f"{element_id}:") |
| 1489 | for attr, value in attrs.items(): |
| 1490 | print(f" {attr}: {value}") |
| 1491 | elif args.expected: |
| 1492 | import json |
| 1493 | expected_path = Path(args.expected) |
| 1494 | if not expected_path.exists(): |
| 1495 | print(f"[Error] Expected coordinates file does not exist: {args.expected}") |
| 1496 | return 1 |
| 1497 | with open(expected_path, 'r', encoding='utf-8') as f: |
| 1498 | expected_coords = json.load(f) |
| 1499 | results = validator.validate_from_file(args.svg_file, expected_coords) |
| 1500 | print(validator.format_results(results)) |
| 1501 | else: |
| 1502 | print("Validation mode requires --expected <json_file>; use --extract to extract coordinates first") |
| 1503 | return 1 |
| 1504 | |
| 1505 | elif args.command == 'analyze': |
| 1506 | analyze_svg_file(args.svg_file) |
| 1507 | |
| 1508 | elif args.command == 'interactive': |
| 1509 | interactive_mode() |
| 1510 | |
| 1511 | elif args.command == 'from-json': |
| 1512 | from_json_config(args.config_file) |
| 1513 | |
| 1514 | else: |
| 1515 | parser.print_help() |
| 1516 | return 1 |
| 1517 | |
| 1518 | return 0 |
| 1519 | |
| 1520 | |
| 1521 | if __name__ == '__main__': |
| 1522 | raise SystemExit(main()) |
| 1523 |