fix: improve rich text rendering and invoice editor preview functionality

This commit addresses several issues with rich text display and the invoice
PDF layout editor:

Rich Text Rendering:
- Enhanced markdown filter to properly detect and preserve HTML content
  from WYSIWYG editor, allowing full rich text styling (colors, fonts,
  alignment) to be displayed correctly
- Improved HTML detection logic to distinguish between HTML and markdown
  content, ensuring markdown lists are properly processed
- Added support for style, class, and id attributes on all rich text
  elements (p, div, span, headings, lists, tables, etc.)
- Fixed list rendering in project/task descriptions with improved CSS:
  - Added explicit display properties for lists
  - Set proper list-style-type (disc for ul, decimal for ol)
  - Improved spacing and nested list support

Invoice Editor Improvements:
- Fixed table header text extraction: now reads actual header text from
  canvas elements instead of hardcoding English text, supporting
  internationalization (e.g., German headers)
- Preserved text alignment (left, center, right) in generated preview
  by reading Konva Text align attribute and applying text-align CSS
- Fixed PDF preview to show updated template:
  - Changed generateCode() to return template body content instead of
    full HTML document (matches preview endpoint expectations)
  - Added cache-busting to preview requests to prevent stale content
  - Improved error handling in preview fetch

Files changed:
- app/utils/template_filters.py: Enhanced markdown filter with HTML
  detection and style preservation
- app/static/enhanced-ui.css: Improved list styling for prose content
- templates/admin/pdf_layout.html: Fixed table header extraction, text
  alignment preservation, and preview generation format
