Compare commits

..

2 Commits

Author SHA1 Message Date
bec4c8bf4c Enhance EJS formatting functionality with new placeholder handling and text tag collapsing
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
This commit introduces several improvements to the EJS formatting logic in the `templateformatter.js` file. It adds new regex patterns for inline placeholders and text-only tags, enhancing the ability to manage EJS content. The `maskEjsBlocks` function is updated to differentiate between control and inline EJS tags, while the `restoreEjsBlocks` function now handles both comment and inline placeholders. Additionally, a new function, `collapseTextOnlyTags`, is introduced to ensure that text content within tags is properly formatted without unnecessary whitespace. Corresponding tests have been added to verify these enhancements, improving the overall template content formatting capabilities.
2026-08-21 22:46:44 +01:00
ff60d6cf09 Enhance document size and template management with padding options
This commit introduces new padding options for document sizes, allowing for customizable print padding and specific padding values (left, right, top, bottom). The document size schema has been updated to include these new fields, and corresponding route handlers have been modified to handle padding data during document size creation and editing. Additionally, the template manager has been updated to apply padding when rendering templates, ensuring that the layout respects the specified padding values. Tests have been added to verify the correct application of padding in both document sizes and templates, improving the overall document formatting capabilities.
2026-08-21 22:39:13 +01:00
14 changed files with 385 additions and 25 deletions

View File

@ -51,6 +51,14 @@ export const hasPermission = async (user, objectType, action) => {
export const checkPermissions = (objectType, action) => async (req, res, next) => {
try {
if (req.user?._objectType == 'host') {
if (
(objectType === 'documentTemplate' && action === 'design') ||
(objectType === 'file' && action === 'download')
) {
return next();
}
}
const allowed = await hasPermission(req.user, objectType, action);
if (!allowed) {
return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });

View File

@ -25,6 +25,31 @@ const documentSizeSchema = new Schema(
required: true,
default: false,
},
printPadding: {
type: Boolean,
required: true,
default: false,
},
paddingLeft: {
type: Number,
required: true,
default: 0,
},
paddingRight: {
type: Number,
required: true,
default: 0,
},
paddingTop: {
type: Number,
required: true,
default: 0,
},
paddingBottom: {
type: Number,
required: true,
default: 0,
},
},
{ timestamps: true }
);

View File

