mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
b4486a627f
- 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
1336 lines
56 KiB
Python
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()
|