This commit is contained in:
Dries Peeters
2025-11-20 21:23:14 +01:00
parent 60fb259f9e
commit 0e9f461e90
3 changed files with 188 additions and 85 deletions
+21 -2
View File
@@ -662,8 +662,27 @@
.prose p, .prose-sm p { margin: 0.5rem 0; }
.prose a, .prose-sm a { color: #3B82F6; text-decoration: underline; }
.dark .prose a, .dark .prose-sm a { color: #60A5FA; }
.prose ul, .prose ol, .prose-sm ul, .prose-sm ol { padding-left: 1.25rem; margin: 0.5rem 0; }
.prose li, .prose-sm li { margin: 0.25rem 0; }
.prose ul, .prose ol, .prose-sm ul, .prose-sm ol {
padding-left: 1.5rem;
margin: 0.75rem 0;
display: block;
list-style-position: outside;
}
.prose ul, .prose-sm ul {
list-style-type: disc;
}
.prose ol, .prose-sm ol {
list-style-type: decimal;
}
.prose li, .prose-sm li {
margin: 0.25rem 0;
display: list-item;
}
.prose ul ul, .prose ol ol, .prose ul ol, .prose ol ul,
.prose-sm ul ul, .prose-sm ol ol, .prose-sm ul ol, .prose-sm ol ul {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.prose code, .prose-sm code {
background: #F7F9FB;
color: #1F2937;
+98 -5
View File
@@ -75,9 +75,67 @@ def register_template_filters(app):
@app.template_filter('markdown')
def markdown_filter(text):
"""Render markdown to safe HTML using bleach sanitation."""
"""Render markdown to safe HTML using bleach sanitation, preserving rich text styling."""
if not text:
return ""
# Check if text appears to be pure HTML (starts with < and looks like HTML document)
# Only treat as HTML if it starts with a tag and doesn't look like markdown
import re
# More specific check: HTML should start with a tag and not be markdown list/bullet syntax
is_html = (re.match(r'^\s*<[a-z]', text, re.IGNORECASE) and
not re.match(r'^\s*[-*+]\s+', text) and # Not markdown list
not re.match(r'^\s*\d+\.\s+', text)) # Not numbered list
if is_html:
if bleach is None:
try:
from markupsafe import escape
return escape(text)
except Exception:
return text
# Allow style attributes for rich text preservation
allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({
'p', 'pre', 'code', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'ul', 'ol', 'li',
'strong', 'em', 'b', 'i', 'u', 's', 'strike', 'blockquote', 'a', 'div', 'span',
'sub', 'sup', 'del', 'ins', 'mark', 'small', 'big'
})
# Build allowed_attrs with style support for common rich text elements
allowed_attrs = {
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
'a': ['href', 'title', 'rel', 'target', 'style'],
'img': ['src', 'alt', 'title', 'style', 'width', 'height'],
'p': ['style', 'class', 'id'],
'div': ['style', 'class', 'id'],
'span': ['style', 'class', 'id'],
'h1': ['style', 'class', 'id'],
'h2': ['style', 'class', 'id'],
'h3': ['style', 'class', 'id'],
'h4': ['style', 'class', 'id'],
'h5': ['style', 'class', 'id'],
'h6': ['style', 'class', 'id'],
'strong': ['style', 'class', 'id'],
'em': ['style', 'class', 'id'],
'b': ['style', 'class', 'id'],
'i': ['style', 'class', 'id'],
'u': ['style', 'class', 'id'],
's': ['style', 'class', 'id'],
'strike': ['style', 'class', 'id'],
'blockquote': ['style', 'class', 'id'],
'ul': ['style', 'class', 'id', 'type'],
'ol': ['style', 'class', 'id', 'type', 'start'],
'li': ['style', 'class', 'id'],
'table': ['style', 'class', 'id'],
'thead': ['style', 'class', 'id'],
'tbody': ['style', 'class', 'id'],
'tr': ['style', 'class', 'id'],
'th': ['style', 'class', 'id'],
'td': ['style', 'class', 'id'],
}
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
# Process as markdown
if _md is None:
# Fallback: escape and basic nl2br
try:
@@ -86,14 +144,49 @@ def register_template_filters(app):
return text
return escape(text).replace('\n', '<br>')
html = _md.markdown(text, extensions=['extra', 'sane_lists', 'smarty'])
# Convert markdown to HTML
html = _md.markdown(text, extensions=['extra', 'sane_lists', 'smarty', 'codehilite'])
if bleach is None:
return html
allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({'p','pre','code','img','h1','h2','h3','h4','h5','h6','table','thead','tbody','tr','th','td','hr','br','ul','ol','li','strong','em','blockquote','a'})
# Sanitize the HTML output from markdown
allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({
'p', 'pre', 'code', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'ul', 'ol', 'li',
'strong', 'em', 'b', 'i', 'u', 's', 'strike', 'blockquote', 'a', 'div', 'span',
'sub', 'sup', 'del', 'ins', 'mark', 'small', 'big'
})
# Build allowed_attrs with style support for common rich text elements
allowed_attrs = {
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
'a': ['href', 'title', 'rel', 'target'],
'img': ['src', 'alt', 'title'],
'a': ['href', 'title', 'rel', 'target', 'style'],
'img': ['src', 'alt', 'title', 'style', 'width', 'height'],
'p': ['style', 'class', 'id'],
'div': ['style', 'class', 'id'],
'span': ['style', 'class', 'id'],
'h1': ['style', 'class', 'id'],
'h2': ['style', 'class', 'id'],
'h3': ['style', 'class', 'id'],
'h4': ['style', 'class', 'id'],
'h5': ['style', 'class', 'id'],
'h6': ['style', 'class', 'id'],
'strong': ['style', 'class', 'id'],
'em': ['style', 'class', 'id'],
'b': ['style', 'class', 'id'],
'i': ['style', 'class', 'id'],
'u': ['style', 'class', 'id'],
's': ['style', 'class', 'id'],
'strike': ['style', 'class', 'id'],
'blockquote': ['style', 'class', 'id'],
'ul': ['style', 'class', 'id', 'type'],
'ol': ['style', 'class', 'id', 'type', 'start'],
'li': ['style', 'class', 'id'],
'table': ['style', 'class', 'id'],
'thead': ['style', 'class', 'id'],
'tbody': ['style', 'class', 'id'],
'tr': ['style', 'class', 'id'],
'th': ['style', 'class', 'id'],
'td': ['style', 'class', 'id'],
}
return bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs, strip=True)
+69 -78
View File
@@ -2482,8 +2482,23 @@ function initializePDFEditor() {
fd.append('css', css);
fd.append('csrf_token', CSRF_TOKEN);
fetch(PREVIEW_URL, { method: 'POST', body: fd })
.then(r => r.text())
// Add cache-busting parameter to ensure fresh preview
const previewUrl = PREVIEW_URL + '?t=' + Date.now();
fetch(previewUrl, {
method: 'POST',
body: fd,
cache: 'no-cache',
headers: {
'Cache-Control': 'no-cache'
}
})
.then(r => {
if (!r.ok) {
throw new Error(`HTTP error! status: ${r.status}`);
}
return r.text();
})
.then(html => {
const doc = frame.contentDocument || frame.contentWindow.document;
doc.open();
@@ -2494,6 +2509,7 @@ function initializePDFEditor() {
})
.catch(err => {
loading.classList.remove('active');
console.error('Preview error:', err);
alert('Preview error: ' + err.message);
closePreviewModal();
});
@@ -2519,8 +2535,10 @@ function initializePDFEditor() {
const fontStyleCss = fontStyle === 'italic' ? 'italic' : 'normal';
const color = attrs.fill || 'black';
const text = (attrs.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Preserve text alignment (Konva Text uses 'align' attribute: 'left', 'center', 'right')
const textAlign = attrs.align || 'left';
bodyContent += ` <div class="element text-element" style="position:absolute;left:${x}px;top:${y}px;font-size:${fontSize}px;font-family:'${fontFamily}';font-weight:${fontWeight};font-style:${fontStyleCss};color:${color};opacity:${opacity};width:${attrs.width || 400}px">${text}</div>\n`;
bodyContent += ` <div class="element text-element" style="position:absolute;left:${x}px;top:${y}px;font-size:${fontSize}px;font-family:'${fontFamily}';font-weight:${fontWeight};font-style:${fontStyleCss};color:${color};opacity:${opacity};width:${attrs.width || 400}px;text-align:${textAlign}">${text}</div>\n`;
} else if (child.className === 'Image') {
const w = Math.round(attrs.width || 100);
const h = Math.round(attrs.height || 50);
@@ -2577,16 +2595,32 @@ function initializePDFEditor() {
}
if (isItemsTable) {
// 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 || '') : '';
// Parse header text (format: "Description | Qty | Price | Total" or German equivalent)
// Default to English if header text is empty or doesn't contain |
let headerParts = ['Description', 'Qty', 'Unit Price', 'Total'];
if (headerText && headerText.includes('|')) {
headerParts = headerText.split('|').map(part => part.trim()).filter(part => part.length > 0);
// Ensure we have at least 4 parts, pad with defaults if needed
while (headerParts.length < 4) {
headerParts.push(['Description', 'Qty', 'Unit Price', 'Total'][headerParts.length]);
}
}
// Generate proper HTML table for invoice items
bodyContent += ` <!-- Items 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: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;">Description</th>\n`;
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:70px;">Qty</th>\n`;
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">Unit Price</th>\n`;
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">Total</th>\n`;
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;">${(headerParts[0] || 'Description').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</th>\n`;
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:70px;">${(headerParts[1] || 'Qty').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</th>\n`;
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[2] || 'Unit Price').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</th>\n`;
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[3] || 'Total').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</th>\n`;
bodyContent += ` </tr>\n`;
bodyContent += ` </thead>\n`;
bodyContent += ` <tbody>\n`;
@@ -2611,16 +2645,32 @@ function initializePDFEditor() {
bodyContent += ` </div>\n`;
bodyContent += ` <!-- Items Table End -->\n`;
} else if (isExpensesTable) {
// 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 || '') : '';
// Parse header text (format: "Expense | Date | Category | Amount" or localized)
// Default to English if header text is empty or doesn't contain |
let headerParts = ['Expense', 'Date', 'Category', 'Amount'];
if (headerText && headerText.includes('|')) {
headerParts = headerText.split('|').map(part => part.trim()).filter(part => part.length > 0);
// Ensure we have at least 4 parts, pad with defaults if needed
while (headerParts.length < 4) {
headerParts.push(['Expense', 'Date', 'Category', 'Amount'][headerParts.length]);
}
}
// Generate proper HTML table for project expenses
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 += ` <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;">Expense</th>\n`;
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:100px;color:#856404;">Date</th>\n`;
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">Category</th>\n`;
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">Amount</th>\n`;
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;color:#856404;">${(headerParts[0] || 'Expense').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</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, '&lt;').replace(/>/g, '&gt;')}</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, '&lt;').replace(/>/g, '&gt;')}</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, '&lt;').replace(/>/g, '&gt;')}</th>\n`;
bodyContent += ` </tr>\n`;
bodyContent += ` </thead>\n`;
bodyContent += ` <tbody>\n`;
@@ -2650,7 +2700,9 @@ function initializePDFEditor() {
child.children.forEach(c => {
if (c.className === 'Text') {
const text = (c.attrs.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
bodyContent += ` <div style="font-size:${c.attrs.fontSize || 12}px;font-weight:${c.attrs.fontStyle === 'bold' ? 'bold' : 'normal'}">${text}</div>\n`;
// Preserve text alignment for text in groups
const textAlign = c.attrs.align || 'left';
bodyContent += ` <div style="font-size:${c.attrs.fontSize || 12}px;font-weight:${c.attrs.fontStyle === 'bold' ? 'bold' : 'normal'};text-align:${textAlign}">${text}</div>\n`;
} else if (c.className === 'Line') {
bodyContent += ` <hr style="border-top:${c.attrs.strokeWidth || 1}px solid ${c.attrs.stroke || 'black'};margin:${c.attrs.y || 0}px 0">\n`;
}
@@ -2660,78 +2712,17 @@ function initializePDFEditor() {
}
});
// Get dimensions for current page size
const currentSizeHtml = CURRENT_PAGE_SIZE || 'A4';
const dimensionsHtml = PAGE_SIZE_DIMENSIONS[currentSizeHtml] || PAGE_SIZE_DIMENSIONS['A4'];
const widthPxHtml = dimensionsHtml.width;
const heightPxHtml = dimensionsHtml.height;
// Wrap in complete HTML document for proper PDF rendering
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Invoice</title>
<style>
@page {
size: ${currentSizeHtml};
margin: 0;
}
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
.invoice-wrapper {
position: relative;
width: ${widthPxHtml}px;
min-height: ${heightPxHtml}px;
background: white;
padding: 20px;
box-sizing: border-box;
}
.element, .text-element {
white-space: pre-wrap;
}
.rectangle-element, .circle-element {
box-sizing: border-box;
}
.line-element {
padding: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
table th {
background-color: #f8f9fa;
font-weight: bold;
text-align: left;
padding: 10px;
border-bottom: 2px solid #333;
}
table td {
padding: 10px;
border-bottom: 1px solid #ddd;
}
table tr:last-child td {
border-bottom: 2px solid #333;
}
</style>
</head>
<body>
<div class="invoice-wrapper">
${bodyContent}</div>
</body>
</html>`;
// Get dimensions for current page size
const currentSize = CURRENT_PAGE_SIZE || 'A4';
const dimensions = PAGE_SIZE_DIMENSIONS[currentSize] || PAGE_SIZE_DIMENSIONS['A4'];
const widthPx = dimensions.width;
const heightPx = dimensions.height;
// For preview, return just the body content (template HTML) wrapped in invoice-wrapper
// The preview endpoint will wrap it with its own HTML structure
const html = `<div class="invoice-wrapper">
${bodyContent}</div>`;
const css = `@page {
size: ${currentSize};
margin: 0;