@json-to-office/jto 0.28.0 → 0.29.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 (24) hide show
  1. package/dist/cli.js +355 -111
  2. package/dist/cli.js.map +1 -1
  3. package/dist/client/assets/{HomePage-BX2wX21e.js → HomePage-g8gOpx6G.js} +3 -3
  4. package/dist/client/assets/{HomePage-BX2wX21e.js.map → HomePage-g8gOpx6G.js.map} +1 -1
  5. package/dist/client/assets/{JsonEditorPage-DseCjMok.js → JsonEditorPage-CzMTUrNe.js} +3 -3
  6. package/dist/client/assets/{JsonEditorPage-DseCjMok.js.map → JsonEditorPage-CzMTUrNe.js.map} +1 -1
  7. package/dist/client/assets/{MonacoPluginProvider-DvO7h20V.js → MonacoPluginProvider-CmMAmp3u.js} +3 -3
  8. package/dist/client/assets/{MonacoPluginProvider-DvO7h20V.js.map → MonacoPluginProvider-CmMAmp3u.js.map} +1 -1
  9. package/dist/client/assets/{button-DzgfrDEj.js → button-BEg2__r6.js} +2 -2
  10. package/dist/client/assets/{button-DzgfrDEj.js.map → button-BEg2__r6.js.map} +1 -1
  11. package/dist/client/assets/{editor-BDi6QWgh.js → editor-DPs_adn-.js} +2 -2
  12. package/dist/client/assets/{editor-BDi6QWgh.js.map → editor-DPs_adn-.js.map} +1 -1
  13. package/dist/client/assets/{editor-monaco-json-CsQqBG2A.js → editor-monaco-json-xnoKHnLp.js} +2 -2
  14. package/dist/client/assets/{editor-monaco-json-CsQqBG2A.js.map → editor-monaco-json-xnoKHnLp.js.map} +1 -1
  15. package/dist/client/assets/{index-Cxjik2Rg.js → index-DgKS-gEZ.js} +4 -4
  16. package/dist/client/assets/index-DgKS-gEZ.js.map +1 -0
  17. package/dist/client/assets/{preview-BOF6SWhK.js → preview-RxIHvUjz.js} +2 -2
  18. package/dist/client/assets/{preview-BOF6SWhK.js.map → preview-RxIHvUjz.js.map} +1 -1
  19. package/dist/client/index.html +1 -1
  20. package/dist/render-server.d.ts +2 -1
  21. package/dist/render-server.js +149 -36
  22. package/dist/render-server.js.map +1 -1
  23. package/package.json +7 -7
  24. package/dist/client/assets/index-Cxjik2Rg.js.map +0 -1
package/dist/cli.js CHANGED
@@ -234,10 +234,10 @@ function collectReferencedNames(config2, customThemes, adapterName) {
234
234
  }
