mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [5.5.7] - 2026-05-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Invoice PDF designer layout** — Restored the missing canvas-area wrapper in the invoice PDF designer so the properties panel sits in the third grid column beside the canvas instead of stacking below it (`app/templates/admin/pdf_layout.html`).
|
||||
- **Invoice PDF preview vs export (#622)** — The JSON-to-HTML preview path now uses the same table style keys as export (header and row text, row background, border width) so the preview matches generated PDFs.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Designer template JSON and ReportLab export** — Saving template JSON from the designer reads items-table and expenses-table width, colors, and separator line settings from the Konva group children; column widths scale to the chosen table width and a style block is emitted for ReportLab (`app/routes/admin.py`, `pdf_layout.html`).
|
||||
- **ReportLab invoice tables** — Column widths scale to `element.width`; tables are wrapped in a two-column outer table so horizontal offset from the left margin is honored; `borderColor` and `borderWidth` from template style are applied (`app/utils/pdf_generator_reportlab.py`).
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Version** — Documented release **5.5.7** to match `setup.py` (single source of truth for the application version).
|
||||
|
||||
## [5.5.6] - 2026-05-14
|
||||
|
||||
### Documentation
|
||||
|
||||
+12
-3
@@ -498,10 +498,19 @@ body {{
|
||||
table_style = element.get("style", {})
|
||||
border_color = format_color(table_style.get("borderColor", "#000000"))
|
||||
header_bg = format_color(table_style.get("headerBackground", "#f8f9fa"))
|
||||
header_text_color = format_color(table_style.get("headerTextColor", "#000000"))
|
||||
row_bg = format_color(table_style.get("rowBackground", "#ffffff"))
|
||||
row_text_color = format_color(table_style.get("rowTextColor", "#000000"))
|
||||
try:
|
||||
border_w = float(table_style.get("borderWidth", 1))
|
||||
except (TypeError, ValueError):
|
||||
border_w = 1.0
|
||||
if border_w < 0.5:
|
||||
border_w = 0.5
|
||||
|
||||
style_parts = [style_str_base]
|
||||
style_parts.append(f"width: {width_px_elem}px")
|
||||
style_parts.append(f"border: 1px solid {border_color}")
|
||||
style_parts.append(f"border: {border_w}px solid {border_color}")
|
||||
style_str = "; ".join(style_parts) + ";"
|
||||
|
||||
table_html = f'<table class="element table-element" style="{style_str}"><thead><tr>'
|
||||
@@ -512,7 +521,7 @@ body {{
|
||||
align = col.get("align", "left")
|
||||
col_width = col.get("width", None)
|
||||
width_attr = f' width="{int(col_width * 96 / 72)}px"' if col_width else ""
|
||||
table_html += f'<th style="text-align: {align}; background-color: {header_bg};"{width_attr}>{html_escape.escape(header)}</th>'
|
||||
table_html += f'<th style="text-align: {align}; background-color: {header_bg}; color: {header_text_color};"{width_attr}>{html_escape.escape(header)}</th>'
|
||||
table_html += "</tr></thead><tbody>"
|
||||
|
||||
# Resolve table data from element's data source (e.g. invoice.all_line_items or invoice.items)
|
||||
@@ -576,7 +585,7 @@ body {{
|
||||
value = ""
|
||||
|
||||
value_escaped = html_escape.escape(str(value))
|
||||
table_html += f'<td style="text-align: {align};">{value_escaped}</td>'
|
||||
table_html += f'<td style="text-align: {align}; color: {row_text_color}; background-color: {row_bg};">{value_escaped}</td>'
|
||||
table_html += "</tr>"
|
||||
else:
|
||||
# No data available, show template placeholders
|
||||
|
||||
@@ -2263,6 +2263,7 @@
|
||||
</div>
|
||||
<div id="pdf-marquee" class="pdf-editor-marquee"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Properties Panel -->
|
||||
<div class="properties-panel">
|
||||
@@ -6120,7 +6121,14 @@ async function initializePDFEditor() {
|
||||
// Extract actual header text from the table group's first Text child
|
||||
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
||||
const textElements = children.filter(c => c.className === 'Text');
|
||||
const headerText = textElements[0] ? (textElements[0].attrs.text || '') : '';
|
||||
const lineElements = children.filter(c => c.className === 'Line');
|
||||
const headerEl = textElements[0];
|
||||
const itemsEl = textElements[1];
|
||||
const lineEl = lineElements[0];
|
||||
const headerAttrs = headerEl ? headerEl.attrs : {};
|
||||
const itemsAttrs = itemsEl ? itemsEl.attrs : {};
|
||||
const lineAttrs = lineEl ? lineEl.attrs : {};
|
||||
const headerText = headerEl ? (headerAttrs.text || '') : '';
|
||||
|
||||
// Parse header text (format: "Description | Qty | Price | Total" or German equivalent)
|
||||
// Default to English if header text is empty or doesn't contain |
|
||||
@@ -6132,6 +6140,19 @@ async function initializePDFEditor() {
|
||||
headerParts.push(['Description', 'Qty', 'Unit Price', 'Total'][headerParts.length]);
|
||||
}
|
||||
}
|
||||
const tableWidthPx = Math.max(50, Math.round(headerAttrs.width || itemsAttrs.width || 515));
|
||||
const itemsFracs = [250 / 540, 70 / 540, 110 / 540, 110 / 540];
|
||||
const totalPtItems = pxToPt(tableWidthPx);
|
||||
let colPtsItems = itemsFracs.map(f => f * totalPtItems);
|
||||
const sumCI = colPtsItems.reduce((a, b) => a + b, 0);
|
||||
if (sumCI > 0) {
|
||||
colPtsItems = colPtsItems.map(p => (p * totalPtItems) / sumCI);
|
||||
}
|
||||
const headerTextHex = rgbToHex(headerAttrs.fill || '#000000');
|
||||
const itemsTextHex = rgbToHex(itemsAttrs.fill || '#000000');
|
||||
const lineStrokeHex = rgbToHex(lineAttrs.stroke || '#333333');
|
||||
const _rawLineW = Number(lineAttrs.strokeWidth);
|
||||
const lineStrokeW = (Number.isFinite(_rawLineW) && _rawLineW > 0) ? _rawLineW : 1;
|
||||
|
||||
// Add to ReportLab template JSON - items table
|
||||
{% raw %}
|
||||
@@ -6147,39 +6168,47 @@ async function initializePDFEditor() {
|
||||
type: 'table',
|
||||
x: pxToPt(x),
|
||||
y: pxToPt(y),
|
||||
width: pxToPt(515),
|
||||
width: totalPtItems,
|
||||
opacity: opacity,
|
||||
columns: [
|
||||
{width: 250, header: headerParts[0] || 'Description', field: 'description', align: 'left'},
|
||||
{width: 70, header: headerParts[1] || 'Qty', field: 'quantity', align: 'center'},
|
||||
{width: 110, header: headerParts[2] || 'Unit Price', field: 'unit_price', align: 'right'},
|
||||
{width: 110, header: headerParts[3] || 'Total', field: 'total_amount', align: 'right'}
|
||||
{width: colPtsItems[0], header: headerParts[0] || 'Description', field: 'description', align: 'left'},
|
||||
{width: colPtsItems[1], header: headerParts[1] || 'Qty', field: 'quantity', align: 'center'},
|
||||
{width: colPtsItems[2], header: headerParts[2] || 'Unit Price', field: 'unit_price', align: 'right'},
|
||||
{width: colPtsItems[3], header: headerParts[3] || 'Total', field: 'total_amount', align: 'right'}
|
||||
],
|
||||
data: itemsData,
|
||||
row_template: itemsRowTemplate
|
||||
row_template: itemsRowTemplate,
|
||||
style: {
|
||||
headerBackground: '#f8f9fa',
|
||||
headerTextColor: headerTextHex,
|
||||
rowBackground: '#ffffff',
|
||||
rowTextColor: itemsTextHex,
|
||||
borderColor: lineStrokeHex,
|
||||
borderWidth: lineStrokeW
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy HTML for preview
|
||||
bodyContent += ` <!-- Items Table Start -->\n`;
|
||||
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:515px;">\n`;
|
||||
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:${tableWidthPx}px;">\n`;
|
||||
bodyContent += ` <table style="width:100%;border-collapse:collapse;font-size:11px;background:white;">\n`;
|
||||
bodyContent += ` <thead>\n`;
|
||||
bodyContent += ` <tr style="background-color:#f8f9fa;border-bottom:2px solid #333;">\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;">${(headerParts[0] || 'Description').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:70px;">${(headerParts[1] || 'Qty').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[2] || 'Unit Price').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[3] || 'Total').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <tr style="background-color:#f8f9fa;border-bottom:${lineStrokeW}px solid ${lineStrokeHex};">\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;color:${headerTextHex};">${(headerParts[0] || 'Description').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:70px;color:${headerTextHex};">${(headerParts[1] || 'Qty').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:${headerTextHex};">${(headerParts[2] || 'Unit Price').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:${headerTextHex};">${(headerParts[3] || 'Total').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` </tr>\n`;
|
||||
bodyContent += ` </thead>\n`;
|
||||
bodyContent += ` <tbody>\n`;
|
||||
{% raw %}
|
||||
bodyContent += ` {% if invoice.all_line_items %}\n`;
|
||||
bodyContent += ` {% for item in invoice.all_line_items %}\n`;
|
||||
bodyContent += ` <tr style="border-bottom:1px solid #ddd;">\n`;
|
||||
bodyContent += ` <td style="padding:10px;vertical-align:top;">{{ item.description }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:center;vertical-align:top;">{{ item.quantity }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;">{{ format_money(item.unit_price) }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;font-weight:bold;">{{ format_money(item.total_amount) }}</td>\n`;
|
||||
bodyContent += ` <tr style="border-bottom:${lineStrokeW}px solid ${lineStrokeHex};">\n`;
|
||||
bodyContent += ` <td style="padding:10px;vertical-align:top;color:${itemsTextHex};">{{ item.description }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:center;vertical-align:top;color:${itemsTextHex};">{{ item.quantity }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;color:${itemsTextHex};">{{ format_money(item.unit_price) }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;font-weight:bold;color:${itemsTextHex};">{{ format_money(item.total_amount) }}</td>\n`;
|
||||
bodyContent += ` </tr>\n`;
|
||||
bodyContent += ` {% endfor %}\n`;
|
||||
bodyContent += ` {% else %}\n`;
|
||||
@@ -6196,7 +6225,14 @@ async function initializePDFEditor() {
|
||||
// Extract actual header text from the table group's first Text child
|
||||
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
||||
const textElements = children.filter(c => c.className === 'Text');
|
||||
const headerText = textElements[0] ? (textElements[0].attrs.text || '') : '';
|
||||
const lineElements = children.filter(c => c.className === 'Line');
|
||||
const headerEl = textElements[0];
|
||||
const itemsEl = textElements[1];
|
||||
const lineEl = lineElements[0];
|
||||
const headerAttrs = headerEl ? headerEl.attrs : {};
|
||||
const itemsAttrs = itemsEl ? itemsEl.attrs : {};
|
||||
const lineAttrs = lineEl ? lineEl.attrs : {};
|
||||
const headerText = headerEl ? (headerAttrs.text || '') : '';
|
||||
|
||||
// Parse header text (format: "Expense | Date | Category | Amount" or localized)
|
||||
// Default to English if header text is empty or doesn't contain |
|
||||
@@ -6208,6 +6244,24 @@ async function initializePDFEditor() {
|
||||
headerParts.push(['Expense', 'Date', 'Category', 'Amount'][headerParts.length]);
|
||||
}
|
||||
}
|
||||
const defExpHeaderBg = '#fff3cd';
|
||||
const defExpHeaderText = '#856404';
|
||||
const defExpRowBg = '#fffbf0';
|
||||
const defExpRowText = '#856404';
|
||||
const defExpBorder = '#856404';
|
||||
const tableWidthPxExp = Math.max(50, Math.round(headerAttrs.width || itemsAttrs.width || 515));
|
||||
const expFracs = [200 / 515, 100 / 515, 105 / 515, 110 / 515];
|
||||
const totalPtExp = pxToPt(tableWidthPxExp);
|
||||
let colPtsExp = expFracs.map(f => f * totalPtExp);
|
||||
const sumCE = colPtsExp.reduce((a, b) => a + b, 0);
|
||||
if (sumCE > 0) {
|
||||
colPtsExp = colPtsExp.map(p => (p * totalPtExp) / sumCE);
|
||||
}
|
||||
const expHeaderTextHex = rgbToHex(headerAttrs.fill || defExpHeaderText);
|
||||
const expItemsTextHex = rgbToHex(itemsAttrs.fill || defExpRowText);
|
||||
const expLineStrokeHex = rgbToHex(lineAttrs.stroke || defExpBorder);
|
||||
const _rawExpLW = Number(lineAttrs.strokeWidth);
|
||||
const expLineStrokeW = (Number.isFinite(_rawExpLW) && _rawExpLW > 0) ? _rawExpLW : 1;
|
||||
|
||||
// Add to ReportLab template JSON - expenses table
|
||||
{% raw %}
|
||||
@@ -6223,45 +6277,47 @@ async function initializePDFEditor() {
|
||||
type: 'table',
|
||||
x: pxToPt(x),
|
||||
y: pxToPt(y),
|
||||
width: pxToPt(515),
|
||||
width: totalPtExp,
|
||||
opacity: opacity,
|
||||
columns: [
|
||||
{width: 200, header: headerParts[0] || 'Expense', field: 'title', align: 'left'},
|
||||
{width: 100, header: headerParts[1] || 'Date', field: 'expense_date', align: 'center'},
|
||||
{width: 105, header: headerParts[2] || 'Category', field: 'category', align: 'left'},
|
||||
{width: 110, header: headerParts[3] || 'Amount', field: 'total_amount', align: 'right'}
|
||||
{width: colPtsExp[0], header: headerParts[0] || 'Expense', field: 'title', align: 'left'},
|
||||
{width: colPtsExp[1], header: headerParts[1] || 'Date', field: 'expense_date', align: 'center'},
|
||||
{width: colPtsExp[2], header: headerParts[2] || 'Category', field: 'category', align: 'left'},
|
||||
{width: colPtsExp[3], header: headerParts[3] || 'Amount', field: 'total_amount', align: 'right'}
|
||||
],
|
||||
data: expensesData,
|
||||
row_template: expensesRowTemplate,
|
||||
style: {
|
||||
headerBackground: '#fff3cd',
|
||||
headerTextColor: '#856404',
|
||||
rowBackground: '#fffbf0',
|
||||
rowTextColor: '#856404'
|
||||
headerBackground: defExpHeaderBg,
|
||||
headerTextColor: expHeaderTextHex,
|
||||
rowBackground: defExpRowBg,
|
||||
rowTextColor: expItemsTextHex,
|
||||
borderColor: expLineStrokeHex,
|
||||
borderWidth: expLineStrokeW
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy HTML for preview
|
||||
bodyContent += ` <!-- Expenses Table Start -->\n`;
|
||||
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:515px;">\n`;
|
||||
bodyContent += ` <table style="width:100%;border-collapse:collapse;font-size:11px;background:#fffbf0;">\n`;
|
||||
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:${tableWidthPxExp}px;">\n`;
|
||||
bodyContent += ` <table style="width:100%;border-collapse:collapse;font-size:11px;background:${defExpRowBg};">\n`;
|
||||
bodyContent += ` <thead>\n`;
|
||||
bodyContent += ` <tr style="background-color:#fff3cd;border-bottom:2px solid #856404;">\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;color:#856404;">${(headerParts[0] || 'Expense').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:100px;color:#856404;">${(headerParts[1] || 'Date').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">${(headerParts[2] || 'Category').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">${(headerParts[3] || 'Amount').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <tr style="background-color:${defExpHeaderBg};border-bottom:${expLineStrokeW}px solid ${expLineStrokeHex};">\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;color:${expHeaderTextHex};">${(headerParts[0] || 'Expense').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:100px;color:${expHeaderTextHex};">${(headerParts[1] || 'Date').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;width:110px;color:${expHeaderTextHex};">${(headerParts[2] || 'Category').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:${expHeaderTextHex};">${(headerParts[3] || 'Amount').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` </tr>\n`;
|
||||
bodyContent += ` </thead>\n`;
|
||||
bodyContent += ` <tbody>\n`;
|
||||
{% raw %}
|
||||
bodyContent += ` {% if invoice.expenses %}\n`;
|
||||
bodyContent += ` {% for expense in invoice.expenses %}\n`;
|
||||
bodyContent += ` <tr style="border-bottom:1px solid #f0e5c1;">\n`;
|
||||
bodyContent += ` <td style="padding:10px;vertical-align:top;color:#856404;">{{ expense.title }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:center;vertical-align:top;color:#856404;">{{ expense.expense_date }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;vertical-align:top;color:#856404;">{{ expense.category }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;font-weight:bold;color:#856404;">{{ format_money(expense.total_amount) }}</td>\n`;
|
||||
bodyContent += ` <tr style="border-bottom:${expLineStrokeW}px solid #f0e5c1;">\n`;
|
||||
bodyContent += ` <td style="padding:10px;vertical-align:top;color:${expItemsTextHex};">{{ expense.title }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:center;vertical-align:top;color:${expItemsTextHex};">{{ expense.expense_date }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;vertical-align:top;color:${expItemsTextHex};">{{ expense.category }}</td>\n`;
|
||||
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;font-weight:bold;color:${expItemsTextHex};">{{ format_money(expense.total_amount) }}</td>\n`;
|
||||
bodyContent += ` </tr>\n`;
|
||||
bodyContent += ` {% endfor %}\n`;
|
||||
bodyContent += ` {% else %}\n`;
|
||||
|
||||
@@ -363,13 +363,14 @@ class ReportLabTemplateRenderer:
|
||||
# Tables are rendered as flowables (they flow naturally)
|
||||
# Add spacer to position table vertically based on y coordinate
|
||||
table_y = element.get("y", 0)
|
||||
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
|
||||
margin_left = margins.get("left", 20) * mm
|
||||
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)
|
||||
@@ -378,12 +379,36 @@ class ReportLabTemplateRenderer:
|
||||
|
||||
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)
|
||||
col_widths = self._scaled_table_col_widths(element)
|
||||
total_table_w = sum(col_widths) if col_widths else 0.0
|
||||
table_x = element.get("x") or 0
|
||||
try:
|
||||
table_x_f = float(table_x)
|
||||
except (TypeError, ValueError):
|
||||
table_x_f = 0.0
|
||||
try:
|
||||
margin_left_f = float(margin_left)
|
||||
except (TypeError, ValueError):
|
||||
margin_left_f = 0.0
|
||||
left_offset = max(0.0, table_x_f - margin_left_f)
|
||||
if left_offset > 0.01 and total_table_w > 0.01:
|
||||
spacer_para = Paragraph("", self._get_style({"style": {}}, "NormalText"))
|
||||
outer = Table([[spacer_para, table_flowable]], colWidths=[left_offset, total_table_w])
|
||||
outer.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]
|
||||
)
|
||||
)
|
||||
outer.hAlign = "LEFT"
|
||||
story.append(outer)
|
||||
else:
|
||||
story.append(table_flowable)
|
||||
else:
|
||||
# For absolute positioning, store element data to draw in page callback
|
||||
element_data = {"element": element, "renderer": self}
|
||||
@@ -665,6 +690,41 @@ class ReportLabTemplateRenderer:
|
||||
total_amount=amt,
|
||||
)
|
||||
|
||||
def _scaled_table_col_widths(self, element: Dict[str, Any]) -> List[float]:
|
||||
"""Column widths in points, scaled so their sum matches element.width when set."""
|
||||
columns = element.get("columns") or []
|
||||
if not columns:
|
||||
return []
|
||||
raw: List[float] = []
|
||||
for c in columns:
|
||||
try:
|
||||
raw.append(float(c.get("width", 100) or 100))
|
||||
except (TypeError, ValueError):
|
||||
raw.append(100.0)
|
||||
s = sum(raw)
|
||||
if s <= 0:
|
||||
raw = [100.0] * len(columns)
|
||||
s = sum(raw)
|
||||
target_f: Optional[float] = None
|
||||
tw = element.get("width")
|
||||
if tw is not None:
|
||||
try:
|
||||
twf = float(tw)
|
||||
if twf > 0:
|
||||
target_f = twf
|
||||
except (TypeError, ValueError):
|
||||
target_f = None
|
||||
if target_f is not None and abs(s - target_f) > 0.01:
|
||||
scale = target_f / s
|
||||
raw = [w * scale for w in raw]
|
||||
min_w = 10.0
|
||||
raw = [max(min_w, w) for w in raw]
|
||||
s2 = sum(raw)
|
||||
if target_f is not None and s2 > target_f + 0.01:
|
||||
scale2 = target_f / s2
|
||||
raw = [w * scale2 for w in raw]
|
||||
return raw
|
||||
|
||||
def _render_table(self, element: Dict[str, Any]) -> Table:
|
||||
"""Render a table element"""
|
||||
columns = element.get("columns", [])
|
||||
@@ -732,14 +792,8 @@ class ReportLabTemplateRenderer:
|
||||
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
|
||||
# Column widths in points (scaled to element.width when set)
|
||||
col_widths = self._scaled_table_col_widths(element)
|
||||
|
||||
# Create table with proper column widths
|
||||
table = Table(table_data, colWidths=col_widths, repeatRows=1)
|
||||
@@ -761,6 +815,15 @@ class ReportLabTemplateRenderer:
|
||||
row_bg = colors.HexColor(style_config.get("rowBackground", "#ffffff"))
|
||||
row_text_color = colors.HexColor(style_config.get("rowTextColor", "#000000"))
|
||||
|
||||
try:
|
||||
grid_w = float(style_config.get("borderWidth", 0.5))
|
||||
except (TypeError, ValueError):
|
||||
grid_w = 0.5
|
||||
if grid_w <= 0:
|
||||
grid_w = 0.5
|
||||
grid_color_hex = _normalize_color(style_config.get("borderColor", "#e2e8f0")) or "#e2e8f0"
|
||||
grid_color = colors.HexColor(grid_color_hex)
|
||||
|
||||
commands = [
|
||||
# Header row styling
|
||||
("BACKGROUND", (0, 0), (-1, 0), header_bg),
|
||||
@@ -776,7 +839,7 @@ class ReportLabTemplateRenderer:
|
||||
("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")),
|
||||
("GRID", (0, 0), (-1, -1), grid_w, grid_color),
|
||||
]
|
||||
|
||||
# Apply column alignments (both header and data rows)
|
||||
|
||||
Reference in New Issue
Block a user