|
|
|
|
|
""" |
|
|
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 |
|
|
|
|
|
|
|
|
font_paths = [] |
|
|
|
|
|
if font_family: |
|
|
if font_family.lower() == "ms gothic": |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
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 |
|
|
return font |
|
|
except (OSError, IOError): |
|
|
continue |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
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) |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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}" |
|
|
|
|
|
|
|
|
if hidden_cells.get(cell_key) or (row, col) in merged_spanned_cells: |
|
|
continue |
|
|
|
|
|
|
|
|
x = sum(get_col_width(c) for c in range(col)) |
|
|
y = sum(get_row_height(r) for r in range(row)) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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""" |
|
|
|
|
|
cell_borders = table_properties.get("cellBorders", {}) |
|
|
has_global_borders = cell_borders.get("all", False) |
|
|
|
|
|
|
|
|
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 |
|
|
} |
|
|
|
|
|
|
|
|
if cell_data and "cellStyle" in cell_data: |
|
|
cell_style = cell_data["cellStyle"] |
|
|
|
|
|
|
|
|
border_mappings = { |
|
|
"borderTopWidth": "top", |
|
|
"borderBottomWidth": "bottom", |
|
|
"borderLeftWidth": "left", |
|
|
"borderRightWidth": "right" |
|
|
} |
|
|
|
|
|
|
|
|
has_custom_borders = any(key in cell_style for key in border_mappings.keys()) |
|
|
|
|
|
if has_custom_borders: |
|
|
|
|
|
for width_key, border_side in border_mappings.items(): |
|
|
if width_key in cell_style: |
|
|
|
|
|
width = cell_style[width_key] |
|
|
has_border = width > 0 |
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
cell_coords = self.calculate_cell_coordinates(properties) |
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
if table_bg_color != (255, 255, 255, 255): |
|
|
draw.rectangle([ |
|
|
(table_x, table_y), |
|
|
(table_x + table_width, table_y + table_height) |
|
|
], fill=table_bg_color) |
|
|
|
|
|
|
|
|
cell_data = properties.get("cellData", {}) |
|
|
header_bg_color = self.hex_to_rgb(properties.get("headerBackgroundColor", "#e8f7f0")) |
|
|
|
|
|
|
|
|
for (row, col), coords in cell_coords.items(): |
|
|
cell_key = f"{row}-{col}" |
|
|
current_cell_data = cell_data.get(cell_key, {}) |
|
|
|
|
|
|
|
|
cell_x = table_x + coords["x"] |
|
|
cell_y = table_y + coords["y"] |
|
|
cell_w = coords["width"] |
|
|
cell_h = coords["height"] |
|
|
|
|
|
|
|
|
cell_style = current_cell_data.get("cellStyle", {}) |
|
|
cell_bg_color = cell_style.get("backgroundColor") |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
cell_value = current_cell_data.get("value", "") |
|
|
if cell_value: |
|
|
|
|
|
text_color = self.hex_to_rgb( |
|
|
cell_style.get("color", |
|
|
properties.get("color", "#000000")) |
|
|
)[:3] |
|
|
|
|
|
font_weight = cell_style.get("fontWeight", "normal") |
|
|
if row == 0: |
|
|
font_weight = properties.get("headerFontWeight", "bold") |
|
|
|
|
|
text_align = cell_style.get("textAlign", "left") |
|
|
cell_font_size = cell_style.get("fontSize", font_size) |
|
|
|
|
|
|
|
|
font = self.get_font(font_family, cell_font_size, font_weight) |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
text_width, text_height = draw.textsize(str(cell_value), font=font) |
|
|
|
|
|
|
|
|
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: |
|
|
text_x = cell_x + self.CELL_PADDING |
|
|
|
|
|
text_y = cell_y + (cell_h - text_height) // 2 |
|
|
|
|
|
|
|
|
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 |
|
|
""" |
|
|
|
|
|
canvas = Image.new('RGBA', (canvas_width, canvas_height), (255, 255, 255, 255)) |
|
|
draw = ImageDraw.Draw(canvas) |
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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 |
|
|
""" |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
canvas = self.render_tables_to_canvas(json_data, canvas_width, canvas_height) |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
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()) |