@@ -2,6 +2,15 @@ const sharp = require('sharp');
22const fs = require ( 'fs' ) ;
33const path = require ( 'path' ) ;
44const 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.
716async 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 ( / @ f o n t - f a c e | u r l \( / i. test ( asText ) ) {
92+ const m = asText . match ( / u r l \( ( [ ^ ) ] + ) \) / 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 ( / @ f o n t - f a c e | u r l \( / i. test ( asText2 ) ) {
127+ const urls = [ ] ;
128+ let m ;
129+ const re = / u r l \( ( [ ^ ) ] + ) \) / 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 => / \. t t f ( \? | $ ) / i. test ( u ) ) || urls . find ( u => / \. o t f ( \? | $ ) / i. test ( u ) ) || urls . find ( u => / \. w o f f ( \? | $ ) / i. test ( u ) ) || urls . find ( u => / \. w o f f 2 ( \? | $ ) / 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