Some checks failed
farmcontrol/farmcontrol-ws/pipeline/head There was a failure building this commit
- Changed log level in config.json from "debug" to "trace" for more detailed logging. - Added new dependencies "pdf-to-img" and "sharp" in package.json for image processing capabilities. - Introduced 'renderTemplateJPG' event handling in SocketHost and SocketUser classes to support JPG rendering. - Implemented the renderJPG method in TemplateManager for converting templates to JPG format, including error handling and logging.
658 lines
18 KiB
JavaScript
658 lines
18 KiB
JavaScript
import ejs from 'ejs';
|
|
import log4js from 'log4js';
|
|
import posthtml from 'posthtml';
|
|
|
|
import { documentTemplateModel } from '../database/schemas/management/documenttemplate.schema.js';
|
|
import '../database/schemas/management/documentsize.schema.js';
|
|
// Load configuration
|
|
import { loadConfig } from '../config.js';
|
|
import fs from 'fs';
|
|
import dayjs from 'dayjs';
|
|
import utc from 'dayjs/plugin/utc.js';
|
|
import timezone from 'dayjs/plugin/timezone.js';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname, join } from 'path';
|
|
import { getObject, listObjects } from '../database/database.js';
|
|
import { getModelByName } from '../utils.js';
|
|
import { generatePDF } from './pdffactory.js';
|
|
import { convertPDFToImage } from './pdfUtils.js';
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
// Extend plugins
|
|
dayjs.extend(utc);
|
|
dayjs.extend(timezone);
|
|
|
|
const config = loadConfig();
|
|
|
|
const logger = log4js.getLogger('Template Manager');
|
|
logger.level = config.server.logLevel;
|
|
|
|
let baseTemplate;
|
|
let baseCSS;
|
|
let previewTemplate;
|
|
let renderTemplateEjs;
|
|
let contentPlaceholder;
|
|
let previewPaginationScript;
|
|
|
|
async function loadTemplates() {
|
|
// Synchronously load files
|
|
baseTemplate = fs.readFileSync(
|
|
join(__dirname, '/assets/basetemplate.ejs'),
|
|
'utf8'
|
|
);
|
|
baseCSS = fs.readFileSync(join(__dirname, '/assets/styles.css'), 'utf8');
|
|
previewTemplate = fs.readFileSync(
|
|
join(__dirname, '/assets/previewtemplate.ejs'),
|
|
'utf8'
|
|
);
|
|
renderTemplateEjs = fs.readFileSync(
|
|
join(__dirname, '/assets/rendertemplate.ejs'),
|
|
'utf8'
|
|
);
|
|
contentPlaceholder = fs.readFileSync(
|
|
join(__dirname, '/assets/contentplaceholder.ejs'),
|
|
'utf8'
|
|
);
|
|
previewPaginationScript = fs.readFileSync(
|
|
join(__dirname, '/assets/previewpagination.js'),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
loadTemplates();
|
|
|
|
function getNodeStyles(attributes) {
|
|
var styles = '';
|
|
if (attributes?.padding) {
|
|
styles += `padding: ${attributes.padding};`;
|
|
}
|
|
if (attributes?.width) {
|
|
styles += `width: ${attributes.width};`;
|
|
}
|
|
if (attributes?.height) {
|
|
styles += `height: ${attributes.height};`;
|
|
}
|
|
if (attributes?.maxWidth) {
|
|
styles += `max-width: ${attributes.maxWidth};`;
|
|
}
|
|
if (attributes?.maxHeight) {
|
|
styles += `max-height: ${attributes.maxHeight};`;
|
|
}
|
|
if (attributes?.gap && attributes?.vertical != 'true') {
|
|
styles += `column-gap: ${attributes.gap};`;
|
|
}
|
|
if (attributes?.gap && attributes?.vertical == 'true') {
|
|
styles += `row-gap: ${attributes.gap};`;
|
|
}
|
|
if (attributes?.justify) {
|
|
styles += `justify-content: ${attributes.justify};`;
|
|
}
|
|
if (attributes?.align) {
|
|
styles += `align-items: ${attributes.align};`;
|
|
}
|
|
if (attributes?.border) {
|
|
styles += `border: ${attributes.border};`;
|
|
}
|
|
if (attributes?.borderRadius) {
|
|
styles += `border-radius: ${attributes.borderRadius};`;
|
|
}
|
|
if (attributes?.vertical == 'true') {
|
|
styles += `flex-direction: column;`;
|
|
}
|
|
if (attributes?.grow) {
|
|
styles += `flex-grow: ${attributes.grow};`;
|
|
}
|
|
if (attributes?.shrink) {
|
|
styles += `flex-shrink: ${attributes.shrink};`;
|
|
}
|
|
if (attributes?.color) {
|
|
styles += `color: ${attributes.color};`;
|
|
}
|
|
if (attributes?.background) {
|
|
styles += `background: ${attributes.background};`;
|
|
}
|
|
if (attributes?.scale) {
|
|
styles += `transform: scale(${attributes.scale});`;
|
|
}
|
|
if (attributes?.textAlign) {
|
|
styles += `text-align: ${attributes.textAlign};`;
|
|
}
|
|
if (attributes?.textSize) {
|
|
styles += `font-size: ${attributes.textSize};`;
|
|
}
|
|
if (attributes?.wordWrap) {
|
|
styles += `word-wrap: ${attributes.wordWrap};`;
|
|
}
|
|
return styles;
|
|
}
|
|
|
|
async function transformCustomElements(content) {
|
|
const result = await posthtml([
|
|
tree =>
|
|
tree.match({ tag: 'Title1' }, node => ({
|
|
...node,
|
|
tag: 'h1',
|
|
attrs: { class: 'documentTitle' }
|
|
})),
|
|
tree =>
|
|
tree.match({ tag: 'Title2' }, node => ({
|
|
...node,
|
|
tag: 'h2',
|
|
attrs: { class: 'documentTitle' }
|
|
})),
|
|
tree =>
|
|
tree.match({ tag: 'Title3' }, node => ({
|
|
...node,
|
|
tag: 'h3',
|
|
attrs: { class: 'documentText' }
|
|
})),
|
|
tree =>
|
|
tree.match({ tag: 'Title4' }, node => ({
|
|
...node,
|
|
tag: 'h4',
|
|
attrs: { class: 'documentText' }
|
|
})),
|
|
tree =>
|
|
tree.match({ tag: 'Text' }, node => ({
|
|
...node,
|
|
tag: 'p',
|
|
attrs: { class: 'documentText', style: getNodeStyles(node.attrs) }
|
|
})),
|
|
tree =>
|
|
tree.match({ tag: 'Bold' }, node => ({
|
|
...node,
|
|
tag: 'strong',
|
|
attrs: {
|
|
style: 'font-weight: bold;',
|
|
class: 'documentBoldText',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
})),
|
|
tree =>
|
|
tree.match({ tag: 'Barcode' }, node => {
|
|
return {
|
|
tag: 'div',
|
|
content: [
|
|
{
|
|
tag: 'svg',
|
|
attrs: {
|
|
class: 'documentBarcode',
|
|
'jsbarcode-displayValue': 'false',
|
|
'jsbarcode-value': node.content[0],
|
|
'jsbarcode-format': node.attrs.format,
|
|
'jsbarcode-width': node.attrs.barcodeWidth,
|
|
'jsbarcode-margin': 0
|
|
}
|
|
}
|
|
],
|
|
attrs: {
|
|
class: 'documentBarcodeContainer',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
};
|
|
}),
|
|
tree =>
|
|
tree.match({ tag: 'Container' }, node => ({
|
|
...node,
|
|
tag: 'div',
|
|
attrs: {
|
|
class: 'documentContainer',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
})),
|
|
tree =>
|
|
tree.match({ tag: 'Flex' }, node => {
|
|
return {
|
|
...node,
|
|
tag: 'div',
|
|
attrs: {
|
|
class: 'documentFlex',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
};
|
|
}),
|
|
tree =>
|
|
tree.match({ tag: 'Divider' }, node => {
|
|
return {
|
|
...node,
|
|
tag: 'hr',
|
|
attrs: {
|
|
class: 'documentDivider',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
};
|
|
}),
|
|
tree =>
|
|
tree.match({ tag: 'ProgressBar' }, node => {
|
|
return {
|
|
...node,
|
|
tag: 'div',
|
|
attrs: {
|
|
class: 'documentProgressBar',
|
|
style: getNodeStyles(node.attrs)
|
|
},
|
|
content: [
|
|
{
|
|
tag: 'div',
|
|
attrs: {
|
|
class: 'documentProgressBarInner',
|
|
style: `width: ${Math.round((node.content[0] || 0) * 100)}%`
|
|
}
|
|
}
|
|
]
|
|
};
|
|
}),
|
|
|
|
tree =>
|
|
tree.match({ tag: 'DateTime' }, node => {
|
|
const dateTime = dayjs.utc(node.content[0]);
|
|
return {
|
|
content: [dateTime.format('YYYY-MM-DD hh:mm:ss')],
|
|
tag: 'span',
|
|
attrs: {
|
|
class: 'documentDateTime',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
};
|
|
}),
|
|
tree =>
|
|
tree.match({ tag: 'Table' }, node => {
|
|
return {
|
|
...node,
|
|
tag: 'table',
|
|
attrs: {
|
|
class: 'documentTable',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
};
|
|
}),
|
|
tree =>
|
|
tree.match({ tag: 'Row' }, node => {
|
|
const rowType = node.attrs?.type?.toLowerCase() || '';
|
|
|
|
// Transform Col children based on the row type (header/footer/body)
|
|
const transformCols = content => {
|
|
if (!Array.isArray(content)) return content;
|
|
return content.map(child => {
|
|
if (typeof child === 'string' || child == null) {
|
|
return child;
|
|
}
|
|
if (child.tag !== 'Col') {
|
|
return child;
|
|
}
|
|
|
|
const baseAttrs = {
|
|
...child.attrs,
|
|
style: getNodeStyles(child.attrs)
|
|
};
|
|
|
|
if (rowType === 'header') {
|
|
// Header row columns become table headers
|
|
return {
|
|
...child,
|
|
tag: 'th',
|
|
attrs: baseAttrs
|
|
};
|
|
}
|
|
|
|
// Footer and body rows both use <td>; footer is distinguished by the row class
|
|
return {
|
|
...child,
|
|
tag: 'td',
|
|
attrs: baseAttrs
|
|
};
|
|
});
|
|
};
|
|
|
|
const content = transformCols(node.content);
|
|
|
|
if (rowType === 'header') {
|
|
return {
|
|
...node,
|
|
tag: 'tr',
|
|
content,
|
|
attrs: {
|
|
class: 'documentTableRowHeader',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
};
|
|
}
|
|
|
|
if (rowType === 'footer') {
|
|
return {
|
|
...node,
|
|
tag: 'tr',
|
|
content,
|
|
attrs: {
|
|
class: 'documentTableRowFooter',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
};
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
tag: 'tr',
|
|
content,
|
|
attrs: {
|
|
class: 'documentTableRow',
|
|
style: getNodeStyles(node.attrs)
|
|
}
|
|
};
|
|
})
|
|
]).process(content);
|
|
|
|
return result.html;
|
|
}
|
|
|
|
export class TemplateManager {
|
|
constructor() {
|
|
this.fc = {
|
|
listObjects: this.listObjects.bind(this),
|
|
getObject: this.getObject.bind(this),
|
|
formatDate: this.formatDate.bind(this)
|
|
};
|
|
}
|
|
/**
|
|
* Previews an EJS template by rendering it with provided data
|
|
* @param {string} templateString - The EJS template as a string
|
|
* @param {Object} data - Data object to pass to the template
|
|
* @param {Object} options - EJS rendering options
|
|
* @returns {Promise<string>} The rendered HTML string
|
|
*/
|
|
async renderTemplate(
|
|
id,
|
|
content,
|
|
data = {},
|
|
scale = 1,
|
|
options = {},
|
|
preview = true
|
|
) {
|
|
try {
|
|
// Set default options for EJS rendering
|
|
const defaultOptions = {
|
|
async: true,
|
|
...options
|
|
};
|
|
logger.debug('Rendering template:', id);
|
|
|
|
const documentTemplate = await getObject({
|
|
model: documentTemplateModel,
|
|
id,
|
|
populate: [
|
|
{ path: 'documentSize' },
|
|
{ path: 'parent', strictPopulate: false }
|
|
],
|
|
cached: true
|
|
});
|
|
|
|
if (documentTemplate == null) {
|
|
return { error: 'Document template not found.' };
|
|
}
|
|
|
|
const documentSize = documentTemplate.documentSize;
|
|
if (documentSize == null) {
|
|
return { error: 'Document template size not found.' };
|
|
}
|
|
|
|
// Validate content parameter
|
|
if (content == null || typeof content !== 'string') {
|
|
return { error: 'Template content is required and must be a string.' };
|
|
}
|
|
|
|
// Make sure data has default undefefined values and then merge with data
|
|
var templateData = {};
|
|
if (documentTemplate.global == true) {
|
|
templateData = { content: contentPlaceholder, fc: this.fc };
|
|
} else {
|
|
const objectType = documentTemplate?.objectType;
|
|
const model = getModelByName(objectType);
|
|
if (model == null) {
|
|
return { error: `Unknown object type: ${objectType}` };
|
|
}
|
|
const defaultKeys = Object.keys(model.schema.obj);
|
|
const defaultValues = {};
|
|
for (const key of defaultKeys) {
|
|
defaultValues[key] = null;
|
|
}
|
|
templateData = { ...defaultValues, ...data, fc: this.fc };
|
|
}
|
|
|
|
// Render the template
|
|
const templateContent = await ejs.render(
|
|
content,
|
|
templateData,
|
|
defaultOptions
|
|
);
|
|
|
|
var templateWithParentContent;
|
|
|
|
var parentTemplate = documentTemplate.parent;
|
|
|
|
if (documentTemplate.parent != undefined) {
|
|
if (typeof parentTemplate === 'string') {
|
|
parentTemplate = await getObject({
|
|
model: documentTemplateModel,
|
|
id: parentTemplate,
|
|
populate: [
|
|
{ path: 'documentSize' },
|
|
{ path: 'parent', strictPopulate: false }
|
|
],
|
|
cached: true
|
|
});
|
|
}
|
|
// Validate parent content
|
|
if (
|
|
parentTemplate.content == null ||
|
|
typeof parentTemplate.content !== 'string'
|
|
) {
|
|
logger.error(
|
|
'Parent template content is required and must be a string.',
|
|
parentTemplate.content
|
|
);
|
|
return {
|
|
error:
|
|
'Parent template content is required and must be a string.' +
|
|
parentTemplate.content
|
|
};
|
|
}
|
|
templateWithParentContent = await ejs.render(
|
|
parentTemplate.content,
|
|
{ content: templateContent, fc: this.fc },
|
|
defaultOptions
|
|
);
|
|
} else {
|
|
templateWithParentContent = templateContent;
|
|
}
|
|
|
|
// Validate rendered content before transformation
|
|
if (
|
|
templateWithParentContent == null ||
|
|
typeof templateWithParentContent !== 'string'
|
|
) {
|
|
return { error: 'Failed to render template content.' };
|
|
}
|
|
|
|
const templateHtml = await transformCustomElements(
|
|
templateWithParentContent
|
|
);
|
|
|
|
// Validate transformed HTML
|
|
if (templateHtml == null || typeof templateHtml !== 'string') {
|
|
return { error: 'Failed to transform template content.' };
|
|
}
|
|
|
|
var innerHtml = null;
|
|
|
|
if (preview == true) {
|
|
innerHtml = await ejs.render(
|
|
previewTemplate,
|
|
{ content: templateHtml },
|
|
defaultOptions
|
|
);
|
|
} else {
|
|
innerHtml = await ejs.render(
|
|
renderTemplateEjs,
|
|
{ content: templateHtml },
|
|
defaultOptions
|
|
);
|
|
}
|
|
|
|
// Validate inner HTML
|
|
if (innerHtml == null || typeof innerHtml !== 'string') {
|
|
return { error: 'Failed to render inner template content.' };
|
|
}
|
|
|
|
const infiniteHeight = documentSize.infiniteHeight == true;
|
|
|
|
const baseHtml = await ejs.render(
|
|
baseTemplate,
|
|
{
|
|
content: innerHtml,
|
|
width: `${documentSize.width}mm`,
|
|
height: infiniteHeight ? 'fit-content' : `${documentSize.height}mm`,
|
|
scaledWidth: `${documentSize.width * scale}mm`,
|
|
scaledHeight: infiniteHeight
|
|
? 'auto'
|
|
: `${documentSize.height * scale}mm`,
|
|
scale: `${scale}`,
|
|
baseCSS: baseCSS,
|
|
previewPaginationScript: preview ? previewPaginationScript : ''
|
|
},
|
|
defaultOptions
|
|
);
|
|
|
|
const previewObject = {
|
|
html: baseHtml,
|
|
width: documentSize.width,
|
|
height: infiniteHeight ? 'auto' : documentSize.height,
|
|
infiniteHeight: infiniteHeight
|
|
};
|
|
|
|
return previewObject;
|
|
} catch (error) {
|
|
logger.warn('Error whilst previewing template:', error.message);
|
|
return { error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates if a template string is valid EJS syntax
|
|
* @param {string} templateString - The EJS template as a string
|
|
* @returns {boolean} True if template is valid, false otherwise
|
|
*/
|
|
validateTemplate(templateString) {
|
|
try {
|
|
// Try to compile the template to check for syntax errors
|
|
ejs.compile(templateString);
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders a template to PDF format
|
|
* @param {string} id - The document template ID
|
|
* @param {string} content - The template content
|
|
* @param {Object} data - Data object to pass to the template
|
|
* @param {number} scale - Scale factor for rendering
|
|
* @param {Object} options - EJS rendering options
|
|
* @returns {Promise<Object>} Object containing PDF buffer or error
|
|
*/
|
|
async renderPDF(id, content, data = {}, options = {}) {
|
|
try {
|
|
logger.debug('Rendering PDF for template:', id);
|
|
|
|
const renderedTemplate = await this.renderTemplate(
|
|
id,
|
|
content,
|
|
data,
|
|
1,
|
|
options,
|
|
false
|
|
);
|
|
|
|
if (renderedTemplate.error != undefined) {
|
|
return { error: renderedTemplate.error };
|
|
}
|
|
const baseHtml = renderedTemplate.html;
|
|
|
|
// Generate PDF using PDF factory
|
|
const pdfBuffer = await generatePDF(baseHtml, {
|
|
width: renderedTemplate.width,
|
|
height: renderedTemplate.height,
|
|
infiniteHeight: renderedTemplate.infiniteHeight
|
|
});
|
|
|
|
const pdfObject = {
|
|
pdf: pdfBuffer
|
|
};
|
|
return pdfObject;
|
|
} catch (error) {
|
|
logger.warn('Error whilst rendering PDF:', error.message);
|
|
return { error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders a template to image format for receipt printers
|
|
* @param {string} id - The document template ID
|
|
* @param {string} content - The template content
|
|
* @param {Object} data - Data object to pass to the template
|
|
* @param {Object} options - Image conversion options (e.g. width, height)
|
|
* @returns {Promise<Object>} Object containing image buffers or error
|
|
*/
|
|
async renderJPG(id, content, data = {}, options = {}) {
|
|
try {
|
|
logger.debug('Rendering JPG for template:', id);
|
|
|
|
const pdfResult = await this.renderPDF(id, content, data, options);
|
|
if (pdfResult.error != undefined) {
|
|
return { error: pdfResult.error };
|
|
}
|
|
|
|
const images = await convertPDFToImage(pdfResult.pdf, options);
|
|
return { images };
|
|
} catch (error) {
|
|
logger.warn('Error whilst rendering JPG:', error.message);
|
|
return { error: error.message };
|
|
}
|
|
}
|
|
|
|
async listObjects(objectType, filter = {}, populate = []) {
|
|
const model = getModelByName(objectType);
|
|
if (model == undefined) {
|
|
throw new Error('Farm Control: Object type not found.');
|
|
}
|
|
const objects = await listObjects({
|
|
model,
|
|
filter,
|
|
populate
|
|
});
|
|
return objects;
|
|
}
|
|
|
|
formatDate(date, format) {
|
|
return dayjs(date).format(format);
|
|
}
|
|
|
|
async getObject(objectType = undefined, id = undefined, populate = []) {
|
|
if (objectType == undefined || objectType == '') {
|
|
logger.warn('Object type is required');
|
|
return { error: 'Object type is required' };
|
|
}
|
|
if (id == undefined || id == '') {
|
|
logger.warn('Object ID is required');
|
|
return { error: 'Object ID is required' };
|
|
}
|
|
const model = getModelByName(objectType);
|
|
if (model == undefined) {
|
|
logger.warn('Object type not found:', objectType);
|
|
}
|
|
const object = await getObject({ model, id, cached: true, populate });
|
|
return object;
|
|
}
|
|
}
|