@json-to-office/jto 0.8.3 → 0.9.0

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.
Files changed (39) hide show
  1. package/dist/cli.js +1487 -114
  2. package/dist/cli.js.map +1 -1
  3. package/dist/client/assets/HomePage-CtePCdPD.js +99 -0
  4. package/dist/client/assets/HomePage-CtePCdPD.js.map +1 -0
  5. package/dist/client/assets/{JsonEditorPage-DCvwatOj.js → JsonEditorPage-Dk_qYbQR.js} +3 -3
  6. package/dist/client/assets/{JsonEditorPage-DCvwatOj.js.map → JsonEditorPage-Dk_qYbQR.js.map} +1 -1
  7. package/dist/client/assets/{MonacoPluginProvider-DrTR8ld3.js → MonacoPluginProvider-BmWwTUcW.js} +3 -3
  8. package/dist/client/assets/{MonacoPluginProvider-DrTR8ld3.js.map → MonacoPluginProvider-BmWwTUcW.js.map} +1 -1
  9. package/dist/client/assets/{button-D_d9zKq6.js → button-jTamq7gj.js} +2 -2
  10. package/dist/client/assets/{button-D_d9zKq6.js.map → button-jTamq7gj.js.map} +1 -1
  11. package/dist/client/assets/{editor-AIcSsY9W.js → editor-QOUTSCme.js} +2 -2
  12. package/dist/client/assets/{editor-AIcSsY9W.js.map → editor-QOUTSCme.js.map} +1 -1
  13. package/dist/client/assets/{editor-monaco-json-DHxCqVbk.js → editor-monaco-json-Bi4IKPau.js} +2 -2
  14. package/dist/client/assets/{editor-monaco-json-DHxCqVbk.js.map → editor-monaco-json-Bi4IKPau.js.map} +1 -1
  15. package/dist/client/assets/index-CLUTL9ST.js +5 -0
  16. package/dist/client/assets/index-CLUTL9ST.js.map +1 -0
  17. package/dist/client/assets/index-DKIIAAih.css +1 -0
  18. package/dist/client/assets/preview-CtIrY86t.js +3 -0
  19. package/dist/client/assets/preview-CtIrY86t.js.map +1 -0
  20. package/dist/client/assets/{radix-ui-BZ5iKMtq.js → radix-ui-BiXCNJNt.js} +2 -2
  21. package/dist/client/assets/{radix-ui-BZ5iKMtq.js.map → radix-ui-BiXCNJNt.js.map} +1 -1
  22. package/dist/client/assets/{state-vendor-BDrPu9qj.js → state-vendor-DTum9m7F.js} +2 -2
  23. package/dist/client/assets/{state-vendor-BDrPu9qj.js.map → state-vendor-DTum9m7F.js.map} +1 -1
  24. package/dist/client/assets/{ui-vendor-Dyg3GRT-.js → ui-vendor-D3QbouTA.js} +10 -5
  25. package/dist/client/assets/ui-vendor-D3QbouTA.js.map +1 -0
  26. package/dist/client/index.html +5 -4
  27. package/dist/client/templates/Wiseair 16-9.pptx.json +6254 -0
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.js +12 -6
  30. package/dist/index.js.map +1 -1
  31. package/package.json +7 -6
  32. package/dist/client/assets/HomePage-N11lGfcq.js +0 -99
  33. package/dist/client/assets/HomePage-N11lGfcq.js.map +0 -1
  34. package/dist/client/assets/index-B0s8Zyy_.css +0 -1
  35. package/dist/client/assets/index-DmdAtyxx.js +0 -3
  36. package/dist/client/assets/index-DmdAtyxx.js.map +0 -1
  37. package/dist/client/assets/preview-BrqBUZLp.js +0 -3
  38. package/dist/client/assets/preview-BrqBUZLp.js.map +0 -1
  39. package/dist/client/assets/ui-vendor-Dyg3GRT-.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1180,6 +1180,150 @@ var init_logger = __esm({
1180
1180
  });
1181
1181
 
1182
1182
  // src/server/services/generator.ts
1183
+ import {
1184
+ collectFontNamesFromDocx,
1185
+ collectFontNamesFromPptx,
1186
+ POPULAR_GOOGLE_FONTS,
1187
+ getUpstreamOverride,
1188
+ isSafeFont as isSafeFont2
1189
+ } from "@json-to-office/shared";
1190
+ function collectReferencedNames(config2, customThemes, adapterName) {
1191
+ const collect = adapterName === "docx" ? collectFontNamesFromDocx : collectFontNamesFromPptx;
1192
+ const names = /* @__PURE__ */ new Set();
1193
+ for (const n of collect(config2)) names.add(n);
1194
+ for (const theme of Object.values(customThemes ?? {})) {
1195
+ for (const n of collect(theme)) names.add(n);
1196
+ }
1197
+ return names;
1198
+ }
1199
+ function collectReferencedWeights(config2, customThemes) {
1200
+ const weights = /* @__PURE__ */ new Set();
1201
+ const visit = (node) => {
1202
+ if (node == null) return;
1203
+ if (Array.isArray(node)) {
1204
+ for (const item of node) visit(item);
1205
+ return;
1206
+ }
1207
+ if (typeof node === "object") {
1208
+ for (const [k, v] of Object.entries(node)) {
1209
+ if (k === "fontWeight" && typeof v === "number" && v >= 100 && v <= 900) {
1210
+ weights.add(v);
1211
+ } else {
1212
+ visit(v);
1213
+ }
1214
+ }
1215
+ }
1216
+ };
1217
+ visit(config2);
1218
+ for (const theme of Object.values(customThemes ?? {})) visit(theme);
1219
+ return weights;
1220
+ }
1221
+ function collectReferencedItalic(config2, customThemes) {
1222
+ let found = false;
1223
+ const visit = (node) => {
1224
+ if (found || node == null) return;
1225
+ if (Array.isArray(node)) {
1226
+ for (const item of node) visit(item);
1227
+ return;
1228
+ }
1229
+ if (typeof node === "object") {
1230
+ for (const [k, v] of Object.entries(node)) {
1231
+ if (k === "italic" && v === true) {
1232
+ found = true;
1233
+ return;
1234
+ }
1235
+ visit(v);
1236
+ }
1237
+ }
1238
+ };
1239
+ visit(config2);
1240
+ if (!found) {
1241
+ for (const theme of Object.values(customThemes ?? {})) {
1242
+ visit(theme);
1243
+ if (found) break;
1244
+ }
1245
+ }
1246
+ return found;
1247
+ }
1248
+ function autoGoogleFontEntries(names, skipFamilies, referencedWeights, referencedItalic, warnings) {
1249
+ const googleByLower = new Map(
1250
+ POPULAR_GOOGLE_FONTS.map((f) => [f.family.toLowerCase(), f])
1251
+ );
1252
+ const entries = [];
1253
+ for (const name of names) {
1254
+ if (isSafeFont2(name)) continue;
1255
+ if (skipFamilies.has(name.toLowerCase())) continue;
1256
+ const match = googleByLower.get(name.toLowerCase());
1257
+ if (!match) continue;
1258
+ const wanted = (() => {
1259
+ if (!referencedWeights || referencedWeights.size === 0) {
1260
+ const filtered2 = match.weights.filter((w) => w === 400 || w === 700);
1261
+ if (filtered2.length > 0) return filtered2;
1262
+ return match.weights.length > 0 ? [Math.min(...match.weights)] : [400];
1263
+ }
1264
+ const want = /* @__PURE__ */ new Set([400, ...referencedWeights]);
1265
+ const filtered = match.weights.filter((w) => want.has(w));
1266
+ return filtered.length > 0 ? filtered : [400];
1267
+ })();
1268
+ const override = getUpstreamOverride(match.family);
1269
+ if (override) {
1270
+ const overrideWantedSet = (() => {
1271
+ if (!referencedWeights || referencedWeights.size === 0) {
1272
+ return /* @__PURE__ */ new Set([400, 700]);
1273
+ }
1274
+ return /* @__PURE__ */ new Set([400, ...referencedWeights]);
1275
+ })();
1276
+ const keepItalic = referencedItalic !== false;
1277
+ const variants = override.variants.filter(
1278
+ (v) => overrideWantedSet.has(v.weight) && (keepItalic || !v.italic)
1279
+ );
1280
+ let selected = variants;
1281
+ if (selected.length === 0) {
1282
+ const missing = [...overrideWantedSet].filter((w) => !override.variants.some((v) => v.weight === w)).sort((a, b) => a - b);
1283
+ warnings?.push(
1284
+ `FONT_WEIGHT_NOT_IN_OVERRIDE: family "${match.family}" \u2014 referenced weight(s) ${missing.join(", ")} not in upstream override (has ${override.variants.map((v) => v.weight).filter((w, i, a) => a.indexOf(w) === i).sort((a, b) => a - b).join(", ")}). Fetching all override variants as a fallback.`
1285
+ );
1286
+ selected = override.variants;
1287
+ }
1288
+ entries.push({
1289
+ id: match.family,
1290
+ family: match.family,
1291
+ sources: selected.map(
1292
+ (v) => v.kind === "variable" ? {
1293
+ kind: "variable",
1294
+ url: v.url,
1295
+ weight: v.weight,
1296
+ italic: v.italic ?? false,
1297
+ ...v.axes ? { axes: v.axes } : {}
1298
+ } : {
1299
+ kind: "url",
1300
+ url: v.url,
1301
+ weight: v.weight,
1302
+ italic: v.italic ?? false
1303
+ }
1304
+ )
1305
+ });
1306
+ continue;
1307
+ }
1308
+ entries.push({
1309
+ id: match.family,
1310
+ family: match.family,
1311
+ sources: [
1312
+ {
1313
+ kind: "google",
1314
+ family: match.family,
1315
+ weights: wanted,
1316
+ // Only request italics when the catalog advertises them. Requesting
1317
+ // italics for a family without italic variants (e.g. Inter) makes
1318
+ // Google return 404s that surface as `FONT_WEIGHT_UNAVAILABLE`
1319
+ // warnings and confuse diagnostics.
1320
+ italics: match.hasItalic
1321
+ }
1322
+ ]
1323
+ });
1324
+ }
1325
+ return entries;
1326
+ }
1183
1327
  var GeneratorService;
