Files
TimeTracker/app/utils/pdf_generator_reportlab.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

1336 lines
56 KiB
Python

"""
ReportLab PDF Template Renderer
Converts ReportLab template JSON (generated by visual editor) into PDF documents
using ReportLab's programmatic PDF generation.
"""
import io
import json
import os
import tempfile
from datetime import datetime
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Union
from reportlab.lib.pagesizes import A3, A4, A5, LEGAL, LETTER, TABLOID
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm, inch, mm
# ReportLab doesn't export 'pt' from units
# In ReportLab, the default unit is already points (1 point = 1 unit = 1/72 inch)
pt = 1 # 1 point = 1 unit in ReportLab
from flask import current_app
from flask_babel import gettext as _
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from reportlab.platypus import (
Flowable,
Image,
KeepTogether,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from app.models import Settings
from app.utils.pdf_template_schema import (
PAGE_SIZE_DIMENSIONS_MM,
ElementType,
TextAlign,
get_page_dimensions_points,
validate_template_json,
)
class AbsolutePositionedFlowable(Flowable):
"""Flowable that stores element info for canvas drawing (elements drawn in page callbacks)"""
def __init__(self, element_data):
Flowable.__init__(self)
self.element_data = element_data # Store element data for drawing in page callback
def draw(self):
"""This won't be called - elements are drawn in page callbacks"""
pass
def wrap(self, availWidth, availHeight):
"""Return zero size so it doesn't affect flow"""
return (0, 0)
def _normalize_color(color: Any) -> str:
"""
Convert color to hex format for ReportLab.
Handles named colors, hex colors, and returns default if invalid.
Args:
color: Color value (string like 'white', 'black', '#ffffff', etc.)
Returns:
Hex color string (e.g., '#ffffff')
"""
if not color or color == "transparent":
return None
# If already a hex color, return as-is
if isinstance(color, str) and color.startswith("#"):
return color
# Map common named colors to hex
color_map = {
"white": "#ffffff",
"black": "#000000",
"red": "#ff0000",
"green": "#00ff00",
"blue": "#0000ff",
"yellow": "#ffff00",
"cyan": "#00ffff",
"magenta": "#ff00ff",
"gray": "#808080",
"grey": "#808080",
"orange": "#ffa500",
"purple": "#800080",
"pink": "#ffc0cb",
"brown": "#a52a2a",
"silver": "#c0c0c0",
"gold": "#ffd700",
}
# Convert to lowercase for case-insensitive matching
color_lower = str(color).lower().strip()
if color_lower in color_map:
return color_map[color_lower]
# Try to use ReportLab's built-in colors
try:
if hasattr(colors, color_lower.capitalize()):
color_obj = getattr(colors, color_lower.capitalize())
# Convert ReportLab color to hex
if hasattr(color_obj, "hexval"):
return color_obj.hexval()
# Some colors might be tuples
if isinstance(color_obj, tuple) and len(color_obj) == 3:
r, g, b = color_obj
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
except Exception:
pass
# Default to black if we can't convert
return "#000000"
class ReportLabTemplateRenderer:
"""Render ReportLab template JSON into PDF"""
def __init__(self, template_json: Dict[str, Any], data_context: Dict[str, Any], page_size: str = "A4"):
"""
Initialize the renderer.
Args:
template_json: Template definition dictionary
data_context: Data to fill template (invoice/quote, settings, etc.)
page_size: Override page size from template
"""
self.template = template_json
self.context = data_context
template_page_size = template_json.get("page", {}).get("size", "A4")
self.page_size = page_size or template_page_size
# Log initialization
try:
from flask import current_app
element_count = len(template_json.get("elements", []))
current_app.logger.info(
f"[PDF_EXPORT] ReportLab renderer initialized - PageSize: '{self.page_size}', Template PageSize: '{template_page_size}', Elements: {element_count}"
)
except Exception:
pass # Logging not available or not in request context
# Initialize styles
self.styles = getSampleStyleSheet()
self._setup_custom_styles()
# Page size mapping for ReportLab
self.PAGE_SIZE_MAP = {
"A4": A4,
"A5": A5,
"A3": A3,
"Letter": LETTER,
"Legal": LEGAL,
"Tabloid": TABLOID,
}
def _setup_custom_styles(self):
"""Setup custom paragraph styles"""
# Add default styles if not already present
# Use unique names to avoid conflicts with default stylesheet
# Try to add custom styles, catching any errors if they already exist
# ReportLab will raise an error if a style with the same name already exists
try:
# Check if style exists by trying to access it
_ = self.styles["CustomHeading1"]
# If we get here, style already exists, skip adding
except (KeyError, AttributeError):
# Style doesn't exist, try to add it
try:
self.styles.add(
ParagraphStyle(
name="CustomHeading1",
parent=self.styles["Heading1"],
fontSize=24,
spaceAfter=12,
textColor=colors.HexColor("#007bff"),
)
)
except (ValueError, KeyError, AttributeError, Exception):
# Failed to add style - might already exist or other error
# Just continue without this custom style
pass
try:
# Check if style exists by trying to access it
_ = self.styles["NormalText"]
# If we get here, style already exists, skip adding
except (KeyError, AttributeError):
# Style doesn't exist, try to add it
try:
self.styles.add(
ParagraphStyle(
name="NormalText",
parent=self.styles["Normal"],
fontSize=10,
spaceAfter=6,
)
)
except (ValueError, KeyError, AttributeError, Exception):
# Failed to add style - might already exist or other error
# Just continue without this custom style
pass
def render_to_bytes(self) -> bytes:
"""
Render template to PDF bytes.
Returns:
PDF content as bytes
"""
from flask import current_app
try:
current_app.logger.info(f"[PDF_EXPORT] ReportLab render_to_bytes started - PageSize: '{self.page_size}'")
except Exception:
pass # Logging not available or not in request context
# Create temporary file
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_file:
tmp_path = tmp_file.name
try:
# Get page size and dimensions
page_size_obj = self.PAGE_SIZE_MAP.get(self.page_size, A4)
page_dimensions = get_page_dimensions_points(self.page_size)
try:
current_app.logger.info(
f"[PDF_EXPORT] ReportLab page size configured - PageSize: '{self.page_size}', Dimensions: {page_dimensions['width']}x{page_dimensions['height']}pt"
)
except Exception:
pass
# Get margins (default to 20mm if not specified)
page_config = self.template.get("page", {})
margins = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20})
try:
current_app.logger.info(
f"[PDF_EXPORT] ReportLab margins configured - PageSize: '{self.page_size}', Margins: top={margins.get('top', 20)}mm, right={margins.get('right', 20)}mm, bottom={margins.get('bottom', 20)}mm, left={margins.get('left', 20)}mm"
)
except Exception:
pass
# Create document
doc = SimpleDocTemplate(
tmp_path,
pagesize=page_size_obj,
rightMargin=margins.get("right", 20) * mm,
leftMargin=margins.get("left", 20) * mm,
topMargin=margins.get("top", 20) * mm,
bottomMargin=margins.get("bottom", 20) * mm,
)
# Build story (flowables)
story = self._build_story()
story_length = len(story)
try:
current_app.logger.info(
f"[PDF_EXPORT] ReportLab story built - PageSize: '{self.page_size}', Story length: {story_length} flowables"
)
except Exception:
pass
# Store reference to renderer in doc for access in callbacks
self._doc = doc
# Store page dimensions for boundary checking
self.page_width = page_dimensions["width"]
self.page_height = page_dimensions["height"]
# Build PDF with custom page callbacks for absolute positioning
try:
current_app.logger.info(
f"[PDF_EXPORT] ReportLab building PDF - PageSize: '{self.page_size}', Starting doc.build()"
)
except Exception:
pass
doc.build(story, onFirstPage=self._on_page, onLaterPages=self._on_page)
# Read PDF bytes
with open(tmp_path, "rb") as f:
pdf_bytes = f.read()
pdf_size_bytes = len(pdf_bytes)
try:
current_app.logger.info(
f"[PDF_EXPORT] ReportLab PDF build completed successfully - PageSize: '{self.page_size}', PDFSize: {pdf_size_bytes} bytes"
)
except Exception:
pass
return pdf_bytes
except Exception as e:
try:
current_app.logger.error(
f"[PDF_EXPORT] ReportLab render_to_bytes failed - PageSize: '{self.page_size}', Error: {str(e)}",
exc_info=True,
)
except Exception:
pass
raise
finally:
# Clean up temporary file
if os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except Exception:
pass
def _build_story(self) -> List[Flowable]:
"""Build the PDF story (list of flowables) from template elements"""
from flask import current_app
# Store elements for drawing in page callbacks (absolute positioning)
self.absolute_elements = []
story = []
elements = self.template.get("elements", [])
element_count = len(elements)
try:
current_app.logger.info(
f"[PDF_EXPORT] ReportLab building story - PageSize: '{self.page_size}', Elements: {element_count}"
)
except Exception:
pass
# Sort elements by y position (top to bottom) for proper rendering order
sorted_elements = sorted(elements, key=lambda e: e.get("y", 0))
element_types = {}
for element in sorted_elements:
element_type = element.get("type")
element_types[element_type] = element_types.get(element_type, 0) + 1
# Process element (existing code continues here)
if element_type == ElementType.SPACER:
height = element.get("height", 20)
story.append(Spacer(1, height * pt))
elif element_type == ElementType.TABLE:
# Tables are rendered as flowables (they flow naturally)
# Add spacer to position table vertically based on y coordinate
table_y = element.get("y", 0)
if table_y > 0:
# Tables are flowables, so they start from the top margin
# The y position in the template is from top of page (0,0 = top-left)
# We need to position the table relative to the top margin
page_config = self.template.get("page", {})
margins = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20})
margin_top = margins.get("top", 20) * mm
# table_y is absolute from top of page, so we need to account for the margin
# The spacer should position the table at the correct y position
spacer_height = max(0, table_y - margin_top)
if spacer_height > 0:
story.append(Spacer(1, spacer_height))
table_flowable = self._render_table(element)
if table_flowable:
# Wrap table to respect width if specified
table_width = element.get("width")
if table_width:
# Table width is already set in _render_table via colWidths
pass
story.append(table_flowable)
else:
# For absolute positioning, store element data to draw in page callback
element_data = {"element": element, "renderer": self}
positioned = AbsolutePositionedFlowable(element_data)
self.absolute_elements.append(element_data)
story.append(positioned)
try:
current_app.logger.info(
f"[PDF_EXPORT] ReportLab story built - PageSize: '{self.page_size}', Flowables: {len(story)}, Absolute elements: {len(self.absolute_elements)}, Element types: {element_types}"
)
except Exception:
pass
return story
def _render_element(self, element: Dict[str, Any]) -> Optional[Flowable]:
"""Render a single element to a ReportLab flowable"""
element_type = element.get("type")
if element_type == ElementType.TEXT:
return self._render_text(element)
elif element_type == ElementType.IMAGE:
return self._render_image(element)
elif element_type == ElementType.RECTANGLE:
return self._render_rectangle(element)
elif element_type == ElementType.CIRCLE:
return self._render_circle(element)
elif element_type == ElementType.LINE:
return self._render_line(element)
elif element_type == ElementType.TABLE:
return self._render_table(element)
return None
def _render_text(self, element: Dict[str, Any]) -> Paragraph:
"""Render a text element"""
text = element.get("text", "")
# Process template variables (Jinja2-style)
text = self._process_template_variables(text)
# Convert newlines to <br/> tags for Paragraph (which expects HTML-like markup)
# Handle different line ending formats (\n, \r\n, \r)
text = text.replace("\r\n", "<br/>").replace("\r", "<br/>").replace("\n", "<br/>")
# Get style
style_name = element.get("style_name", "NormalText")
style = self._get_style(element, style_name)
# Create paragraph
return Paragraph(text, style)
def _render_image(self, element: Dict[str, Any]) -> Optional[Image]:
"""Render an image element"""
source = element.get("source", "")
# Process template variables for source
source = self._process_template_variables(source)
# Issue #432: Explicit skip for decorative images with empty source (never add flowable or draw)
if element.get("decorative", False) and (not source or not source.strip()):
if current_app:
current_app.logger.warning("Skipping decorative image flowable with empty source")
return None
if not source or not source.strip():
return None
# Handle base64 data URI with validated decode (Issue #432)
if source.startswith("data:image"):
import base64
parts = source.split(",", 1)
if len(parts) < 2 or not (parts[1] and parts[1].strip()):
if current_app:
current_app.logger.warning("Skipping image: data URI has no base64 payload")
return None
try:
img_data = base64.b64decode(parts[1])
img_reader = ImageReader(io.BytesIO(img_data))
width = element.get("width", 100)
height = element.get("height", 100)
return Image(img_reader, width=width, height=height)
except Exception as e:
if current_app:
current_app.logger.error(f"Error decoding base64 image: {e}")
else:
print(f"Error decoding base64 image: {e}")
return None
# Handle template image URLs (convert to file path or base64)
# Issue #537: Also handle full URLs; use robust path resolution
if "/uploads/template_images/" in source:
try:
from app.utils.template_filters import get_image_base64
filename = self._extract_template_image_filename(source)
if not filename:
if current_app:
current_app.logger.warning(f"Could not extract filename from template image URL: {source}")
return None
relative_path = f"app/static/uploads/template_images/{filename}"
base64_data = get_image_base64(relative_path)
if base64_data:
import base64 as b64
header, data = base64_data.split(",", 1)
img_data = b64.b64decode(data)
img_reader = ImageReader(io.BytesIO(img_data))
width = element.get("width", 100)
height = element.get("height", 100)
return Image(img_reader, width=width, height=height)
# Fallback: try direct file path (Issue #537: robust path resolution)
file_path = self._resolve_template_image_path(filename)
if file_path and os.path.exists(file_path):
width = element.get("width", 100)
height = element.get("height", 100)
return Image(file_path, width=width, height=height)
if current_app:
current_app.logger.warning(
f"Template image not found: {source} (tried get_image_base64 and _resolve_template_image_path)"
)
except Exception as e:
if current_app:
current_app.logger.error(f"Error loading template image {source}: {e}")
else:
print(f"Error loading template image {source}: {e}")
return None
# Handle file path
if os.path.exists(source):
try:
width = element.get("width", 100) # Already in points from generateCode
height = element.get("height", 100) # Already in points from generateCode
# Image() constructor preserves transparency automatically for PNG images
return Image(source, width=width, height=height)
except Exception as e:
if current_app:
current_app.logger.error(f"Error loading image {source}: {e}")
else:
print(f"Error loading image {source}: {e}")
return None
return None
def _render_rectangle(self, element: Dict[str, Any]) -> Flowable:
"""Render a rectangle element"""
class RectangleFlowable(Flowable):
def __init__(self, width, height, fill, stroke, stroke_width):
self.width = width
self.height = height
self.fill = fill
self.stroke = stroke
self.stroke_width = stroke_width
Flowable.__init__(self)
def draw(self):
self.canv.saveState()
if self.fill:
fill_hex = _normalize_color(self.fill)
if fill_hex:
self.canv.setFillColor(colors.HexColor(fill_hex))
if self.stroke:
stroke_hex = _normalize_color(self.stroke)
if stroke_hex:
self.canv.setStrokeColor(colors.HexColor(stroke_hex))
self.canv.setLineWidth(self.stroke_width)
self.canv.rect(0, 0, self.width, self.height, fill=bool(self.fill), stroke=bool(self.stroke))
self.canv.restoreState()
def wrap(self, availWidth, availHeight):
return (self.width, self.height)
width = element.get("width", 100) * pt
height = element.get("height", 100) * pt
fill = element.get("style", {}).get("fill", element.get("fill"))
stroke = element.get("style", {}).get("stroke", element.get("stroke"))
stroke_width = element.get("style", {}).get("strokeWidth", element.get("strokeWidth", 1))
return RectangleFlowable(width, height, fill, stroke, stroke_width or 0)
def _render_circle(self, element: Dict[str, Any]) -> Flowable:
"""Render a circle element"""
class CircleFlowable(Flowable):
def __init__(self, radius, fill, stroke, stroke_width):
self.radius = radius
self.fill = fill
self.stroke = stroke
self.stroke_width = stroke_width
Flowable.__init__(self)
def draw(self):
self.canv.saveState()
if self.fill:
fill_hex = _normalize_color(self.fill)
if fill_hex:
self.canv.setFillColor(colors.HexColor(fill_hex))
if self.stroke:
stroke_hex = _normalize_color(self.stroke)
if stroke_hex:
self.canv.setStrokeColor(colors.HexColor(stroke_hex))
self.canv.setLineWidth(self.stroke_width)
self.canv.circle(self.radius, self.radius, self.radius, fill=bool(self.fill), stroke=bool(self.stroke))
self.canv.restoreState()
def wrap(self, availWidth, availHeight):
size = self.radius * 2
return (size, size)
radius = (element.get("width", 100) / 2) * pt
fill = element.get("style", {}).get("fill", element.get("fill"))
stroke = element.get("style", {}).get("stroke", element.get("stroke"))
stroke_width = element.get("style", {}).get("strokeWidth", element.get("strokeWidth", 1))
return CircleFlowable(radius, fill, stroke, stroke_width or 0)
def _render_line(self, element: Dict[str, Any]) -> Flowable:
"""Render a line element"""
class LineFlowable(Flowable):
def __init__(self, width, stroke, stroke_width):
self.width = width
self.stroke = stroke
self.stroke_width = stroke_width
Flowable.__init__(self)
def draw(self):
self.canv.saveState()
if self.stroke:
stroke_hex = _normalize_color(self.stroke)
if stroke_hex:
self.canv.setStrokeColor(colors.HexColor(stroke_hex))
self.canv.setLineWidth(self.stroke_width)
self.canv.line(0, 0, self.width, 0)
self.canv.restoreState()
def wrap(self, availWidth, availHeight):
return (self.width, self.stroke_width)
width = element.get("width", 100) * pt
stroke = element.get("style", {}).get("stroke", element.get("stroke", "#000000"))
stroke_width = element.get("style", {}).get("strokeWidth", element.get("strokeWidth", 1))
return LineFlowable(width, stroke, stroke_width or 1)
def _normalize_extra_good_for_items_row(self, good: Any) -> SimpleNamespace:
"""Convert an ExtraGood to a row-like object with description, quantity, unit_price, total_amount.
Description is built from name + optional description, SKU, category (same shape as items table)."""
description_parts = [getattr(good, "name", "") or ""]
if getattr(good, "description", None):
description_parts.append(f"\n{good.description}")
if getattr(good, "sku", None):
description_parts.append(f"\n{_('SKU')}: {good.sku}")
if getattr(good, "category", None):
cat = good.category.title() if isinstance(good.category, str) else str(good.category)
description_parts.append(f"\n{_('Category')}: {cat}")
description = "\n".join(description_parts)
return SimpleNamespace(
description=description,
quantity=getattr(good, "quantity", 0),
unit_price=getattr(good, "unit_price", 0),
total_amount=getattr(good, "total_amount", 0),
)
def _normalize_expense_for_items_row(self, expense: Any) -> SimpleNamespace:
"""Convert an Expense to a row-like object with description, quantity=1, unit_price, total_amount.
Same shape as items table for backward compatibility when template uses invoice.items."""
desc_parts = [getattr(expense, "title", str(expense)) or ""]
if getattr(expense, "description", None):
desc_parts.append(str(expense.description))
description = "\n".join(desc_parts)
amt = getattr(expense, "total_amount", None) or getattr(expense, "amount", 0)
return SimpleNamespace(
description=description,
quantity=1,
unit_price=amt,
total_amount=amt,
)
def _render_table(self, element: Dict[str, Any]) -> Table:
"""Render a table element"""
columns = element.get("columns", [])
# Build header row - process template variables in headers
headers = []
for col in columns:
header_text = col.get("header", "")
header_text = self._process_template_variables(header_text)
# Convert newlines to <br/> tags for Paragraph
header_text = header_text.replace("\r\n", "<br/>").replace("\r", "<br/>").replace("\n", "<br/>")
# Use Paragraph for headers to handle line breaks
header_style = self._get_style({"style": {}}, "NormalText")
headers.append(Paragraph(header_text, header_style))
table_data = [headers]
# Get data source
data_source = element.get("data", "")
if data_source:
# Process template variable to get actual data
data = self._resolve_data_source(data_source)
# When this table is the invoice items table, include extra_goods and expenses so they appear in the PDF
var_name = data_source.replace("{{", "").replace("}}", "").strip()
if var_name == "invoice.items":
invoice = self.context.get("invoice")
if invoice is not None:
extra_goods = getattr(invoice, "extra_goods", None)
if extra_goods is not None:
if hasattr(extra_goods, "all"):
extra_goods = list(extra_goods.all())
elif hasattr(extra_goods, "__iter__") and not isinstance(extra_goods, (str, bytes)):
extra_goods = list(extra_goods)
else:
extra_goods = []
data = list(data) + [self._normalize_extra_good_for_items_row(g) for g in extra_goods]
expenses = getattr(invoice, "expenses", None)
if expenses is not None:
if hasattr(expenses, "all"):
expenses = list(expenses.all())
elif hasattr(expenses, "__iter__") and not isinstance(expenses, (str, bytes)):
expenses = list(expenses)
else:
expenses = []
data = list(data) + [self._normalize_expense_for_items_row(e) for e in expenses]
row_template = element.get("row_template", {})
if isinstance(data, list) and data:
for item in data:
row = []
for col in columns:
field = col.get("field", "")
# Process row template with item context
value = self._process_row_template(field, row_template.get(field, ""), item)
cell_text = str(value) if value is not None else ""
# Convert newlines to <br/> tags for Paragraph
cell_text = cell_text.replace("\r\n", "<br/>").replace("\r", "<br/>").replace("\n", "<br/>")
# Use Paragraph for cells to handle line breaks
cell_style = self._get_style({"style": {}}, "NormalText")
row.append(Paragraph(cell_text, cell_style))
table_data.append(row)
else:
# No data - add empty row message
num_cols = len(columns)
empty_row = [Paragraph("No data", self._get_style({"style": {}}, "NormalText"))]
empty_row.extend([Paragraph("", self._get_style({"style": {}}, "NormalText"))] * (num_cols - 1))
table_data.append(empty_row)
# Calculate column widths (convert from points to ReportLab units)
# Note: columns already have width in points from generateCode
col_widths = []
total_width = 0
for col in columns:
col_width = col.get("width", 100) # Already in points
col_widths.append(col_width)
total_width += col_width
# Create table with proper column widths
table = Table(table_data, colWidths=col_widths, repeatRows=1)
# Apply table style
table_style = self._get_table_style(element, columns)
table.setStyle(table_style)
# Wrap table in KeepTogether to prevent breaking across pages
return KeepTogether(table)
def _get_table_style(self, element: Dict[str, Any], columns: List[Dict[str, Any]]) -> TableStyle:
"""Get table style from element configuration"""
style_config = element.get("style", {})
# Get default colors from style config or use defaults
header_bg = colors.HexColor(style_config.get("headerBackground", "#f8f9fa"))
header_text_color = colors.HexColor(style_config.get("headerTextColor", "#000000"))
row_bg = colors.HexColor(style_config.get("rowBackground", "#ffffff"))
row_text_color = colors.HexColor(style_config.get("rowTextColor", "#000000"))
commands = [
# Header row styling
("BACKGROUND", (0, 0), (-1, 0), header_bg),
("TEXTCOLOR", (0, 0), (-1, 0), header_text_color),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 12),
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
("TOPPADDING", (0, 0), (-1, 0), 12),
# Data rows styling
("BACKGROUND", (0, 1), (-1, -1), row_bg),
("TEXTCOLOR", (0, 1), (-1, -1), row_text_color),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), 10),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#ffffff"), colors.HexColor("#f9fafb")]),
# Grid and borders
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
]
# Apply column alignments (both header and data rows)
for idx, col in enumerate(columns):
align = col.get("align", "left").lower()
if align == "right":
commands.append(("ALIGN", (idx, 0), (idx, -1), "RIGHT")) # Header + data
elif align == "center":
commands.append(("ALIGN", (idx, 0), (idx, -1), "CENTER")) # Header + data
else: # left
commands.append(("ALIGN", (idx, 0), (idx, -1), "LEFT")) # Header + data
return TableStyle(commands)
def _get_style(self, element: Dict[str, Any], default_style_name: str = "NormalText") -> ParagraphStyle:
"""Get paragraph style from element configuration"""
style_config = element.get("style", {})
# Get base style
base_style = self.styles.get(default_style_name, self.styles["Normal"])
# Create custom style
custom_style = ParagraphStyle(
name=f"Custom_{id(element)}",
parent=base_style,
fontSize=style_config.get("size", base_style.fontSize),
textColor=colors.HexColor(style_config.get("color", "#000000")),
fontName=style_config.get("font", base_style.fontName),
alignment=self._get_alignment(style_config.get("align", "left")),
spaceAfter=style_config.get("spaceAfter", base_style.spaceAfter),
)
return custom_style
def _get_alignment(self, align: str) -> int:
"""Convert alignment string to ReportLab constant"""
align_map = {
"left": TA_LEFT,
"center": TA_CENTER,
"right": TA_RIGHT,
"justify": TA_JUSTIFY,
}
return align_map.get(align.lower(), TA_LEFT)
def _process_template_variables(self, text: str) -> str:
"""Process Jinja2-style template variables in text"""
from flask import render_template_string
try:
# Render with context
rendered = render_template_string(text, **self.context)
return rendered
except Exception as e:
if current_app:
current_app.logger.error(f"Error processing template variables: {e}")
else:
print(f"Error processing template variables: {e}")
return text
def _resolve_data_source(self, data_source: str) -> List[Any]:
"""Resolve data source template variable to actual data"""
# Remove template syntax
var_name = data_source.replace("{{", "").replace("}}", "").strip()
# Resolve from context (which is a dict)
try:
parts = var_name.split(".")
value = self.context
# Navigate through nested attributes/dict keys
for part in parts:
if isinstance(value, dict):
value = value.get(part)
else:
value = getattr(value, part, None)
if value is None:
break
# Convert to list if it's a query object or SQLAlchemy result
if value is None:
return []
if hasattr(value, "all"): # SQLAlchemy query
return list(value.all())
elif hasattr(value, "__iter__") and not isinstance(value, (str, bytes)):
return list(value)
elif value is not None:
return [value]
return []
except Exception as e:
if current_app:
current_app.logger.error(f"Error resolving data source {data_source}: {e}")
else:
print(f"Error resolving data source {data_source}: {e}")
import traceback
if current_app:
current_app.logger.debug(traceback.format_exc())
else:
print(traceback.format_exc())
return []
def _process_row_template(self, field: str, template: str, item: Any) -> str:
"""Process row template with item context"""
if not template:
# Fallback to direct attribute access
return str(getattr(item, field, ""))
# Process template with item in context
from flask import render_template_string
try:
return render_template_string(template, item=item, **self.context)
except Exception as e:
if current_app:
current_app.logger.error(f"Error processing row template: {e}")
else:
print(f"Error processing row template: {e}")
return str(getattr(item, field, ""))
def _on_page(self, canv: canvas.Canvas, doc: SimpleDocTemplate):
"""Handle page rendering - draw absolutely positioned elements and page number"""
# Get page dimensions
page_width = getattr(self, "page_width", doc.pagesize[0])
page_height = getattr(self, "page_height", doc.pagesize[1])
# Get margins
margins = self.template.get("page", {}).get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20})
margin_top = margins.get("top", 20) * mm
margin_left = margins.get("left", 20) * mm
for element_data in getattr(self, "absolute_elements", []):
element = element_data["element"]
element_type = element.get("type")
# Get position (already in points from generateCode); coerce to float (JSON may give int/str)
try:
x = float(element.get("x", 0) or 0)
y = float(element.get("y", 0) or 0)
width = float(element.get("width", 0) or 0)
height = float(element.get("height", 0) or 0)
except (TypeError, ValueError):
x = y = width = height = 0
# Validate element position to page boundaries
# Elements are positioned relative to page (0,0 = top-left of page)
# IMAGE: skip only when rect does not intersect page (so overflowing images still draw)
# Others: skip when top-left is outside page
if element_type == ElementType.IMAGE:
if x + width <= 0 or x >= page_width or y >= page_height or y + height <= 0:
continue
else:
if x < 0 or y < 0 or x >= page_width or y >= page_height:
continue
# For elements with explicit dimensions, constrain them to page boundaries
# Issue #537: IMAGE elements - don't constrain; draw at full size and clip in _draw_image_on_canvas
# draw at full size and let PDF clip; user expects overflowing images to still show
is_image = element_type == ElementType.IMAGE
if width > 0 and height > 0 and not is_image:
# Constrain dimensions if they would extend beyond page
if x + width > page_width:
width = max(0, page_width - x)
if y + height > page_height:
height = max(0, page_height - y)
# Skip if constrained to zero size
if width <= 0 or height <= 0:
continue
# Update element with constrained dimensions for drawing
element_copy = element.copy()
element_copy["x"] = x
element_copy["y"] = y
element_copy["width"] = width
element_copy["height"] = height
# Elements are positioned relative to page (0,0 = top-left of page)
# ReportLab's SimpleDocTemplate margins define the content area, but when drawing
# directly on canvas in _on_page(), we use absolute page coordinates
# So we should NOT add margins - elements are already at correct page positions
actual_x = x
# y is from top, so invert it for ReportLab (which uses bottom-left origin)
actual_y = page_height - y
try:
if element_type == ElementType.TEXT:
self._draw_text_on_canvas(canv, element_copy, actual_x, actual_y, page_height)
elif element_type == ElementType.IMAGE:
self._draw_image_on_canvas(canv, element_copy, actual_x, actual_y, page_height)
elif element_type == ElementType.RECTANGLE:
self._draw_rectangle_on_canvas(canv, element_copy, actual_x, actual_y, page_height)
elif element_type == ElementType.CIRCLE:
self._draw_circle_on_canvas(canv, element_copy, actual_x, actual_y, page_height)
elif element_type == ElementType.LINE:
self._draw_line_on_canvas(canv, element_copy, actual_x, actual_y, page_height)
# Tables are handled as flowables, not drawn on canvas
except Exception as e:
if current_app:
current_app.logger.error(f"Error drawing element {element_type} on canvas: {e}")
else:
print(f"Error drawing element {element_type} on canvas: {e}")
# Add page number (within page boundaries)
page_num = canv.getPageNumber()
text = f"Page {page_num}"
canv.saveState()
canv.setFont("Helvetica", 9)
canv.setFillColor(colors.HexColor("#666666"))
# Ensure page number is within page boundaries
page_num_x = min(doc.leftMargin + doc.width, page_width - 10)
page_num_y = max(doc.bottomMargin - 0.5 * cm, 10)
canv.drawRightString(page_num_x, page_num_y, text)
canv.restoreState()
def _draw_text_on_canvas(
self, canv: canvas.Canvas, element: Dict[str, Any], x: float, y: float, page_height: float
):
"""Draw text element on canvas"""
text = element.get("text", "")
text = self._process_template_variables(text)
style = element.get("style", {})
font = style.get("font", "Helvetica")
size = style.get("size", 10)
color = style.get("color", "#000000")
align = style.get("align", "left")
# Get page width for boundary checking
page_width = getattr(self, "page_width", 595) # Default A4 width
canv.saveState()
canv.setFont(font, size)
color_hex = _normalize_color(color)
if color_hex:
canv.setFillColor(colors.HexColor(color_hex))
# Constrain text width to page boundaries
width = element.get("width", 400)
max_x = min(x + width, page_width)
constrained_width = max_x - x
# Split text by newlines to handle line breaks properly
# Also handle \r\n (Windows) and \r (old Mac) line endings
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
# Calculate line height (font size * 1.2 for spacing)
line_height = size * 1.2
# Draw each line separately
current_y = y
for line in lines:
# Skip empty lines but still advance y position
if not line.strip() and len(lines) > 1:
current_y -= line_height
continue
# Handle text alignment with boundary constraints for each line
if align == "right":
draw_x = min(x + constrained_width, page_width - 5)
canv.drawRightString(draw_x, current_y, line)
elif align == "center":
draw_x = x + constrained_width / 2
canv.drawCentredString(draw_x, current_y, line)
else:
draw_x = min(x, page_width - 5)
canv.drawString(draw_x, current_y, line)
# Move to next line (decrease y since ReportLab uses bottom-left origin)
current_y -= line_height
canv.restoreState()
def _resolve_template_image_path(self, filename: str) -> Optional[str]:
"""
Resolve template image filename to absolute file path.
Issue #537: Tries multiple paths to handle different deployment layouts.
Returns:
Absolute file path if file exists, None otherwise.
"""
if not filename or not isinstance(filename, str):
return None
filename = filename.strip()
if not filename:
return None
try:
# Same path logic as upload route (app/routes/admin.py upload_template_image)
root = getattr(current_app, "root_path", None) if current_app else None
if not root:
return None
upload_folder = "app/static/uploads/template_images"
candidates = [
os.path.join(root, "..", upload_folder, filename),
os.path.join(root, "static", "uploads", "template_images", filename),
os.path.join(os.path.dirname(root), upload_folder, filename),
]
for path in candidates:
resolved = os.path.abspath(path)
if os.path.exists(resolved) and os.path.isfile(resolved):
return resolved
except Exception as e:
if current_app:
current_app.logger.warning(f"Error resolving template image path for '{filename}': {e}")
return None
def _extract_template_image_filename(self, source: str) -> Optional[str]:
"""
Extract filename from template image URL/path.
Handles: /uploads/template_images/xxx, https://host/uploads/template_images/xxx
"""
if not source or not isinstance(source, str):
return None
source = source.strip()
marker = "/uploads/template_images/"
idx = source.find(marker)
if idx >= 0:
return source[idx + len(marker) :].strip()
return None
def _draw_image_on_canvas(
self, canv: canvas.Canvas, element: Dict[str, Any], x: float, y: float, page_height: float
):
"""Draw image element on canvas"""
source = element.get("source", "")
source = self._process_template_variables(source)
# Issue #432: Explicit skip for decorative images with empty source (never draw)
if element.get("decorative", False) and (not source or not source.strip()):
if current_app:
current_app.logger.warning(f"Skipping decorative image with empty source at position ({x}, {y})")
return
if not source or not source.strip():
if current_app:
current_app.logger.warning(f"Skipping image with empty source at position ({x}, {y})")
return
width = element.get("width", 100)
height = element.get("height", 100)
# Issue #537: Decorative images use exact dimensions (no aspect ratio preservation)
preserve_aspect = not element.get("decorative", False)
# Issue #537: Draw images at full size when overflowing; clip to page so visible portion shows
if width <= 0 or height <= 0:
return
page_width = getattr(self, "page_width", 595)
def _draw_image_clipped(img_source, draw_x, draw_y, draw_w, draw_h):
"""Draw image with clip to page rectangle so overflow is clipped."""
canv.saveState()
try:
# Clip to page: path rect then clipPath (no stroke/fill)
path = canv.beginPath()
path.rect(0, 0, page_width, page_height)
canv.clipPath(path, stroke=0, fill=0)
canv.drawImage(
img_source,
draw_x,
draw_y,
width=draw_w,
height=draw_h,
preserveAspectRatio=preserve_aspect,
mask="auto",
)
finally:
canv.restoreState()
try:
draw_y = y - height
# Handle base64 data URI with validated decode (Issue #432)
if source.startswith("data:image"):
import base64
parts = source.split(",", 1)
if len(parts) < 2 or not (parts[1] and parts[1].strip()):
if current_app:
current_app.logger.warning("Skipping image draw: data URI has no base64 payload")
return
try:
img_data = base64.b64decode(parts[1])
img_reader = ImageReader(io.BytesIO(img_data))
_draw_image_clipped(img_reader, x, draw_y, width, height)
except Exception as e:
if current_app:
current_app.logger.error(f"Error decoding base64 image for canvas: {e}")
return
# Handle template image URLs (convert to file path or base64)
# Issue #537: Also handle full URLs; try file path first for reliability
elif "/uploads/template_images/" in source:
try:
filename = self._extract_template_image_filename(source)
if not filename:
if current_app:
current_app.logger.warning(f"Could not extract filename from template image URL: {source}")
raise ValueError("Invalid template image URL")
img_reader = None
# Try direct file path first (most reliable across deployments)
file_path = self._resolve_template_image_path(filename)
if file_path and os.path.exists(file_path):
img_reader = ImageReader(file_path)
if not img_reader:
from app.utils.template_filters import get_image_base64
relative_path = f"app/static/uploads/template_images/{filename}"
base64_data = get_image_base64(relative_path)
if base64_data:
import base64 as b64
header, data = base64_data.split(",", 1)
img_data = b64.b64decode(data)
img_reader = ImageReader(io.BytesIO(img_data))
if img_reader:
if current_app:
current_app.logger.info(
f"[PDF_EXPORT] Drawing template image: {filename} at ({x}, {y - height}) size {width}x{height}"
)
_draw_image_clipped(img_reader, x, draw_y, width, height)
elif current_app:
current_app.logger.warning(
f"Template image not found: {source} (tried file path and get_image_base64)"
)
except Exception as e:
if current_app:
current_app.logger.error(f"Error loading template image {source}: {e}")
else:
print(f"Error loading template image {source}: {e}")
elif os.path.exists(source):
_draw_image_clipped(source, x, draw_y, width, height)
except Exception as e:
if current_app:
current_app.logger.error(f"Error drawing image on canvas: {e}")
else:
print(f"Error drawing image on canvas: {e}")
def _draw_rectangle_on_canvas(
self, canv: canvas.Canvas, element: Dict[str, Any], x: float, y: float, page_height: float
):
"""Draw rectangle element on canvas"""
width = element.get("width", 100)
height = element.get("height", 100)
style = element.get("style", {})
fill = style.get("fill", element.get("fill"))
stroke = style.get("stroke", element.get("stroke", "black"))
stroke_width = style.get("strokeWidth", element.get("strokeWidth", 1)) or 0
# Get page dimensions for boundary checking
page_width = getattr(self, "page_width", 595) # Default A4 width
# Constrain rectangle to page boundaries
# y is in ReportLab coordinates (bottom-left origin), so y - height is the bottom
max_width = page_width - x
max_height = y # y is the top, so y is the max height from bottom
width = min(width, max_width)
height = min(height, max_height)
# Skip if rectangle would be outside page boundaries
if width <= 0 or height <= 0 or x >= page_width or y <= 0:
return
canv.saveState()
if fill and fill != "transparent":
fill_hex = _normalize_color(fill)
if fill_hex:
canv.setFillColor(colors.HexColor(fill_hex))
if stroke and stroke_width > 0:
stroke_hex = _normalize_color(stroke)
if stroke_hex:
canv.setStrokeColor(colors.HexColor(stroke_hex))
canv.setLineWidth(stroke_width)
canv.rect(
x,
y - height,
width,
height,
fill=bool(fill and fill != "transparent"),
stroke=bool(stroke and stroke_width > 0),
)
canv.restoreState()
def _draw_circle_on_canvas(
self, canv: canvas.Canvas, element: Dict[str, Any], x: float, y: float, page_height: float
):
"""Draw circle element on canvas"""
width = element.get("width", 100)
radius = width / 2
style = element.get("style", {})
fill = style.get("fill", element.get("fill"))
stroke = style.get("stroke", element.get("stroke", "black"))
stroke_width = style.get("strokeWidth", element.get("strokeWidth", 1)) or 0
# Get page dimensions for boundary checking
page_width = getattr(self, "page_width", 595) # Default A4 width
# Constrain circle to page boundaries
# Circle center will be at (x + radius, y - radius)
# Ensure circle doesn't extend beyond page
max_radius_x = min(page_width - x, x) # Distance to nearest horizontal edge
max_radius_y = min(y, page_height - y) # Distance to nearest vertical edge
max_radius = min(max_radius_x, max_radius_y)
radius = min(radius, max_radius)
# Skip if circle would be outside page boundaries
if radius <= 0 or x + radius > page_width or y - radius < 0:
return
canv.saveState()
if fill and fill != "transparent":
fill_hex = _normalize_color(fill)
if fill_hex:
canv.setFillColor(colors.HexColor(fill_hex))
if stroke and stroke_width > 0:
stroke_hex = _normalize_color(stroke)
if stroke_hex:
canv.setStrokeColor(colors.HexColor(stroke_hex))
canv.setLineWidth(stroke_width)
# Circle center is at (x + radius, y - radius), radius is half width
canv.circle(
x + radius,
y - radius,
radius,
fill=bool(fill and fill != "transparent"),
stroke=bool(stroke and stroke_width > 0),
)
canv.restoreState()
def _draw_line_on_canvas(
self, canv: canvas.Canvas, element: Dict[str, Any], x: float, y: float, page_height: float
):
"""Draw line element on canvas"""
width = element.get("width", 100)
style = element.get("style", {})
stroke = style.get("stroke", element.get("stroke", "black"))
stroke_width = style.get("strokeWidth", element.get("strokeWidth", 1)) or 1
# Get page dimensions for boundary checking
page_width = getattr(self, "page_width", 595) # Default A4 width
# Constrain line to page boundaries
# Ensure line doesn't extend beyond page
max_width = page_width - x
width = min(width, max_width)
# Skip if line would be outside page boundaries
if width <= 0 or x >= page_width or y < 0 or y > page_height:
return
canv.saveState()
stroke_hex = _normalize_color(stroke)
if stroke_hex:
canv.setStrokeColor(colors.HexColor(stroke_hex))
canv.setLineWidth(stroke_width)
canv.line(x, y, x + width, y)
canv.restoreState()