@goodandready/dsh-image-gen 0.11.34 → 0.11.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  // lib/config-schema.js — Zod configuration schema and normalization helpers (#317)
2
2
 
3
3
  import z from '@deepseek-ai/schemastery'
4
- import { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS } from './providers.js'
4
+ import { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS } from './provider-utils.js'
5
5
 
6
6
  // Polyfill for .volatile() schema annotation if runtime schemastery lacks it (#295)
7
7
  if (typeof z.prototype?.volatile !== 'function') {
@@ -343,15 +343,25 @@ export function isVolatileRef(value) {
343
343
  return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
344
344
  }
345
345
 
346
- export function plainConfig(cfg) {
347
- if (isVolatileRef(cfg)) return plainConfig(cfg.get())
348
- if (!cfg || typeof cfg !== 'object') return cfg
349
- const out = {}
350
- for (const key of Object.keys(cfg)) {
351
- const value = cfg[key]
352
- out[key] = isVolatileRef(value) ? value.get() : value
346
+ /**
347
+ * Deeply unwrap Cosmokit/Schemastery Volatile boxes into plain primitives/objects/arrays (#282, #417).
348
+ */
349
+ export function plainConfig(value) {
350
+ if (value === null || value === undefined) return value
351
+ if (typeof value === 'function') return plainConfig(value())
352
+ if (isVolatileRef(value)) return plainConfig(value.get())
353
+ if (Array.isArray(value)) return value.map(plainConfig)
354
+ if (typeof value === 'object') {
355
+ if (Buffer.isBuffer(value) || value instanceof Date || value instanceof RegExp || value instanceof URL) {
356
+ return value
357
+ }
358
+ const out = {}
359
+ for (const [k, v] of Object.entries(value)) {
360
+ out[k] = plainConfig(v)
361
+ }
362
+ return out
353
363
  }
354
- return out
364
+ return value
355
365
  }
356
366
 
357
367
  export function volatileConfig(cfg) {
@@ -1,4 +1,4 @@
1
- import { resolveInside } from './security.js'
1
+ import { resolveInside, escapeXml, sanitizeSvgColor } from './security.js'
2
2
  import sharp from 'sharp'
3
3
  // @ts-check
4
4
  import path from 'node:path'
@@ -593,9 +593,11 @@ export function buildIcoBuffer(pngFrames) {
593
593
 
594
594
  async function getDefaultIconBytes(name, themeColor) {
595
595
  const initial = (name || 'A').trim().charAt(0).toUpperCase() || 'A'
596
+ const escapedInitial = escapeXml(initial)
597
+ const safeThemeColor = sanitizeSvgColor(themeColor || '#0f172a', '#0f172a')
596
598
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
597
- <rect width="512" height="512" rx="100" fill="${themeColor || '#0f172a'}"/>
598
- <text x="50%" y="54%" font-family="system-ui, -apple-system, sans-serif" font-size="280" font-weight="bold" fill="#ffffff" dominant-baseline="middle" text-anchor="middle">${initial}</text>
599
+ <rect width="512" height="512" rx="100" fill="${safeThemeColor}"/>
600
+ <text x="50%" y="54%" font-family="system-ui, -apple-system, sans-serif" font-size="280" font-weight="bold" fill="#ffffff" dominant-baseline="middle" text-anchor="middle">${escapedInitial}</text>
599
601
  </svg>`
600
602
  return sharp(Buffer.from(svg)).png().toBuffer()
601
603
  }
package/lib/index.js CHANGED
@@ -367,13 +367,14 @@ export function apply(ctx, rawConfig) {
367
367
  try {
368
368
  const u = new URL(req.url, 'http://localhost')
369
369
  const qProvider = u.searchParams.get('provider')
370
+ const effectiveCfg = live()
370
371
  const provider = (typeof qProvider === 'string' && qProvider.trim())
371
- || (typeof config?.provider === 'string' && config.provider.trim())
372
+ || (typeof effectiveCfg?.provider === 'string' && effectiveCfg.provider.trim())
372
373
  || 'fal'
373
374
  const deps = {
374
375
  fetchImpl: fetch,
375
376
  resolveKey: (ref) => resolveApiKey(ctx, ref),
376
- cfg: (typeof getConfig === 'function') ? getConfig() : config,
377
+ cfg: effectiveCfg,
377
378
  ctx,
378
379
  }
379
380
  const result = await testProviderConnection(deps, provider)
package/lib/providers.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import sharp from 'sharp'
2
+ import { plainConfig } from './config-schema.js'
2
3
  import { CURATED_STYLES, normalizeStyleKey } from './prompt-polisher.js'
3
4
  /**
4
5
  * Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
@@ -221,16 +222,17 @@ export async function saveAttachmentSafe(ctx, { bytes, mediaType, name }) {
221
222
 
222
223
 
223
224
  export function makeProviders(deps, job) {
224
- const subscription = createSubscriptionGenerator(deps, job)
225
+ const safeDeps = deps?.cfg ? { ...deps, cfg: plainConfig(deps.cfg) } : deps
226
+ const subscription = createSubscriptionGenerator(safeDeps, job)
225
227
  return {
226
- fal: createFalGenerator(deps, job),
227
- custom: createCustomGenerator(deps, job),
228
+ fal: createFalGenerator(safeDeps, job),
229
+ custom: createCustomGenerator(safeDeps, job),
228
230
  codex: subscription('codex'),
229
231
  grok: subscription('grok'),
230
- local: createLocalGenerator(deps, job),
231
- seedream: createSeedreamGenerator(deps, job),
232
- gemini: createGeminiGenerator(deps, job),
233
- replicate: createReplicateGenerator(deps, job),
232
+ local: createLocalGenerator(safeDeps, job),
233
+ seedream: createSeedreamGenerator(safeDeps, job),
234
+ gemini: createGeminiGenerator(safeDeps, job),
235
+ replicate: createReplicateGenerator(safeDeps, job),
234
236
  }
235
237
  }
236
238
 
@@ -279,7 +281,8 @@ export function embedPngMetadata(pngBytes, metadata = {}) {
279
281
  return Buffer.concat([buf.subarray(0, insertPos), chunk, buf.subarray(insertPos)])
280
282
  }
281
283
 
282
- export async function removeBackgroundFal({ fetchImpl, resolveKey, cfg }, { imageBytes, mediaType = 'image/png', model = 'fal-ai/birefnet', signal }) {
284
+ export async function removeBackgroundFal({ fetchImpl, resolveKey, cfg: rawCfg }, { imageBytes, mediaType = 'image/png', model = 'fal-ai/birefnet', signal }) {
285
+ const cfg = plainConfig(rawCfg)
283
286
  const key = await resolveKey(cfg.apiKeyEnv)
284
287
  const base64 = Buffer.isBuffer(imageBytes) ? imageBytes.toString('base64') : Buffer.from(imageBytes).toString('base64')
285
288
  const dataUrl = `data:${mediaType};base64,${base64}`
@@ -297,7 +300,8 @@ export async function removeBackgroundFal({ fetchImpl, resolveKey, cfg }, { imag
297
300
  return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
298
301
  }
299
302
 
300
- export async function upscaleImageFal({ fetchImpl, resolveKey, cfg }, { imageBytes, mediaType = 'image/png', scale = 2, creativity, prompt, model = 'fal-ai/clarity-upscaler', signal }) {
303
+ export async function upscaleImageFal({ fetchImpl, resolveKey, cfg: rawCfg }, { imageBytes, mediaType = 'image/png', scale = 2, creativity, prompt, model = 'fal-ai/clarity-upscaler', signal }) {
304
+ const cfg = plainConfig(rawCfg)
301
305
  const key = await resolveKey(cfg.apiKeyEnv)
302
306
  const base64 = Buffer.isBuffer(imageBytes) ? imageBytes.toString('base64') : Buffer.from(imageBytes).toString('base64')
303
307
  const dataUrl = `data:${mediaType};base64,${base64}`
@@ -579,8 +583,9 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
579
583
  /** Direct image editing (inpainting / img2img) via selected provider. */
580
584
  /** @internal — direct provider call path for integration tests */
581
585
  export async function editImageDirect(deps, job) {
582
- const providers = makeProviders(deps, job)
583
- const providerKey = (typeof job?.provider === 'string' && job.provider.trim()) || (typeof deps?.cfg?.provider === 'string' && deps.cfg.provider.trim()) || (typeof deps?.cfg?.defaultProvider === 'string' && deps.cfg.defaultProvider.trim()) || 'fal'
586
+ const safeDeps = deps?.cfg ? { ...deps, cfg: plainConfig(deps.cfg) } : deps
587
+ const providers = makeProviders(safeDeps, job)
588
+ const providerKey = (typeof job?.provider === 'string' && job.provider.trim()) || (typeof safeDeps?.cfg?.provider === 'string' && safeDeps.cfg.provider.trim()) || (typeof safeDeps?.cfg?.defaultProvider === 'string' && safeDeps.cfg.defaultProvider.trim()) || 'fal'
584
589
  const fn = providers[providerKey] || providers.fal || providers.custom
585
590
  if (!fn) throw new Error(`Provider "${providerKey}" does not support image editing`)
586
591
  return fn(job.seed, job.prompt)
@@ -589,11 +594,12 @@ export async function editImageDirect(deps, job) {
589
594
  /** Direct image variation via selected provider. */
590
595
  /** @internal — direct provider call path for integration tests */
591
596
  export async function varyImageDirect(deps, job) {
592
- const providers = makeProviders(deps, {
597
+ const safeDeps = deps?.cfg ? { ...deps, cfg: plainConfig(deps.cfg) } : deps
598
+ const providers = makeProviders(safeDeps, {
593
599
  ...job,
594
600
  strength: job.variationStrength ?? job.strength ?? 0.35,
595
601
  })
596
- const providerKey = (typeof job?.provider === 'string' && job.provider.trim()) || (typeof deps?.cfg?.provider === 'string' && deps.cfg.provider.trim()) || (typeof deps?.cfg?.defaultProvider === 'string' && deps.cfg.defaultProvider.trim()) || 'fal'
602
+ const providerKey = (typeof job?.provider === 'string' && job.provider.trim()) || (typeof safeDeps?.cfg?.provider === 'string' && safeDeps.cfg.provider.trim()) || (typeof safeDeps?.cfg?.defaultProvider === 'string' && safeDeps.cfg.defaultProvider.trim()) || 'fal'
597
603
  const fn = providers[providerKey] || providers.fal || providers.custom
598
604
  if (!fn) throw new Error(`Provider "${providerKey}" does not support image variations`)
599
605
  return fn(job.seed, job.prompt)
@@ -628,7 +634,8 @@ export function toLosslessJson(val) {
628
634
  * Returns { ok: boolean, latencyMs: number, message: string }.
629
635
  */
630
636
  export async function testProviderConnection(deps, providerId) {
631
- const { fetchImpl = fetch, resolveKey, cfg } = deps || {}
637
+ const { fetchImpl = fetch, resolveKey } = deps || {}
638
+ const cfg = plainConfig(deps?.cfg)
632
639
  const start = Date.now()
633
640
  const rawProvider = (typeof providerId === 'string' && providerId.trim())
634
641
  || (typeof cfg?.provider === 'string' && cfg.provider.trim())
package/lib/security.js CHANGED
@@ -294,3 +294,49 @@ export function resolveInside(baseDir, candidatePath) {
294
294
 
295
295
  return resolved
296
296
  }
297
+
298
+ const SAFE_HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
299
+ const SAFE_RGB_COLOR = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+)\s*)?\)$/i
300
+ const SAFE_HSL_COLOR = /^hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+)\s*)?\)$/i
301
+ const SAFE_NAMED_COLOR = /^(?:transparent|currentColor|none|[a-zA-Z]{3,20})$/i
302
+
303
+ /**
304
+ * Escapes characters for safe inclusion in XML/SVG documents and attributes (#414).
305
+ * Replaces &, <, >, ", ' with their corresponding XML entities.
306
+ *
307
+ * @param {string|unknown} val
308
+ * @returns {string} XML-safe string
309
+ */
310
+ export function escapeXml(val) {
311
+ if (val === null || val === undefined) return ''
312
+ return String(val)
313
+ .replace(/&/g, '&amp;')
314
+ .replace(/</g, '&lt;')
315
+ .replace(/>/g, '&gt;')
316
+ .replace(/"/g, '&quot;')
317
+ .replace(/'/g, '&apos;')
318
+ }
319
+
320
+ /**
321
+ * Sanitizes a color value for safe inclusion in SVG attributes (fill, stroke, stop-color) (#414).
322
+ * If the input is not a recognized safe color (hex, rgb/rgba, hsl/hsla, or safe named color),
323
+ * returns the provided fallback color.
324
+ *
325
+ * @param {string|unknown} color
326
+ * @param {string} [fallback='#000000']
327
+ * @returns {string} Sanitized color string
328
+ */
329
+ export function sanitizeSvgColor(color, fallback = '#000000') {
330
+ if (typeof color !== 'string') return fallback
331
+ const trimmed = color.trim()
332
+ if (
333
+ SAFE_HEX_COLOR.test(trimmed) ||
334
+ SAFE_RGB_COLOR.test(trimmed) ||
335
+ SAFE_HSL_COLOR.test(trimmed) ||
336
+ SAFE_NAMED_COLOR.test(trimmed)
337
+ ) {
338
+ return trimmed
339
+ }
340
+ return fallback
341
+ }
342
+
@@ -10,7 +10,7 @@ import {
10
10
  toLosslessJson,
11
11
  saveAttachmentSafe,
12
12
  } from '../providers.js'
13
- import { sanitizeErrorAndLogs, resolveInside } from '../security.js'
13
+ import { sanitizeErrorAndLogs, resolveInside, escapeXml, sanitizeSvgColor } from '../security.js'
14
14
  import { resolveConversationImage } from '../resolve-image.js'
15
15
 
16
16
  export function registerProcessingAdvancedTools(ctx, deps) {
@@ -126,6 +126,7 @@ export function registerProcessingAdvancedTools(ctx, deps) {
126
126
  const mime = src.mediaType || 'image/png'
127
127
  const dataUri = `data:${mime};base64,${b64}`
128
128
  const label = labels[idx] || String(idx + 1)
129
+ const escapedLabel = escapeXml(label)
129
130
 
130
131
  tilesSvg += `
131
132
  <g transform="translate(${x}, ${y})">
@@ -133,14 +134,11 @@ export function registerProcessingAdvancedTools(ctx, deps) {
133
134
  <rect width="${tileW}" height="${tileH}" rx="8" fill="#18191c" stroke="#2c2e33" stroke-width="1"/>
134
135
  <image href="${dataUri}" width="${tileW}" height="${tileH}" preserveAspectRatio="xMidYMid slice" clip-path="url(#clip-${idx})"/>
135
136
  <rect x="12" y="12" width="28" height="24" rx="4" fill="rgba(0,0,0,0.65)"/>
136
- <text x="26" y="29" fill="#ffffff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="13" font-weight="700" text-anchor="middle">${label}</text>
137
+ <text x="26" y="29" fill="#ffffff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="13" font-weight="700" text-anchor="middle">${escapedLabel}</text>
137
138
  </g>`
138
139
  })
139
140
 
140
- const escapedTitle = title
141
- .replace(/&/g, '&amp;')
142
- .replace(/</g, '&lt;')
143
- .replace(/>/g, '&gt;')
141
+ const escapedTitle = escapeXml(title || '')
144
142
 
145
143
  const svgContent = `<?xml version="1.0" encoding="UTF-8"?>
146
144
  <svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" viewBox="0 0 ${totalW} ${totalH}">
@@ -247,7 +245,7 @@ export function registerProcessingAdvancedTools(ctx, deps) {
247
245
  const source = await resolveConversationImage(ctx, exec, args.image)
248
246
  const targetRatioKey = args.aspect_ratio || '1:1'
249
247
  const mode = args.mode || 'auto_focus'
250
- const bgColor = args.background || '#0b0c0e'
248
+ const bgColor = sanitizeSvgColor(args.background || '#0b0c0e', '#0b0c0e')
251
249
 
252
250
  const ratioMap = {
253
251
  '1:1': 1.0,
@@ -413,8 +411,11 @@ export function registerProcessingAdvancedTools(ctx, deps) {
413
411
 
414
412
  const projectName = args.project_name || 'Project Brand'
415
413
  const tagline = args.tagline || 'Modern AI-driven application workspace'
416
- const themeColor = args.theme_color || '#4f46e5'
414
+ const themeColor = sanitizeSvgColor(args.theme_color || '#4f46e5', '#4f46e5')
417
415
  const initial = projectName.charAt(0).toUpperCase()
416
+ const escapedInitial = escapeXml(initial)
417
+ const escapedProject = escapeXml(projectName)
418
+ const escapedTagline = escapeXml(tagline)
418
419
 
419
420
  let logoDataUri = null
420
421
  if (args.logo_image) {
@@ -439,7 +440,7 @@ export function registerProcessingAdvancedTools(ctx, deps) {
439
440
  const faviconSvg = `<?xml version="1.0" encoding="UTF-8"?>
440
441
  <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
441
442
  <rect width="64" height="64" rx="16" fill="${themeColor}"/>
442
- ${logoDataUri ? `<image href="${logoDataUri}" x="8" y="8" width="48" height="48" preserveAspectRatio="xMidYMid meet"/>` : `<text x="32" y="44" fill="#ffffff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="34" font-weight="900" text-anchor="middle">${initial}</text>`}
443
+ ${logoDataUri ? `<image href="${logoDataUri}" x="8" y="8" width="48" height="48" preserveAspectRatio="xMidYMid meet"/>` : `<text x="32" y="44" fill="#ffffff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="34" font-weight="900" text-anchor="middle">${escapedInitial}</text>`}
443
444
  </svg>`
444
445
  const faviconPath = path.join(targetDir, 'favicon.svg')
445
446
  await writeFile(faviconPath, faviconSvg, 'utf8')
@@ -459,7 +460,7 @@ export function registerProcessingAdvancedTools(ctx, deps) {
459
460
  </linearGradient>
460
461
  </defs>
461
462
  <rect width="${size}" height="${size}" rx="${rx}" fill="url(#brandGrad-${size})"/>
462
- ${logoDataUri ? `<image href="${logoDataUri}" x="${size * 0.15}" y="${size * 0.15}" width="${size * 0.7}" height="${size * 0.7}" preserveAspectRatio="xMidYMid meet"/>` : `<text x="${size / 2}" y="${yOffset}" fill="#ffffff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="${fontSize}" font-weight="900" text-anchor="middle">${initial}</text>`}
463
+ ${logoDataUri ? `<image href="${logoDataUri}" x="${size * 0.15}" y="${size * 0.15}" width="${size * 0.7}" height="${size * 0.7}" preserveAspectRatio="xMidYMid meet"/>` : `<text x="${size / 2}" y="${yOffset}" fill="#ffffff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="${fontSize}" font-weight="900" text-anchor="middle">${escapedInitial}</text>`}
463
464
  </svg>`
464
465
  const iconPath = path.join(targetDir, `icon-${size}.svg`)
465
466
  await writeFile(iconPath, iconSvg, 'utf8')
@@ -486,8 +487,6 @@ export function registerProcessingAdvancedTools(ctx, deps) {
486
487
  exportedFiles.push(manifestPath)
487
488
 
488
489
  // 4. OpenGraph Social Card (1200x630)
489
- const escapedProject = projectName.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
490
- const escapedTagline = tagline.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
491
490
  const ogCardSvg = `<?xml version="1.0" encoding="UTF-8"?>
492
491
  <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
493
492
  <defs>
@@ -511,7 +510,7 @@ export function registerProcessingAdvancedTools(ctx, deps) {
511
510
 
512
511
  <g transform="translate(860, 200)">
513
512
  <rect width="180" height="180" rx="36" fill="${themeColor}" fill-opacity="0.15" stroke="${themeColor}" stroke-width="2"/>
514
- ${logoDataUri ? `<image href="${logoDataUri}" x="20" y="20" width="140" height="140" preserveAspectRatio="xMidYMid meet"/>` : `<text x="90" y="125" fill="#ffffff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="96" font-weight="900" text-anchor="middle">${initial}</text>`}
513
+ ${logoDataUri ? `<image href="${logoDataUri}" x="20" y="20" width="140" height="140" preserveAspectRatio="xMidYMid meet"/>` : `<text x="90" y="125" fill="#ffffff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="96" font-weight="900" text-anchor="middle">${escapedInitial}</text>`}
515
514
  </g>
516
515
 
517
516
  <text x="130" y="480" fill="#64748b" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="14" font-weight="500">Auto-generated by @goodandready/dsh-image-gen</text>
@@ -1,4 +1,5 @@
1
1
  // vision-ocr-helpers.js — OCR text localization and seamless replacement (#180).
2
+ import { escapeXml, sanitizeSvgColor } from './security.js'
2
3
 
3
4
  /**
4
5
  * Parses bounding box representations into normalized [x1, y1, x2, y2] (0-1000 scale).
@@ -87,6 +88,7 @@ export function buildTextInpaintMaskSvg(width, height, bboxes, paddingPx = 8) {
87
88
  export function buildTextOverlaySvg(width, height, items, fontFamily = 'sans-serif') {
88
89
  const w = Math.max(1, width || 1024)
89
90
  const h = Math.max(1, height || 1024)
91
+ const safeFont = escapeXml(fontFamily)
90
92
 
91
93
  const textNodes = (items || []).map((item) => {
92
94
  const norm = normalizeBbox(item.bbox, w, h)
@@ -98,18 +100,14 @@ export function buildTextOverlaySvg(width, height, items, fontFamily = 'sans-ser
98
100
  const boxH = Math.max(1, Math.round(((y2n - y1n) / 1000) * h))
99
101
 
100
102
  const computedFontSize = item.fontSize || Math.max(12, Math.round(boxH * 0.72))
101
- const color = item.color || '#FFFFFF'
103
+ const color = sanitizeSvgColor(item.color || '#FFFFFF', '#FFFFFF')
102
104
  const centerX = pxX1 + Math.round(boxW / 2)
103
105
  const baselineY = pxY1 + Math.round(boxH * 0.75)
104
106
 
105
107
  // Escape XML entities
106
- const safeText = String(item.text)
107
- .replace(/&/g, '&amp;')
108
- .replace(/</g, '&lt;')
109
- .replace(/>/g, '&gt;')
110
- .replace(/"/g, '&quot;')
108
+ const safeText = escapeXml(item.text)
111
109
 
112
- return `<text x="${centerX}" y="${baselineY}" text-anchor="middle" font-family="${fontFamily}" font-size="${computedFontSize}" font-weight="700" fill="${color}" filter="drop-shadow(0 2px 4px rgba(0,0,0,0.6))">${safeText}</text>`
110
+ return `<text x="${centerX}" y="${baselineY}" text-anchor="middle" font-family="${safeFont}" font-size="${computedFontSize}" font-weight="700" fill="${color}" filter="drop-shadow(0 2px 4px rgba(0,0,0,0.6))">${safeText}</text>`
113
111
  }).filter(Boolean).join('\n ')
114
112
 
115
113
  return `<?xml version="1.0" encoding="UTF-8"?>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.34",
3
+ "version": "0.11.36",
4
4
  "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
5
5
  "keywords": [
6
6
  "deepseek-harness",