mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-23 06:40:53 -05:00
64eee1acd9
Fixes issue #432 where decorative images would disappear after saving the layout and PDF preview would show mostly black. Root causes: - Konva's toJSON() doesn't serialize custom attributes like imageUrl - Warning system adds 'element-overlap' suffix to names, breaking exact matches - PDF generation tried to render images with empty/invalid sources Fixes applied: 1. Serialization fixes (quote_pdf_layout.html, pdf_layout.html): - Manually inject imageUrl into JSON after Konva serialization - Store imageUrl in a map before serialization and match by position/index - Ensure primary name is 'decorative-image' before serialization - Fix name matching to handle 'decorative-image element-overlap' names 2. Restoration fixes (quote_pdf_layout.html, pdf_layout.html): - Search for decorative images using .includes() instead of exact match - Match by position when searching saved JSON for imageUrl - Create placeholder elements when imageUrl is missing - Ensure decorative image groups remain visible even without imageUrl 3. PDF generation fixes (pdf_generator_reportlab.py): - Skip decorative images with empty/invalid sources gracefully - Log warnings instead of attempting to render invalid images - Prevents black screen in PDF preview 4. Enhanced logging: - Added [INVOICE] and [SAVE]/[LOAD] prefixes for better debugging - Verify imageUrl presence in JSON before saving and after loading The fixes ensure decorative images are properly saved with their imageUrl attribute, correctly restored on load, and don't break PDF generation when the image source is missing or invalid.
1153 lines
50 KiB
Python
1153 lines
50 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 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 _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)
|
|
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()
|