235
235
  function collectReferencedWeights(config2, customThemes) {
236
236
  const weights = /* @__PURE__ */ new Set();
237
- const visit2 = (node) => {
237
+ const visit3 = (node) => {
238
238
  if (node == null) return;
239
239
  if (Array.isArray(node)) {
240
- for (const item of node) visit2(item);
240
+ for (const item of node) visit3(item);
241
241
  return;
242
242
  }
243
243
  if (typeof node === "object") {
@@ -245,21 +245,21 @@ function collectReferencedWeights(config2, customThemes) {
245
245
  if (k === "fontWeight" && typeof v === "number" && v >= 100 && v <= 900) {
246
246
  weights.add(v);
247
247
  } else {
248
- visit2(v);
248
+ visit3(v);
249
249
  }
250
250
  }
251
251
  }
252
252
  };
253
- visit2(config2);
254
- for (const theme of Object.values(customThemes ?? {})) visit2(theme);
253
+ visit3(config2);
254
+ for (const theme of Object.values(customThemes ?? {})) visit3(theme);
255
255
  return weights;
256
256
  }
257
257
  function collectReferencedItalic(config2, customThemes) {
258
258
  let found = false;
259
- const visit2 = (node) => {
259
+ const visit3 = (node) => {
260
260
  if (found || node == null) return;
261
261
  if (Array.isArray(node)) {
262
- for (const item of node) visit2(item);
262
+ for (const item of node) visit3(item);
263
263
  return;
264
264
  }
265
265
  if (typeof node === "object") {
@@ -268,14 +268,14 @@ function collectReferencedItalic(config2, customThemes) {
268
268
  found = true;
269
269
  return;
270
270
  }
271
- visit2(v);
271
+ visit3(v);
272
272
  }
273
273
  }
274
274
  };
275
- visit2(config2);
275
+ visit3(config2);
276
276
  if (!found) {
277
277
  for (const theme of Object.values(customThemes ?? {})) {
278
- visit2(theme);
278
+ visit3(theme);
279
279
  if (found) break;
280
280
  }
281
281
  }
@@ -1640,36 +1640,36 @@ function isPrivateHost(host) {
1640
1640
  const firstIpv6Group = Number.parseInt(normalized.split(":")[0] || "0", 16);
1641
1641
  return firstIpv6Group >= 64512 && firstIpv6Group <= 65023 || firstIpv6Group >= 65152 && firstIpv6Group <= 65215;
1642
1642
  }
1643
- function assertAllowedUrl(source, path7, allowedHosts) {
1643
+ function assertAllowedUrl(source, path8, allowedHosts) {
1644
1644
  if (source.startsWith("data:")) return;
1645
1645
  let url;
1646
1646
  try {
1647
1647
  url = new URL(source);
1648
1648
  } catch {
1649
1649
  throw new UnsafeOutboundSourceError(
1650
- path7,
1650
+ path8,
1651
1651
  "local and relative file paths are disabled for HTTP requests"
1652
1652
  );
1653
1653
  }
1654
1654
  if (url.protocol !== "https:") {
1655
- throw new UnsafeOutboundSourceError(path7, "only HTTPS URLs are allowed");
1655
+ throw new UnsafeOutboundSourceError(path8, "only HTTPS URLs are allowed");
1656
1656
  }
1657
1657
  if (url.username || url.password) {
1658
1658
  throw new UnsafeOutboundSourceError(
1659
- path7,
1659
+ path8,
1660
1660
  "URLs containing credentials are not allowed"
1661
1661
  );
1662
1662
  }
1663
1663
  const host = url.hostname.toLowerCase().replace(/\.$/, "");
1664
1664
  if (isPrivateHost(host)) {
1665
1665
  throw new UnsafeOutboundSourceError(
1666
- path7,
1666
+ path8,
1667
1667
  `private or local host "${host}" is not allowed`
1668
1668
  );
1669
1669
  }
1670
1670
  if (!allowedHosts.some((pattern) => hostMatches(host, pattern))) {
1671
1671
  throw new UnsafeOutboundSourceError(
1672
- path7,
1672
+ path8,
1673
1673
  `host "${host}" is not in OUTBOUND_HOST_ALLOWLIST`
1674
1674
  );
1675
1675
  }
@@ -1687,13 +1687,13 @@ function extractCssReferences(input) {
1687
1687
  function extractAbsoluteUrls(input) {
1688
1688
  return input.match(/(?:https?|file):\/\/[^\s'"<>)}\]]+/gi) ?? [];
1689
1689
  }
1690
- function assertSafeSvg(svg, path7, allowedHosts) {
1690
+ function assertSafeSvg(svg, path8, allowedHosts) {
1691
1691
  if (/<\s*script\b/i.test(svg) || /<\s*(?:foreignObject|iframe|object|embed)\b/i.test(svg) || /\bon[a-z]+\s*=/i.test(svg)) {
1692
- throw new UnsafeOutboundSourceError(path7, "active SVG content is disabled");
1692
+ throw new UnsafeOutboundSourceError(path8, "active SVG content is disabled");
1693
1693
  }
1694
1694
  if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(svg)) {
1695
1695
  throw new UnsafeOutboundSourceError(
1696
- path7,
1696
+ path8,
1697
1697
  "SVG document types and entities are disabled"
1698
1698
  );
1699
1699
  }
@@ -1702,20 +1702,20 @@ function assertSafeSvg(svg, path7, allowedHosts) {
1702
1702
  while (match = refPattern.exec(svg)) {
1703
1703
  const ref = match[2].trim();
1704
1704
  if (!ref || ref.startsWith("#") || ref.startsWith("data:")) continue;
1705
- assertAllowedUrl(ref, path7, allowedHosts);
1705
+ assertAllowedUrl(ref, path8, allowedHosts);
1706
1706
  }
1707
1707
  for (const ref of extractCssReferences(svg)) {
1708
1708
  if (ref.startsWith("#") || ref.startsWith("data:")) continue;
1709
- assertAllowedUrl(ref, path7, allowedHosts);
1709
+ assertAllowedUrl(ref, path8, allowedHosts);
1710
1710
  }
1711
1711
  }
1712
- function assertSafeEncodedImage(source, path7, allowedHosts) {
1712
+ function assertSafeEncodedImage(source, path8, allowedHosts) {
1713
1713
  let candidate = source.trim();
1714
1714
  let declaredSvg = false;
1715
1715
  if (candidate.startsWith("data:")) {
1716
1716
  const comma = candidate.indexOf(",");
1717
1717
  if (comma < 0) {
1718
- throw new UnsafeOutboundSourceError(path7, "malformed image data URL");
1718
+ throw new UnsafeOutboundSourceError(path8, "malformed image data URL");
1719
1719
  }
1720
1720
  const metadata = candidate.slice(5, comma);
1721
1721
  declaredSvg = metadata.split(";")[0].toLowerCase() === "image/svg+xml";
@@ -1724,7 +1724,7 @@ function assertSafeEncodedImage(source, path7, allowedHosts) {
1724
1724
  try {
1725
1725
  candidate = /(?:^|;)base64(?:;|$)/i.test(metadata) ? Buffer.from(payload, "base64").toString("utf8") : decodeURIComponent(payload);
1726
1726
  } catch {
1727
- throw new UnsafeOutboundSourceError(path7, "malformed SVG data URL");
1727
+ throw new UnsafeOutboundSourceError(path8, "malformed SVG data URL");
1728
1728
  }
1729
1729
  } else if (!/<\s*svg\b/i.test(candidate)) {
1730
1730
  candidate = Buffer.from(candidate, "base64").toString("utf8");
@@ -1732,37 +1732,37 @@ function assertSafeEncodedImage(source, path7, allowedHosts) {
1732
1732
  const svgStart = candidate.search(/<\s*svg\b/i);
1733
1733
  if (svgStart < 0) {
1734
1734
  if (declaredSvg) {
1735
- throw new UnsafeOutboundSourceError(path7, "malformed SVG image data");
1735
+ throw new UnsafeOutboundSourceError(path8, "malformed SVG image data");
1736
1736
  }
1737
1737
  return;
1738
1738
  }
1739
- assertSafeSvg(candidate, path7, allowedHosts);
1739
+ assertSafeSvg(candidate, path8, allowedHosts);
1740
1740
  }
1741
- function assertSafeResources(resources, path7, allowedHosts) {
1741
+ function assertSafeResources(resources, path8, allowedHosts) {
1742
1742
  if (typeof resources.js === "string" && resources.js.trim()) {
1743
1743
  throw new UnsafeOutboundSourceError(
1744
- `${path7}.js`,
1744
+ `${path8}.js`,
1745
1745
  "remote renderer JavaScript resources are disabled"
1746
1746
  );
1747
1747
  }
1748
1748
  if (typeof resources.css === "string") {
1749
1749
  for (const ref of extractCssReferences(resources.css)) {
1750
1750
  if (ref.startsWith("data:")) continue;
1751
- assertAllowedUrl(ref, `${path7}.css`, allowedHosts);
1751
+ assertAllowedUrl(ref, `${path8}.css`, allowedHosts);
1752
1752
  }
1753
1753
  }
1754
1754
  if (Array.isArray(resources.files) && resources.files.length > 0) {
1755
1755
  throw new UnsafeOutboundSourceError(
1756
- `${path7}.files`,
1756
+ `${path8}.files`,
1757
1757
  "remote renderer JavaScript resources are disabled"
1758
1758
  );
1759
1759
  }
1760
1760
  }
1761
- function assertNoRemoteReferences(value, path7, allowedHosts, seen) {
1761
+ function assertNoRemoteReferences(value, path8, allowedHosts, seen) {
1762
1762
  if (typeof value === "string") {
1763
1763
  if (/\b(?:fetch|XMLHttpRequest|WebSocket|EventSource)\s*\(/i.test(value) || /\b(?:javascript|file):/i.test(value)) {
1764
1764
  throw new UnsafeOutboundSourceError(
1765
- path7,
1765
+ path8,
1766
1766
  "network-capable JavaScript and file URLs are disabled"
1767
1767
  );
1768
1768
  }
@@ -1771,11 +1771,11 @@ function assertNoRemoteReferences(value, path7, allowedHosts, seen) {
1771
1771
  ...extractCssReferences(value)
1772
1772
  ]) {
1773
1773
  if (url.startsWith("data:")) continue;
1774
- assertAllowedUrl(url, path7, allowedHosts);
1774
+ assertAllowedUrl(url, path8, allowedHosts);
1775
1775
  }
1776
1776
  if (/^\/\//.test(value.trim())) {
1777
1777
  throw new UnsafeOutboundSourceError(
1778
- path7,
1778
+ path8,
1779
1779
  "protocol-relative URLs are disabled"
1780
1780
  );
1781
1781
  }
@@ -1785,20 +1785,20 @@ function assertNoRemoteReferences(value, path7, allowedHosts, seen) {
1785
1785
  seen.add(value);
1786
1786
  if (Array.isArray(value)) {
1787
1787
  value.forEach(
1788
- (entry, index) => assertNoRemoteReferences(entry, `${path7}[${index}]`, allowedHosts, seen)
1788
+ (entry, index) => assertNoRemoteReferences(entry, `${path8}[${index}]`, allowedHosts, seen)
1789
1789
  );
1790
1790
  return;
1791
1791
  }
1792
1792
  for (const [key, child] of Object.entries(value)) {
1793
- assertNoRemoteReferences(child, `${path7}.${key}`, allowedHosts, seen);
1793
+ assertNoRemoteReferences(child, `${path8}.${key}`, allowedHosts, seen);
1794
1794
  }
1795
1795
  }
1796
- function visit(value, path7, containerKey, allowedHosts, seen) {
1796
+ function visit(value, path8, containerKey, allowedHosts, seen) {
1797
1797
  if (!value || typeof value !== "object" || seen.has(value)) return;
1798
1798
  seen.add(value);
1799
1799
  if (Array.isArray(value)) {
1800
1800
  value.forEach(
1801
- (entry, index) => visit(entry, `${path7}[${index}]`, containerKey, allowedHosts, seen)
1801
+ (entry, index) => visit(entry, `${path8}[${index}]`, containerKey, allowedHosts, seen)
1802
1802
  );
1803
1803
  return;
1804
1804
  }
@@ -1806,7 +1806,7 @@ function visit(value, path7, containerKey, allowedHosts, seen) {
1806
1806
  for (const key of Object.keys(record)) {
1807
1807
  if (DANGEROUS_KEYS.has(key)) {
1808
1808
  throw new UnsafeOutboundSourceError(
1809
- `${path7}.${key}`,
1809
+ `${path8}.${key}`,
1810
1810
  "prototype mutation keys are disabled"
1811
1811
  );
1812
1812
  }
@@ -1814,31 +1814,31 @@ function visit(value, path7, containerKey, allowedHosts, seen) {
1814
1814
  const kind = typeof record.kind === "string" ? record.kind : void 0;
1815
1815
  if (kind === "file" && typeof record.path === "string") {
1816
1816
  throw new UnsafeOutboundSourceError(
1817
- `${path7}.path`,
1817
+ `${path8}.path`,
1818
1818
  "local file sources are disabled for HTTP requests"
1819
1819
  );
1820
1820
  }
1821
1821
  if ((kind === "url" || kind === "variable") && typeof record.url === "string") {
1822
- assertAllowedUrl(record.url, `${path7}.url`, allowedHosts);
1822
+ assertAllowedUrl(record.url, `${path8}.url`, allowedHosts);
1823
1823
  }
1824
1824
  const componentName = typeof record.name === "string" ? record.name.toLowerCase() : void 0;
1825
1825
  const props = isRecord(record.props) ? record.props : void 0;
1826
1826
  if (componentName && props) {
1827
1827
  if (COMPONENT_SOURCE_NAMES.has(componentName) && typeof props.path === "string") {
1828
- assertAllowedUrl(props.path, `${path7}.props.path`, allowedHosts);
1829
- assertSafeEncodedImage(props.path, `${path7}.props.path`, allowedHosts);
1828
+ assertAllowedUrl(props.path, `${path8}.props.path`, allowedHosts);
1829
+ assertSafeEncodedImage(props.path, `${path8}.props.path`, allowedHosts);
1830
1830
  }
1831
1831
  if (COMPONENT_SOURCE_NAMES.has(componentName) && typeof props.base64 === "string") {
1832
1832
  assertSafeEncodedImage(
1833
1833
  props.base64,
1834
- `${path7}.props.base64`,
1834
+ `${path8}.props.base64`,
1835
1835
  allowedHosts
1836
1836
  );
1837
1837
  }
1838
1838
  if ((componentName === "highcharts" || componentName === "visual") && typeof props.serverUrl === "string") {
1839
1839
  assertAllowedUrl(
1840
1840
  props.serverUrl,
1841
- `${path7}.props.serverUrl`,
1841
+ `${path8}.props.serverUrl`,
1842
1842
  allowedHosts
1843
1843
  );
1844
1844
  }
@@ -1846,30 +1846,30 @@ function visit(value, path7, containerKey, allowedHosts, seen) {
1846
1846
  if (isRecord(props.resources)) {
1847
1847
  assertSafeResources(
1848
1848
  props.resources,
1849
- `${path7}.props.resources`,
1849
+ `${path8}.props.resources`,
1850
1850
  allowedHosts
1851
1851
  );
1852
1852
  }
1853
1853
  assertNoRemoteReferences(
1854
1854
  props.options,
1855
- `${path7}.props.options`,
1855
+ `${path8}.props.options`,
1856
1856
  allowedHosts,
1857
1857
  /* @__PURE__ */ new WeakSet()
1858
1858
  );
1859
1859
  }
1860
1860
  }
1861
1861
  if (containerKey === "image" && typeof record.path === "string") {
1862
- assertAllowedUrl(record.path, `${path7}.path`, allowedHosts);
1863
- assertSafeEncodedImage(record.path, `${path7}.path`, allowedHosts);
1862
+ assertAllowedUrl(record.path, `${path8}.path`, allowedHosts);
1863
+ assertSafeEncodedImage(record.path, `${path8}.path`, allowedHosts);
1864
1864
  }
1865
1865
  if (typeof record.svg === "string") {
1866
- assertSafeSvg(record.svg, `${path7}.svg`, allowedHosts);
1866
+ assertSafeSvg(record.svg, `${path8}.svg`, allowedHosts);
1867
1867
  }
1868
1868
  if (containerKey === "resources") {
1869
- assertSafeResources(record, path7, allowedHosts);
1869
+ assertSafeResources(record, path8, allowedHosts);
1870
1870
  }
1871
1871
  for (const [key, child] of Object.entries(record)) {
1872
- visit(child, `${path7}.${key}`, key, allowedHosts, seen);
1872
+ visit(child, `${path8}.${key}`, key, allowedHosts, seen);
1873
1873
  }
1874
1874
  }
1875
1875
  function assertSafeOutboundSources(value, policy, rootPath = "request") {
@@ -1890,9 +1890,9 @@ var init_outbound_source_policy = __esm({
1890
1890
  "use strict";
1891
1891
  init_esm_shims();
1892
1892
  UnsafeOutboundSourceError = class extends Error {
1893
- constructor(path7, reason) {
1894
- super(`Unsafe outbound source at ${path7}: ${reason}`);
1895
- this.path = path7;
1893
+ constructor(path8, reason) {
1894
+ super(`Unsafe outbound source at ${path8}: ${reason}`);
1895
+ this.path = path8;
1896
1896
  this.name = "UnsafeOutboundSourceError";
1897
1897
  }
1898
1898
  };
@@ -1910,19 +1910,80 @@ import {
1910
1910
  clampVisualDpi,
1911
1911
  DEFAULT_VISUAL_DPI,
1912
1912
  MIN_VISUAL_DPI,
1913
- MAX_VISUAL_DPI
1913
+ MAX_VISUAL_DPI,
1914
+ MAX_RASTERIZE_BATCH_SLIDES
1914
1915
  } from "@json-to-office/shared";
1915
- import { createLibreOfficePptxRasterizer } from "@json-to-office/jto-cli";
1916
+ import {
1917
+ createLibreOfficePptxRasterizer,
1918
+ createLibreOfficePptxBatchRasterizer
1919
+ } from "@json-to-office/jto-cli";
1916
1920
  function getSharedRasterizer() {
1917
1921
  if (!sharedRasterizer) {
1918
1922
  sharedRasterizer = createLibreOfficePptxRasterizer();
1919
1923
  }
1920
1924
  return sharedRasterizer;
1921
1925
  }
1926
+ function getSharedBatchRasterizer() {
1927
+ if (!sharedBatchRasterizer) {
1928
+ sharedBatchRasterizer = createLibreOfficePptxBatchRasterizer();
1929
+ }
1930
+ return sharedBatchRasterizer;
1931
+ }
1932
+ function estimateSlidePixels(presentation, dpi) {
1933
+ const props = presentation?.props;
1934
+ const dim = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
1935
+ const widthIn = dim(props?.slideWidth, DEFAULT_SLIDE_WIDTH_IN);
1936
+ const heightIn = dim(props?.slideHeight, DEFAULT_SLIDE_HEIGHT_IN);
1937
+ return widthIn * dpi * (heightIn * dpi);
1938
+ }
1939
+ function assertPixelBudget(slides) {
1940
+ let total = 0;
1941
+ for (const slide of slides) {
1942
+ const pixels = estimateSlidePixels(slide.presentation, slide.dpi);
1943
+ if (pixels > MAX_SLIDE_PIXELS) {
1944
+ throw new HTTPException3(400, {
1945
+ message: "Requested slide dimensions are too large"
1946
+ });
1947
+ }
1948
+ total += pixels;
1949
+ }
1950
+ if (total > MAX_BATCH_PIXELS) {
1951
+ throw new HTTPException3(400, {
1952
+ message: "Requested batch raster size is too large"
1953
+ });
1954
+ }
1955
+ }
1956
+ function resolveSafeBaseDir(baseDir) {
1957
+ if (baseDir === void 0) return void 0;
1958
+ const resolved = path6.resolve(baseDir);
1959
+ const cwd = process.cwd();
1960
+ if (resolved !== cwd && !resolved.startsWith(cwd + path6.sep)) {
1961
+ throw new HTTPException3(400, {
1962
+ message: "baseDir must be inside the server working directory"
1963
+ });
1964
+ }
1965
+ return resolved;
1966
+ }
1967
+ function toHttpException(error) {
1968
+ if (error instanceof HTTPException3) return error;
1969
+ if (error instanceof UnsafeOutboundSourceError) {
1970
+ return new HTTPException3(400, { message: error.message });
1971
+ }
1972
+ const msg = error instanceof Error ? error.message.toLowerCase() : String(error);
1973
+ if (msg.includes("not found") || msg.includes("rasterization needs")) {
1974
+ return new HTTPException3(503, { message: error.message });
1975
+ }
1976
+ if (msg.includes("invalid") || msg.includes("validation")) {
1977
+ return new HTTPException3(400, { message: error.message });
1978
+ }
1979
+ return new HTTPException3(500, {
1980
+ message: "Internal server error during rasterization"
1981
+ });
1982
+ }
1922
1983
  function registerRasterizeRoute(router, options = {}) {
1923
1984
  const getRasterizer = options.getRasterizer ?? getSharedRasterizer;
1924
- router.post(
1925
- "/rasterize",
1985
+ const getBatchRasterizer = options.getBatchRasterizer ?? getSharedBatchRasterizer;
1986
+ const shared = [
1926
1987
  ...options.preMiddleware ?? [],
1927
1988
  bodyLimit({
1928
1989
  maxSize: 32 * 1024 * 1024,
@@ -1930,22 +1991,26 @@ function registerRasterizeRoute(router, options = {}) {
1930
1991
  throw new HTTPException3(413, { message: "Request body too large" });
1931
1992
  }
1932
1993
  }),
1933
- jsonOnly,
1994
+ jsonOnly
1995
+ ];
1996
+ const guard = async (run) => {
1997
+ try {
1998
+ return await run();
1999
+ } catch (error) {
2000
+ options.onError?.(error);
2001
+ throw toHttpException(error);
2002
+ }
2003
+ };
2004
+ router.post(
2005
+ "/rasterize",
2006
+ ...shared,
1934
2007
  tbValidator(RasterizeRequestSchema),
1935
2008
  async (c) => {
1936
2009
  const { presentation, dpi, baseDir } = getValidated(c, "json");
1937
- let safeBaseDir;
1938
- if (baseDir !== void 0) {
1939
- const resolved = path6.resolve(baseDir);
1940
- const cwd = process.cwd();
1941
- if (resolved !== cwd && !resolved.startsWith(cwd + path6.sep)) {
1942
- throw new HTTPException3(400, {
1943
- message: "baseDir must be inside the server working directory"
1944
- });
1945
- }
1946
- safeBaseDir = resolved;
1947
- }
1948
- try {
2010
+ const safeBaseDir = resolveSafeBaseDir(baseDir);
2011
+ const effectiveDpi = clampVisualDpi(dpi ?? DEFAULT_VISUAL_DPI);
2012
+ assertPixelBudget([{ presentation, dpi: effectiveDpi }]);
2013
+ const result = await guard(async () => {
1949
2014
  if (options.sourcePolicy) {
1950
2015
  assertSafeOutboundSources(
1951
2016
  presentation,
@@ -1953,33 +2018,57 @@ function registerRasterizeRoute(router, options = {}) {
1953
2018
  "presentation"
1954
2019
  );
1955
2020
  }
1956
- const result = await getRasterizer()({
2021
+ return getRasterizer()({
1957
2022
  presentation,
1958
- dpi: clampVisualDpi(dpi ?? DEFAULT_VISUAL_DPI),
2023
+ dpi: effectiveDpi,
1959
2024
  baseDir: safeBaseDir
1960
2025
  });
1961
- return c.json(result);
1962
- } catch (error) {
1963
- options.onError?.(error);
1964
- if (error instanceof HTTPException3) throw error;
1965
- if (error instanceof UnsafeOutboundSourceError) {
1966
- throw new HTTPException3(400, { message: error.message });
1967
- }
1968
- const msg = error instanceof Error ? error.message.toLowerCase() : String(error);
1969
- if (msg.includes("not found") || msg.includes("rasterization needs")) {
1970
- throw new HTTPException3(503, { message: error.message });
1971
- }
1972
- if (msg.includes("invalid") || msg.includes("validation")) {
1973
- throw new HTTPException3(400, { message: error.message });
2026
+ });
2027
+ return c.json(result);
2028
+ }
2029
+ );
2030
+ router.post(
2031
+ "/rasterize/batch",
2032
+ ...shared,
2033
+ tbValidator(RasterizeBatchRequestSchema),
2034
+ async (c) => {
2035
+ const { slides, baseDir } = getValidated(c, "json");
2036
+ const safeBaseDir = resolveSafeBaseDir(baseDir);
2037
+ const effectiveSlides = slides.map((slide) => ({
2038
+ presentation: slide.presentation,
2039
+ dpi: clampVisualDpi(slide.dpi ?? DEFAULT_VISUAL_DPI)
2040
+ }));
2041
+ assertPixelBudget(effectiveSlides);
2042
+ const result = await guard(async () => {
2043
+ if (options.sourcePolicy) {
2044
+ slides.forEach(
2045
+ (slide, index) => assertSafeOutboundSources(
2046
+ slide.presentation,
2047
+ options.sourcePolicy,
2048
+ `slides[${index}].presentation`
2049
+ )
2050
+ );
1974
2051
  }
1975
- throw new HTTPException3(500, {
1976
- message: "Internal server error during rasterization"
2052
+ return getBatchRasterizer()({
2053
+ slides: effectiveSlides,
2054
+ baseDir: safeBaseDir
1977
2055
  });
1978
- }
2056
+ });
2057
+ return c.json({
2058
+ results: result.results.map((slide) => {
2059
+ if (slide.ok) return slide;
2060
+ options.onError?.(new Error(slide.error));
2061
+ return slide.stage === "build" ? { ok: false, error: slide.error, stage: slide.stage } : {
2062
+ ok: false,
2063
+ error: "Slide rasterization failed",
2064
+ stage: slide.stage
2065
+ };
2066
+ })
2067
+ });
1979
2068
  }
1980
2069
  );
1981
2070
  }
1982
- var RasterizeRequestSchema, sharedRasterizer, jsonOnly;
2071
+ var RasterizeRequestSchema, RasterizeBatchRequestSchema, sharedRasterizer, sharedBatchRasterizer, jsonOnly, DEFAULT_SLIDE_WIDTH_IN, DEFAULT_SLIDE_HEIGHT_IN, MAX_SLIDE_PIXELS, MAX_BATCH_PIXELS;
1983
2072
  var init_rasterize_route = __esm({
1984
2073
  "src/server/rasterize-route.ts"() {
1985
2074
  "use strict";
@@ -1996,6 +2085,24 @@ var init_rasterize_route = __esm({
1996
2085
  },
1997
2086
  { additionalProperties: false }
1998
2087
  );
2088
+ RasterizeBatchRequestSchema = Type2.Object(
2089
+ {
2090
+ slides: Type2.Array(
2091
+ Type2.Object(
2092
+ {
2093
+ presentation: Type2.Object({}, { additionalProperties: true }),
2094
+ dpi: Type2.Optional(
2095
+ Type2.Number({ minimum: MIN_VISUAL_DPI, maximum: MAX_VISUAL_DPI })
2096
+ )
2097
+ },
2098
+ { additionalProperties: false }
2099
+ ),
2100
+ { minItems: 1, maxItems: MAX_RASTERIZE_BATCH_SLIDES }
2101
+ ),
2102
+ baseDir: Type2.Optional(Type2.String())
2103
+ },
2104
+ { additionalProperties: false }
2105
+ );
1999
2106
  jsonOnly = async (c, next) => {
2000
2107
  const contentType = c.req.header("content-type");
2001
2108
  if (!contentType || !contentType.includes("application/json")) {
@@ -2005,6 +2112,122 @@ var init_rasterize_route = __esm({
2005
2112
  }
2006
2113
  await next();
2007
2114
  };
2115
+ DEFAULT_SLIDE_WIDTH_IN = 13.34;
2116
+ DEFAULT_SLIDE_HEIGHT_IN = 7.5;
2117
+ MAX_SLIDE_PIXELS = 64e6;
2118
+ MAX_BATCH_PIXELS = 256e6;
2119
+ }
2120
+ });
2121
+
2122
+ // src/server/services/template-media-inliner.ts
2123
+ import { promises as fs5 } from "fs";
2124
+ import path7 from "path";
2125
+ function isRecord2(value) {
2126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2127
+ }
2128
+ function isInlineCandidate(source) {
2129
+ if (!source || source.includes("\0")) return false;
2130
+ if (source.startsWith("data:")) return false;
2131
+ if (path7.isAbsolute(source)) return false;
2132
+ try {
2133
+ new URL(source);
2134
+ return false;
2135
+ } catch {
2136
+ return true;
2137
+ }
2138
+ }
2139
+ async function toDataUrl(source, baseDir, state) {
2140
+ let cacheKey = source;
2141
+ let result = null;
2142
+ try {
2143
+ const resolved = await fs5.realpath(path7.resolve(baseDir, source));
2144
+ cacheKey = resolved;
2145
+ const cached2 = state.cache.get(cacheKey);
2146
+ if (cached2 !== void 0) return cached2;
2147
+ const contained = resolved === state.baseDirReal || resolved.startsWith(state.baseDirReal + path7.sep);
2148
+ if (contained) {
2149
+ const mime = MIME_BY_EXTENSION[path7.extname(resolved).toLowerCase()];
2150
+ if (mime) {
2151
+ const stat = await fs5.stat(resolved);
2152
+ if (stat.isFile() && stat.size <= state.maxFileBytes && stat.size <= state.totalBudget) {
2153
+ const buffer = await fs5.readFile(resolved);
2154
+ state.totalBudget -= buffer.length;
2155
+ result = `data:${mime};base64,${buffer.toString("base64")}`;
2156
+ }
2157
+ }
2158
+ }
2159
+ } catch {
2160
+ }
2161
+ state.cache.set(cacheKey, result);
2162
+ return result;
2163
+ }
2164
+ async function visit2(value, containerKey, baseDir, state, seen) {
2165
+ if (!value || typeof value !== "object" || seen.has(value)) return;
2166
+ seen.add(value);
2167
+ if (Array.isArray(value)) {
2168
+ for (const entry of value) {
2169
+ await visit2(entry, containerKey, baseDir, state, seen);
2170
+ }
2171
+ return;
2172
+ }
2173
+ const record = value;
2174
+ const componentName = typeof record.name === "string" ? record.name.toLowerCase() : void 0;
2175
+ const props = isRecord2(record.props) ? record.props : void 0;
2176
+ if (componentName === "image" && props && typeof props.path === "string" && isInlineCandidate(props.path)) {
2177
+ const inlined = await toDataUrl(props.path, baseDir, state);
2178
+ if (inlined !== null) props.path = inlined;
2179
+ }
2180
+ if (containerKey === "image" && typeof record.path === "string" && isInlineCandidate(record.path)) {
2181
+ const inlined = await toDataUrl(record.path, baseDir, state);
2182
+ if (inlined !== null) record.path = inlined;
2183
+ }
2184
+ for (const [key, child] of Object.entries(record)) {
2185
+ await visit2(child, key, baseDir, state, seen);
2186
+ }
2187
+ }
2188
+ async function inlineTemplateMedia(jsonDefinition, baseDir, options = {}) {
2189
+ if (typeof jsonDefinition === "string") {
2190
+ try {
2191
+ jsonDefinition = JSON.parse(jsonDefinition);
2192
+ } catch {
2193
+ return jsonDefinition;
2194
+ }
2195
+ }
2196
+ if (!jsonDefinition || typeof jsonDefinition !== "object") {
2197
+ return jsonDefinition;
2198
+ }
2199
+ let baseDirReal;
2200
+ try {
2201
+ baseDirReal = await fs5.realpath(baseDir);
2202
+ } catch {
2203
+ return jsonDefinition;
2204
+ }
2205
+ const clone = structuredClone(jsonDefinition);
2206
+ const state = {
2207
+ baseDirReal,
2208
+ maxFileBytes: options.maxFileBytes ?? 10 * 1024 * 1024,
2209
+ totalBudget: options.maxTotalBytes ?? 32 * 1024 * 1024,
2210
+ cache: /* @__PURE__ */ new Map()
2211
+ };
2212
+ await visit2(clone, void 0, baseDir, state, /* @__PURE__ */ new WeakSet());
2213
+ return clone;
2214
+ }
2215
+ var MIME_BY_EXTENSION;
2216
+ var init_template_media_inliner = __esm({
2217
+ "src/server/services/template-media-inliner.ts"() {
2218
+ "use strict";
2219
+ init_esm_shims();
2220
+ MIME_BY_EXTENSION = {
2221
+ ".png": "image/png",
2222
+ ".jpg": "image/jpeg",
2223
+ ".jpeg": "image/jpeg",
2224
+ ".gif": "image/gif",
2225
+ ".svg": "image/svg+xml",
2226
+ ".webp": "image/webp",
2227
+ ".bmp": "image/bmp",
2228
+ ".tiff": "image/tiff",
2229
+ ".tif": "image/tiff"
2230
+ };
2008
2231
  }
2009
2232
  });
2010
2233
 
@@ -2019,9 +2242,9 @@ import {
2019
2242
  import { dirname } from "path";
2020
2243
  function createFormatRouter(adapter) {
2021
2244
  const router = new Hono2();
2022
- const assertRequestSources = (value, path7) => {
2245
+ const assertRequestSources = (value, path8) => {
2023
2246
  try {
2024
- assertSafeOutboundSources(value, config.outboundSources, path7);
2247
+ assertSafeOutboundSources(value, config.outboundSources, path8);
2025
2248
  } catch (error) {
2026
2249
  if (error instanceof UnsafeOutboundSourceError) {
2027
2250
  throw new HTTPException4(400, { message: error.message });
@@ -2049,6 +2272,15 @@ function createFormatRouter(adapter) {
2049
2272
  return void 0;
2050
2273
  }
2051
2274
  };
2275
+ const inlineDiscoveredMedia = async (jsonDefinition, baseDir) => {
2276
+ if (baseDir === void 0 || config.outboundSources.mode === "development") {
2277
+ return jsonDefinition;
2278
+ }
2279
+ return inlineTemplateMedia(jsonDefinition, baseDir, {
2280
+ maxFileBytes: config.requestLimits.maxFileSize,
2281
+ maxTotalBytes: config.requestLimits.maxBodySize
2282
+ });
2283
+ };
2052
2284
  const contentTypeMw = async (c, next) => {
2053
2285
  const contentType = c.req.header("content-type");
2054
2286
  if (!contentType || !contentType.includes("application/json")) {
@@ -2072,7 +2304,12 @@ function createFormatRouter(adapter) {
2072
2304
  const { jsonDefinition, customThemes, options } = getValidated(c, "json");
2073
2305
  const requestId = c.get("requestId");
2074
2306
  try {
2075
- assertRequestSources(jsonDefinition, "jsonDefinition");
2307
+ const baseDir = await resolveSourceBaseDir(options);
2308
+ const effectiveDefinition = await inlineDiscoveredMedia(
2309
+ jsonDefinition,
2310
+ baseDir
2311
+ );
2312
+ assertRequestSources(effectiveDefinition, "jsonDefinition");
2076
2313
  assertRequestSources(customThemes, "customThemes");
2077
2314
  assertRequestSources(options, "options");
2078
2315
  const bypassCache = c.req.header("X-Bypass-Cache") === "true" || c.req.query("bypass-cache") === "true" || options?.bypassCache === true;
@@ -2084,11 +2321,10 @@ function createFormatRouter(adapter) {
2084
2321
  } else if (rawFonts) {
2085
2322
  sanitizedFonts = rawFonts;
2086
2323
  }
2087
- const baseDir = await resolveSourceBaseDir(options);
2088
2324
  const clientOptions = { ...options };
2089
2325
  delete clientOptions.baseDir;
2090
2326
  const result = await generatorService.generate({
2091
- jsonDefinition,
2327
+ jsonDefinition: effectiveDefinition,
2092
2328
  customThemes,
2093
2329
  options: {
2094
2330
  ...clientOptions,
@@ -2352,12 +2588,16 @@ function createFormatRouter(adapter) {
2352
2588
  );
2353
2589
  const { jsonDefinition, customThemes, options } = getValidated(c, "json");
2354
2590
  try {
2355
- assertRequestSources(jsonDefinition, "jsonDefinition");
2591
+ const baseDir = await resolveSourceBaseDir(options);
2592
+ const effectiveDefinition = await inlineDiscoveredMedia(
2593
+ jsonDefinition,
2594
+ baseDir
2595
+ );
2596
+ assertRequestSources(effectiveDefinition, "jsonDefinition");
2356
2597
  assertRequestSources(customThemes, "customThemes");
2357
2598
  assertRequestSources(options, "options");
2358
- const baseDir = await resolveSourceBaseDir(options);
2359
2599
  const generated = await generatorService.generate({
2360
- jsonDefinition,
2600
+ jsonDefinition: effectiveDefinition,
2361
2601
  customThemes,
2362
2602
  options: {
2363
2603
  bypassCache: true,
@@ -2509,6 +2749,9 @@ function createFormatRouter(adapter) {
2509
2749
  rateLimiter({
2510
2750
  limit: process.env.NODE_ENV === "production" ? 10 : 1e3,
2511
2751
  window: 15 * 60 * 1e3,
2752
+ // One bucket for /rasterize AND /rasterize/batch — without a shared
2753
+ // namespace the default per-path key would double the budget.
2754
+ namespace: "rasterize",
2512
2755
  trustProxy: config.rateLimit.trustProxy
2513
2756
  })
2514
2757
  ],
@@ -2530,6 +2773,7 @@ var init_format = __esm({
2530
2773
  init_config();
2531
2774
  init_outbound_source_policy();
2532
2775
  init_libreoffice_converter();
2776
+ init_template_media_inliner();
2533
2777
  }
2534
2778
  });
2535
2779
 
@@ -3863,9 +4107,9 @@ var init_unified_server = __esm({
3863
4107
  return await apiApp.fetch(c.req.raw, c.env);
3864
4108
  });
3865
4109
  this.app.use("*", async (c, next) => {
3866
- const path7 = c.req.path;
3867
- if (path7.startsWith("/api")) {
3868
- if (/\.(ts|tsx|js|jsx|css|map)$/.test(path7)) {
4110
+ const path8 = c.req.path;
4111
+ if (path8.startsWith("/api")) {
4112
+ if (/\.(ts|tsx|js|jsx|css|map)$/.test(path8)) {
3869
4113
  return next();
3870
4114
  }
3871
4115
  const response = await apiApp.fetch(c.req.raw, c.env);
@@ -3968,8 +4212,8 @@ var init_unified_server = __esm({
3968
4212
  }
3969
4213
  if (this.viteServer) {
3970
4214
  this.app.use("*", async (c, next) => {
3971
- const path7 = c.req.path;
3972
- if ((path7.startsWith("/api") || path7 === "/health") && !/\.(ts|tsx|js|jsx|css|map)$/.test(path7)) {
4215
+ const path8 = c.req.path;
4216
+ if ((path8.startsWith("/api") || path8 === "/health") && !/\.(ts|tsx|js|jsx|css|map)$/.test(path8)) {
3973
4217
  return next();
3974
4218
  }
3975
4219
  const env = c.env;
@@ -3988,7 +4232,7 @@ var init_unified_server = __esm({
3988
4232
  }
3989
4233
  async setupBuiltClient(clientPath) {
3990
4234
  const { serveStatic } = await import("@hono/node-server/serve-static");
3991
- const fs5 = await import("fs");
4235
+ const fs6 = await import("fs");
3992
4236
  const { extname } = await import("path");
3993
4237
  const mime = await import("mime-types");
3994
4238
  const format = this.adapter.name.replace(/[^a-zA-Z0-9]/g, "");
@@ -3996,7 +4240,7 @@ var init_unified_server = __esm({
3996
4240
  "/assets/*",
3997
4241
  serveStatic({
3998
4242
  root: clientPath,
3999
- rewriteRequestPath: (path7) => path7.replace(/^\/assets/, "/assets")
4243
+ rewriteRequestPath: (path8) => path8.replace(/^\/assets/, "/assets")
4000
4244
  })
4001
4245
  );
4002
4246
  this.app.use("/*", async (c, next) => {
@@ -4005,9 +4249,9 @@ var init_unified_server = __esm({
4005
4249
  const ext = extname(reqPath);
4006
4250
  if (ext) {
4007
4251
  const filePath = resolve(clientPath, reqPath.slice(1));
4008
- if (fs5.existsSync(filePath)) {
4252
+ if (fs6.existsSync(filePath)) {
4009
4253
  const mimeType = mime.lookup(filePath) || "application/octet-stream";
4010
- const content = fs5.readFileSync(filePath);
4254
+ const content = fs6.readFileSync(filePath);
4011
4255
  c.header("Content-Type", mimeType);
4012
4256
  return c.body(content);
4013
4257
  }
@@ -4019,8 +4263,8 @@ var init_unified_server = __esm({
4019
4263
  if (reqPath.startsWith("/api") || reqPath === "/health")
4020
4264
  return c.notFound();
4021
4265
  const indexPath = resolve(clientPath, "index.html");
4022
- if (fs5.existsSync(indexPath)) {
4023
- let html = fs5.readFileSync(indexPath, "utf-8");
4266
+ if (fs6.existsSync(indexPath)) {
4267
+ let html = fs6.readFileSync(indexPath, "utf-8");
4024
4268
  html = html.replace(
4025
4269
  "</head>",
4026
4270
  `<script>window.__JTO_FORMAT__ = '${format}';</script>
@@ -4181,7 +4425,7 @@ function createDevCommand(adapter) {
4181
4425
  }
4182
4426
 
4183
4427
  // src/cli.ts
4184
- var PACKAGE_VERSION = true ? "0.28.0" : "dev-mode";
4428
+ var PACKAGE_VERSION = true ? "0.29.0" : "dev-mode";
4185
4429
  var program = new Command2();
4186
4430
  program.name("jto").description("JSON to Office CLI - Generate .docx and .pptx from JSON").version(PACKAGE_VERSION);
4187
4431
  registerCoreCommands(program, {