fix(pdf-layout): decorative image persistence and PDF preview (Issue #432)

Decorative images now survive save/load and no longer cause a black PDF preview:

- Sync imageUrl onto groups before generateCode() so template_json has
  correct source (invoice and quote layout editors).
- Inject name/imageUrl into design_json with position-based matching so
  reordering does not swap or drop URLs.
- Restore name and imageUrl from saved JSON onto canvas on load
  (synchronous) so Konva custom attrs are not required.
- Omit decorative image elements with empty source from template_json;
  placeholders stay visible in the editor but are not sent to ReportLab.
- ReportLab: explicitly skip decorative images with empty source; validate
  base64 data URI payload and decode in try/except to avoid bad PDF output.

Documentation: PDF_LAYOUT_CUSTOMIZATION.md and PDF_EDITOR_ENHANCED_FEATURES.md
updated with decorative image description, state persistence details, and
troubleshooting. CHANGELOG.md updated under [Unreleased] Fixed.
This commit is contained in:
Dries Peeters
2026-02-16 07:37:59 +01:00
parent b0809e2f90
commit d39c5a2f37
6 changed files with 312 additions and 385 deletions
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **PDF layout: decorative image persistence and PDF preview (Issue #432)** — Decorative images now survive save/load: image URLs are synced onto groups before generating the template, injected into the saved design JSON using position-based matching, and restored from the saved JSON onto the canvas on load. Empty decorative image elements are no longer added to the ReportLab template, and the PDF generator skips empty or invalid image sources and validates base64 data URIs, preventing a mostly-black or broken PDF preview.
### Added
- **ZugFerd / Factur-X support for invoice PDFs** — When enabled in Admin → Settings → Peppol e-Invoicing, exported invoice PDFs embed EN 16931 UBL XML as `ZUGFeRD-invoice.xml`, producing hybrid human- and machine-readable invoices. Uses the same UBL as Peppol; these PDFs can be sent via Peppol or email. New setting `invoices_zugferd_pdf`, migration `128_add_invoices_zugferd_pdf`, dependency `pikepdf`, and [docs/admin/configuration/PEPPOL_EINVOICING.md](docs/admin/configuration/PEPPOL_EINVOICING.md) updated for both Peppol and ZugFerd.
- **Subcontractor role and assigned clients** — Users with the Subcontractor role can be restricted to specific clients and their projects. Admins assign clients in Admin → Users → Edit user (section "Assigned Clients (Subcontractor)"). Scope is applied to clients, projects, time entries, reports, invoices, timer, and API v1; direct access to other clients/projects returns 403. New table `user_clients`, migration `127_add_user_clients_table`, and docs in [docs/SUBCONTRACTOR_ROLE.md](docs/SUBCONTRACTOR_ROLE.md).
+131 -197
View File
@@ -5531,17 +5531,19 @@ function initializePDFEditor() {
console.log('Decorative image dimensions:', actualWidth, 'x', actualHeight, 'URL:', imageUrl);
// Add to ReportLab template JSON
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl || '', // Store the image URL
opacity: opacity,
decorative: true // Mark as decorative image
});
// Issue #432: Only add to template JSON when source is non-empty (avoid empty decorative elements in PDF)
if (imageUrl && imageUrl.trim() !== '') {
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl,
opacity: opacity,
decorative: true
});
}
// Legacy HTML for preview - only generate image tag, not rectangle
if (imageUrl && imageUrl.trim() !== '') {
@@ -5752,19 +5754,21 @@ function initializePDFEditor() {
console.log('Decorative image dimensions (fallback):', actualWidth, 'x', actualHeight, 'URL:', imageUrl);
// Add to ReportLab template JSON
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl || '', // Store the image URL
opacity: opacity,
decorative: true // Mark as decorative image
});
// Issue #432: Only add to template JSON when source is non-empty
if (imageUrl && imageUrl.trim() !== '') {
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl,
opacity: opacity,
decorative: true
});
}
// Legacy HTML for preview - only generate image tag, not rectangle
// Legacy HTML for preview
if (imageUrl && imageUrl.trim() !== '') {
bodyContent += ` <img src="${imageUrl}" style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;opacity:${opacity}" alt="Decorative image" class="image-element">\n`;
} else {
@@ -6063,6 +6067,78 @@ table tr:last-child td {
ensureTableGroupNames(layer, background);
layer.draw();
}
// Issue #432: Sync imageUrl onto decorative-image groups BEFORE generateCode() so template_json has correct source
layer.find('[name="decorative-image"]').forEach((decorativeImageGroup, idx) => {
try {
let imageUrl = decorativeImageGroup.getAttr('imageUrl');
if (!imageUrl) imageUrl = decorativeImageGroup.attrs.imageUrl;
if (!imageUrl) {
const imageNode = decorativeImageGroup.findOne('Image');
if (imageNode) {
const imgAttrs = imageNode.attrs || {};
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
}
}
if (imageUrl) {
decorativeImageGroup.setAttr('imageUrl', imageUrl);
decorativeImageGroup.attrs.imageUrl = imageUrl;
}
} catch(e) {
console.warn(`[INVOICE] [SAVE] Could not get/set imageUrl for decorative image ${idx}:`, e);
}
});
let allDecorativeGroups = layer.find('[name="decorative-image"]');
layer.find('Group').forEach(group => {
const name = group.name() || group.getAttr('name') || '';
if (name.includes('decorative-image') && !allDecorativeGroups.includes(group)) {
allDecorativeGroups = allDecorativeGroups.concat([group]);
}
});
allDecorativeGroups.forEach((decorativeImageGroup, idx) => {
try {
const currentName = decorativeImageGroup.name() || decorativeImageGroup.getAttr('name') || '';
if (!currentName.includes('decorative-image')) {
decorativeImageGroup.setAttr('name', 'decorative-image');
decorativeImageGroup.name('decorative-image');
if (decorativeImageGroup.attrs) decorativeImageGroup.attrs.name = 'decorative-image';
}
const imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl;
if (imageUrl) {
decorativeImageGroup.setAttr('imageUrl', imageUrl);
decorativeImageGroup.attrs.imageUrl = imageUrl;
}
} catch(e) {}
});
const decorativeImageUrlMap = new Map();
let decorativeImageGroups = layer.find('[name="decorative-image"]');
layer.find('Group').forEach(group => {
const name = group.name() || group.getAttr('name') || '';
if (name.includes('decorative-image') && !decorativeImageGroups.includes(group)) {
decorativeImageGroups = decorativeImageGroups.concat([group]);
}
});
decorativeImageGroups.forEach((decorativeImageGroup, index) => {
const currentPrimaryName = decorativeImageGroup.name();
if (currentPrimaryName !== 'decorative-image') {
decorativeImageGroup.name('decorative-image');
decorativeImageGroup.setAttr('name', 'decorative-image');
if (decorativeImageGroup.attrs) decorativeImageGroup.attrs.name = 'decorative-image';
}
let imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl || '';
if (!imageUrl) {
const imageNode = decorativeImageGroup.findOne('Image');
if (imageNode) {
const imgAttrs = imageNode.attrs || {};
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
}
}
const x = decorativeImageGroup.x() || 0;
const y = decorativeImageGroup.y() || 0;
decorativeImageUrlMap.set(`${x}_${y}`, imageUrl || '');
decorativeImageUrlMap.set(`${x}_${y}_${index}`, imageUrl || '');
decorativeImageUrlMap.set(`index_${index}`, imageUrl || '');
});
const { html, css, json } = generateCode();
// Log what we're saving for debugging
@@ -6076,53 +6152,6 @@ table tr:last-child td {
console.log('Number of elements:', layer.children.length);
console.log('Page size:', CURRENT_PAGE_SIZE);
// Log all Groups to see if items-table is there
layer.children.forEach((child, idx) => {
if (child.className === 'Group') {
console.log(`Group ${idx}: name="${child.attrs.name || 'unnamed'}"`, child.attrs);
// Check for decorative images
if (child.attrs.name === 'decorative-image') {
const imageUrl = child.getAttr('imageUrl') || child.attrs.imageUrl || '';
console.log(` Decorative image ${idx}: imageUrl="${imageUrl}"`);
}
}
});
// Ensure imageUrl is in attrs for all decorative images before serialization
layer.find('[name="decorative-image"]').forEach((decorativeImageGroup, idx) => {
try {
// Try multiple methods to get imageUrl
let imageUrl = decorativeImageGroup.getAttr('imageUrl');
console.log(`[INVOICE] [SAVE] Decorative image ${idx}: getAttr('imageUrl') =`, imageUrl);
if (!imageUrl) {
imageUrl = decorativeImageGroup.attrs.imageUrl;
console.log(`[INVOICE] [SAVE] Decorative image ${idx}: attrs.imageUrl =`, imageUrl);
}
if (!imageUrl) {
// Check if there's an Image node with the URL
const imageNode = decorativeImageGroup.findOne('Image');
if (imageNode) {
// Try to get URL from image node's source if available
const imgAttrs = imageNode.attrs || {};
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
console.log(`[INVOICE] [SAVE] Decorative image ${idx}: Found imageUrl in Image node:`, imageUrl);
}
}
if (imageUrl) {
// Use setAttr to ensure Konva properly serializes it
decorativeImageGroup.setAttr('imageUrl', imageUrl);
// Also set in attrs for redundancy
decorativeImageGroup.attrs.imageUrl = imageUrl;
console.log(`[INVOICE] [SAVE] ✅ Ensured imageUrl in attrs for decorative image ${idx}:`, imageUrl);
} else {
console.warn(`[INVOICE] [SAVE] ⚠️ No imageUrl found for decorative image group ${idx}`);
}
} catch(e) {
console.warn(`[INVOICE] [SAVE] Could not get/set imageUrl for decorative image ${idx}:`, e);
}
});
// Save both legacy HTML/CSS and new JSON template
// Ensure JSON is always present and valid
if (!json || !json.trim()) {
@@ -6140,99 +6169,6 @@ table tr:last-child td {
return;
}
// Final check: ensure all decorative images have imageUrl properly set before serialization
// Also ensure base name "decorative-image" is preserved (even if "element-overlap" is added)
let allDecorativeGroups = layer.find('[name="decorative-image"]');
const allGroupsForCheck = layer.find('Group');
allGroupsForCheck.forEach(group => {
const name = group.name() || group.getAttr('name') || '';
if (name.includes('decorative-image') && !allDecorativeGroups.includes(group)) {
allDecorativeGroups = allDecorativeGroups.concat([group]);
}
});
allDecorativeGroups.forEach((decorativeImageGroup, idx) => {
try {
// Ensure base name is set (preserve "decorative-image" even if "element-overlap" exists)
const currentName = decorativeImageGroup.name() || decorativeImageGroup.getAttr('name') || '';
if (!currentName.includes('decorative-image')) {
decorativeImageGroup.setAttr('name', 'decorative-image');
decorativeImageGroup.name('decorative-image');
if (decorativeImageGroup.attrs) {
decorativeImageGroup.attrs.name = 'decorative-image';
}
console.log(`[INVOICE] [SAVE] Fixed name for decorative image ${idx}: was "${currentName}", now "decorative-image"`);
}
// Double-check that imageUrl is set via both methods
const imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl;
if (imageUrl) {
decorativeImageGroup.setAttr('imageUrl', imageUrl);
decorativeImageGroup.attrs.imageUrl = imageUrl;
console.log(`[INVOICE] [SAVE] Final check: imageUrl set for decorative image ${idx}:`, imageUrl);
} else {
console.warn(`[INVOICE] [SAVE] ⚠️ Final check: No imageUrl found for decorative image ${idx}`);
}
} catch(e) {
console.warn(`[INVOICE] [SAVE] Final check: Could not set imageUrl for decorative image ${idx}:`, e);
}
});
// CRITICAL FIX: Get imageUrl from actual nodes before serialization and store in a map
// This ensures we can inject imageUrl into JSON after serialization
const decorativeImageUrlMap = new Map();
// Find all decorative-image groups, including those with modified names like "decorative-image element-overlap"
let decorativeImageGroups = layer.find('[name="decorative-image"]');
const allGroups = layer.find('Group');
allGroups.forEach(group => {
const name = group.name() || group.getAttr('name') || '';
if (name.includes('decorative-image') && !decorativeImageGroups.includes(group)) {
decorativeImageGroups = decorativeImageGroups.concat([group]);
}
});
console.log('[INVOICE] [SAVE] Found', decorativeImageGroups.length, 'decorative image group(s) before serialization (including modified names)');
decorativeImageGroups.forEach((decorativeImageGroup, index) => {
// CRITICAL: Ensure primary name is "decorative-image" before serialization
// Konva's toJSON() uses the primary name() method, not additional names from addName()
const currentPrimaryName = decorativeImageGroup.name();
if (currentPrimaryName !== 'decorative-image') {
decorativeImageGroup.name('decorative-image');
decorativeImageGroup.setAttr('name', 'decorative-image');
if (decorativeImageGroup.attrs) {
decorativeImageGroup.attrs.name = 'decorative-image';
}
console.log(`[INVOICE] [SAVE] Fixed primary name for decorative image ${index}: was "${currentPrimaryName}", now "decorative-image"`);
}
// Try multiple methods to get imageUrl
let imageUrl = decorativeImageGroup.getAttr('imageUrl') || '';
if (!imageUrl) {
imageUrl = decorativeImageGroup.attrs.imageUrl || '';
}
// Also check if there's an Image node with the URL stored in it
if (!imageUrl) {
const imageNode = decorativeImageGroup.findOne('Image');
if (imageNode) {
// Try to get URL from image node's source if available
const imgAttrs = imageNode.attrs || {};
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
}
}
const x = decorativeImageGroup.x() || 0;
const y = decorativeImageGroup.y() || 0;
// Use position as key to match nodes after serialization
const key = `${x}_${y}_${index}`;
decorativeImageUrlMap.set(key, imageUrl);
// Also store by index as fallback
decorativeImageUrlMap.set(`index_${index}`, imageUrl);
console.log(`[INVOICE] [SAVE] Stored imageUrl for decorative image ${index} at (${x}, ${y}):`, imageUrl, 'key:', key);
if (!imageUrl) {
console.warn(`[INVOICE] [SAVE] ⚠️ No imageUrl found for decorative image ${index} at (${x}, ${y})`);
}
});
// Serialize the stage
const stageJson = stage.toJSON();
@@ -6258,45 +6194,20 @@ table tr:last-child td {
}
// Manually inject imageUrl into serialized JSON for decorative images (backup pass)
// Konva's toJSON() might not include custom attributes, so we need to add them manually
// Prefer position-based matching so reordering does not swap URLs (Issue #432)
let decorativeImageIndex = 0;
function ensureImageUrlInJson(node, parentKey = '') {
// CRITICAL FIX: Check if name includes 'decorative-image' (handles "decorative-image element-overlap")
const nodeName = node.attrs && node.attrs.name ? node.attrs.name : '';
if (nodeName && nodeName.includes('decorative-image')) {
// Try to match by position
const x = node.attrs.x || 0;
const y = node.attrs.y || 0;
const positionKey = `${x}_${y}_${decorativeImageIndex}`;
const indexKey = `index_${decorativeImageIndex}`;
// Get imageUrl from map
let imageUrl = decorativeImageUrlMap.get(positionKey) ||
decorativeImageUrlMap.get(indexKey) || '';
// If still not found, try to find by matching all keys
if (!imageUrl) {
for (const [key, url] of decorativeImageUrlMap.entries()) {
if (url) {
imageUrl = url;
break;
}
}
}
if (imageUrl) {
// Ensure imageUrl is in the JSON attrs
if (!node.attrs) {
node.attrs = {};
}
node.attrs.imageUrl = imageUrl;
console.log(`[INVOICE] [SAVE] ✅ Injected imageUrl into JSON for decorative image ${decorativeImageIndex} at (${x}, ${y}):`, imageUrl);
} else {
console.warn(`[INVOICE] [SAVE] ⚠️ No imageUrl found for decorative image ${decorativeImageIndex} at position (${x}, ${y})`);
// Debug: show what's in the map
console.log(`[INVOICE] [SAVE] Map contents:`, Array.from(decorativeImageUrlMap.entries()));
}
// Position-based first (no index), then with index, then index-only fallback
let imageUrl = decorativeImageUrlMap.get(`${x}_${y}`) ||
decorativeImageUrlMap.get(`${x}_${y}_${decorativeImageIndex}`) ||
decorativeImageUrlMap.get(`index_${decorativeImageIndex}`) || '';
if (!node.attrs) node.attrs = {};
node.attrs.name = 'decorative-image';
node.attrs.imageUrl = (typeof imageUrl === 'string' ? imageUrl : '');
decorativeImageIndex++;
}
if (node.children) {
@@ -6304,7 +6215,6 @@ table tr:last-child td {
}
}
// Ensure imageUrl is in JSON for all decorative images
if (stageJson.children) {
stageJson.children.forEach((child, idx) => ensureImageUrlInJson(child, `children[${idx}]`));
}
@@ -6557,6 +6467,30 @@ table tr:last-child td {
stage = restoredStage;
layer = stage.children[0];
// Issue #432: Synchronously restore name and imageUrl from saved JSON onto live nodes
// (Konva may not restore custom attrs; this ensures they are set before any setTimeout)
const layerJson = savedJson.children && savedJson.children[0];
if (layerJson && layerJson.children && layer.children) {
for (let i = 0; i < layer.children.length; i++) {
const liveChild = layer.children[i];
const jsonChild = layerJson.children[i];
if (!liveChild || !jsonChild || liveChild.className !== 'Group') continue;
const savedName = (jsonChild.attrs && jsonChild.attrs.name) ? jsonChild.attrs.name : '';
if (savedName && savedName.includes('decorative-image')) {
const savedImageUrl = (jsonChild.attrs && jsonChild.attrs.imageUrl) ? jsonChild.attrs.imageUrl : '';
if (liveChild.setAttr) {
liveChild.setAttr('name', 'decorative-image');
liveChild.setAttr('imageUrl', savedImageUrl || '');
}
if (liveChild.attrs) {
liveChild.attrs.name = 'decorative-image';
liveChild.attrs.imageUrl = savedImageUrl || '';
}
if (liveChild.name) liveChild.name('decorative-image');
}
}
}
// Debug: Log the saved JSON to check if imageUrl is there
const savedJsonString = JSON.stringify(savedJson);
console.log('[INVOICE] [LOAD] Saved JSON structure (first 2000 chars):', savedJsonString.substring(0, 2000));
+132 -151
View File
@@ -5073,26 +5073,27 @@ function initializePDFEditor() {
console.log('Decorative image dimensions:', actualWidth, 'x', actualHeight, 'URL:', imageUrl);
// Add to ReportLab template JSON
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl || '', // Store the image URL
opacity: opacity,
decorative: true // Mark as decorative image
});
// Issue #432: Only add to template JSON when source is non-empty
if (imageUrl && imageUrl.trim() !== '') {
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl,
opacity: opacity,
decorative: true
});
}
// Legacy HTML for preview - only generate image tag, not rectangle
// Legacy HTML for preview
if (imageUrl && imageUrl.trim() !== '') {
bodyContent += ` <img src="${imageUrl}" style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;opacity:${opacity}" alt="Decorative image" class="image-element">\n`;
} else {
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;background:#f0f0f0;border:2px dashed #999;opacity:${opacity};display:flex;align-items:center;justify-content:center;color:#666;font-size:12px;">Decorative Image</div>\n`;
}
// Skip processing children of decorative-image groups - they are just placeholders
return; // Use return instead of continue in forEach
return;
}
if (isItemsTable) {
@@ -5292,19 +5293,21 @@ function initializePDFEditor() {
console.log('Decorative image dimensions (fallback):', actualWidth, 'x', actualHeight, 'URL:', imageUrl);
// Add to ReportLab template JSON
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl || '', // Store the image URL
opacity: opacity,
decorative: true // Mark as decorative image
});
// Issue #432: Only add to template JSON when source is non-empty
if (imageUrl && imageUrl.trim() !== '') {
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl,
opacity: opacity,
decorative: true
});
}
// Legacy HTML for preview - only generate image tag, not rectangle
// Legacy HTML for preview
if (imageUrl && imageUrl.trim() !== '') {
bodyContent += ` <img src="${imageUrl}" style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;opacity:${opacity}" alt="Decorative image" class="image-element">\n`;
} else {
@@ -5600,6 +5603,78 @@ table tr:last-child td {
ensureQuoteTableGroupNames(layer, background);
layer.draw();
}
// Issue #432: Sync imageUrl onto decorative-image groups BEFORE generateCode() so template_json has correct source
layer.find('[name="decorative-image"]').forEach((decorativeImageGroup, idx) => {
try {
let imageUrl = decorativeImageGroup.getAttr('imageUrl');
if (!imageUrl) imageUrl = decorativeImageGroup.attrs.imageUrl;
if (!imageUrl) {
const imageNode = decorativeImageGroup.findOne('Image');
if (imageNode) {
const imgAttrs = imageNode.attrs || {};
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
}
}
if (imageUrl) {
decorativeImageGroup.setAttr('imageUrl', imageUrl);
decorativeImageGroup.attrs.imageUrl = imageUrl;
}
} catch(e) {
console.warn('Could not get/set imageUrl for decorative image:', e);
}
});
let allDecorativeGroups = layer.find('[name="decorative-image"]');
layer.find('Group').forEach(group => {
const name = group.name() || group.getAttr('name') || '';
if (name.includes('decorative-image') && !allDecorativeGroups.includes(group)) {
allDecorativeGroups = allDecorativeGroups.concat([group]);
}
});
allDecorativeGroups.forEach((decorativeImageGroup, idx) => {
try {
const currentName = decorativeImageGroup.name() || decorativeImageGroup.getAttr('name') || '';
if (!currentName.includes('decorative-image')) {
decorativeImageGroup.setAttr('name', 'decorative-image');
decorativeImageGroup.name('decorative-image');
if (decorativeImageGroup.attrs) decorativeImageGroup.attrs.name = 'decorative-image';
}
const imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl;
if (imageUrl) {
decorativeImageGroup.setAttr('imageUrl', imageUrl);
decorativeImageGroup.attrs.imageUrl = imageUrl;
}
} catch(e) {}
});
const decorativeImageUrlMap = new Map();
let decorativeImageGroups = layer.find('[name="decorative-image"]');
layer.find('Group').forEach(group => {
const name = group.name() || group.getAttr('name') || '';
if (name.includes('decorative-image') && !decorativeImageGroups.includes(group)) {
decorativeImageGroups = decorativeImageGroups.concat([group]);
}
});
decorativeImageGroups.forEach((decorativeImageGroup, index) => {
const currentPrimaryName = decorativeImageGroup.name();
if (currentPrimaryName !== 'decorative-image') {
decorativeImageGroup.name('decorative-image');
decorativeImageGroup.setAttr('name', 'decorative-image');
if (decorativeImageGroup.attrs) decorativeImageGroup.attrs.name = 'decorative-image';
}
let imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl || '';
if (!imageUrl) {
const imageNode = decorativeImageGroup.findOne('Image');
if (imageNode) {
const imgAttrs = imageNode.attrs || {};
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
}
}
const x = decorativeImageGroup.x() || 0;
const y = decorativeImageGroup.y() || 0;
decorativeImageUrlMap.set(`${x}_${y}`, imageUrl || '');
decorativeImageUrlMap.set(`${x}_${y}_${index}`, imageUrl || '');
decorativeImageUrlMap.set(`index_${index}`, imageUrl || '');
});
const { html, css, json } = generateCode();
// Log what we're saving for debugging
@@ -5610,83 +5685,10 @@ table tr:last-child td {
console.log('Number of elements:', layer.children.length);
console.log('Page size:', CURRENT_PAGE_SIZE);
// Log all Groups to see if items-table is there
layer.children.forEach((child, idx) => {
if (child.className === 'Group') {
console.log(`Group ${idx}: name="${child.attrs.name || 'unnamed'}"`, child.attrs);
// Check for decorative images
if (child.attrs.name === 'decorative-image') {
const imageUrl = child.getAttr('imageUrl') || child.attrs.imageUrl || '';
console.log(` Decorative image ${idx}: imageUrl="${imageUrl}"`);
}
}
});
// Ensure imageUrl is in attrs for all decorative images before serialization
layer.find('[name="decorative-image"]').forEach(decorativeImageGroup => {
try {
// Try multiple methods to get imageUrl
let imageUrl = decorativeImageGroup.getAttr('imageUrl');
if (!imageUrl) {
imageUrl = decorativeImageGroup.attrs.imageUrl;
}
if (!imageUrl) {
// Check if there's an Image node with the URL
const imageNode = decorativeImageGroup.findOne('Image');
if (imageNode) {
// Try to get URL from image node's source if available
const imgAttrs = imageNode.attrs || {};
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
}
}
if (imageUrl) {
// Use setAttr to ensure Konva properly serializes it
decorativeImageGroup.setAttr('imageUrl', imageUrl);
// Also set in attrs for redundancy
decorativeImageGroup.attrs.imageUrl = imageUrl;
console.log('Ensured imageUrl in attrs for decorative image:', imageUrl);
} else {
console.warn('No imageUrl found for decorative image group');
}
} catch(e) {
console.warn('Could not get/set imageUrl for decorative image:', e);
}
});
// Final check: ensure all decorative images have imageUrl properly set before serialization
layer.find('[name="decorative-image"]').forEach(decorativeImageGroup => {
try {
// Double-check that imageUrl is set via both methods
const imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl;
if (imageUrl) {
decorativeImageGroup.setAttr('imageUrl', imageUrl);
decorativeImageGroup.attrs.imageUrl = imageUrl;
console.log('Final check: imageUrl set for decorative image:', imageUrl);
}
} catch(e) {
console.warn('Final check: Could not set imageUrl:', e);
}
});
// CRITICAL FIX: Get imageUrl from actual nodes before serialization and store in a map
// This ensures we can inject imageUrl into JSON after serialization
const decorativeImageUrlMap = new Map();
layer.find('[name="decorative-image"]').forEach((decorativeImageGroup, index) => {
let imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl || '';
// Use position as key to match nodes after serialization
const key = `${decorativeImageGroup.x()}_${decorativeImageGroup.y()}_${index}`;
decorativeImageUrlMap.set(key, imageUrl);
// Also store by index as fallback
decorativeImageUrlMap.set(`index_${index}`, imageUrl);
console.log(`Stored imageUrl for decorative image ${index}:`, imageUrl, 'key:', key);
});
// Serialize the stage
const stageJson = stage.toJSON();
// CRITICAL FIX: Explicitly inject name and imageUrl into serialized JSON for decorative images
// by matching live layer children to JSON by index (Konva may not serialize custom attrs)
// Explicitly inject name and imageUrl into serialized JSON (Issue #432)
const layerJson = stageJson.children && stageJson.children[0];
if (layer && layerJson && layerJson.children && layer.children) {
for (let i = 0; i < layer.children.length; i++) {
@@ -5705,42 +5707,18 @@ table tr:last-child td {
}
}
// Manually inject imageUrl into serialized JSON for decorative images (backup pass)
// Konva's toJSON() might not include custom attributes, so we need to add them manually
let decorativeImageIndex = 0;
function ensureImageUrlInJson(node, parentKey = '') {
if (node.attrs && node.attrs.name === 'decorative-image') {
// Try to match by position
const nodeName = node.attrs && node.attrs.name ? node.attrs.name : '';
if (nodeName && nodeName.includes('decorative-image')) {
const x = node.attrs.x || 0;
const y = node.attrs.y || 0;
const positionKey = `${x}_${y}_${decorativeImageIndex}`;
const indexKey = `index_${decorativeImageIndex}`;
// Get imageUrl from map
let imageUrl = decorativeImageUrlMap.get(positionKey) ||
decorativeImageUrlMap.get(indexKey) || '';
// If still not found, try to find by matching all keys
if (!imageUrl) {
for (const [key, url] of decorativeImageUrlMap.entries()) {
if (url) {
imageUrl = url;
break;
}
}
}
if (imageUrl) {
// Ensure imageUrl is in the JSON attrs
if (!node.attrs) {
node.attrs = {};
}
node.attrs.imageUrl = imageUrl;
console.log(`Injected imageUrl into JSON for decorative image ${decorativeImageIndex}:`, imageUrl);
} else {
console.warn(`No imageUrl found for decorative image ${decorativeImageIndex} at position (${x}, ${y})`);
}
let imageUrl = decorativeImageUrlMap.get(`${x}_${y}`) ||
decorativeImageUrlMap.get(`${x}_${y}_${decorativeImageIndex}`) ||
decorativeImageUrlMap.get(`index_${decorativeImageIndex}`) || '';
if (!node.attrs) node.attrs = {};
node.attrs.name = 'decorative-image';
node.attrs.imageUrl = (typeof imageUrl === 'string' ? imageUrl : '');
decorativeImageIndex++;
}
if (node.children) {
@@ -5748,34 +5726,18 @@ table tr:last-child td {
}
}
// Ensure imageUrl is in JSON for all decorative images
if (stageJson.children) {
stageJson.children.forEach((child, idx) => ensureImageUrlInJson(child, `children[${idx}]`));
}
// Debug: Log decorative image groups in the JSON
function logDecorativeImagesInJson(node, depth = 0) {
if (node.attrs && node.attrs.name === 'decorative-image') {
console.log(' '.repeat(depth) + 'Found decorative-image in JSON, imageUrl:', node.attrs.imageUrl || 'NOT FOUND');
}
if (node.children) {
node.children.forEach(child => logDecorativeImagesInJson(child, depth + 1));
}
}
console.log('=== Checking decorative images in serialized JSON ===');
logDecorativeImagesInJson(stageJson);
document.getElementById('save-html').value = html;
document.getElementById('save-css').value = css;
document.getElementById('save-design-json').value = JSON.stringify(stageJson);
// Ensure JSON is always present and valid
if (!json || !json.trim()) {
console.error('No JSON generated from template!');
alert('Error: Could not generate template JSON. Please try again.');
return;
}
// Validate JSON before saving
try {
JSON.parse(json);
} catch (e) {
@@ -5783,7 +5745,6 @@ table tr:last-child td {
alert('Error: Generated template JSON is invalid. Please try again.');
return;
}
document.getElementById('save-template-json').value = json;
// Use page size from selector if available, otherwise use CURRENT_PAGE_SIZE
const pageSizeForSave = (pageSizeSelector && pageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
@@ -6020,8 +5981,28 @@ table tr:last-child td {
stage = restoredStage;
layer = stage.children[0];
// Debug: Log the saved JSON to check if imageUrl is there
console.log('Saved JSON structure (first 2000 chars):', JSON.stringify(savedJson).substring(0, 2000));
// Issue #432: Synchronously restore name and imageUrl from saved JSON onto live nodes
const layerJson = savedJson.children && savedJson.children[0];
if (layerJson && layerJson.children && layer.children) {
for (let i = 0; i < layer.children.length; i++) {
const liveChild = layer.children[i];
const jsonChild = layerJson.children[i];
if (!liveChild || !jsonChild || liveChild.className !== 'Group') continue;
const savedName = (jsonChild.attrs && jsonChild.attrs.name) ? jsonChild.attrs.name : '';
if (savedName && savedName.includes('decorative-image')) {
const savedImageUrl = (jsonChild.attrs && jsonChild.attrs.imageUrl) ? jsonChild.attrs.imageUrl : '';
if (liveChild.setAttr) {
liveChild.setAttr('name', 'decorative-image');
liveChild.setAttr('imageUrl', savedImageUrl || '');
}
if (liveChild.attrs) {
liveChild.attrs.name = 'decorative-image';
liveChild.attrs.imageUrl = savedImageUrl || '';
}
if (liveChild.name) liveChild.name('decorative-image');
}
}
}
// Find or create background by name and resize it
background = layer.findOne('[name="background"]');
+36 -33
View File
@@ -429,29 +429,27 @@ class ReportLabTemplateRenderer:
# 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
# Issue #432: Explicit skip for decorative images with empty source (never add flowable or draw)
if element.get("decorative", False) and (not source or not source.strip()):
if current_app:
current_app.logger.warning("Skipping decorative image flowable with empty source")
return None
# Handle base64 data URI
if not source or not source.strip():
return None
# Handle base64 data URI with validated decode (Issue #432)
if source.startswith("data:image"):
# Extract base64 data
import base64
header, data = source.split(",", 1)
parts = source.split(",", 1)
if len(parts) < 2 or not (parts[1] and parts[1].strip()):
if current_app:
current_app.logger.warning("Skipping image: data URI has no base64 payload")
return None
try:
img_data = base64.b64decode(data)
img_data = base64.b64decode(parts[1])
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
width = element.get("width", 100)
height = element.get("height", 100)
return Image(img_reader, width=width, height=height)
except Exception as e:
if current_app:
@@ -1026,19 +1024,16 @@ class ReportLabTemplateRenderer:
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
# Issue #432: Explicit skip for decorative images with empty source (never draw)
if element.get("decorative", False) and (not source or not source.strip()):
if current_app:
current_app.logger.warning(f"Skipping decorative image with empty source at position ({x}, {y})")
return
if not source or not source.strip():
# 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)
@@ -1058,14 +1053,22 @@ class ReportLabTemplateRenderer:
return
try:
# Handle base64 data URI
# Handle base64 data URI with validated decode (Issue #432)
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')
parts = source.split(",", 1)
if len(parts) < 2 or not (parts[1] and parts[1].strip()):
if current_app:
current_app.logger.warning("Skipping image draw: data URI has no base64 payload")
return
try:
img_data = base64.b64decode(parts[1])
img_reader = ImageReader(io.BytesIO(img_data))
canv.drawImage(img_reader, x, y - height, width=width, height=height, preserveAspectRatio=True, mask='auto')
except Exception as e:
if current_app:
current_app.logger.error(f"Error decoding base64 image for canvas: {e}")
return
# Handle template image URLs (convert to file path or base64)
elif source.startswith("/uploads/template_images/"):
try:
+9 -3
View File
@@ -16,6 +16,7 @@ The editor now includes a comprehensive set of draggable elements organized into
- **Line**: Horizontal divider line
- **Rectangle**: Customizable rectangular shape
- **Circle**: Customizable circular shape
- **Decorative Image**: Placeholder that you can assign an uploaded image to; appears in the PDF when an image is set. Supports position, size, and opacity. Save the layout after uploading to persist the image with the design.
#### Company Information Elements
- **Company Logo**: Displays uploaded company logo
@@ -152,9 +153,11 @@ The editor generates clean HTML and CSS:
### State Persistence
Designs are saved as:
1. **JSON**: Complete Konva.js stage state (for editing)
2. **HTML**: Generated template markup
3. **CSS**: Corresponding styles
1. **design_json**: Complete Konva.js stage state (for re-opening the editor). Custom attributes such as decorative-image `name` and `imageUrl` are injected into the serialized JSON so they survive save/load.
2. **template_json**: ReportLab template (elements list) used for PDF generation. Decorative image elements are only included when they have a non-empty image source, so the PDF is never broken by missing images.
3. **HTML/CSS**: Generated template markup and styles (legacy preview).
**Decorative images:** The editor syncs each decorative images `imageUrl` onto its group before generating the template and uses position-based matching when injecting URLs into the saved design JSON. On load, `name` and `imageUrl` are restored from the saved JSON onto the live nodes so decorative images appear correctly even if Konva does not persist custom attributes. Placeholders (no image uploaded) remain visible in the editor but are omitted from the PDF.
## Usage Guide
@@ -238,6 +241,9 @@ Add custom properties by:
## Troubleshooting
### Decorative Image Missing After Save
If a decorative image disappears when you reopen the layout, ensure you clicked **Save Design** after uploading the image. The editor persists decorative images by syncing the image URL to the design and template before save; if you leave before the save completes or before uploading an image, the placeholder may not be stored correctly. If the PDF preview shows a black or wrong area, the same fix applies: save the layout after assigning the image.
### Element Not Appearing
- Check console for errors
- Verify element type in templates object
+1 -1
View File
@@ -31,7 +31,7 @@ To access the PDF layout editor:
The PDF Layout Editor is powered by **Konva.js** and consists of three main sections:
1. **Element Library (Left Sidebar)**: Drag-and-drop elements organized by category
- Basic Elements (text, shapes, lines)
- Basic Elements (text, shapes, lines, decorative images)
- Company Information (logo, name, address, contact details)
- Invoice Data (numbers, dates, client info, totals)
- Advanced Elements (QR codes, watermarks, page numbers)