返回 ppt-master
svg_position_calculator.py
根目录 / skills / ppt-master / scripts / svg_position_calculator.py
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
1523 lines PYTHON