@ -63,7 +63,7 @@ const isAuthenticated = async (req, res, next) => {
try {
const session = await getSession(token);
if (session && session.expiresAt > Date.now()) {
req.user = session.user;
req.user = { ...session.user, _objectType: 'user' };
req.session = session;
return next();
}
@ -71,7 +71,7 @@ const isAuthenticated = async (req, res, next) => {
// Try email-render JWT (short-lived token for Puppeteer email notifications)
const user = await lookupUserByToken(token);
if (user) {
req.user = user;
req.user = { ...user, _objectType: 'user' };
req.session = { user };
return next();
}
@ -85,6 +85,7 @@ const isAuthenticated = async (req, res, next) => {
if (hostId && authCode) {
const host = await getObject({ model: hostModel, id: hostId });
if (host && host.authCode === authCode) {
req.user = { ...host, _objectType: 'host' };
return next();
}
}

View File

@ -5,8 +5,32 @@ import { getFilter, convertPropertiesString, getSort } from '../../utils.js';
const router = express.Router();
const listAllowedFilters = ['name', 'width', 'height', 'createdAt', 'updatedAt', '_reference'];
const listAllowedSorters = ['name', 'width', 'height', 'infiniteHeight', 'createdAt', 'updatedAt'];
const listAllowedFilters = [
'name',
'width',
'height',
'printPadding',
'paddingLeft',
'paddingRight',
'paddingTop',
'paddingBottom',
'createdAt',
'updatedAt',
'_reference',
];
const listAllowedSorters = [
'name',
'width',
'height',
'infiniteHeight',
'printPadding',
'paddingLeft',
'paddingRight',
'paddingTop',
'paddingBottom',
'createdAt',
'updatedAt',
];
const propertiesAllowedFilters = [];
import {
listDocumentSizesRouteHandler,

View File

@ -77,13 +77,34 @@ describe('Document Size Service Route Handlers', () => {
describe('newDocumentSizeRouteHandler', () => {
it('should create a new document size', async () => {
req.body = { name: 'Letter', width: 216, height: 279 };
req.body = {
name: 'Letter',
width: 216,
height: 279,
infiniteHeight: false,
printPadding: false,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
};
const mockSize = { _id: '456', ...req.body };
newObject.mockResolvedValue(mockSize);
await newDocumentSizeRouteHandler(req, res);
expect(newObject).toHaveBeenCalled();
expect(newObject).toHaveBeenCalledWith(
expect.objectContaining({
newData: expect.objectContaining({
name: 'Letter',
printPadding: false,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
}),
})
);
expect(res.send).toHaveBeenCalledWith(mockSize);
});
});

View File

@ -113,7 +113,7 @@ describe('Document Template Service Route Handlers', () => {
'<Text>Hello</Text>',
{ name: 'Test' },
1,
{},
{ padding: true },
true
);
expect(res.send).toHaveBeenCalledWith(mockPreview);
@ -155,12 +155,34 @@ describe('Document Template Service Route Handlers', () => {
'<Text>Hello</Text>',
{ name: 'Test' },
'pdf',
{}
{ padding: true }
);
expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/pdf');
expect(res.send).toHaveBeenCalledWith(pdf);
});
it('passes padding false when requested by the caller', async () => {
req.params.id = 'template-1';
req.query = { type: 'pdf', padding: 'false' };
req.body = { object: { name: 'Test' } };
templateManager.renderDownload.mockResolvedValue({
type: 'pdf',
mime: 'application/pdf',
extension: 'pdf',
buffers: [Buffer.from('pdf-data')],
});
await downloadDocumentTemplateRouteHandler(req, res);
expect(templateManager.renderDownload).toHaveBeenCalledWith(
'template-1',
undefined,
{ name: 'Test' },
'pdf',
{ padding: false }
);
});
it('should return base64 images when multiple pages are rendered', async () => {
req.params.id = 'template-1';
req.query = { type: 'png' };

View File

@ -116,6 +116,11 @@ export const editDocumentSizeRouteHandler = async (req, res) => {
width: req.body.width,
height: req.body.height,
infiniteHeight: req.body.infiniteHeight,
printPadding: req.body.printPadding,
paddingLeft: req.body.paddingLeft,
paddingRight: req.body.paddingRight,
paddingTop: req.body.paddingTop,
paddingBottom: req.body.paddingBottom,
};
// Create audit log before updating
const result = await editObject({
@ -143,6 +148,11 @@ export const newDocumentSizeRouteHandler = async (req, res) => {
width: req.body.width,
height: req.body.height,
infiniteHeight: req.body.infiniteHeight,
printPadding: req.body.printPadding,
paddingLeft: req.body.paddingLeft,
paddingRight: req.body.paddingRight,
paddingTop: req.body.paddingTop,
paddingBottom: req.body.paddingBottom,
};
const result = await newObject({
model: documentSizeModel,

View File

@ -20,6 +20,27 @@ import { formatTemplateContent } from '../../templates/templateformatter.js';
const logger = log4js.getLogger('Document Templates');
logger.level = config.server.logLevel;
function parseOptionalBoolean(value) {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (value === true || value === false) {
return value;
}
const lower = String(value).trim().toLowerCase();
if (lower === 'true' || lower === '1') {
return true;
}
if (lower === 'false' || lower === '0') {
return false;
}
return undefined;
}
function getPaddingOption(req, defaultValue = true) {
return parseOptionalBoolean(req.body?.padding ?? req.query?.padding) ?? defaultValue;
}
export const listDocumentTemplatesRouteHandler = async (
req,
res,
@ -293,6 +314,7 @@ export const previewDocumentTemplateRouteHandler = async (req, res) => {
const content = req.body?.content;
const testObject = req.body?.testObject || req.body?.object || {};
const scale = req.body?.scale ?? 1;
const padding = getPaddingOption(req, true);
logger.debug(`Previewing document template with ID: ${id}`);
@ -301,7 +323,7 @@ export const previewDocumentTemplateRouteHandler = async (req, res) => {
content,
testObject,
scale,
{},
{ padding },
true
);
@ -320,7 +342,9 @@ export const downloadDocumentTemplateRouteHandler = async (req, res) => {
const data = req.body?.object || req.body?.testObject || {};
const width = req.body?.width || req.query.width;
const filename = req.body?.filename || req.query.filename || 'document';
const options = {};
const options = {
padding: getPaddingOption(req, true),
};
if (width) {
options.width = Number(width);
}

View File

@ -59,4 +59,25 @@ describe('formatTemplateContent', () => {
expect(result.content).not.toMatch(/\(\s+<%/);
expect(result.content).not.toMatch(/%>\s+\)/);
});
it('should not add a space next to a colon before EJS output', () => {
const input =
'<Barcode format="code128" height="33px" barcodeWidth="3">INV: <%= _reference %>\n </Barcode>';
const result = formatTemplateContent(input);
expect(result.content).toBe(
'<Barcode format="code128" height="33px" barcodeWidth="3">INV:<%= _reference %></Barcode>'
);
});
it('should keep barcode content and the closing tag on one line', () => {
const input =
'<Barcode format="code128" height="33px" barcodeWidth="3">INV:<%= _reference %></Barcode>';
const result = formatTemplateContent(input);
expect(result.content).toBe(
'<Barcode format="code128" height="33px" barcodeWidth="3">INV:<%= _reference %></Barcode>'
);
expect(result.content).not.toMatch(/%>\s*\n\s*<\/Barcode>/);
});
});

View File

@ -91,11 +91,51 @@ jest.unstable_mockModule('../../config.js', () => ({
},
}));
const { TemplateManager } = await import('../templatemanager.js');
const { TemplateManager, resolveDocumentPadding } = await import('../templatemanager.js');
const { getObject } = await import('../../database/database.js');
const ejs = (await import('ejs')).default;
const { generatePDF } = await import('../pdffactory.js');
describe('resolveDocumentPadding', () => {
const paddedSize = {
printPadding: false,
paddingLeft: 10,
paddingRight: 8,
paddingTop: 6,
paddingBottom: 4,
};
it('does not apply padding unless requested or printPadding is true', () => {
expect(resolveDocumentPadding(paddedSize, false)).toEqual({
applyPadding: false,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
});
});
it('applies padding when requested', () => {
expect(resolveDocumentPadding(paddedSize, true)).toEqual({
applyPadding: true,
paddingLeft: 10,
paddingRight: 8,
paddingTop: 6,
paddingBottom: 4,
});
});
it('applies padding when printPadding is true even if not requested', () => {
expect(resolveDocumentPadding({ ...paddedSize, printPadding: true }, false)).toEqual({
applyPadding: true,
paddingLeft: 10,
paddingRight: 8,
paddingTop: 6,
paddingBottom: 4,
});
});
});
describe('TemplateManager', () => {
let templateManager;
@ -161,6 +201,87 @@ describe('TemplateManager', () => {
expect(generatePDF).toHaveBeenCalled();
expect(result.pdf).toBeDefined();
});
it('adds padding to page size when padding is requested', async () => {
getObject.mockResolvedValue({
documentSize: {
width: 100,
height: 50,
infiniteHeight: false,
printPadding: false,
paddingLeft: 10,
paddingRight: 10,
paddingTop: 5,
paddingBottom: 5,
},
global: false,
objectType: 'printer',
});
await templateManager.renderPDF('temp-id', 'content', {}, { padding: true });
expect(generatePDF).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
width: 120,
height: 60,
})
);
});
it('adds padding when printPadding is true even if padding is false', async () => {
getObject.mockResolvedValue({
documentSize: {
width: 100,
height: 50,
infiniteHeight: false,
printPadding: true,
paddingLeft: 10,
paddingRight: 10,
paddingTop: 5,
paddingBottom: 5,
},
global: false,
objectType: 'printer',
});
await templateManager.renderPDF('temp-id', 'content', {}, { padding: false });
expect(generatePDF).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
width: 120,
height: 60,
})
);
});
it('keeps the original page size when padding is not applied', async () => {
getObject.mockResolvedValue({
documentSize: {
width: 100,
height: 50,
infiniteHeight: false,
printPadding: false,
paddingLeft: 10,
paddingRight: 10,
paddingTop: 5,
paddingBottom: 5,
},
global: false,
objectType: 'printer',
});
await templateManager.renderPDF('temp-id', 'content', {}, { padding: false });
expect(generatePDF).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
width: 100,
height: 50,
})
);
});
});
describe('renderDownload', () => {

View File

@ -18,18 +18,22 @@
}
.previewWrapper {
width: <%= (scaledWidth) || '50mm' %>;
height: <%= (scaledHeight) || '50mm' %>;
width: <%= (scaledPageWidth) || (scaledWidth) || '50mm' %>;
height: <%= (scaledPageHeight) || (scaledHeight) || '50mm' %>;
}
.previewDocument {
width: <%= (width) || '50mm' %>;
height: <%= (height) || '50mm' %>;
padding: <%= (paddingTop) || '0mm' %> <%= (paddingRight) || '0mm' %> <%= (paddingBottom) || '0mm' %> <%= (paddingLeft) || '0mm' %>;
box-sizing: content-box;
transform: scale(<%= scale || '1' %>);
transform-origin: top left;
}
.renderDocument {
width: <%= (scaledWidth) || '50mm' %>;
height: <%= (scaledHeight) || '50mm' %>;
width: <%= (width) || '50mm' %>;
height: <%= (height) || '50mm' %>;
padding: <%= (paddingTop) || '0mm' %> <%= (paddingRight) || '0mm' %> <%= (paddingBottom) || '0mm' %> <%= (paddingLeft) || '0mm' %>;
box-sizing: content-box;
transform: scale(<%= scale || '1' %>);
}
</style>

View File

@ -25,6 +25,11 @@ body {
transform-origin: top left;
position: relative;
overflow: hidden;
box-sizing: content-box;
}
.renderDocument {
box-sizing: content-box;
}
.documentText {

View File

@ -4,7 +4,9 @@ const { html: beautifyHtml, js: beautifyJs } = beautify;
const EJS_BLOCK_REGEX = /<%[\s\S]*?%>/g;
const EJS_TAG_REGEX = /^<%([=#-]?)([\s\S]*?)%>/;
const EJS_PLACEHOLDER_REGEX = /<!--EJS_PH_(\d+)-->/g;
const EJS_COMMENT_PLACEHOLDER_REGEX = /<!--EJS_PH_(\d+)-->/g;
const EJS_INLINE_PLACEHOLDER_REGEX = /___EJS_PH_(\d+)___/g;
const TEXT_ONLY_TAG_REGEX = /<([A-Za-z][\w:-]*)(\s[^>]*)?>([^<]*)<\/\1>/g;
const HTML_BEAUTIFY_OPTIONS = {
indent_size: 2,
@ -47,28 +49,53 @@ function formatEjsTag(tag) {
}
}
function isControlEjs(tag) {
const match = tag.match(EJS_TAG_REGEX);
if (!match) {
return false;
}
const [, modifier] = match;
return !modifier;
}
function maskEjsBlocks(content) {
const ejsBlocks = [];
const masked = content.replace(EJS_BLOCK_REGEX, (match) => {
const index = ejsBlocks.length;
ejsBlocks.push(match);
return `\n<!--EJS_PH_${index}-->\n`;
if (isControlEjs(match)) {
return `\n<!--EJS_PH_${index}-->\n`;
}
return `___EJS_PH_${index}___`;
});
return { masked, ejsBlocks };
}
function restoreEjsBlocks(content, ejsBlocks) {
return content.replace(EJS_PLACEHOLDER_REGEX, (_, index) =>
formatEjsTag(ejsBlocks[Number(index)])
);
const restore = (_, index) => formatEjsTag(ejsBlocks[Number(index)]);
return content
.replace(EJS_COMMENT_PLACEHOLDER_REGEX, restore)
.replace(EJS_INLINE_PLACEHOLDER_REGEX, restore);
}
function collapseTextOnlyTags(content) {
return content.replace(TEXT_ONLY_TAG_REGEX, (_, tagName, attrs, inner) => {
const collapsedInner = inner.replace(/^\s+|\s+$/g, '');
return `<${tagName}${attrs || ''}>${collapsedInner}</${tagName}>`;
});
}
function tightenParenthesesAroundEjs(content) {
return content.replace(/\(\s+(<%)/g, '($1').replace(/(%>)\s+\)/g, '$1)');
}
function tightenColonsAroundEjs(content) {
return content.replace(/:\s+(<%)/g, ':$1');
}
export function formatTemplateContent(content) {
if (content == null || typeof content !== 'string') {
return { error: 'Content is required and must be a string.', code: 400 };
@ -81,8 +108,10 @@ export function formatTemplateContent(content) {
try {
const { masked, ejsBlocks } = maskEjsBlocks(content);
let formatted = beautifyHtml(masked, HTML_BEAUTIFY_OPTIONS);
formatted = collapseTextOnlyTags(formatted);
formatted = restoreEjsBlocks(formatted, ejsBlocks);
formatted = tightenParenthesesAroundEjs(formatted);
formatted = tightenColonsAroundEjs(formatted);
return { content: formatted.trimEnd() };
} catch (error) {
return { error: error.message || 'Failed to format template content.', code: 400 };

View File

@ -357,6 +357,32 @@ function imageToSvg(pngBuffer, widthPx, heightPx, documentWidthMm, documentHeigh
</svg>`;
}
function toPaddingMm(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
}
export function resolveDocumentPadding(documentSize = {}, paddingRequested = false) {
const applyPadding = documentSize.printPadding == true || paddingRequested == true;
if (!applyPadding) {
return {
applyPadding: false,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
};
}
return {
applyPadding: true,
paddingLeft: toPaddingMm(documentSize.paddingLeft),
paddingRight: toPaddingMm(documentSize.paddingRight),
paddingTop: toPaddingMm(documentSize.paddingTop),
paddingBottom: toPaddingMm(documentSize.paddingBottom),
};
}
export class TemplateManager {
constructor() {
this.fc = {
@ -368,9 +394,10 @@ export class TemplateManager {
async renderTemplate(id, content, data = {}, scale = 1, options = {}, preview = true) {
try {
const { padding: paddingRequested = false, ...ejsOptions } = options;
const defaultOptions = {
async: true,
...options,
...ejsOptions,
};
logger.debug('Rendering template:', id);
@ -477,6 +504,11 @@ export class TemplateManager {
}
const infiniteHeight = documentSize.infiniteHeight == true;
const padding = resolveDocumentPadding(documentSize, paddingRequested == true);
const pageWidth = (documentSize.width || 0) + padding.paddingLeft + padding.paddingRight;
const pageHeight = infiniteHeight
? null
: (documentSize.height || 0) + padding.paddingTop + padding.paddingBottom;
const baseHtml = await ejs.render(
baseTemplate,
@ -486,6 +518,12 @@ export class TemplateManager {
height: infiniteHeight ? 'fit-content' : `${documentSize.height}mm`,
scaledWidth: `${documentSize.width * scale}mm`,
scaledHeight: infiniteHeight ? 'auto' : `${documentSize.height * scale}mm`,
scaledPageWidth: `${pageWidth * scale}mm`,
scaledPageHeight: infiniteHeight ? 'auto' : `${pageHeight * scale}mm`,
paddingTop: `${padding.paddingTop}mm`,
paddingRight: `${padding.paddingRight}mm`,
paddingBottom: `${padding.paddingBottom}mm`,
paddingLeft: `${padding.paddingLeft}mm`,
scale: `${scale}`,
baseCSS: baseCSS,
previewPaginationScript: preview ? previewPaginationScript : '',
@ -495,9 +533,14 @@ export class TemplateManager {
return {
html: baseHtml,
width: documentSize.width,
height: infiniteHeight ? 'auto' : documentSize.height,
width: pageWidth,
height: infiniteHeight ? 'auto' : pageHeight,
infiniteHeight: infiniteHeight,
printPadding: documentSize.printPadding == true,
paddingLeft: padding.paddingLeft,
paddingRight: padding.paddingRight,
paddingTop: padding.paddingTop,
paddingBottom: padding.paddingBottom,
};
} catch (error) {
console.error(error);
@ -548,12 +591,13 @@ export class TemplateManager {
try {
logger.debug(`Rendering ${format} for template:`, id);
const { padding: _padding, ...imageOptions } = options;
const pdfResult = await this.renderPDF(id, content, data, options);
if (pdfResult.error != undefined) {
return { error: pdfResult.error, code: pdfResult.code };
}
const images = await convertPDFToImage(pdfResult.pdf, options);
const images = await convertPDFToImage(pdfResult.pdf, imageOptions);
const converted = [];
for (const image of images) {
if (format === 'png') {
@ -573,12 +617,13 @@ export class TemplateManager {
try {
logger.debug('Rendering SVG for template:', id);
const { padding: _padding, ...imageOptions } = options;
const pdfResult = await this.renderPDF(id, content, data, options);
if (pdfResult.error != undefined) {
return { error: pdfResult.error, code: pdfResult.code };
}
const images = await convertPDFToImage(pdfResult.pdf, options);
const images = await convertPDFToImage(pdfResult.pdf, imageOptions);
const svgs = [];
for (const image of images) {
const png = await sharp(image).png().toBuffer();