trace_src / render.py
kiaisoft's picture
Upload render.py
5cf94fe verified
#!/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())