Skip to content

Commit f34e00d

Browse files
authored
Merge pull request #61 from rishit-exe/staging
fix: Font in certificate
2 parents 76e48c4 + d51eb93 commit f34e00d

2 files changed

Lines changed: 184 additions & 22 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"mongoose": "^8.18.2",
3333
"morgan": "^1.10.1",
3434
"nodemailer": "^7.0.6",
35+
"opentype.js": "^1.3.4",
3536
"pdfkit": "^0.17.2",
3637
"sharp": "^0.32.0",
3738
"swagger-jsdoc": "^6.2.8",

src/utils/certificates/overlay-sharp.js

Lines changed: 183 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ const sharp = require('sharp');
22
const fs = require('fs');
33
const path = require('path');
44
const https = require('https');
5+
// try optional opentype dependency for converting text to outlines (vector paths)
6+
let opentype = null;
7+
try {
8+
opentype = require('opentype.js');
9+
} catch (e) {
10+
// not installed — we'll fall back to embedding fonts or text elements
11+
console.log("'opentype.js' not found, proceeding without font outline support.");
12+
opentype = null;
13+
}
514

615
// Minimal fetch helper that returns a Buffer for a given URL or local path.
716
async function fetchBuffer(src, timeout = 8000) {
@@ -70,17 +79,113 @@ module.exports = async function textOverlay(name, url, color, font_size, yOffset
7079
const resizedMeta = await sharp(resizedBuffer).metadata();
7180
const resizedHeight = resizedMeta.height || Math.round(targetWidth * 0.7);
7281

73-
// load font (some jimp options may point to .fnt descriptor or a font file; attempt to fetch whatever provided)
82+
// load font (some jimp options may point to a font file or to a CSS stylesheet like Google Fonts)
7483
let fontBuf = null;
84+
let detectedFontUrl = null; // if we fetch a secondary URL from CSS
7585
try {
76-
fontBuf = await fetchBuffer(jimpOptions[fontKey]);
86+
const raw = await fetchBuffer(jimpOptions[fontKey]);
87+
if (raw) {
88+
// Heuristic: if the fetched payload looks like text/CSS and contains url(...) then
89+
// treat it as a stylesheet (Google Fonts) and extract the first referenced font file URL.
90+
const asText = raw.toString('utf8');
91+
if (/@font-face|url\(/i.test(asText)) {
92+
const m = asText.match(/url\(([^)]+)\)/i);
93+
if (m && m[1]) {
94+
let fontFileUrl = m[1].trim().replace(/['"]/g, '');
95+
// handle protocol-relative URLs
96+
if (fontFileUrl.startsWith('//')) fontFileUrl = 'https:' + fontFileUrl;
97+
// remember for mime detection
98+
detectedFontUrl = fontFileUrl;
99+
try {
100+
fontBuf = await fetchBuffer(fontFileUrl);
101+
} catch (e) {
102+
// failed to fetch referenced font file; fall back to using raw (CSS) as null
103+
fontBuf = null;
104+
}
105+
} else {
106+
// stylesheet fetched but no url found
107+
fontBuf = null;
108+
}
109+
} else {
110+
// raw binary font data
111+
fontBuf = raw;
112+
}
113+
}
77114
} catch (err) {
78115
// Not fatal; continue without embedded font
79116
fontBuf = null;
80117
}
81118

119+
// If the fetched resource looked like a stylesheet, try to extract all url(...) entries
120+
// and prefer a TTF/OTF if present. Otherwise fall back to the first url found.
121+
if (!fontBuf && jimpOptions[fontKey]) {
122+
try {
123+
const raw2 = await fetchBuffer(jimpOptions[fontKey]);
124+
if (raw2) {
125+
const asText2 = raw2.toString('utf8');
126+
if (/@font-face|url\(/i.test(asText2)) {
127+
const urls = [];
128+
let m;
129+
const re = /url\(([^)]+)\)/ig;
130+
while ((m = re.exec(asText2)) !== null) {
131+
let u = m[1].trim().replace(/['"]/g, '');
132+
if (u.startsWith('//')) u = 'https:' + u;
133+
urls.push(u);
134+
}
135+
if (urls.length) {
136+
// prefer ttf/otf, then woff, then woff2, otherwise first
137+
const pick = urls.find(u => /\.ttf(\?|$)/i.test(u)) || urls.find(u => /\.otf(\?|$)/i.test(u)) || urls.find(u => /\.woff(\?|$)/i.test(u)) || urls.find(u => /\.woff2(\?|$)/i.test(u)) || urls[0];
138+
if (pick) {
139+
detectedFontUrl = pick;
140+
try {
141+
fontBuf = await fetchBuffer(pick);
142+
} catch (e) {
143+
fontBuf = null;
144+
}
145+
}
146+
}
147+
}
148+
}
149+
} catch (e) {
150+
// ignore
151+
}
152+
}
153+
82154
const fontB64 = fontBuf ? fontBuf.toString('base64') : null;
83155

156+
// Determine mime-type and format string for @font-face. Prefer the detectedFontUrl extension,
157+
// otherwise try to infer nothing and default to ttf/truetype.
158+
function mimeAndFormatFromUrl(url) {
159+
if (!url) return { mime: 'font/ttf', format: 'truetype' };
160+
const lower = url.split('?')[0].toLowerCase();
161+
if (lower.endsWith('.woff2')) return { mime: 'font/woff2', format: 'woff2' };
162+
if (lower.endsWith('.woff')) return { mime: 'font/woff', format: 'woff' };
163+
if (lower.endsWith('.otf')) return { mime: 'font/otf', format: 'opentype' };
164+
if (lower.endsWith('.ttf')) return { mime: 'font/ttf', format: 'truetype' };
165+
// default
166+
return { mime: 'font/ttf', format: 'truetype' };
167+
}
168+
169+
const detectedUrlForMime = detectedFontUrl || (jimpOptions[fontKey] || '');
170+
const { mime: fontMime, format: fontFormat } = mimeAndFormatFromUrl(detectedUrlForMime);
171+
172+
// DEBUG: report font discovery details so user can verify what was embedded
173+
try {
174+
const srcLabel = (detectedFontUrl && detectedFontUrl === (path.resolve(__dirname, '../../../PlaywriteVariableFont.ttf'))) ? 'local-override' : (detectedFontUrl ? 'fetched' : 'none');
175+
const fontBufLen = fontBuf ? fontBuf.length : 0;
176+
console.log(`[CERT-FONT] source=${srcLabel} detectedUrl=${detectedUrlForMime} mime=${fontMime} format=${fontFormat} size=${fontBufLen}`);
177+
} catch (e) {
178+
// ignore logging errors
179+
}
180+
181+
// Strict mode: require opentype.js and a fetched font buffer to produce outlines.
182+
if (!opentype) {
183+
return { buffer: null, error: true, error_message: 'opentype.js is required for strict outline rendering' };
184+
}
185+
if (!fontBuf) {
186+
return { buffer: null, error: true, error_message: 'Font file could not be fetched for outline rendering' };
187+
}
188+
84189
// Build SVG overlay with embedded font (if available)
85190
const fontSizePx = parseInt(String(font_size), 10) || 64;
86191
const fill = (String(color || 'WHITE').toLowerCase() === 'white') ? '#FFFFFF' : '#000000';
@@ -103,33 +208,89 @@ module.exports = async function textOverlay(name, url, color, font_size, yOffset
103208
if (textY < 0) textY = 0;
104209
if (textY > overlayHeight) textY = overlayHeight;
105210

106-
const fontFace = fontB64 ? `@font-face{font-family:UserFont; src: url('data:font/ttf;base64,${fontB64}') format('truetype');}` : '';
211+
const fontFace = fontB64 ? `@font-face{font-family:UserFont; src: url('data:${fontMime};base64,${fontB64}') format('${fontFormat}');}` : '';
107212

108213
// Apply uppercase if requested
109214
const renderedName = uppercase ? String(name || '').toUpperCase() : String(name || '');
110215

111-
// Compute x position: support text_align and xOffset. If align=center, use 50% plus xOff pixels (via translate).
112-
// For left/right, compute pixel positions relative to the image width.
113-
let textElement = '';
114-
if (align === 'center') {
115-
// Use translate to nudge horizontally by xOff while keeping center anchoring
116-
textElement = `<text x="50%" y="${textY}" class="name" transform="translate(${xOff},0)">${escapeXml(renderedName)}</text>`;
117-
} else if (align === 'left') {
118-
const leftX = Math.max(0, 0 + xOff + 20); // small padding
119-
textElement = `<text x="${leftX}" y="${textY}" class="name" text-anchor="start">${escapeXml(renderedName)}</text>`;
120-
} else if (align === 'right') {
121-
const rightX = Math.max(0, targetWidth - xOff - 20);
122-
textElement = `<text x="${rightX}" y="${textY}" class="name" text-anchor="end">${escapeXml(renderedName)}</text>`;
123-
} else {
124-
// default to center
125-
textElement = `<text x="50%" y="${textY}" class="name" transform="translate(${xOff},0)">${escapeXml(renderedName)}</text>`;
126-
}
216+
// Convert the text to SVG path outlines using opentype (strict)
217+
let svg = null;
218+
try {
219+
const font = opentype.parse(fontBuf.buffer ? fontBuf.buffer : fontBuf);
220+
// Create a path at origin (0,0) using the font size
221+
const path = font.getPath(renderedName, 0, fontSizePx, fontSizePx);
222+
223+
// compute bounding box from path commands
224+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
225+
for (const cmd of path.commands) {
226+
if (typeof cmd.x === 'number') {
227+
minX = Math.min(minX, cmd.x);
228+
maxX = Math.max(maxX, cmd.x);
229+
}
230+
if (typeof cmd.y === 'number') {
231+
minY = Math.min(minY, cmd.y);
232+
maxY = Math.max(maxY, cmd.y);
233+
}
234+
if (typeof cmd.x1 === 'number') {
235+
minX = Math.min(minX, cmd.x1);
236+
maxX = Math.max(maxX, cmd.x1);
237+
}
238+
if (typeof cmd.y1 === 'number') {
239+
minY = Math.min(minY, cmd.y1);
240+
maxY = Math.max(maxY, cmd.y1);
241+
}
242+
if (typeof cmd.x2 === 'number') {
243+
minX = Math.min(minX, cmd.x2);
244+
maxX = Math.max(maxX, cmd.x2);
245+
}
246+
if (typeof cmd.y2 === 'number') {
247+
minY = Math.min(minY, cmd.y2);
248+
maxY = Math.max(maxY, cmd.y2);
249+
}
250+
}
251+
if (!isFinite(minX)) { minX = 0; minY = 0; maxX = 0; maxY = 0; }
252+
const bboxWidth = maxX - minX;
253+
const bboxHeight = maxY - minY;
127254

128-
const svg = `<?xml version="1.0" encoding="UTF-8"?>\
255+
// compute target x position based on alignment and xOff
256+
let tx = 0;
257+
if (align === 'center') {
258+
tx = Math.round((targetWidth - bboxWidth) / 2 - minX + xOff);
259+
} else if (align === 'left') {
260+
tx = Math.round(0 + xOff + 20 - minX);
261+
} else if (align === 'right') {
262+
tx = Math.round(targetWidth - xOff - 20 - bboxWidth - minX);
263+
} else {
264+
tx = Math.round((targetWidth - bboxWidth) / 2 - minX + xOff);
265+
}
266+
267+
// vertical center the path at textY
268+
const ty = Math.round(textY - (minY + bboxHeight / 2));
269+
270+
// convert path to SVG path data
271+
const pathData = path.toPathData ? path.toPathData() : (() => {
272+
// fallback: build path d manually
273+
const parts = [];
274+
for (const c of path.commands) {
275+
if (c.type === 'M') parts.push(`M ${c.x} ${c.y}`);
276+
else if (c.type === 'L') parts.push(`L ${c.x} ${c.y}`);
277+
else if (c.type === 'C') parts.push(`C ${c.x1} ${c.y1} ${c.x2} ${c.y2} ${c.x} ${c.y}`);
278+
else if (c.type === 'Q') parts.push(`Q ${c.x1} ${c.y1} ${c.x} ${c.y}`);
279+
else if (c.type === 'Z') parts.push('Z');
280+
}
281+
return parts.join(' ');
282+
})();
283+
284+
svg = `<?xml version="1.0" encoding="UTF-8"?>\
129285
<svg xmlns="http://www.w3.org/2000/svg" width="${targetWidth}" height="${overlayHeight}">\
130-
<style>${fontFace} .name{ font-family: ${fontB64 ? 'UserFont' : 'sans-serif'}; font-size: ${fontSizePx}px; fill: ${fill}; dominant-baseline: middle; }</style>\
131-
${textElement}\
286+
<g fill="${fill}">\
287+
<path d="${pathData}" transform="translate(${tx},${ty})" />\
288+
</g>\
132289
</svg>`;
290+
try { console.log(`[CERT-FONT] outlines=used bbox=${Math.round(bboxWidth)}x${Math.round(bboxHeight)} tx=${tx} ty=${ty}`); } catch(e) {}
291+
} catch (outlineErr) {
292+
return { buffer: null, error: true, error_message: `opentype outline generation failed: ${outlineErr && outlineErr.message ? outlineErr.message : String(outlineErr)}` };
293+
}
133294

134295
const svgBuffer = Buffer.from(svg);
135296

0 commit comments

Comments
 (0)