mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-21 05:40:26 -05:00
743b7b9d4d
- Admin PDF preview: build all_line_items on invoice wrapper and resolve table data from element data source (invoice.all_line_items or invoice.items) so preview matches exported PDF. - ReportLab: when template uses invoice.items, append both extra_goods and expenses to table data so all line types appear in PDF. - Export PDF: explicitly load items, extra_goods, and expenses before generation so data is in session. - Docs: recommend invoice.all_line_items for custom templates; document backward compatibility and preview behavior. Refs #503 Co-authored-by: Cursor <cursoragent@cursor.com>
1211 lines
53 KiB
Python
1211 lines
53 KiB
Python
"""
|
|
ReportLab PDF Template Renderer
|
|
|
|
Converts ReportLab template JSON (generated by visual editor) into PDF documents
|
|
using ReportLab's programmatic PDF generation.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import io
|
|
from types import SimpleNamespace
|
|
from typing import Dict, List, Any, Optional, Union
|
|
from datetime import datetime
|
|
|
|
from reportlab.lib.pagesizes import A4, A5, A3, LETTER, LEGAL, TABLOID
|
|
from reportlab.lib.units import cm, mm, inch
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
|
|
# 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 reportlab.lib import colors
|
|
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
|
|
from reportlab.platypus import (
|
|
SimpleDocTemplate,
|
|
Paragraph,
|
|
Spacer,
|
|
Table,
|
|
TableStyle,
|
|
Image,
|
|
PageBreak,
|
|
KeepTogether,
|
|
Flowable
|
|
)
|
|
from reportlab.pdfgen import canvas
|
|
from reportlab.lib.utils import ImageReader
|
|
|
|
from flask import current_app
|
|
from flask_babel import gettext as _
|
|
from app.models import Settings
|
|
from app.utils.pdf_template_schema import (
|
|
validate_template_json,
|
|
get_page_dimensions_points,
|
|
PAGE_SIZE_DIMENSIONS_MM,
|
|
ElementType,
|
|
TextAlign
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
# CRITICAL FIX: Skip decorative images with empty or invalid source
|
|
# Decorative images are optional and should not break PDF generation if missing
|
|
if not source or not source.strip():
|
|
# For decorative images, silently skip if source is empty
|
|
if element.get("decorative", False):
|
|
if current_app:
|
|
current_app.logger.warning(f"Skipping decorative image flowable with empty source")
|
|
return None
|
|
# For non-decorative images, also return None
|
|
return None
|
|
|
|
# Handle base64 data URI
|
|
if source.startswith("data:image"):
|
|
# Extract base64 data
|
|
import base64
|
|
header, data = source.split(",", 1)
|
|
try:
|
|
img_data = base64.b64decode(data)
|
|
img_reader = ImageReader(io.BytesIO(img_data))
|
|
|
|
width = element.get("width", 100) # Already in points from generateCode
|
|
height = element.get("height", 100) # Already in points from generateCode
|
|
|
|
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)
|
|
if source.startswith("/uploads/template_images/"):
|
|
try:
|
|
from app.utils.template_filters import get_image_base64
|
|
# Extract filename from URL
|
|
filename = source.split("/uploads/template_images/")[-1]
|
|
# Build relative path (as get_image_base64 expects)
|
|
relative_path = f"app/static/uploads/template_images/{filename}"
|
|
# Convert to base64
|
|
base64_data = get_image_base64(relative_path)
|
|
if base64_data:
|
|
# Convert base64 data URI to ImageReader
|
|
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)
|
|
# Image() constructor preserves transparency automatically for PNG images
|
|
return Image(img_reader, width=width, height=height)
|
|
else:
|
|
# Fallback: try direct file path
|
|
if current_app:
|
|
upload_folder = os.path.join(current_app.root_path, "..", "app/static/uploads/template_images")
|
|
file_path = os.path.join(upload_folder, filename)
|
|
if os.path.exists(file_path):
|
|
width = element.get("width", 100)
|
|
height = element.get("height", 100)
|
|
return Image(file_path, width=width, height=height)
|
|
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)
|
|
x = element.get("x", 0)
|
|
y = element.get("y", 0)
|
|
width = element.get("width", 0)
|
|
height = element.get("height", 0)
|
|
|
|
# Validate element position to page boundaries
|
|
# Elements are positioned relative to page (0,0 = top-left of page)
|
|
# Skip elements that are completely outside page boundaries
|
|
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
|
|
# Text elements might not have width/height, which is fine - they'll be drawn without size constraints
|
|
if width > 0 and height > 0:
|
|
# 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 _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)
|
|
|
|
# CRITICAL FIX: Skip decorative images with empty or invalid source to prevent black screen
|
|
# Decorative images are optional and should not break PDF generation if missing
|
|
if not source or not source.strip():
|
|
# For decorative images, silently skip if source is empty
|
|
if element.get("decorative", False):
|
|
if current_app:
|
|
current_app.logger.warning(f"Skipping decorative image with empty source at position ({x}, {y})")
|
|
return
|
|
# For non-decorative images, also skip but log warning
|
|
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)
|
|
|
|
# Get page dimensions for boundary checking
|
|
page_width = getattr(self, 'page_width', 595) # Default A4 width
|
|
|
|
# Constrain image dimensions to page boundaries
|
|
# y is in ReportLab coordinates (bottom-left origin), so y - height is the bottom
|
|
# Ensure image doesn't extend beyond page
|
|
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 image would be outside page boundaries
|
|
if width <= 0 or height <= 0 or x >= page_width or y <= 0:
|
|
return
|
|
|
|
try:
|
|
# Handle base64 data URI
|
|
if source.startswith("data:image"):
|
|
import base64
|
|
header, data = source.split(",", 1)
|
|
img_data = base64.b64decode(data)
|
|
img_reader = ImageReader(io.BytesIO(img_data))
|
|
# mask='auto' preserves transparency for PNG images
|
|
canv.drawImage(img_reader, x, y - height, width=width, height=height, preserveAspectRatio=True, mask='auto')
|
|
# Handle template image URLs (convert to file path or base64)
|
|
elif source.startswith("/uploads/template_images/"):
|
|
try:
|
|
from app.utils.template_filters import get_image_base64
|
|
# Extract filename from URL
|
|
filename = source.split("/uploads/template_images/")[-1]
|
|
# Build relative path (as get_image_base64 expects)
|
|
relative_path = f"app/static/uploads/template_images/{filename}"
|
|
# Convert to base64
|
|
base64_data = get_image_base64(relative_path)
|
|
if base64_data:
|
|
# Convert base64 data URI to ImageReader
|
|
import base64 as b64
|
|
header, data = base64_data.split(",", 1)
|
|
img_data = b64.b64decode(data)
|
|
img_reader = ImageReader(io.BytesIO(img_data))
|
|
# preserveAspectRatio=True preserves transparency
|
|
canv.drawImage(img_reader, x, y - height, width=width, height=height, preserveAspectRatio=True, mask='auto')
|
|
else:
|
|
# Fallback: try direct file path
|
|
if current_app:
|
|
upload_folder = os.path.join(current_app.root_path, "..", "app/static/uploads/template_images")
|
|
file_path = os.path.join(upload_folder, filename)
|
|
if os.path.exists(file_path):
|
|
# mask='auto' preserves transparency for PNG images
|
|
canv.drawImage(file_path, x, y - height, width=width, height=height, preserveAspectRatio=True, mask='auto')
|
|
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):
|
|
# mask='auto' preserves transparency for PNG images
|
|
canv.drawImage(source, x, y - height, width=width, height=height, preserveAspectRatio=True, mask='auto')
|
|
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()
|