#!/usr/bin/env python3 """ Table Canvas Renderer Renders all tables from JSON data onto a single white canvas with specified dimensions """ import json import os from typing import Dict, List, Tuple, Any, Optional from PIL import Image, ImageDraw, ImageFont import argparse class TableCanvasRenderer: """Renders multiple tables from JSON data onto a canvas""" def __init__(self): """Initialize the table canvas renderer""" self.DEFAULT_FONT_SIZE = 11 self.CELL_PADDING = 4 self.DEFAULT_BORDER_WIDTH = 1 self.DEFAULT_BORDER_COLOR = "#000000" self.DEFAULT_BACKGROUND_COLOR = "#ffffff" def get_font(self, font_family: str = None, font_size: int = None, font_weight: str = "normal"): """Get PIL font object with fallbacks""" if font_size is None: font_size = self.DEFAULT_FONT_SIZE # Try to load the specified font font_paths = [] if font_family: if font_family.lower() == "ms gothic": # Common paths for MS Gothic on different systems font_paths.extend([ "C:/Windows/Fonts/msgothic.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf" ]) else: font_paths.append(font_family) # Fallback fonts font_paths.extend([ "arial.ttf", "Arial.ttf", "/System/Library/Fonts/Arial.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" ]) for font_path in font_paths: try: font = ImageFont.truetype(font_path, font_size) # For bold text, try to find bold variant if font_weight == "bold": bold_path = font_path.replace("Regular", "Bold").replace(".ttf", "b.ttf") try: font = ImageFont.truetype(bold_path, font_size) except: pass # Use regular font if bold not available return font except (OSError, IOError): continue # Ultimate fallback to default PIL font try: return ImageFont.load_default() except: return None def hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int, int]: """Convert hex color to RGBA tuple""" if not hex_color or hex_color == "transparent": return (255, 255, 255, 0) # Transparent hex_color = hex_color.lstrip('#') if len(hex_color) == 6: return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + (255,) return (255, 255, 255, 255) # Default white def extract_tables_from_json(self, json_data: Any) -> List[Dict]: """Extract all table items from JSON data""" if isinstance(json_data, list): tables = [item for item in json_data if item.get("type") == "table"] elif isinstance(json_data, dict) and json_data.get("type") == "table": tables = [json_data] else: tables = [] return tables def calculate_cell_coordinates(self, table_properties: Dict) -> Dict[Tuple[int, int], Dict]: """Calculate coordinates for all visible cells in the table""" rows = table_properties.get("rows", 0) columns = table_properties.get("columns", 0) column_widths = table_properties.get("columnWidths", {}) row_heights = table_properties.get("rowHeights", {}) merged_cells = table_properties.get("mergedCells", {}) hidden_cells = table_properties.get("hiddenCells", {}) def get_col_width(col: int) -> int: return column_widths.get(str(col), 100) def get_row_height(row: int) -> int: return row_heights.get(str(row), 30) # Build set of cells that are covered by merged cells (excluding origin) merged_spanned_cells = set() for cell_key, merge_info in merged_cells.items(): base_row, base_col = map(int, cell_key.split('-')) rowspan = merge_info.get('rowspan', 1) colspan = merge_info.get('colspan', 1) # Add all spanned cells except the origin cell for r in range(base_row, base_row + rowspan): for c in range(base_col, base_col + colspan): if (r, c) != (base_row, base_col): merged_spanned_cells.add((r, c)) cell_coords = {} for row in range(rows): for col in range(columns): cell_key = f"{row}-{col}" # Skip hidden cells and cells covered by merges if hidden_cells.get(cell_key) or (row, col) in merged_spanned_cells: continue # Calculate position by summing previous column widths/row heights x = sum(get_col_width(c) for c in range(col)) y = sum(get_row_height(r) for r in range(row)) # Check if this cell is a merge origin if cell_key in merged_cells: merge_info = merged_cells[cell_key] colspan = merge_info.get("colspan", 1) rowspan = merge_info.get("rowspan", 1) else: colspan = 1 rowspan = 1 # Calculate cell dimensions width = sum(get_col_width(c) for c in range(col, col + colspan)) height = sum(get_row_height(r) for r in range(row, row + rowspan)) cell_coords[(row, col)] = { "x": x, "y": y, "width": width, "height": height, "colspan": colspan, "rowspan": rowspan } return cell_coords def determine_cell_borders(self, cell_data: Optional[Dict], table_properties: Dict) -> Tuple[int, int, int, int]: """Determine border visibility for each side of a cell""" # Get global border settings cell_borders = table_properties.get("cellBorders", {}) has_global_borders = cell_borders.get("all", False) # Default borders based on global setting borders = { "top": 1 if has_global_borders else 0, "bottom": 1 if has_global_borders else 0, "left": 1 if has_global_borders else 0, "right": 1 if has_global_borders else 0 } # Check for cell-specific border overrides if cell_data and "cellStyle" in cell_data: cell_style = cell_data["cellStyle"] # Border property mappings border_mappings = { "borderTopWidth": "top", "borderBottomWidth": "bottom", "borderLeftWidth": "left", "borderRightWidth": "right" } # If any border width property exists, this cell has custom borders has_custom_borders = any(key in cell_style for key in border_mappings.keys()) if has_custom_borders: # Apply custom border settings for each side for width_key, border_side in border_mappings.items(): if width_key in cell_style: # Check border width width = cell_style[width_key] has_border = width > 0 # Check border style if specified style_key = width_key.replace("Width", "Style") if style_key in cell_style: style = cell_style[style_key] if style == "none": has_border = False borders[border_side] = 1 if has_border else 0 return borders["top"], borders["bottom"], borders["left"], borders["right"] def draw_table_on_canvas(self, draw: ImageDraw.Draw, table_data: Dict): """Draw a single table on the canvas""" properties = table_data.get("properties", {}) table_x = table_data.get("x", 0) table_y = table_data.get("y", 0) table_width = table_data.get("width", properties.get("width", 800)) table_height = table_data.get("height", properties.get("height", 600)) # Calculate cell coordinates cell_coords = self.calculate_cell_coordinates(properties) # Get table styling font_size = properties.get("fontSize", self.DEFAULT_FONT_SIZE) font_family = properties.get("fontFamily", "Arial") table_bg_color = self.hex_to_rgb(properties.get("backgroundColor", self.DEFAULT_BACKGROUND_COLOR)) # Draw table background if specified if table_bg_color != (255, 255, 255, 255): # If not default white draw.rectangle([ (table_x, table_y), (table_x + table_width, table_y + table_height) ], fill=table_bg_color) # Get cell data and styling cell_data = properties.get("cellData", {}) header_bg_color = self.hex_to_rgb(properties.get("headerBackgroundColor", "#e8f7f0")) # Draw cells for (row, col), coords in cell_coords.items(): cell_key = f"{row}-{col}" current_cell_data = cell_data.get(cell_key, {}) # Calculate cell position on canvas cell_x = table_x + coords["x"] cell_y = table_y + coords["y"] cell_w = coords["width"] cell_h = coords["height"] # Get cell styling cell_style = current_cell_data.get("cellStyle", {}) cell_bg_color = cell_style.get("backgroundColor") # Use header background for header rows if no specific background if row == 0 and not cell_bg_color: cell_bg_color = properties.get("headerBackgroundColor", "#e8f7f0") elif not cell_bg_color: cell_bg_color = properties.get("backgroundColor", self.DEFAULT_BACKGROUND_COLOR) # Draw cell background if cell_bg_color and cell_bg_color != "transparent": bg_color = self.hex_to_rgb(cell_bg_color) draw.rectangle([ (cell_x, cell_y), (cell_x + cell_w, cell_y + cell_h) ], fill=bg_color) # Draw cell borders top, bottom, left, right = self.determine_cell_borders(current_cell_data, properties) border_color = self.hex_to_rgb( cell_style.get("borderBottomColor", cell_style.get("borderColor", self.DEFAULT_BORDER_COLOR)) )[:3] # Remove alpha for line drawing border_width = max(1, cell_style.get("borderWidth", self.DEFAULT_BORDER_WIDTH)) if top: draw.line([(cell_x, cell_y), (cell_x + cell_w, cell_y)], fill=border_color, width=border_width) if bottom: draw.line([(cell_x, cell_y + cell_h), (cell_x + cell_w, cell_y + cell_h)], fill=border_color, width=border_width) if left: draw.line([(cell_x, cell_y), (cell_x, cell_y + cell_h)], fill=border_color, width=border_width) if right: draw.line([(cell_x + cell_w, cell_y), (cell_x + cell_w, cell_y + cell_h)], fill=border_color, width=border_width) # Draw cell text cell_value = current_cell_data.get("value", "") if cell_value: # Get text styling text_color = self.hex_to_rgb( cell_style.get("color", properties.get("color", "#000000")) )[:3] # Remove alpha font_weight = cell_style.get("fontWeight", "normal") if row == 0: # Header row font_weight = properties.get("headerFontWeight", "bold") text_align = cell_style.get("textAlign", "left") cell_font_size = cell_style.get("fontSize", font_size) # Get font font = self.get_font(font_family, cell_font_size, font_weight) # Calculate text position try: text_bbox = draw.textbbox((0, 0), str(cell_value), font=font) text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] except: # Fallback for older PIL versions text_width, text_height = draw.textsize(str(cell_value), font=font) # Position based on alignment if text_align == "center": text_x = cell_x + (cell_w - text_width) // 2 elif text_align == "right": text_x = cell_x + cell_w - text_width - self.CELL_PADDING else: # left text_x = cell_x + self.CELL_PADDING text_y = cell_y + (cell_h - text_height) // 2 # Draw text draw.text((text_x, text_y), str(cell_value), fill=text_color, font=font) def render_tables_to_canvas(self, json_data: Any, canvas_width: int, canvas_height: int) -> Image.Image: """ Render all tables from JSON data onto a white canvas Args: json_data: JSON data containing table definitions canvas_width: Width of the canvas canvas_height: Height of the canvas Returns: PIL Image with all tables rendered """ # Create white canvas canvas = Image.new('RGBA', (canvas_width, canvas_height), (255, 255, 255, 255)) draw = ImageDraw.Draw(canvas) # Extract all tables from JSON tables = self.extract_tables_from_json(json_data) if not tables: print("No tables found in JSON data") return canvas print(f"Found {len(tables)} table(s) to render") # Draw each table on the canvas for i, table_data in enumerate(tables): try: table_id = table_data.get("id", f"table_{i}") print(f" Drawing table {i+1}/{len(tables)} (id: {table_id})") self.draw_table_on_canvas(draw, table_data) except Exception as e: print(f" Error drawing table {i+1}: {e}") continue print("Canvas rendering complete") return canvas def render_from_file(self, json_file: str, canvas_width: int, canvas_height: int, output_file: str = None) -> Image.Image: """ Render tables from JSON file to canvas Args: json_file: Path to JSON file canvas_width: Width of the canvas canvas_height: Height of the canvas output_file: Optional path to save the rendered canvas Returns: PIL Image with all tables rendered """ # Read JSON data try: with open(json_file, 'r', encoding='utf-8') as f: json_data = json.load(f) except Exception as e: print(f"Error reading JSON file: {e}") return None # Render to canvas canvas = self.render_tables_to_canvas(json_data, canvas_width, canvas_height) # Save if output file specified if output_file: try: canvas.save(output_file) print(f"Canvas saved to: {output_file}") except Exception as e: print(f"Error saving canvas: {e}") return canvas def main(): """Main function for command line usage""" parser = argparse.ArgumentParser(description="Render tables from JSON data to canvas") parser.add_argument("json_file", help="Path to JSON file containing table data") parser.add_argument("width", type=int, help="Canvas width in pixels") parser.add_argument("height", type=int, help="Canvas height in pixels") parser.add_argument("--output", "-o", help="Output image file path (optional)") args = parser.parse_args() # Validate arguments if args.width <= 0 or args.height <= 0: print("Canvas width and height must be positive integers") return if not os.path.exists(args.json_file): print(f"JSON file not found: {args.json_file}") return # Generate output filename if not provided output_file = args.output if not output_file: base_name = os.path.splitext(os.path.basename(args.json_file))[0] output_file = f"{base_name}_canvas_{args.width}x{args.height}.png" # Create renderer and process file renderer = TableCanvasRenderer() canvas = renderer.render_from_file(args.json_file, args.width, args.height, output_file) if canvas is None: print("Failed to render canvas") return 1 print(f"Successfully rendered {args.width}x{args.height} canvas") return 0 if __name__ == "__main__": exit(main())