1184
1328
  var init_generator = __esm({
1185
1329
  "src/server/services/generator.ts"() {
@@ -1201,10 +1345,55 @@ var init_generator = __esm({
1201
1345
  async generate(request) {
1202
1346
  const { jsonDefinition, customThemes, options } = request;
1203
1347
  const config2 = typeof jsonDefinition === "string" ? JSON.parse(jsonDefinition) : jsonDefinition;
1204
- const bypassCache = options?.bypassCache === true;
1348
+ const referencedNames = collectReferencedNames(
1349
+ config2,
1350
+ customThemes,
1351
+ this.adapter.name
1352
+ );
1353
+ const referencedWeights = collectReferencedWeights(config2, customThemes);
1354
+ const referencedItalic = collectReferencedItalic(config2, customThemes);
1355
+ const callerFonts = options?.fonts;
1356
+ const callerExtraEntriesRaw = callerFonts?.extraEntries;
1357
+ const callerExtraEntries = Array.isArray(
1358
+ callerExtraEntriesRaw
1359
+ ) ? callerExtraEntriesRaw : [];
1360
+ const callerStrict = typeof callerFonts?.strict === "boolean" ? callerFonts.strict : void 0;
1361
+ const rawMode = callerFonts?.mode;
1362
+ const fontMode = rawMode === "substitute" || rawMode === "custom" ? rawMode : void 0;
1363
+ const rawSub = callerFonts?.substitution;
1364
+ const fontSubstitution = rawSub && typeof rawSub === "object" && !Array.isArray(rawSub) ? rawSub : void 0;
1365
+ const callerFamilies = new Set(
1366
+ callerExtraEntries.map((e) => e.family.toLowerCase())
1367
+ );
1368
+ const overrideWarnings = [];
1369
+ const autoEntries = fontMode === "substitute" ? [] : autoGoogleFontEntries(
1370
+ referencedNames,
1371
+ callerFamilies,
1372
+ referencedWeights,
1373
+ referencedItalic,
1374
+ overrideWarnings
1375
+ );
1376
+ const extraEntries = [...callerExtraEntries, ...autoEntries];
1377
+ if (callerExtraEntries.length > 0) {
1378
+ const googleFamiliesLower = new Set(
1379
+ POPULAR_GOOGLE_FONTS.map((f) => f.family.toLowerCase())
1380
+ );
1381
+ for (const e of callerExtraEntries) {
1382
+ const lower = e.family.toLowerCase();
1383
+ if (googleFamiliesLower.has(lower) && referencedNames.has(e.family)) {
1384
+ overrideWarnings.push(
1385
+ `[FONT_OVERRIDE_LOCAL] ${e.family}: caller-supplied source used; Google Fonts auto-fetch skipped for this family.`
1386
+ );
1387
+ }
1388
+ }
1389
+ }
1390
+ const bypassCache = options?.bypassCache === true || extraEntries.length > 0;
1205
1391
  const cacheKeyData = {
1206
1392
  config: config2,
1207
- customThemes: customThemes && Object.keys(customThemes).length > 0 ? customThemes : null
1393
+ customThemes: customThemes && Object.keys(customThemes).length > 0 ? customThemes : null,
1394
+ fontMode: fontMode ?? null,
1395
+ fontSubstitution: fontSubstitution ?? null,
1396
+ fontStrict: callerStrict ?? null
1208
1397
  };
1209
1398
  const cacheKey = this.cacheService.generateCacheKey(cacheKeyData);
1210
1399
  const hasDynamicContent = this.cacheService.hasDynamicContent(config2);
@@ -1226,24 +1415,68 @@ var init_generator = __esm({
1226
1415
  });
1227
1416
  const registry = PluginRegistry.getInstance();
1228
1417
  let buffer;
1418
+ const resolvedFonts = [];
1419
+ const needsFontOpts = extraEntries.length > 0 || fontMode !== void 0 || fontSubstitution !== void 0 || callerStrict !== void 0;
1420
+ const fontOpts = needsFontOpts ? {
1421
+ ...extraEntries.length > 0 && { extraEntries },
1422
+ ...fontMode && { mode: fontMode },
1423
+ ...fontSubstitution && { substitution: fontSubstitution },
1424
+ ...callerStrict !== void 0 && { strict: callerStrict },
1425
+ onResolved: (r) => {
1426
+ resolvedFonts.push(...r);
1427
+ }
1428
+ } : void 0;
1229
1429
  if (registry.hasPlugins()) {
1230
1430
  const plugins = registry.getPlugins();
1231
1431
  const generator = await this.adapter.createGenerator(plugins, {
1232
- customThemes
1432
+ customThemes,
1433
+ fonts: fontOpts
1233
1434
  });
1234
1435
  buffer = await generator.generateBuffer(config2);
1235
1436
  } else {
1236
- buffer = await this.adapter.generateBuffer(config2, { customThemes });
1437
+ buffer = await this.adapter.generateBuffer(config2, {
1438
+ customThemes,
1439
+ fonts: fontOpts
1440
+ });
1237
1441
  }
1238
1442
  this.cacheService.set(cacheKey, buffer, config2, {
1239
1443
  bypassCache: bypassCache || hasDynamicContent
1240
1444
  });
1445
+ const CANONICAL_WEIGHTS = /* @__PURE__ */ new Set([
1446
+ 100,
1447
+ 200,
1448
+ 300,
1449
+ 400,
1450
+ 500,
1451
+ 600,
1452
+ 700,
1453
+ 800,
1454
+ 900
1455
+ ]);
1456
+ const nonCanonical = [...referencedWeights].filter(
1457
+ (w) => !CANONICAL_WEIGHTS.has(w)
1458
+ );
1459
+ const extraWarnings = overrideWarnings.map((message) => ({
1460
+ component: "fontRegistry",
1461
+ message,
1462
+ severity: "info",
1463
+ context: { code: "FONT_OVERRIDE_LOCAL" }
1464
+ }));
1465
+ for (const w of nonCanonical) {
1466
+ extraWarnings.push({
1467
+ component: "fontRegistry",
1468
+ message: `[FONT_NONCANONICAL_WEIGHT] fontWeight ${w} is not one of 100/200/.../900; render path rounds to Regular or Bold via bold-only fallback.`,
1469
+ severity: "info",
1470
+ context: { code: "FONT_NONCANONICAL_WEIGHT" }
1471
+ });
1472
+ }
1241
1473
  return {
1242
1474
  filename: `${config2.metadata?.title || this.adapter.label}${this.adapter.extension}`,
1243
1475
  fileId: Date.now().toString(),
1244
1476
  buffer,
1245
1477
  cached: false,
1246
- warnings: null
1478
+ warnings: extraWarnings.length > 0 ? extraWarnings : null,
1479
+ resolvedFonts
1247
1480
  };
1248
1481
  }
1249
1482
  async validate(jsonDefinition) {
@@ -1368,20 +1601,414 @@ var init_cache = __esm({
1368
1601
  }
1369
1602
  });
1370
1603
 
1604
+ // src/server/services/font-staging/noop-stager.ts
1605
+ var NoopFontStager;
1606
+ var init_noop_stager = __esm({
1607
+ "src/server/services/font-staging/noop-stager.ts"() {
1608
+ "use strict";
1609
+ init_esm_shims();
1610
+ NoopFontStager = class {
1611
+ async stage() {
1612
+ return {
1613
+ envOverrides: {},
1614
+ cleanup: async () => {
1615
+ }
1616
+ };
1617
+ }
1618
+ };
1619
+ }
1620
+ });
1621
+
1622
+ // src/server/services/font-staging/types.ts
1623
+ function nextStagingId() {
1624
+ counter += 1;
1625
+ return `${process.pid}-${counter}`;
1626
+ }
1627
+ function safeFilenamePart(s) {
1628
+ return s.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 48);
1629
+ }
1630
+ var counter;
1631
+ var init_types = __esm({
1632
+ "src/server/services/font-staging/types.ts"() {
1633
+ "use strict";
1634
+ init_esm_shims();
1635
+ counter = 0;
1636
+ }
1637
+ });
1638
+
1639
+ // src/server/services/font-staging/fontconfig-stager.ts
1640
+ import { promises as fs10 } from "fs";
1641
+ import path13 from "path";
1642
+ import {
1643
+ synthesizeFamilyName,
1644
+ rewriteFontFamilyName
1645
+ } from "@json-to-office/shared";
1646
+ function escapeXml(s) {
1647
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1648
+ }
1649
+ var SYSTEM_FONTS_CONF_CANDIDATES, FontconfigStager;
1650
+ var init_fontconfig_stager = __esm({
1651
+ "src/server/services/font-staging/fontconfig-stager.ts"() {
1652
+ "use strict";
1653
+ init_esm_shims();
1654
+ init_types();
1655
+ SYSTEM_FONTS_CONF_CANDIDATES = [
1656
+ "/etc/fonts/fonts.conf",
1657
+ "/opt/homebrew/etc/fonts/fonts.conf",
1658
+ "/usr/local/etc/fonts/fonts.conf"
1659
+ ];
1660
+ FontconfigStager = class {
1661
+ async stage(fonts, tempDir) {
1662
+ const id = nextStagingId();
1663
+ const fontsDir = path13.join(tempDir, "fonts");
1664
+ await fs10.mkdir(fontsDir, { recursive: true });
1665
+ let serial = 0;
1666
+ for (const r of fonts) {
1667
+ if (r.sources.length === 0) continue;
1668
+ for (const s of r.sources) {
1669
+ serial += 1;
1670
+ const suffix = s.italic ? "i" : "r";
1671
+ const synth = synthesizeFamilyName(r.family, s.weight, s.italic);
1672
+ const data = synth.family === r.family ? s.data : rewriteFontFamilyName(s.data, synth.family);
1673
+ const name = `${safeFilenamePart(synth.family)}-${s.weight}${suffix}-${id}-${serial}.ttf`;
1674
+ await fs10.writeFile(path13.join(fontsDir, name), data);
1675
+ }
1676
+ }
1677
+ await fs10.chmod(fontsDir, 365).catch(() => {
1678
+ });
1679
+ const includeLines = await this.pickSystemIncludes();
1680
+ const cacheDir = path13.join(tempDir, "fc-cache");
1681
+ await fs10.mkdir(cacheDir, { recursive: true });
1682
+ const configPath = path13.join(tempDir, "fontconfig.xml");
1683
+ const configXml = [
1684
+ '<?xml version="1.0"?>',
1685
+ '<!DOCTYPE fontconfig SYSTEM "fonts.dtd">',
1686
+ "<fontconfig>",
1687
+ ` <dir>${escapeXml(fontsDir)}</dir>`,
1688
+ ` <cachedir>${escapeXml(cacheDir)}</cachedir>`,
1689
+ ...includeLines,
1690
+ "</fontconfig>",
1691
+ ""
1692
+ ].join("\n");
1693
+ await fs10.writeFile(configPath, configXml, "utf8");
1694
+ return {
1695
+ envOverrides: {
1696
+ FONTCONFIG_FILE: configPath,
1697
+ XDG_CACHE_HOME: cacheDir
1698
+ },
1699
+ cleanup: async () => {
1700
+ }
1701
+ };
1702
+ }
1703
+ async pickSystemIncludes() {
1704
+ for (const candidate of SYSTEM_FONTS_CONF_CANDIDATES) {
1705
+ try {
1706
+ await fs10.access(candidate);
1707
+ return [
1708
+ ` <include ignore_missing="yes">${escapeXml(candidate)}</include>`
1709
+ ];
1710
+ } catch {
1711
+ }
1712
+ }
1713
+ return [` <include ignore_missing="yes">/etc/fonts/fonts.conf</include>`];
1714
+ }
1715
+ };
1716
+ }
1717
+ });
1718
+
1719
+ // src/server/services/font-staging/windows-stager.ts
1720
+ import { promises as fs11 } from "fs";
1721
+ import path14 from "path";
1722
+ import {
1723
+ synthesizeFamilyName as synthesizeFamilyName2,
1724
+ rewriteFontFamilyName as rewriteFontFamilyName2
1725
+ } from "@json-to-office/shared";
1726
+ async function getGdiBindings() {
1727
+ if (cachedBindings) return cachedBindings;
1728
+ const koffi = await import("koffi");
1729
+ const mod = koffi.default ?? koffi;
1730
+ const gdi32 = mod.load("gdi32.dll");
1731
+ cachedBindings = {
1732
+ addFont: gdi32.func("int __stdcall AddFontResourceW(str16)"),
1733
+ removeFont: gdi32.func("bool __stdcall RemoveFontResourceW(str16)")
1734
+ };
1735
+ return cachedBindings;
1736
+ }
1737
+ var cachedBindings, WindowsFontStager;
1738
+ var init_windows_stager = __esm({
1739
+ "src/server/services/font-staging/windows-stager.ts"() {
1740
+ "use strict";
1741
+ init_esm_shims();
1742
+ init_types();
1743
+ cachedBindings = null;
1744
+ WindowsFontStager = class {
1745
+ async stage(fonts, tempDir) {
1746
+ const id = nextStagingId();
1747
+ const fontsDir = path14.join(tempDir, "fonts");
1748
+ await fs11.mkdir(fontsDir, { recursive: true });
1749
+ const stagedPaths = [];
1750
+ let serial = 0;
1751
+ for (const r of fonts) {
1752
+ if (r.sources.length === 0) continue;
1753
+ for (const s of r.sources) {
1754
+ serial += 1;
1755
+ const suffix = s.italic ? "i" : "r";
1756
+ const synth = synthesizeFamilyName2(r.family, s.weight, s.italic);
1757
+ const data = synth.family === r.family ? s.data : rewriteFontFamilyName2(s.data, synth.family);
1758
+ const name = `${safeFilenamePart(synth.family)}-${s.weight}${suffix}-${id}-${serial}.ttf`;
1759
+ const fullPath = path14.join(fontsDir, name);
1760
+ await fs11.writeFile(fullPath, data);
1761
+ stagedPaths.push(fullPath);
1762
+ }
1763
+ }
1764
+ if (stagedPaths.length === 0) {
1765
+ return { envOverrides: {}, cleanup: async () => {
1766
+ } };
1767
+ }
1768
+ const { addFont, removeFont } = await getGdiBindings();
1769
+ const registered = [];
1770
+ for (const p of stagedPaths) {
1771
+ const added = addFont(p);
1772
+ if (added > 0) registered.push(p);
1773
+ }
1774
+ let cleaned = false;
1775
+ return {
1776
+ envOverrides: {
1777
+ // Force GDI backend so the freshly-registered fonts are visible.
1778
+ // Skia on Windows uses DirectWrite which does not reliably see
1779
+ // fonts added via AddFontResourceW.
1780
+ SAL_DISABLE_SKIA: "1"
1781
+ },
1782
+ cleanup: async () => {
1783
+ if (cleaned) return;
1784
+ cleaned = true;
1785
+ for (const p of registered) {
1786
+ try {
1787
+ removeFont(p);
1788
+ } catch {
1789
+ }
1790
+ }
1791
+ }
1792
+ };
1793
+ }
1794
+ };
1795
+ }
1796
+ });
1797
+
1798
+ // src/server/services/font-staging/macos-stager.ts
1799
+ import { promises as fs12 } from "fs";
1800
+ import path15 from "path";
1801
+ import {
1802
+ synthesizeFamilyName as synthesizeFamilyName3,
1803
+ rewriteFontFamilyName as rewriteFontFamilyName3
1804
+ } from "@json-to-office/shared";
1805
+ var PYTHON_MACRO, REGISTRY_MOD_XCU, MacOSCoreTextStager;
1806
+ var init_macos_stager = __esm({
1807
+ "src/server/services/font-staging/macos-stager.ts"() {
1808
+ "use strict";
1809
+ init_esm_shims();
1810
+ init_types();
1811
+ PYTHON_MACRO = `# Auto-generated by @json-to-office/jto. Runs inside soffice on OnStartApp
1812
+ # to make staged fonts visible to LibreOffice's font enumeration. macOS 26
1813
+ # blocks Session/Persistent CT registration for unsigned callers, but
1814
+ # Process scope still works from inside the target process \u2014 which is
1815
+ # exactly where this macro runs.
1816
+ import os
1817
+ import sys
1818
+ import ctypes
1819
+ import ctypes.util
1820
+
1821
+
1822
+ def _log(msg):
1823
+ # soffice swallows Python stdout in headless mode; stderr surfaces to
1824
+ # the parent's pipe. The stager doesn't read this today but the
1825
+ # converter logs stderr on failure, which is how we'll debug.
1826
+ sys.stderr.write("[jto-font-register] " + msg + "\\n")
1827
+
1828
+
1829
+ def register(*_args):
1830
+ paths_env = os.environ.get("JTO_FONT_PATHS", "")
1831
+ if not paths_env:
1832
+ return
1833
+ try:
1834
+ cf = ctypes.CDLL(ctypes.util.find_library("CoreFoundation"))
1835
+ ct = ctypes.CDLL(ctypes.util.find_library("CoreText"))
1836
+ except Exception as e:
1837
+ _log("failed to load CoreFoundation/CoreText: " + repr(e))
1838
+ return
1839
+ cf.CFURLCreateFromFileSystemRepresentation.argtypes = [
1840
+ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.c_bool,
1841
+ ]
1842
+ cf.CFURLCreateFromFileSystemRepresentation.restype = ctypes.c_void_p
1843
+ cf.CFRelease.argtypes = [ctypes.c_void_p]
1844
+ ct.CTFontManagerRegisterFontsForURL.argtypes = [
1845
+ ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p,
1846
+ ]
1847
+ ct.CTFontManagerRegisterFontsForURL.restype = ctypes.c_bool
1848
+
1849
+ kCTFontManagerScopeProcess = 1
1850
+ registered = 0
1851
+ for p in paths_env.split(os.pathsep):
1852
+ if not p:
1853
+ continue
1854
+ try:
1855
+ b = p.encode("utf-8")
1856
+ url = cf.CFURLCreateFromFileSystemRepresentation(
1857
+ None, b, len(b), False
1858
+ )
1859
+ if not url:
1860
+ _log("CFURL failed for " + p)
1861
+ continue
1862
+ ok = ct.CTFontManagerRegisterFontsForURL(
1863
+ url, kCTFontManagerScopeProcess, None
1864
+ )
1865
+ cf.CFRelease(url)
1866
+ if ok:
1867
+ registered += 1
1868
+ else:
1869
+ _log("CT register returned false for " + p)
1870
+ except Exception as e:
1871
+ _log("exception registering " + p + ": " + repr(e))
1872
+ _log("registered " + str(registered) + " font(s) at Process scope")
1873
+
1874
+
1875
+ # Expose under "register" (event-binding URL) and module-level run so
1876
+ # command-line vnd.sun.star.script invocation works either way.
1877
+ g_exportedScripts = (register,)
1878
+ `;
1879
+ REGISTRY_MOD_XCU = `<?xml version="1.0" encoding="UTF-8"?>
1880
+ <oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema">
1881
+ <item oor:path="/org.openoffice.Office.Events/ApplicationEvents/Bindings">
1882
+ <node oor:name="OnStartApp" oor:op="replace">
1883
+ <prop oor:name="BindingURL" oor:type="xs:string">
1884
+ <value>vnd.sun.star.script:JtoFontRegister.py$register?language=Python&amp;location=user</value>
1885
+ </prop>
1886
+ </node>
1887
+ </item>
1888
+ <item oor:path="/org.openoffice.Office.Common/Security/Scripting">
1889
+ <prop oor:name="MacroSecurityLevel" oor:op="fuse">
1890
+ <value>0</value>
1891
+ </prop>
1892
+ </item>
1893
+ </oor:items>
1894
+ `;
1895
+ MacOSCoreTextStager = class {
1896
+ async stage(fonts, tempDir) {
1897
+ const embeddable = fonts.filter((r) => r.sources.length > 0);
1898
+ if (embeddable.length === 0) {
1899
+ return { envOverrides: {}, cleanup: async () => {
1900
+ } };
1901
+ }
1902
+ const fontsDir = path15.join(tempDir, "fonts");
1903
+ await fs12.mkdir(fontsDir, { recursive: true });
1904
+ const id = nextStagingId();
1905
+ const fontPaths = [];
1906
+ const staged = [];
1907
+ let serial = 0;
1908
+ for (const r of embeddable) {
1909
+ for (const s of r.sources) {
1910
+ serial += 1;
1911
+ const suffix = s.italic ? "i" : "r";
1912
+ const synth = synthesizeFamilyName3(r.family, s.weight, s.italic);
1913
+ const data = synth.family === r.family ? s.data : rewriteFontFamilyName3(s.data, synth.family);
1914
+ const name = `${safeFilenamePart(synth.family)}-${s.weight}${suffix}-${id}-${serial}.ttf`;
1915
+ const full = path15.join(fontsDir, name);
1916
+ await fs12.writeFile(full, data);
1917
+ fontPaths.push(full);
1918
+ staged.push({
1919
+ family: synth.family,
1920
+ weight: s.weight,
1921
+ italic: s.italic,
1922
+ path: full
1923
+ });
1924
+ }
1925
+ }
1926
+ if (process.env.JTO_DEBUG_FONTS === "1") {
1927
+ console.log(
1928
+ "[jto macos-stager] staged " + staged.length + " font(s) for CT Process-scope registration; JTO_FONT_PATHS has " + fontPaths.length + " entries\n" + staged.map(
1929
+ (s) => ` ${s.family} (w=${s.weight}${s.italic ? " italic" : ""}) \u2192 ${path15.basename(s.path)}`
1930
+ ).join("\n")
1931
+ );
1932
+ }
1933
+ const profileUser = path15.join(tempDir, "user-profile", "user");
1934
+ const scriptsDir = path15.join(profileUser, "Scripts", "python");
1935
+ await fs12.mkdir(scriptsDir, { recursive: true });
1936
+ await fs12.writeFile(
1937
+ path15.join(scriptsDir, "JtoFontRegister.py"),
1938
+ PYTHON_MACRO
1939
+ );
1940
+ await fs12.writeFile(
1941
+ path15.join(profileUser, "registrymodifications.xcu"),
1942
+ REGISTRY_MOD_XCU
1943
+ );
1944
+ return {
1945
+ envOverrides: {
1946
+ // Colon-separated list of staged TTF paths (`:` matches Python's
1947
+ // `os.pathsep` on macOS). The macro reads this at OnStartApp.
1948
+ JTO_FONT_PATHS: fontPaths.join(":"),
1949
+ // Force LibreOffice's Core Graphics backend. Skia on macOS can
1950
+ // skip Core Text's freshly-registered fonts in some builds.
1951
+ SAL_DISABLE_SKIA: "1"
1952
+ },
1953
+ cleanup: async () => {
1954
+ }
1955
+ };
1956
+ }
1957
+ };
1958
+ }
1959
+ });
1960
+
1961
+ // src/server/services/font-staging/index.ts
1962
+ function getFontStager(platform = process.platform) {
1963
+ const hit = cached.get(platform);
1964
+ if (hit) return hit;
1965
+ let stager;
1966
+ switch (platform) {
1967
+ case "win32":
1968
+ stager = new WindowsFontStager();
1969
+ break;
1970
+ case "darwin":
1971
+ stager = new MacOSCoreTextStager();
1972
+ break;
1973
+ case "linux":
1974
+ case "freebsd":
1975
+ case "openbsd":
1976
+ stager = new FontconfigStager();
1977
+ break;
1978
+ default:
1979
+ stager = new NoopFontStager();
1980
+ }
1981
+ cached.set(platform, stager);
1982
+ return stager;
1983
+ }
1984
+ var cached;
1985
+ var init_font_staging = __esm({
1986
+ "src/server/services/font-staging/index.ts"() {
1987
+ "use strict";
1988
+ init_esm_shims();
1989
+ init_noop_stager();
1990
+ init_fontconfig_stager();
1991
+ init_windows_stager();
1992
+ init_macos_stager();
1993
+ cached = /* @__PURE__ */ new Map();
1994
+ }
1995
+ });
1996
+
1371
1997
  // src/server/services/libreoffice-converter.ts
1372
1998
  import { execFile } from "child_process";
1373
- import { promises as fs10 } from "fs";
1999
+ import { promises as fs13 } from "fs";
1374
2000
  import os from "os";
1375
- import path13 from "path";
1376
- function executeFile(binary, args, timeoutMs) {
1377
- return new Promise((resolve16, reject) => {
2001
+ import path16 from "path";
2002
+ function executeFile(binary, args, timeoutMs, envOverrides) {
2003
+ return new Promise((resolve18, reject) => {
1378
2004
  execFile(
1379
2005
  binary,
1380
2006
  args,
1381
2007
  {
1382
2008
  timeout: timeoutMs,
1383
2009
  maxBuffer: MAX_EXEC_BUFFER_BYTES,
1384
- windowsHide: true
2010
+ windowsHide: true,
2011
+ env: envOverrides ? { ...process.env, ...envOverrides } : process.env
1385
2012
  },
1386
2013
  (error, stdout, stderr) => {
1387
2014
  if (error) {
@@ -1391,7 +2018,7 @@ function executeFile(binary, args, timeoutMs) {
1391
2018
  reject(execError);
1392
2019
  return;
1393
2020
  }
1394
- resolve16({ stdout: stdout ?? "", stderr: stderr ?? "" });
2021
+ resolve18({ stdout: stdout ?? "", stderr: stderr ?? "" });
1395
2022
  }
1396
2023
  );
1397
2024
  });
@@ -1403,7 +2030,7 @@ function toErrorText(value) {
1403
2030
  return String(value);
1404
2031
  }
1405
2032
  function sanitizeBaseName(originalName) {
1406
- const parsed = path13.parse(originalName || "document");
2033
+ const parsed = path16.parse(originalName || "document");
1407
2034
  return (parsed.name || "document").replace(/[^a-zA-Z0-9._-]/g, "_") || "document";
1408
2035
  }
1409
2036
  var DEFAULT_CONVERSION_TIMEOUT_MS, BINARY_PROBE_TIMEOUT_MS, MAX_EXEC_BUFFER_BYTES, LibreOfficeError, LibreOfficeBinaryNotFoundError, LibreOfficeTimeoutError, LibreOfficeConversionError, LibreOfficeOutputNotFoundError, LibreOfficeConverterService;
@@ -1412,6 +2039,7 @@ var init_libreoffice_converter = __esm({
1412
2039
  "use strict";
1413
2040
  init_esm_shims();
1414
2041
  init_config();
2042
+ init_font_staging();
1415
2043
  DEFAULT_CONVERSION_TIMEOUT_MS = 3e4;
1416
2044
  BINARY_PROBE_TIMEOUT_MS = 5e3;
1417
2045
  MAX_EXEC_BUFFER_BYTES = 20 * 1024 * 1024;
@@ -1471,24 +2099,35 @@ var init_libreoffice_converter = __esm({
1471
2099
  this.format = format;
1472
2100
  this.timeoutMs = timeoutMs || config.LIBREOFFICE_TIMEOUT_MS || DEFAULT_CONVERSION_TIMEOUT_MS;
1473
2101
  }
1474
- async convertToPdf(input, originalName = "document") {
2102
+ async convertToPdf(input, originalName = "document", resolvedFonts) {
1475
2103
  if (!input || input.length === 0) {
1476
2104
  throw new LibreOfficeConversionError("Input file is empty");
1477
2105
  }
1478
2106
  const binaryPath = await this.resolveBinaryPath();
1479
- const tempDir = await fs10.mkdtemp(
1480
- path13.join(os.tmpdir(), "jto-libreoffice-")
2107
+ const tempDir = await fs13.mkdtemp(
2108
+ path16.join(os.tmpdir(), "jto-libreoffice-")
1481
2109
  );
1482
2110
  const outputBaseName = sanitizeBaseName(originalName);
1483
2111
  const ext = this.format === "pptx" ? ".pptx" : ".docx";
1484
- const inputPath = path13.join(tempDir, `${outputBaseName}${ext}`);
1485
- const pdfPath = path13.join(tempDir, `${outputBaseName}.pdf`);
2112
+ const inputPath = path16.join(tempDir, `${outputBaseName}${ext}`);
2113
+ const pdfPath = path16.join(tempDir, `${outputBaseName}.pdf`);
2114
+ const stager = getFontStager();
2115
+ const fontsToStage = (resolvedFonts ?? []).filter(
2116
+ (r) => r.sources.length > 0
2117
+ );
2118
+ const stageHandle = fontsToStage.length > 0 ? await stager.stage(fontsToStage, tempDir) : null;
1486
2119
  try {
1487
- await fs10.writeFile(inputPath, input);
2120
+ await fs13.writeFile(inputPath, input);
1488
2121
  const filterName = this.format === "pptx" ? "impress_pdf_Export" : "writer_pdf_Export";
1489
- await this.runConversion(binaryPath, inputPath, tempDir, filterName);
2122
+ await this.runConversion(
2123
+ binaryPath,
2124
+ inputPath,
2125
+ tempDir,
2126
+ filterName,
2127
+ stageHandle?.envOverrides
2128
+ );
1490
2129
  try {
1491
- return await fs10.readFile(pdfPath);
2130
+ return await fs13.readFile(pdfPath);
1492
2131
  } catch (error) {
1493
2132
  if (error.code === "ENOENT") {
1494
2133
  throw new LibreOfficeOutputNotFoundError(pdfPath);
@@ -1496,7 +2135,11 @@ var init_libreoffice_converter = __esm({
1496
2135
  throw error;
1497
2136
  }
1498
2137
  } finally {
1499
- await fs10.rm(tempDir, { recursive: true, force: true }).catch(() => {
2138
+ if (stageHandle) {
2139
+ await stageHandle.cleanup().catch(() => {
2140
+ });
2141
+ }
2142
+ await fs13.rm(tempDir, { recursive: true, force: true }).catch(() => {
1500
2143
  });
1501
2144
  }
1502
2145
  }
@@ -1523,9 +2166,9 @@ var init_libreoffice_converter = __esm({
1523
2166
  throw new LibreOfficeBinaryNotFoundError(candidates);
1524
2167
  }
1525
2168
  async isBinaryAvailable(binaryPath) {
1526
- if (binaryPath.includes(path13.sep)) {
2169
+ if (binaryPath.includes(path16.sep)) {
1527
2170
  try {
1528
- await fs10.access(binaryPath);
2171
+ await fs13.access(binaryPath);
1529
2172
  } catch {
1530
2173
  return false;
1531
2174
  }
@@ -1538,8 +2181,8 @@ var init_libreoffice_converter = __esm({
1538
2181
  return code !== "ENOENT" && code !== "EACCES";
1539
2182
  }
1540
2183
  }
1541
- async runConversion(binaryPath, inputPath, outputDir, filterName) {
1542
- const userProfilePath = path13.join(outputDir, "user-profile").replace(/\\/g, "/");
2184
+ async runConversion(binaryPath, inputPath, outputDir, filterName, envOverrides) {
2185
+ const userProfilePath = path16.join(outputDir, "user-profile").replace(/\\/g, "/");
1543
2186
  const userInstallation = `file:///${userProfilePath.replace(/^\//, "")}`;
1544
2187
  const args = [
1545
2188
  "--headless",
@@ -1553,8 +2196,31 @@ var init_libreoffice_converter = __esm({
1553
2196
  outputDir,
1554
2197
  inputPath
1555
2198
  ];
2199
+ if (process.env.JTO_DEBUG_FONTS === "1") {
2200
+ console.log(
2201
+ "[jto libreoffice-converter] spawning soffice with env overrides: " + JSON.stringify(
2202
+ envOverrides ? Object.fromEntries(
2203
+ Object.entries(envOverrides).map(([k, v]) => [
2204
+ k,
2205
+ k === "JTO_FONT_PATHS" ? `<${v.split(":").length} paths>` : v
2206
+ ])
2207
+ ) : {}
2208
+ )
2209
+ );
2210
+ }
1556
2211
  try {
1557
- await executeFile(binaryPath, args, this.timeoutMs);
2212
+ const result = await executeFile(
2213
+ binaryPath,
2214
+ args,
2215
+ this.timeoutMs,
2216
+ envOverrides
2217
+ );
2218
+ if (process.env.JTO_DEBUG_FONTS === "1") {
2219
+ const stderr = result.stderr?.trim();
2220
+ console.log(
2221
+ "[jto libreoffice-converter] conversion ok; stderr len=" + (stderr?.length ?? 0) + (stderr ? "\n" + stderr : " (empty)")
2222
+ );
2223
+ }
1558
2224
  } catch (error) {
1559
2225
  const execError = error;
1560
2226
  if (execError.code === "ETIMEDOUT")
@@ -1670,11 +2336,27 @@ var init_health = __esm({
1670
2336
 
1671
2337
  // src/server/schemas/loose.ts
1672
2338
  import { Type as Type2 } from "@sinclair/typebox";
1673
- var LooseDocumentGenerationRequestSchema, LooseDocumentValidationRequestSchema;
2339
+ var FontOptionsSchema, LooseDocumentGenerationRequestSchema, LooseDocumentValidationRequestSchema;
1674
2340
  var init_loose = __esm({
1675
2341
  "src/server/schemas/loose.ts"() {
1676
2342
  "use strict";
1677
2343
  init_esm_shims();
2344
+ FontOptionsSchema = Type2.Object(
2345
+ {
2346
+ mode: Type2.Optional(
2347
+ Type2.Union([Type2.Literal("substitute"), Type2.Literal("custom")])
2348
+ ),
2349
+ substitution: Type2.Optional(
2350
+ Type2.Record(
2351
+ Type2.String({ maxLength: 128 }),
2352
+ Type2.String({ maxLength: 128 }),
2353
+ { maxProperties: 256 }
2354
+ )
2355
+ ),
2356
+ strict: Type2.Optional(Type2.Boolean())
2357
+ },
2358
+ { additionalProperties: false }
2359
+ );
1678
2360
  LooseDocumentGenerationRequestSchema = Type2.Object(
1679
2361
  {
1680
2362
  jsonDefinition: Type2.Union([
@@ -1684,7 +2366,16 @@ var init_loose = __esm({
1684
2366
  // Allow any object
1685
2367
  ]),
1686
2368
  customThemes: Type2.Optional(Type2.Record(Type2.String(), Type2.Unknown())),
1687
- options: Type2.Optional(Type2.Object({}, { additionalProperties: true }))
2369
+ options: Type2.Optional(
2370
+ Type2.Object(
2371
+ {
2372
+ bypassCache: Type2.Optional(Type2.Boolean()),
2373
+ returnUrl: Type2.Optional(Type2.Boolean()),
2374
+ fonts: Type2.Optional(FontOptionsSchema)
2375
+ },
2376
+ { additionalProperties: true }
2377
+ )
2378
+ )
1688
2379
  },
1689
2380
  { additionalProperties: true }
1690
2381
  );
@@ -1812,6 +2503,7 @@ var init_rate_limit = __esm({
1812
2503
  // src/server/routes/format.ts
1813
2504
  import { Hono as Hono2 } from "hono";
1814
2505
  import { HTTPException as HTTPException3 } from "hono/http-exception";
2506
+ import { bodyLimit } from "hono/body-limit";
1815
2507
  function createFormatRouter(adapter) {
1816
2508
  const router = new Hono2();
1817
2509
  const contentTypeMw = async (c, next) => {
@@ -1838,10 +2530,22 @@ function createFormatRouter(adapter) {
1838
2530
  const requestId = c.get("requestId");
1839
2531
  try {
1840
2532
  const bypassCache = c.req.header("X-Bypass-Cache") === "true" || c.req.query("bypass-cache") === "true" || options?.bypassCache === true;
2533
+ let sanitizedFonts;
2534
+ const rawFonts = options?.fonts;
2535
+ if (rawFonts && "strict" in rawFonts) {
2536
+ sanitizedFonts = { ...rawFonts };
2537
+ delete sanitizedFonts.strict;
2538
+ } else if (rawFonts) {
2539
+ sanitizedFonts = rawFonts;
2540
+ }
1841
2541
  const result = await generatorService.generate({
1842
2542
  jsonDefinition,
1843
2543
  customThemes,
1844
- options: { ...options, bypassCache }
2544
+ options: {
2545
+ ...options,
2546
+ ...sanitizedFonts !== void 0 && { fonts: sanitizedFonts },
2547
+ bypassCache
2548
+ }
1845
2549
  });
1846
2550
  const cacheService = getContainer().get("cacheService");
1847
2551
  const cacheStats = cacheService.getStats();
@@ -1961,6 +2665,68 @@ function createFormatRouter(adapter) {
1961
2665
  }
1962
2666
  }
1963
2667
  );
2668
+ router.post(
2669
+ "/preview/libreoffice-from-json",
2670
+ bodyLimit({
2671
+ // Doc JSON + custom themes. 2 MB covers large report fixtures; anything
2672
+ // bigger is almost certainly an attempt to OOM the worker.
2673
+ maxSize: 2 * 1024 * 1024,
2674
+ onError: () => {
2675
+ throw new HTTPException3(413, { message: "Request body too large" });
2676
+ }
2677
+ }),
2678
+ rateLimiter({
2679
+ limit: process.env.NODE_ENV === "production" ? 20 : 1e3,
2680
+ window: 15 * 60 * 1e3,
2681
+ keyGenerator: (c) => c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous"
2682
+ }),
2683
+ contentTypeMw,
2684
+ tbValidator(LooseDocumentGenerationRequestSchema),
2685
+ async (c) => {
2686
+ const requestId = c.get("requestId");
2687
+ const generatorService = getContainer().get("generatorService");
2688
+ const libreOfficeService = getContainer().get(
2689
+ "libreOfficeConverterService"
2690
+ );
2691
+ const { jsonDefinition, customThemes } = getValidated(c, "json");
2692
+ try {
2693
+ const generated = await generatorService.generate({
2694
+ jsonDefinition,
2695
+ customThemes,
2696
+ options: { bypassCache: true }
2697
+ });
2698
+ const pdfBuffer = await libreOfficeService.convertToPdf(
2699
+ generated.buffer,
2700
+ generated.filename,
2701
+ generated.resolvedFonts
2702
+ );
2703
+ const pdfName = generated.filename.replace(/\.[^.]+$/i, "") + ".pdf";
2704
+ c.header("Content-Type", "application/pdf");
2705
+ c.header("Content-Disposition", `inline; filename="${pdfName}"`);
2706
+ c.header("Content-Length", String(pdfBuffer.length));
2707
+ return c.body(pdfBuffer);
2708
+ } catch (error) {
2709
+ logger.error("LibreOffice (JSON) preview failed", {
2710
+ error,
2711
+ requestId
2712
+ });
2713
+ if (error instanceof HTTPException3) throw error;
2714
+ if (error instanceof LibreOfficeBinaryNotFoundError) {
2715
+ throw new HTTPException3(503, {
2716
+ message: "LibreOffice is not available. Install LibreOffice or set LIBREOFFICE_PATH."
2717
+ });
2718
+ }
2719
+ if (error instanceof LibreOfficeTimeoutError || error instanceof LibreOfficeConversionError || error instanceof LibreOfficeOutputNotFoundError) {
2720
+ throw new HTTPException3(500, {
2721
+ message: "LibreOffice preview conversion failed."
2722
+ });
2723
+ }
2724
+ throw new HTTPException3(500, {
2725
+ message: "Internal server error during preview conversion"
2726
+ });
2727
+ }
2728
+ }
2729
+ );
1964
2730
  router.post(
1965
2731
  "/standard-components",
1966
2732
  contentTypeMw,
@@ -2455,15 +3221,15 @@ var init_ai_schema = __esm({
2455
3221
 
2456
3222
  // src/server/services/prompt-loader.ts
2457
3223
  import { readFileSync as readFileSync5, existsSync as existsSync6 } from "fs";
2458
- import { join as join7, dirname as dirname5 } from "path";
3224
+ import { join as join8, dirname as dirname5 } from "path";
2459
3225
  import { fileURLToPath as fileURLToPath2 } from "url";
2460
3226
  function resolvePromptsDir() {
2461
3227
  const candidates = [
2462
- join7(__dirname2, "..", "prompts"),
3228
+ join8(__dirname2, "..", "prompts"),
2463
3229
  // dev: src/server/prompts
2464
- join7(__dirname2, "prompts"),
3230
+ join8(__dirname2, "prompts"),
2465
3231
  // bundled: dist/prompts
2466
- join7(__dirname2, "..", "src", "server", "prompts")
3232
+ join8(__dirname2, "..", "src", "server", "prompts")
2467
3233
  // prod from dist/
2468
3234
  ];
2469
3235
  for (const dir of candidates) {
@@ -2473,7 +3239,7 @@ function resolvePromptsDir() {
2473
3239
  }
2474
3240
  function readPromptFile(name) {
2475
3241
  if (cache.has(name)) return cache.get(name);
2476
- const filePath = join7(PROMPTS_DIR, name);
3242
+ const filePath = join8(PROMPTS_DIR, name);
2477
3243
  const content = readFileSync5(filePath, "utf-8");
2478
3244
  cache.set(name, content);
2479
3245
  return content;
@@ -2836,6 +3602,110 @@ var init_ai = __esm({
2836
3602
  }
2837
3603
  });
2838
3604
 
3605
+ // src/server/routes/fonts.ts
3606
+ import { createHash } from "crypto";
3607
+ import { Hono as Hono5 } from "hono";
3608
+ import { HTTPException as HTTPException4 } from "hono/http-exception";
3609
+ import { bodyLimit as bodyLimit2 } from "hono/body-limit";
3610
+ import {
3611
+ SAFE_FONTS,
3612
+ POPULAR_GOOGLE_FONTS as POPULAR_GOOGLE_FONTS2,
3613
+ fetchGoogleFontSources
3614
+ } from "@json-to-office/shared";
3615
+ var fontsRouter, MATERIALIZE_FAMILY_MAX, MATERIALIZE_WEIGHTS_MAX, CATALOG_BODY, CATALOG_ETAG;
3616
+ var init_fonts = __esm({
3617
+ "src/server/routes/fonts.ts"() {
3618
+ "use strict";
3619
+ init_esm_shims();
3620
+ init_rate_limit();
3621
+ fontsRouter = new Hono5();
3622
+ MATERIALIZE_FAMILY_MAX = 64;
3623
+ MATERIALIZE_WEIGHTS_MAX = 9;
3624
+ CATALOG_BODY = JSON.stringify({
3625
+ safe: SAFE_FONTS,
3626
+ google: POPULAR_GOOGLE_FONTS2
3627
+ });
3628
+ CATALOG_ETAG = `"${createHash("sha256").update(CATALOG_BODY).digest("base64").slice(0, 24)}"`;
3629
+ fontsRouter.get("/catalog", (c) => {
3630
+ const ifNoneMatch = c.req.header("If-None-Match");
3631
+ if (ifNoneMatch && ifNoneMatch === CATALOG_ETAG) {
3632
+ c.header("ETag", CATALOG_ETAG);
3633
+ c.header("Cache-Control", "public, max-age=86400");
3634
+ return c.body(null, 304);
3635
+ }
3636
+ c.header("Content-Type", "application/json");
3637
+ c.header("ETag", CATALOG_ETAG);
3638
+ c.header("Cache-Control", "public, max-age=86400");
3639
+ return c.body(CATALOG_BODY, 200);
3640
+ });
3641
+ fontsRouter.post(
3642
+ "/materialize",
3643
+ bodyLimit2({
3644
+ // Body is just {family, weights, italics} — 16 KB is generous.
3645
+ maxSize: 16 * 1024,
3646
+ onError: () => {
3647
+ throw new HTTPException4(413, { message: "Request body too large" });
3648
+ }
3649
+ }),
3650
+ rateLimiter({
3651
+ limit: process.env.NODE_ENV === "production" ? 20 : 1e3,
3652
+ window: 15 * 60 * 1e3,
3653
+ keyGenerator: (c) => c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous"
3654
+ }),
3655
+ async (c) => {
3656
+ let body;
3657
+ try {
3658
+ body = await c.req.json();
3659
+ } catch {
3660
+ return c.json({ error: "Invalid JSON body" }, 400);
3661
+ }
3662
+ const family = body.family?.trim();
3663
+ if (!family) {
3664
+ return c.json({ error: "Missing required field: family" }, 400);
3665
+ }
3666
+ if (family.length > MATERIALIZE_FAMILY_MAX) {
3667
+ return c.json(
3668
+ { error: `family exceeds ${MATERIALIZE_FAMILY_MAX} characters` },
3669
+ 400
3670
+ );
3671
+ }
3672
+ const weights = Array.isArray(body.weights) && body.weights.length > 0 ? Array.from(
3673
+ new Set(
3674
+ body.weights.filter(
3675
+ (w) => typeof w === "number" && w >= 100 && w <= 900
3676
+ )
3677
+ )
3678
+ ).slice(0, MATERIALIZE_WEIGHTS_MAX) : [400, 700];
3679
+ const italics = Boolean(body.italics);
3680
+ try {
3681
+ const { sources, warnings } = await fetchGoogleFontSources({
3682
+ family,
3683
+ weights,
3684
+ italics
3685
+ });
3686
+ return c.json({
3687
+ family,
3688
+ sources: sources.map((s) => ({
3689
+ weight: s.weight,
3690
+ italic: s.italic,
3691
+ format: s.format,
3692
+ data: s.data.toString("base64")
3693
+ })),
3694
+ warnings
3695
+ });
3696
+ } catch (err) {
3697
+ return c.json(
3698
+ {
3699
+ error: `Google Fonts materialize failed: ${err.message}`
3700
+ },
3701
+ 502
3702
+ );
3703
+ }
3704
+ }
3705
+ );
3706
+ }
3707
+ });
3708
+
2839
3709
  // src/server/middleware/hono/request-id.ts
2840
3710
  import { randomUUID } from "crypto";
2841
3711
  var requestIdMiddleware;
@@ -2890,7 +3760,7 @@ var init_auth = __esm({
2890
3760
  });
2891
3761
 
2892
3762
  // src/server/middleware/hono/error-handler.ts
2893
- import { HTTPException as HTTPException4 } from "hono/http-exception";
3763
+ import { HTTPException as HTTPException5 } from "hono/http-exception";
2894
3764
  var errorHandler;
2895
3765
  var init_error_handler = __esm({
2896
3766
  "src/server/middleware/hono/error-handler.ts"() {
@@ -2900,7 +3770,7 @@ var init_error_handler = __esm({
2900
3770
  init_config();
2901
3771
  errorHandler = (err, c) => {
2902
3772
  const requestId = c.get("requestId") || "unknown";
2903
- if (err instanceof HTTPException4) {
3773
+ if (err instanceof HTTPException5) {
2904
3774
  return c.json(
2905
3775
  {
2906
3776
  success: false,
@@ -3068,14 +3938,14 @@ var init_request_logger = __esm({
3068
3938
  });
3069
3939
 
3070
3940
  // src/server/app.ts
3071
- import { Hono as Hono5 } from "hono";
3941
+ import { Hono as Hono6 } from "hono";
3072
3942
  import { cors } from "hono/cors";
3073
3943
  import { secureHeaders } from "hono/secure-headers";
3074
3944
  import { logger as honoLogger } from "hono/logger";
3075
3945
  import { timing } from "hono/timing";
3076
3946
  function createAPIApp(adapter) {
3077
3947
  Container.initialize(adapter);
3078
- const honoApp = new Hono5();
3948
+ const honoApp = new Hono6();
3079
3949
  honoApp.use("*", timing());
3080
3950
  honoApp.use("*", honoLogger());
3081
3951
  honoApp.use(
@@ -3103,6 +3973,7 @@ function createAPIApp(adapter) {
3103
3973
  const legacyPath = adapter.name === "docx" ? "/api/documents" : "/api/presentations";
3104
3974
  honoApp.route(legacyPath, formatRouter);
3105
3975
  honoApp.route("/api/discovery", discoveryRouter);
3976
+ honoApp.route("/api/fonts", fontsRouter);
3106
3977
  if (process.env.AI_ENABLED !== "false") {
3107
3978
  honoApp.route("/api/ai", createAiRouter());
3108
3979
  }
@@ -3139,6 +4010,7 @@ var init_app = __esm({
3139
4010
  init_format();
3140
4011
  init_discovery();
3141
4012
  init_ai();
4013
+ init_fonts();
3142
4014
  init_request_id();
3143
4015
  init_auth();
3144
4016
  init_error_handler();
@@ -3154,11 +4026,11 @@ var unified_server_exports = {};
3154
4026
  __export(unified_server_exports, {
3155
4027
  UnifiedServer: () => UnifiedServer
3156
4028
  });
3157
- import { Hono as Hono6 } from "hono";
4029
+ import { Hono as Hono7 } from "hono";
3158
4030
  import { serve } from "@hono/node-server";
3159
4031
  import { logger as logger2 } from "hono/logger";
3160
4032
  import { compress } from "hono/compress";
3161
- import { resolve as resolve15, dirname as dirname6 } from "path";
4033
+ import { resolve as resolve16, dirname as dirname6 } from "path";
3162
4034
  import { fileURLToPath as fileURLToPath3 } from "url";
3163
4035
  var __filename2, __dirname3, UnifiedServer;
3164
4036
  var init_unified_server = __esm({
@@ -3178,7 +4050,7 @@ var init_unified_server = __esm({
3178
4050
  constructor(adapter, config2) {
3179
4051
  this.adapter = adapter;
3180
4052
  this.config = config2;
3181
- this.app = new Hono6();
4053
+ this.app = new Hono7();
3182
4054
  }
3183
4055
  async initialize() {
3184
4056
  await this.setupMiddleware();
@@ -3204,9 +4076,9 @@ var init_unified_server = __esm({
3204
4076
  return await apiApp.fetch(c.req.raw);
3205
4077
  });
3206
4078
  this.app.use("*", async (c, next) => {
3207
- const path14 = c.req.path;
3208
- if (path14.startsWith("/api")) {
3209
- if (/\.(ts|tsx|js|jsx|css|map)$/.test(path14)) {
4079
+ const path17 = c.req.path;
4080
+ if (path17.startsWith("/api")) {
4081
+ if (/\.(ts|tsx|js|jsx|css|map)$/.test(path17)) {
3210
4082
  return next();
3211
4083
  }
3212
4084
  const response = await apiApp.fetch(c.req.raw);
@@ -3239,15 +4111,15 @@ var init_unified_server = __esm({
3239
4111
  let clientPath = null;
3240
4112
  if (process.env.JTO_CLIENT_PATH) {
3241
4113
  const envClientPath = process.env.JTO_CLIENT_PATH;
3242
- if (existsSync7(envClientPath) && existsSync7(resolve15(envClientPath, "index.html"))) {
4114
+ if (existsSync7(envClientPath) && existsSync7(resolve16(envClientPath, "index.html"))) {
3243
4115
  clientPath = envClientPath;
3244
4116
  logger.debug("[Dev Server] Using client from JTO_CLIENT_PATH", {
3245
4117
  path: clientPath
3246
4118
  });
3247
4119
  }
3248
4120
  } else if (isBundled) {
3249
- const bundledClientPath = resolve15(__dirname3, "client");
3250
- if (existsSync7(bundledClientPath) && existsSync7(resolve15(bundledClientPath, "index.html"))) {
4121
+ const bundledClientPath = resolve16(__dirname3, "client");
4122
+ if (existsSync7(bundledClientPath) && existsSync7(resolve16(bundledClientPath, "index.html"))) {
3251
4123
  clientPath = bundledClientPath;
3252
4124
  logger.debug("[Dev Server] Using bundled client at", {
3253
4125
  path: clientPath
@@ -3255,8 +4127,8 @@ var init_unified_server = __esm({
3255
4127
  }
3256
4128
  } else {
3257
4129
  const possiblePaths = [
3258
- resolve15(__dirname3, "../client"),
3259
- resolve15(__dirname3, "../../dist/client")
4130
+ resolve16(__dirname3, "../client"),
4131
+ resolve16(__dirname3, "../../dist/client")
3260
4132
  ];
3261
4133
  for (const p of possiblePaths) {
3262
4134
  if (existsSync7(p)) {
@@ -3278,7 +4150,7 @@ var init_unified_server = __esm({
3278
4150
  logger.warn("[Dev Server] Vite not available for dev mode");
3279
4151
  }
3280
4152
  break;
3281
- } else if (existsSync7(resolve15(p, "index.html"))) {
4153
+ } else if (existsSync7(resolve16(p, "index.html"))) {
3282
4154
  clientPath = p;
3283
4155
  logger.debug("[Dev Server] Using pre-built client at", {
3284
4156
  path: clientPath
@@ -3292,7 +4164,7 @@ var init_unified_server = __esm({
3292
4164
  return this.setupBuiltClient(clientPath);
3293
4165
  }
3294
4166
  if (!this.viteServer && !isBundled) {
3295
- const sourceClientPath = resolve15(__dirname3, "../client");
4167
+ const sourceClientPath = resolve16(__dirname3, "../client");
3296
4168
  try {
3297
4169
  const { createServer: createViteServer } = await import("vite");
3298
4170
  this.viteServer = await createViteServer({
@@ -3309,17 +4181,17 @@ var init_unified_server = __esm({
3309
4181
  }
3310
4182
  if (this.viteServer) {
3311
4183
  this.app.use("*", async (c, next) => {
3312
- const path14 = c.req.path;
3313
- if ((path14.startsWith("/api") || path14 === "/health") && !/\.(ts|tsx|js|jsx|css|map)$/.test(path14)) {
4184
+ const path17 = c.req.path;
4185
+ if ((path17.startsWith("/api") || path17 === "/health") && !/\.(ts|tsx|js|jsx|css|map)$/.test(path17)) {
3314
4186
  return next();
3315
4187
  }
3316
4188
  const env = c.env;
3317
4189
  const req = env.incoming || env.req;
3318
4190
  const res = env.outgoing || env.res;
3319
4191
  if (req && res && this.viteServer) {
3320
- return new Promise((resolve16) => {
4192
+ return new Promise((resolve18) => {
3321
4193
  this.viteServer.middlewares.handle(req, res, () => {
3322
- resolve16(next());
4194
+ resolve18(next());
3323
4195
  });
3324
4196
  });
3325
4197
  }
@@ -3329,26 +4201,26 @@ var init_unified_server = __esm({
3329
4201
  }
3330
4202
  async setupBuiltClient(clientPath) {
3331
4203
  const { serveStatic } = await import("@hono/node-server/serve-static");
3332
- const fs11 = await import("fs");
3333
- const { extname: extname2 } = await import("path");
4204
+ const fs14 = await import("fs");
4205
+ const { extname: extname4 } = await import("path");
3334
4206
  const mime = await import("mime-types");
3335
4207
  const format = this.adapter.name.replace(/[^a-zA-Z0-9]/g, "");
3336
4208
  this.app.use(
3337
4209
  "/assets/*",
3338
4210
  serveStatic({
3339
4211
  root: clientPath,
3340
- rewriteRequestPath: (path14) => path14.replace(/^\/assets/, "/assets")
4212
+ rewriteRequestPath: (path17) => path17.replace(/^\/assets/, "/assets")
3341
4213
  })
3342
4214
  );
3343
4215
  this.app.use("/*", async (c, next) => {
3344
4216
  const reqPath = c.req.path;
3345
4217
  if (reqPath.startsWith("/api") || reqPath === "/health") return next();
3346
- const ext = extname2(reqPath);
4218
+ const ext = extname4(reqPath);
3347
4219
  if (ext) {
3348
- const filePath = resolve15(clientPath, reqPath.slice(1));
3349
- if (fs11.existsSync(filePath)) {
4220
+ const filePath = resolve16(clientPath, reqPath.slice(1));
4221
+ if (fs14.existsSync(filePath)) {
3350
4222
  const mimeType = mime.lookup(filePath) || "application/octet-stream";
3351
- const content = fs11.readFileSync(filePath);
4223
+ const content = fs14.readFileSync(filePath);
3352
4224
  c.header("Content-Type", mimeType);
3353
4225
  return c.body(content);
3354
4226
  }
@@ -3359,9 +4231,9 @@ var init_unified_server = __esm({
3359
4231
  const reqPath = c.req.path;
3360
4232
  if (reqPath.startsWith("/api") || reqPath === "/health")
3361
4233
  return c.notFound();
3362
- const indexPath = resolve15(clientPath, "index.html");
3363
- if (fs11.existsSync(indexPath)) {
3364
- let html = fs11.readFileSync(indexPath, "utf-8");
4234
+ const indexPath = resolve16(clientPath, "index.html");
4235
+ if (fs14.existsSync(indexPath)) {
4236
+ let html = fs14.readFileSync(indexPath, "utf-8");
3365
4237
  html = html.replace(
3366
4238
  "</head>",
3367
4239
  `<script>window.__JTO_FORMAT__ = '${format}';</script>
@@ -3373,12 +4245,12 @@ var init_unified_server = __esm({
3373
4245
  });
3374
4246
  }
3375
4247
  async setupProdClient() {
3376
- const clientPath = resolve15(__dirname3, "client");
4248
+ const clientPath = resolve16(__dirname3, "client");
3377
4249
  return this.setupBuiltClient(clientPath);
3378
4250
  }
3379
4251
  async start() {
3380
4252
  await this.initialize();
3381
- return new Promise((resolve16) => {
4253
+ return new Promise((resolve18) => {
3382
4254
  this.server = serve(
3383
4255
  {
3384
4256
  fetch: this.app.fetch,
@@ -3386,7 +4258,7 @@ var init_unified_server = __esm({
3386
4258
  hostname: this.config.server.host
3387
4259
  },
3388
4260
  () => {
3389
- resolve16();
4261
+ resolve18();
3390
4262
  }
3391
4263
  );
3392
4264
  });
@@ -3394,8 +4266,8 @@ var init_unified_server = __esm({
3394
4266
  async stop() {
3395
4267
  if (this.viteServer) await this.viteServer.close();
3396
4268
  if (this.server) {
3397
- return new Promise((resolve16) => {
3398
- this.server?.close(() => resolve16());
4269
+ return new Promise((resolve18) => {
4270
+ this.server?.close(() => resolve18());
3399
4271
  });
3400
4272
  }
3401
4273
  }
@@ -3405,8 +4277,8 @@ var init_unified_server = __esm({
3405
4277
 
3406
4278
  // src/cli.ts
3407
4279
  init_esm_shims();
3408
- import { Command as Command7 } from "commander";
3409
- import chalk9 from "chalk";
4280
+ import { Command as Command8 } from "commander";
4281
+ import chalk10 from "chalk";
3410
4282
 
3411
4283
  // src/format-adapter.ts
3412
4284
  init_esm_shims();
@@ -3440,7 +4312,8 @@ var DocxFormatAdapter = class {
3440
4312
  const services = buildServicesFromEnv();
3441
4313
  return await core.generateBufferFromJson(docDefinition, {
3442
4314
  customThemes,
3443
- services
4315
+ services,
4316
+ fonts: options.fonts
3444
4317
  });
3445
4318
  }
3446
4319
  async createGenerator(plugins, options) {
@@ -3455,7 +4328,8 @@ var DocxFormatAdapter = class {
3455
4328
  const customThemes2 = await this.loadCustomThemes(options);
3456
4329
  return await core.generateBufferFromJson(docDefinition, {
3457
4330
  customThemes: customThemes2,
3458
- services
4331
+ services,
4332
+ fonts: options.fonts
3459
4333
  });
3460
4334
  },
3461
4335
  hasPlugins: false,
@@ -3468,7 +4342,8 @@ var DocxFormatAdapter = class {
3468
4342
  theme,
3469
4343
  customThemes,
3470
4344
  debug: process.env.DEBUG === "true",
3471
- services
4345
+ services,
4346
+ fonts: options.fonts
3472
4347
  });
3473
4348
  for (const plugin of plugins) {
3474
4349
  generator = generator.addComponent(plugin);
@@ -3660,7 +4535,8 @@ var PptxFormatAdapter = class {
3660
4535
  const services = buildServicesFromEnv();
3661
4536
  return await core.generateBufferFromJson(docDefinition, {
3662
4537
  customThemes,
3663
- services
4538
+ services,
4539
+ fonts: options.fonts
3664
4540
  });
3665
4541
  }
3666
4542
  async createGenerator(plugins, options) {
@@ -3675,7 +4551,8 @@ var PptxFormatAdapter = class {
3675
4551
  const customThemes2 = await this.loadCustomThemes(options);
3676
4552
  return await core.generateBufferFromJson(docDefinition, {
3677
4553
  customThemes: customThemes2,
3678
- services
4554
+ services,
4555
+ fonts: options.fonts
3679
4556
  });
3680
4557
  },
3681
4558
  hasPlugins: false,
@@ -3688,7 +4565,8 @@ var PptxFormatAdapter = class {
3688
4565
  theme,
3689
4566
  customThemes,
3690
4567
  debug: process.env.DEBUG === "true",
3691
- services
4568
+ services,
4569
+ fonts: options.fonts
3692
4570
  });
3693
4571
  for (const plugin of plugins) {
3694
4572
  generator = generator.addComponent(plugin);
@@ -3809,7 +4687,7 @@ init_esm_shims();
3809
4687
  init_plugin_registry();
3810
4688
  import { Command } from "commander";
3811
4689
  import { readFileSync as readFileSync3, writeFileSync } from "fs";
3812
- import { resolve as resolve9, basename as basename4 } from "path";
4690
+ import { resolve as resolve10, basename as basename5 } from "path";
3813
4691
  import ora from "ora";
3814
4692
  import chalk2 from "chalk";
3815
4693
  import boxen from "boxen";
@@ -4243,7 +5121,125 @@ function formatError(error) {
4243
5121
  }
4244
5122
  }
4245
5123
 
5124
+ // src/commands/font-flags.ts
5125
+ init_esm_shims();
5126
+ import { readdirSync, statSync as statSync2 } from "fs";
5127
+ import { basename as basename4, extname, join as join4, resolve as resolve9 } from "path";
5128
+ var WEIGHT_NAMES = {
5129
+ thin: 100,
5130
+ hairline: 100,
5131
+ extralight: 200,
5132
+ ultralight: 200,
5133
+ light: 300,
5134
+ regular: 400,
5135
+ normal: 400,
5136
+ book: 400,
5137
+ medium: 500,
5138
+ semibold: 600,
5139
+ demibold: 600,
5140
+ bold: 700,
5141
+ extrabold: 800,
5142
+ ultrabold: 800,
5143
+ black: 900,
5144
+ heavy: 900
5145
+ };
5146
+ var SUPPORTED_EXT = /* @__PURE__ */ new Set([".ttf", ".otf"]);
5147
+ function parseFontFilename(filename) {
5148
+ const stem = basename4(filename, extname(filename));
5149
+ const parts = stem.split(/[-_\s]+/).filter(Boolean);
5150
+ if (parts.length <= 1) {
5151
+ return { family: stem, weight: 400, italic: false };
5152
+ }
5153
+ const tail = parts[parts.length - 1].toLowerCase();
5154
+ const italic = /italic|oblique/.test(tail);
5155
+ const withoutItalic = tail.replace(/italic|oblique/g, "");
5156
+ let weight = null;
5157
+ if (withoutItalic !== "") {
5158
+ const numeric = parseInt(withoutItalic, 10);
5159
+ if (!Number.isNaN(numeric) && String(numeric) === withoutItalic && numeric >= 100 && numeric <= 900) {
5160
+ weight = numeric;
5161
+ } else if (withoutItalic in WEIGHT_NAMES) {
5162
+ weight = WEIGHT_NAMES[withoutItalic];
5163
+ }
5164
+ } else if (italic) {
5165
+ weight = 400;
5166
+ }
5167
+ if (weight === null) {
5168
+ return { family: stem, weight: 400, italic: false };
5169
+ }
5170
+ const family = parts.slice(0, -1).join(" ");
5171
+ return { family, weight, italic };
5172
+ }
5173
+ function parseFontFlag(spec, cwd = process.cwd()) {
5174
+ const eq = spec.indexOf("=");
5175
+ if (eq < 0) {
5176
+ throw new Error(
5177
+ `--font expects <name>=<path>, got "${spec}". Example: --font Inter=./fonts/Inter-Regular.ttf`
5178
+ );
5179
+ }
5180
+ const family = spec.slice(0, eq).trim();
5181
+ const path17 = spec.slice(eq + 1).trim();
5182
+ if (!family || !path17) {
5183
+ throw new Error(`--font has empty name or path: "${spec}"`);
5184
+ }
5185
+ const absolutePath = resolve9(cwd, path17);
5186
+ const variant = parseFontFilename(path17);
5187
+ return {
5188
+ id: family,
5189
+ family,
5190
+ sources: [
5191
+ {
5192
+ kind: "file",
5193
+ path: absolutePath,
5194
+ weight: variant.weight,
5195
+ italic: variant.italic
5196
+ }
5197
+ ]
5198
+ };
5199
+ }
5200
+ function parseFontsDir(dir, cwd = process.cwd()) {
5201
+ const absoluteDir = resolve9(cwd, dir);
5202
+ let entries;
5203
+ try {
5204
+ entries = readdirSync(absoluteDir);
5205
+ } catch (err) {
5206
+ throw new Error(
5207
+ `--fonts-dir "${dir}" could not be read: ${err.message}`
5208
+ );
5209
+ }
5210
+ const byFamily = /* @__PURE__ */ new Map();
5211
+ for (const name of entries) {
5212
+ const ext = extname(name).toLowerCase();
5213
+ if (!SUPPORTED_EXT.has(ext)) continue;
5214
+ const fullPath = join4(absoluteDir, name);
5215
+ try {
5216
+ if (!statSync2(fullPath).isFile()) continue;
5217
+ } catch {
5218
+ continue;
5219
+ }
5220
+ const variant = parseFontFilename(name);
5221
+ const source = {
5222
+ kind: "file",
5223
+ path: fullPath,
5224
+ weight: variant.weight,
5225
+ italic: variant.italic
5226
+ };
5227
+ const existing = byFamily.get(variant.family);
5228
+ if (existing) {
5229
+ existing.sources.push(source);
5230
+ } else {
5231
+ byFamily.set(variant.family, {
5232
+ id: variant.family,
5233
+ family: variant.family,
5234
+ sources: [source]
5235
+ });
5236
+ }
5237
+ }
5238
+ return [...byFamily.values()];
5239
+ }
5240
+
4246
5241
  // src/commands/generate.ts
5242
+ import { isSafeFont } from "@json-to-office/shared";
4247
5243
  function createGenerateCommand(adapter) {
4248
5244
  return new Command("generate").description(`Generate ${adapter.label} from JSON`).argument("<input>", "Input JSON file path").option("-o, --output <path>", "Output file path").option("-t, --template <name>", "Template to use").option(
4249
5245
  "--plugins [names-or-paths]",
@@ -4251,7 +5247,40 @@ function createGenerateCommand(adapter) {
4251
5247
  ).option("--plugin-dir <dir>", "Directory to search for plugins").option("--theme <name-or-path>", "Theme name or path to theme file").option(
4252
5248
  "--theme-path <path>",
4253
5249
  "Path to theme file (alternative to --theme)"
4254
- ).option("--strict", "Enable strict validation").option("--dry-run", "Preview without writing files").action(async (input, options) => {
5250
+ ).option("--strict", "Enable strict validation").option(
5251
+ "--strict-fonts",
5252
+ "Fail generation on unresolved fontRegistry references"
5253
+ ).option(
5254
+ "--no-google-fonts",
5255
+ "Disable Google Fonts HTTP fetching (offline/CI builds)"
5256
+ ).option(
5257
+ "--font-cache-dir <path>",
5258
+ "Directory to cache fetched Google Fonts TTFs"
5259
+ ).option(
5260
+ "--font <name=path>",
5261
+ "Register a font file (repeatable): <family>=<path to .ttf/.otf>",
5262
+ (value, previous = []) => [...previous, value],
5263
+ []
5264
+ ).option(
5265
+ "--fonts-dir <path>",
5266
+ "Scan directory for .ttf/.otf files and auto-register by filename"
5267
+ ).option(
5268
+ "--font-mode <mode>",
5269
+ 'How to handle non-safe fonts: "custom" (default; keep refs as-is \u2014 recipient needs fonts installed) or "substitute" (rewrite to safe fonts)',
5270
+ (value) => {
5271
+ if (value !== "substitute" && value !== "custom") {
5272
+ throw new Error(
5273
+ `--font-mode must be one of: substitute, custom (got "${value}")`
5274
+ );
5275
+ }
5276
+ return value;
5277
+ }
5278
+ ).option(
5279
+ "--font-substitute <family=safe>",
5280
+ "When --font-mode substitute: map a non-safe family to a specific safe font (repeatable). Falls back to category defaults when omitted.",
5281
+ (value, previous = []) => [...previous, value],
5282
+ []
5283
+ ).option("--dry-run", "Preview without writing files").action(async (input, options) => {
4255
5284
  const spinner = ora("Initializing...").start();
4256
5285
  const startTime = performance.now();
4257
5286
  try {
@@ -4274,13 +5303,13 @@ function createGenerateCommand(adapter) {
4274
5303
  adapter.name
4275
5304
  );
4276
5305
  spinner.text = "Reading input file...";
4277
- const inputPath = resolve9(process.cwd(), input);
5306
+ const inputPath = resolve10(process.cwd(), input);
4278
5307
  const jsonContent = readFileSync3(inputPath, "utf-8");
4279
5308
  const documentDefinition = JSON.parse(jsonContent);
4280
5309
  const factory = new GeneratorFactory(adapter);
4281
- const outputPath = options.output ? resolve9(process.cwd(), options.output) : resolve9(
5310
+ const outputPath = options.output ? resolve10(process.cwd(), options.output) : resolve10(
4282
5311
  process.cwd(),
4283
- basename4(input, ".json") + adapter.extension
5312
+ basename5(input, ".json") + adapter.extension
4284
5313
  );
4285
5314
  const pluginInfo = factory.getPluginInfo();
4286
5315
  if (options.dryRun) {
@@ -4310,11 +5339,52 @@ function createGenerateCommand(adapter) {
4310
5339
  PluginRegistry.getInstance().clear();
4311
5340
  return;
4312
5341
  }
5342
+ const extraEntries = [];
5343
+ for (const spec of options.font ?? []) {
5344
+ extraEntries.push(parseFontFlag(spec));
5345
+ }
5346
+ if (options.fontsDir) {
5347
+ extraEntries.push(...parseFontsDir(options.fontsDir));
5348
+ }
5349
+ const substitution = {};
5350
+ for (const spec of options.fontSubstitute ?? []) {
5351
+ const eq = spec.indexOf("=");
5352
+ if (eq < 0) {
5353
+ throw new Error(
5354
+ `--font-substitute expects <family>=<safe-font>, got "${spec}"`
5355
+ );
5356
+ }
5357
+ const from = spec.slice(0, eq).trim();
5358
+ const to = spec.slice(eq + 1).trim();
5359
+ if (!from || !to) {
5360
+ throw new Error(
5361
+ `--font-substitute expects non-empty family and safe-font, got "${spec}"`
5362
+ );
5363
+ }
5364
+ if (!isSafeFont(to)) {
5365
+ throw new Error(
5366
+ `--font-substitute target "${to}" is not in SAFE_FONTS (got "${spec}")`
5367
+ );
5368
+ }
5369
+ substitution[from] = to;
5370
+ }
4313
5371
  spinner.text = `Generating ${adapter.label}...`;
4314
5372
  const buffer = await factory.generate(documentDefinition, {
4315
5373
  theme: mergedConfig.theme,
4316
5374
  themePath: mergedConfig.themePath,
4317
- validation: mergedConfig.validation
5375
+ validation: mergedConfig.validation,
5376
+ fonts: {
5377
+ strict: options.strictFonts,
5378
+ ...extraEntries.length > 0 && { extraEntries },
5379
+ ...options.fontMode && { mode: options.fontMode },
5380
+ ...Object.keys(substitution).length > 0 && { substitution },
5381
+ googleFonts: {
5382
+ ...options.noGoogleFonts === true && { enabled: false },
5383
+ ...options.fontCacheDir && {
5384
+ cacheDir: resolve10(process.cwd(), options.fontCacheDir)
5385
+ }
5386
+ }
5387
+ }
4318
5388
  });
4319
5389
  spinner.text = "Writing output file...";
4320
5390
  writeFileSync(outputPath, Buffer.from(buffer));
@@ -4359,6 +5429,11 @@ ${chalk2.gray("Examples:")}
4359
5429
  $ jto ${adapter.name} generate doc.json --theme minimal ${chalk2.dim("# Use built-in theme")}
4360
5430
  $ jto ${adapter.name} generate doc.json --theme-path ./theme.json ${chalk2.dim("# Use custom theme")}
4361
5431
  $ jto ${adapter.name} generate doc.json --dry-run ${chalk2.dim("# Preview without writing")}
5432
+ $ jto ${adapter.name} generate doc.json --strict-fonts ${chalk2.dim("# Fail on unresolved fonts")}
5433
+ $ jto ${adapter.name} generate doc.json --font-cache-dir .fontcache ${chalk2.dim("# Cache Google Fonts TTFs")}
5434
+ $ jto ${adapter.name} generate doc.json --no-google-fonts ${chalk2.dim("# Offline build, skip network fetches")}
5435
+ $ jto ${adapter.name} generate doc.json --font Inter=./fonts/Inter-Regular.ttf ${chalk2.dim("# Register one TTF")}
5436
+ $ jto ${adapter.name} generate doc.json --fonts-dir ./fonts ${chalk2.dim("# Auto-register every .ttf/.otf in ./fonts")}
4362
5437
  `
4363
5438
  );
4364
5439
  }
@@ -4368,13 +5443,13 @@ init_esm_shims();
4368
5443
  import { Command as Command2 } from "commander";
4369
5444
  import chalk3 from "chalk";
4370
5445
  import ora2 from "ora";
4371
- import { resolve as resolve11, relative as relative4 } from "path";
5446
+ import { resolve as resolve12, relative as relative4 } from "path";
4372
5447
  import { existsSync as existsSync3 } from "fs";
4373
5448
 
4374
5449
  // src/services/json-validator.ts
4375
5450
  init_esm_shims();
4376
- import { readFileSync as readFileSync4, statSync as statSync2, readdirSync } from "fs";
4377
- import { resolve as resolve10, join as join4, extname } from "path";
5451
+ import { readFileSync as readFileSync4, statSync as statSync3, readdirSync as readdirSync2 } from "fs";
5452
+ import { resolve as resolve11, join as join5, extname as extname2 } from "path";
4378
5453
  import { glob as glob2 } from "glob";
4379
5454
  var JsonValidator = class {
4380
5455
  format;
@@ -4391,7 +5466,7 @@ var JsonValidator = class {
4391
5466
  return results;
4392
5467
  }
4393
5468
  async validateFile(filePath, options = {}) {
4394
- const absolutePath = resolve10(filePath);
5469
+ const absolutePath = resolve11(filePath);
4395
5470
  try {
4396
5471
  const content = readFileSync4(absolutePath, "utf-8");
4397
5472
  let jsonData;
@@ -4513,7 +5588,7 @@ var JsonValidator = class {
4513
5588
  }
4514
5589
  async validateWithCustomSchema(filePath, jsonData, schemaPath, strict) {
4515
5590
  try {
4516
- const schemaContent = readFileSync4(resolve10(schemaPath), "utf-8");
5591
+ const schemaContent = readFileSync4(resolve11(schemaPath), "utf-8");
4517
5592
  const schema = JSON.parse(schemaContent);
4518
5593
  const Ajv = (await import("ajv")).default;
4519
5594
  const addFormats = (await import("ajv-formats")).default;
@@ -4562,20 +5637,20 @@ var JsonValidator = class {
4562
5637
  return null;
4563
5638
  }
4564
5639
  async getFilesToValidate(pathOrPattern, options) {
4565
- const resolvedPath = resolve10(pathOrPattern);
5640
+ const resolvedPath = resolve11(pathOrPattern);
4566
5641
  try {
4567
- const stats = statSync2(resolvedPath);
5642
+ const stats = statSync3(resolvedPath);
4568
5643
  if (stats.isFile()) {
4569
5644
  return [resolvedPath];
4570
5645
  } else if (stats.isDirectory()) {
4571
5646
  if (options.recursive) {
4572
- const pattern = join4(resolvedPath, "**/*.json");
5647
+ const pattern = join5(resolvedPath, "**/*.json");
4573
5648
  return glob2(pattern, {
4574
5649
  ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"]
4575
5650
  });
4576
5651
  } else {
4577
- const files = readdirSync(resolvedPath);
4578
- return files.filter((file) => extname(file).toLowerCase() === ".json").map((file) => join4(resolvedPath, file));
5652
+ const files = readdirSync2(resolvedPath);
5653
+ return files.filter((file) => extname2(file).toLowerCase() === ".json").map((file) => join5(resolvedPath, file));
4579
5654
  }
4580
5655
  }
4581
5656
  } catch {
@@ -4626,7 +5701,7 @@ function createValidateCommand(adapter) {
4626
5701
  const spinner = showSpinner ? ora2("Validating...").start() : null;
4627
5702
  const startTime = performance.now();
4628
5703
  try {
4629
- if (options.schema && !existsSync3(resolve11(options.schema))) {
5704
+ if (options.schema && !existsSync3(resolve12(options.schema))) {
4630
5705
  throw new Error(`Schema file not found: ${options.schema}`);
4631
5706
  }
4632
5707
  const results = await validator.validate(fileOrDirectory, {
@@ -5483,7 +6558,7 @@ function groupByLocation(items) {
5483
6558
  init_esm_shims();
5484
6559
  import { Command as Command5 } from "commander";
5485
6560
  import { mkdirSync, writeFileSync as writeFileSync2, existsSync as existsSync4 } from "fs";
5486
- import { resolve as resolve13, join as join6 } from "path";
6561
+ import { resolve as resolve14, join as join7 } from "path";
5487
6562
  import { execSync } from "child_process";
5488
6563
  import ora5 from "ora";
5489
6564
  import chalk7 from "chalk";
@@ -5509,7 +6584,7 @@ function createInitCommand(adapter) {
5509
6584
  process.exit(EXIT_CODES.FAIL);
5510
6585
  }
5511
6586
  }
5512
- const projectPath = resolve13(process.cwd(), name);
6587
+ const projectPath = resolve14(process.cwd(), name);
5513
6588
  if (existsSync4(projectPath)) {
5514
6589
  console.error(chalk7.red(`Directory ${name} already exists`));
5515
6590
  process.exit(EXIT_CODES.FAIL);
@@ -5535,7 +6610,7 @@ function createInitCommand(adapter) {
5535
6610
  }
5536
6611
  };
5537
6612
  writeFileSync2(
5538
- join6(projectPath, "package.json"),
6613
+ join7(projectPath, "package.json"),
5539
6614
  JSON.stringify(packageJson, null, 2)
5540
6615
  );
5541
6616
  const exampleDocument = adapter.name === "docx" ? {
@@ -5583,7 +6658,7 @@ function createInitCommand(adapter) {
5583
6658
  ]
5584
6659
  };
5585
6660
  writeFileSync2(
5586
- join6(projectPath, "example.json"),
6661
+ join7(projectPath, "example.json"),
5587
6662
  JSON.stringify(exampleDocument, null, 2)
5588
6663
  );
5589
6664
  const gitignore = `node_modules
@@ -5593,7 +6668,7 @@ dist
5593
6668
  .env
5594
6669
  .env.local
5595
6670
  `;
5596
- writeFileSync2(join6(projectPath, ".gitignore"), gitignore);
6671
+ writeFileSync2(join7(projectPath, ".gitignore"), gitignore);
5597
6672
  console.log(chalk7.green(`
5598
6673
  Created project at ${projectPath}
5599
6674
  `));
@@ -5651,7 +6726,7 @@ import boxen3 from "boxen";
5651
6726
  init_esm_shims();
5652
6727
  import { existsSync as existsSync5 } from "fs";
5653
6728
  import { readFile as readFile4 } from "fs/promises";
5654
- import { resolve as resolve14 } from "path";
6729
+ import { resolve as resolve15 } from "path";
5655
6730
  import { pathToFileURL as pathToFileURL2 } from "url";
5656
6731
 
5657
6732
  // src/config/schema.ts
@@ -5796,7 +6871,7 @@ async function loadConfig(configPath) {
5796
6871
  async function findConfigFile() {
5797
6872
  const cwd = process.cwd();
5798
6873
  for (const filename of CONFIG_FILES) {
5799
- const filepath = resolve14(cwd, filename);
6874
+ const filepath = resolve15(cwd, filename);
5800
6875
  if (existsSync5(filepath)) {
5801
6876
  return filepath;
5802
6877
  }
@@ -5896,8 +6971,304 @@ ${chalk8.cyan("Health:")} ${url}/health
5896
6971
  });
5897
6972
  }
5898
6973
 
6974
+ // src/commands/fonts.ts
6975
+ init_esm_shims();
6976
+ import { Command as Command7 } from "commander";
6977
+ import chalk9 from "chalk";
6978
+ import {
6979
+ readFileSync as readFileSync6,
6980
+ statSync as statSync4,
6981
+ mkdirSync as mkdirSync2,
6982
+ writeFileSync as writeFileSync3,
6983
+ renameSync,
6984
+ unlinkSync
6985
+ } from "fs";
6986
+ import { resolve as resolve17, basename as basename6, extname as extname3 } from "path";
6987
+ import {
6988
+ SAFE_FONTS as SAFE_FONTS2,
6989
+ collectFontNamesFromDocx as collectFontNamesFromDocx2,
6990
+ collectFontNamesFromPptx as collectFontNamesFromPptx2,
6991
+ detectFontFormat,
6992
+ fetchGoogleFontSources as fetchGoogleFontSources2,
6993
+ isSafeFont as isSafeFont3,
6994
+ POPULAR_GOOGLE_FONTS as POPULAR_GOOGLE_FONTS3
6995
+ } from "@json-to-office/shared";
6996
+ function formatBytes(bytes) {
6997
+ if (bytes < 1024) return `${bytes} B`;
6998
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
6999
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
7000
+ }
7001
+ function weightLabel(weight, italic) {
7002
+ return `${weight}${italic ? " italic" : ""}`;
7003
+ }
7004
+ function listSafeFonts() {
7005
+ console.log(
7006
+ chalk9.bold("\nSafe fonts (bundled with Office, no embedding needed):")
7007
+ );
7008
+ for (const name of SAFE_FONTS2) {
7009
+ console.log(` ${chalk9.cyan(name)}`);
7010
+ }
7011
+ }
7012
+ function listLocalFonts(dir) {
7013
+ let entries;
7014
+ try {
7015
+ entries = parseFontsDir(dir);
7016
+ } catch (err) {
7017
+ console.log(
7018
+ chalk9.yellow(
7019
+ `
7020
+ (--fonts-dir ${dir} not readable: ${err.message})`
7021
+ )
7022
+ );
7023
+ return;
7024
+ }
7025
+ if (entries.length === 0) {
7026
+ console.log(chalk9.dim(`
7027
+ Local fonts in ${dir}: (none)`));
7028
+ return;
7029
+ }
7030
+ console.log(chalk9.bold(`
7031
+ Local fonts in ${dir}:`));
7032
+ for (const e of entries) {
7033
+ const variants = e.sources.map((s) => {
7034
+ const weight = "weight" in s ? s.weight ?? 400 : 400;
7035
+ const italic = "italic" in s ? Boolean(s.italic) : false;
7036
+ return weightLabel(weight, italic);
7037
+ }).join(", ");
7038
+ console.log(` ${chalk9.cyan(e.family)} ${chalk9.dim(`(${variants})`)}`);
7039
+ }
7040
+ }
7041
+ function listReferencedFonts(docPath, adapterName) {
7042
+ const absolutePath = resolve17(process.cwd(), docPath);
7043
+ let raw;
7044
+ try {
7045
+ raw = readFileSync6(absolutePath, "utf-8");
7046
+ } catch (err) {
7047
+ console.log(
7048
+ chalk9.yellow(`
7049
+ (Could not read ${docPath}: ${err.message})`)
7050
+ );
7051
+ return;
7052
+ }
7053
+ let parsed;
7054
+ try {
7055
+ parsed = JSON.parse(raw);
7056
+ } catch (err) {
7057
+ console.log(
7058
+ chalk9.yellow(
7059
+ `
7060
+ (${docPath} is not valid JSON: ${err.message})`
7061
+ )
7062
+ );
7063
+ return;
7064
+ }
7065
+ const names = adapterName === "docx" ? collectFontNamesFromDocx2(parsed) : collectFontNamesFromPptx2(parsed);
7066
+ if (names.size === 0) {
7067
+ console.log(chalk9.dim(`
7068
+ Fonts referenced in ${docPath}: (none)`));
7069
+ return;
7070
+ }
7071
+ console.log(chalk9.bold(`
7072
+ Fonts referenced in ${docPath}:`));
7073
+ for (const name of [...names].sort()) {
7074
+ const status = isSafeFont3(name) ? chalk9.green("[safe]") : POPULAR_GOOGLE_FONTS3.some(
7075
+ (g) => g.family.toLowerCase() === name.toLowerCase()
7076
+ ) ? chalk9.blue("[google]") : chalk9.yellow("[unresolved]");
7077
+ console.log(` ${chalk9.cyan(name)} ${status}`);
7078
+ }
7079
+ }
7080
+ function createListCommand(adapter) {
7081
+ return new Command7("list").description(
7082
+ "List safe fonts, fonts in ./fonts, and fonts referenced in a document"
7083
+ ).argument(
7084
+ "[document]",
7085
+ "Optional JSON document to scan for font references"
7086
+ ).option(
7087
+ "--fonts-dir <path>",
7088
+ "Directory of local .ttf/.otf files to list (default: ./fonts)",
7089
+ "./fonts"
7090
+ ).action(async (document, options) => {
7091
+ listSafeFonts();
7092
+ if (options.fontsDir) listLocalFonts(options.fontsDir);
7093
+ if (document) {
7094
+ listReferencedFonts(document, adapter.name);
7095
+ }
7096
+ console.log();
7097
+ });
7098
+ }
7099
+ function createInspectCommand() {
7100
+ return new Command7("inspect").description("Print family/weight/italic/format/size for a .ttf/.otf file").argument("<file>", "Path to a font file").action(async (file) => {
7101
+ const absolute = resolve17(process.cwd(), file);
7102
+ let buf;
7103
+ let size;
7104
+ try {
7105
+ buf = readFileSync6(absolute);
7106
+ size = statSync4(absolute).size;
7107
+ } catch (err) {
7108
+ formatError(err);
7109
+ process.exit(EXIT_CODES.FAIL);
7110
+ }
7111
+ const format = detectFontFormat(buf);
7112
+ const parsed = parseFontFilename(basename6(file));
7113
+ const ext = extname3(file).toLowerCase();
7114
+ console.log();
7115
+ console.log(chalk9.bold("Font:"), chalk9.cyan(parsed.family));
7116
+ console.log(chalk9.bold("File:"), file);
7117
+ console.log(
7118
+ chalk9.bold("Format:"),
7119
+ format === "unknown" ? chalk9.yellow(format) : format
7120
+ );
7121
+ console.log(chalk9.bold("Weight:"), parsed.weight);
7122
+ console.log(chalk9.bold("Italic:"), parsed.italic ? "yes" : "no");
7123
+ console.log(chalk9.bold("Size:"), formatBytes(size));
7124
+ if (format !== "ttf" && format !== "otf") {
7125
+ console.log(
7126
+ chalk9.yellow(
7127
+ "\nWarning: embedding requires TTF or OTF. WOFF/WOFF2 won't embed in .docx."
7128
+ )
7129
+ );
7130
+ }
7131
+ if (parsed.family === basename6(file, ext)) {
7132
+ console.log(
7133
+ chalk9.dim(
7134
+ "\n(Family was not parsed from a recognized weight/style suffix \u2014 treating full filename as family.)"
7135
+ )
7136
+ );
7137
+ }
7138
+ console.log();
7139
+ });
7140
+ }
7141
+ function parseWeightsOption(raw) {
7142
+ if (!raw) return [400, 700];
7143
+ const out = [];
7144
+ for (const token of raw.split(/[,\s]+/).filter(Boolean)) {
7145
+ const n = parseInt(token, 10);
7146
+ if (Number.isNaN(n) || n < 100 || n > 900 || n % 100 !== 0) {
7147
+ throw new Error(
7148
+ `--weights must be 100-step integers between 100 and 900, got "${token}"`
7149
+ );
7150
+ }
7151
+ out.push(n);
7152
+ }
7153
+ return [...new Set(out)].sort((a, b) => a - b);
7154
+ }
7155
+ var WEIGHT_NAMES2 = {
7156
+ 100: "Thin",
7157
+ 200: "ExtraLight",
7158
+ 300: "Light",
7159
+ 400: "Regular",
7160
+ 500: "Medium",
7161
+ 600: "SemiBold",
7162
+ 700: "Bold",
7163
+ 800: "ExtraBold",
7164
+ 900: "Black"
7165
+ };
7166
+ function filenameFor(family, weight, italic) {
7167
+ const safeFamily = family.replace(/\s+/g, "");
7168
+ const suffix = `${WEIGHT_NAMES2[weight] ?? weight}${italic ? "Italic" : ""}`;
7169
+ return `${safeFamily}-${suffix}.ttf`;
7170
+ }
7171
+ function createInstallCommand() {
7172
+ return new Command7("install").description(
7173
+ "Download a Google Fonts family into ./fonts/ (filenames compatible with --fonts-dir)"
7174
+ ).argument("<family>", 'Google Fonts family name (e.g. "Inter")').option(
7175
+ "-w, --weights <list>",
7176
+ 'Comma-separated weights (100..900, step 100). Default: "400,700"'
7177
+ ).option("--italics", "Also download italic variants").option(
7178
+ "-d, --dir <path>",
7179
+ "Output directory (default: ./fonts)",
7180
+ "./fonts"
7181
+ ).action(async (family, options) => {
7182
+ const weights = parseWeightsOption(options.weights);
7183
+ const italics = Boolean(options.italics);
7184
+ const outDir = resolve17(process.cwd(), options.dir ?? "./fonts");
7185
+ console.log(
7186
+ chalk9.dim(
7187
+ `Fetching ${family} [${weights.join(", ")}]${italics ? " + italics" : ""} from Google Fonts...`
7188
+ )
7189
+ );
7190
+ const { sources, warnings } = await fetchGoogleFontSources2({
7191
+ family,
7192
+ weights,
7193
+ italics
7194
+ });
7195
+ if (warnings.length > 0) {
7196
+ for (const w of warnings) console.warn(chalk9.yellow(` ${w}`));
7197
+ }
7198
+ if (sources.length === 0) {
7199
+ console.error(chalk9.red(`
7200
+ No variants downloaded for "${family}".`));
7201
+ process.exit(EXIT_CODES.FAIL);
7202
+ }
7203
+ mkdirSync2(outDir, { recursive: true });
7204
+ const written = [];
7205
+ const failures = [];
7206
+ for (const src of sources) {
7207
+ const name = filenameFor(family, src.weight, src.italic);
7208
+ const full = resolve17(outDir, name);
7209
+ const tmp = `${full}.tmp`;
7210
+ try {
7211
+ writeFileSync3(tmp, src.data);
7212
+ renameSync(tmp, full);
7213
+ written.push(name);
7214
+ } catch (err) {
7215
+ try {
7216
+ unlinkSync(tmp);
7217
+ } catch {
7218
+ }
7219
+ failures.push({ name, err: err.message });
7220
+ }
7221
+ }
7222
+ if (failures.length > 0) {
7223
+ console.error(
7224
+ chalk9.red(`
7225
+ ${failures.length} file(s) failed to install:`)
7226
+ );
7227
+ for (const f of failures) {
7228
+ console.error(` ${chalk9.red(f.name)}: ${f.err}`);
7229
+ }
7230
+ }
7231
+ if (written.length > 0) {
7232
+ console.log(
7233
+ chalk9.green(`
7234
+ Installed ${written.length} file(s) into ${outDir}:`)
7235
+ );
7236
+ for (const name of written) console.log(` ${chalk9.cyan(name)}`);
7237
+ console.log(
7238
+ chalk9.dim(
7239
+ `
7240
+ Use them with: jto <format> generate doc.json --fonts-dir ${options.dir ?? "./fonts"}`
7241
+ )
7242
+ );
7243
+ }
7244
+ if (failures.length > 0) {
7245
+ process.exit(EXIT_CODES.FAIL);
7246
+ }
7247
+ });
7248
+ }
7249
+ function createFontsCommand(adapter) {
7250
+ const cmd = new Command7("fonts").description(
7251
+ `Font introspection and Google Fonts install for ${adapter.label}s`
7252
+ );
7253
+ cmd.addCommand(createListCommand(adapter));
7254
+ cmd.addCommand(createInspectCommand());
7255
+ cmd.addCommand(createInstallCommand());
7256
+ cmd.addHelpText(
7257
+ "after",
7258
+ `
7259
+ ${chalk9.gray("Examples:")}
7260
+ $ jto ${adapter.name} fonts list ${chalk9.dim("# Safe + ./fonts")}
7261
+ $ jto ${adapter.name} fonts list doc.json ${chalk9.dim("# + fonts referenced in doc")}
7262
+ $ jto ${adapter.name} fonts inspect ./fonts/Inter-Bold.ttf
7263
+ $ jto ${adapter.name} fonts install Inter ${chalk9.dim("# \u2192 ./fonts/Inter-Regular.ttf + Inter-Bold.ttf")}
7264
+ $ jto ${adapter.name} fonts install "Playfair Display" --weights 400,700 --italics
7265
+ `
7266
+ );
7267
+ return cmd;
7268
+ }
7269
+
5899
7270
  // src/cli.ts
5900
- var PACKAGE_VERSION = true ? "0.8.3" : "dev-mode";
7271
+ var PACKAGE_VERSION = true ? "0.9.0" : "dev-mode";
5901
7272
  function registerFormatCommands(parent, adapter) {
5902
7273
  parent.addCommand(createGenerateCommand(adapter));
5903
7274
  parent.addCommand(createValidateCommand(adapter));
@@ -5905,25 +7276,27 @@ function registerFormatCommands(parent, adapter) {
5905
7276
  parent.addCommand(createDiscoverCommand(adapter));
5906
7277
  parent.addCommand(createInitCommand(adapter));
5907
7278
  parent.addCommand(createDevCommand(adapter));
7279
+ parent.addCommand(createFontsCommand(adapter));
5908
7280
  }
5909
- var program = new Command7();
7281
+ var program = new Command8();
5910
7282
  program.name("jto").description("JSON to Office CLI - Generate .docx and .pptx from JSON").version(PACKAGE_VERSION);
5911
- var docx = new Command7("docx").description("DOCX document commands");
7283
+ var docx = new Command8("docx").description("DOCX document commands");
5912
7284
  registerFormatCommands(docx, new DocxFormatAdapter());
5913
- var pptx = new Command7("pptx").description("PPTX presentation commands");
7285
+ var pptx = new Command8("pptx").description("PPTX presentation commands");
5914
7286
  registerFormatCommands(pptx, new PptxFormatAdapter());
5915
7287
  program.addCommand(docx);
5916
7288
  program.addCommand(pptx);
5917
7289
  program.addHelpText(
5918
7290
  "after",
5919
7291
  `
5920
- ${chalk9.gray("Examples:")}
5921
- $ jto docx generate doc.json ${chalk9.dim("# Generate DOCX from JSON")}
5922
- $ jto pptx generate slides.json ${chalk9.dim("# Generate PPTX from JSON")}
5923
- $ jto docx dev ${chalk9.dim("# Start DOCX dev server")}
5924
- $ jto pptx validate slides.json ${chalk9.dim("# Validate PPTX JSON")}
5925
- $ jto docx schemas ${chalk9.dim("# Export DOCX JSON schemas")}
5926
- $ jto pptx discover ${chalk9.dim("# Discover PPTX plugins")}
7292
+ ${chalk10.gray("Examples:")}
7293
+ $ jto docx generate doc.json ${chalk10.dim("# Generate DOCX from JSON")}
7294
+ $ jto pptx generate slides.json ${chalk10.dim("# Generate PPTX from JSON")}
7295
+ $ jto docx dev ${chalk10.dim("# Start DOCX dev server")}
7296
+ $ jto pptx validate slides.json ${chalk10.dim("# Validate PPTX JSON")}
7297
+ $ jto docx schemas ${chalk10.dim("# Export DOCX JSON schemas")}
7298
+ $ jto pptx discover ${chalk10.dim("# Discover PPTX plugins")}
7299
+ $ jto docx fonts install Inter ${chalk10.dim("# Download a Google Font into ./fonts")}
5927
7300
  `
5928
7301
  );
5929
7302
  program.exitOverride();
@@ -5937,7 +7310,7 @@ program.exitOverride();
5937
7310
  if (error.code === "commander.executeSubCommandAsync") {
5938
7311
  process.exit(error.exitCode);
5939
7312
  }
5940
- console.error(chalk9.red("Error:"), error.message);
7313
+ console.error(chalk10.red("Error:"), error.message);
5941
7314
  process.exit(1);
5942
7315
  }
5943
7316
  })();