@formfeed/mcp 0.2.0 → 0.3.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 (2) hide show
  1. package/bin/formfeed-mcp.mjs +638 -97
  2. package/package.json +1 -1
@@ -17445,16 +17445,16 @@ var require_liquid_node = __commonJS({
17445
17445
  this.trimLeft = false;
17446
17446
  this.trimRight = false;
17447
17447
  const tl = input[contentBegin] === "-";
17448
- const tr = input[contentEnd - 1] === "-";
17448
+ const tr2 = input[contentEnd - 1] === "-";
17449
17449
  let l3 = tl ? contentBegin + 1 : contentBegin;
17450
- let r = tr ? contentEnd - 1 : contentEnd;
17450
+ let r = tr2 ? contentEnd - 1 : contentEnd;
17451
17451
  while (l3 < r && TYPES[input.charCodeAt(l3)] & BLANK)
17452
17452
  l3++;
17453
17453
  while (r > l3 && TYPES[input.charCodeAt(r - 1)] & BLANK)
17454
17454
  r--;
17455
17455
  this.contentRange = [l3, r];
17456
17456
  this.trimLeft = tl || trimLeft2;
17457
- this.trimRight = tr || trimRight2;
17457
+ this.trimRight = tr2 || trimRight2;
17458
17458
  }
17459
17459
  get content() {
17460
17460
  return this.input.slice(this.contentRange[0], this.contentRange[1]);
@@ -77456,6 +77456,11 @@ var currencyValues6 = { currency: (
77456
77456
  Object.keys(nl2)
77457
77457
  ) };
77458
77458
 
77459
+ // packages/engine/src/lib/helpers/office.ts
77460
+ function officeUnsupported(name) {
77461
+ return `${name}() is not supported in office templates`;
77462
+ }
77463
+
77459
77464
  // packages/engine/src/lib/helpers/format.ts
77460
77465
  var dateLocales = {
77461
77466
  de,
@@ -77738,7 +77743,8 @@ var formatHelpers = [
77738
77743
  example: "{{ address | nl2br }}",
77739
77744
  category: "text"
77740
77745
  },
77741
- fn: (_ctx, value) => escapeHtml(str(value)).replace(/\r?\n/g, "<br>\n")
77746
+ // office templates turn line breaks into breaks themselves, and escape the text once
77747
+ fn: (ctx, value) => ctx.mode === "office" ? str(value) : escapeHtml(str(value)).replace(/\r?\n/g, "<br>\n")
77742
77748
  },
77743
77749
  {
77744
77750
  name: "markdown",
@@ -77749,11 +77755,14 @@ var formatHelpers = [
77749
77755
  example: "{{ notes | markdown }}",
77750
77756
  category: "text"
77751
77757
  },
77752
- fn: (_ctx, value) => f.parse(str(value), {
77753
- async: false,
77754
- gfm: true,
77755
- breaks: false
77756
- })
77758
+ fn: (ctx, value) => {
77759
+ if (ctx.mode === "office") throw new Error(officeUnsupported("markdown"));
77760
+ return f.parse(str(value), {
77761
+ async: false,
77762
+ gfm: true,
77763
+ breaks: false
77764
+ });
77765
+ }
77757
77766
  },
77758
77767
  {
77759
77768
  name: "safe",
@@ -77831,7 +77840,7 @@ var formatHelpers = [
77831
77840
  example: "{{ pageBreak() }}",
77832
77841
  category: "document"
77833
77842
  },
77834
- fn: () => '<div class="page-break" style="break-after:page"></div>'
77843
+ fn: (ctx) => ctx.drawing ? ctx.drawing({ kind: "page-break" }) : '<div class="page-break" style="break-after:page"></div>'
77835
77844
  }
77836
77845
  ];
77837
77846
 
@@ -77888,7 +77897,12 @@ var codeHelpers = [
77888
77897
  example: '<img src="{{ qrcode(order.url, { size: 120 }) }}">',
77889
77898
  category: "code"
77890
77899
  },
77891
- fn: (_ctx, value, options) => svgDataUri(qrSvg(String(value ?? ""), options ?? {}))
77900
+ fn: (ctx, value, options) => {
77901
+ const o = options ?? {};
77902
+ const svg = qrSvg(String(value ?? ""), o);
77903
+ if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "QR code" });
77904
+ return svgDataUri(svg);
77905
+ }
77892
77906
  },
77893
77907
  {
77894
77908
  name: "barcode",
@@ -77898,7 +77912,7 @@ var codeHelpers = [
77898
77912
  example: `<img src="{{ barcode(item.sku, { type: 'code128' }) }}">`,
77899
77913
  category: "code"
77900
77914
  },
77901
- fn: (_ctx, value, options) => {
77915
+ fn: (ctx, value, options) => {
77902
77916
  const o = options ?? {};
77903
77917
  const bcid = barcodeTypes[String(o.type ?? "code128").toLowerCase()] ?? "code128";
77904
77918
  const svg = ToSVG({
@@ -77910,6 +77924,7 @@ var codeHelpers = [
77910
77924
  includetext: o.text !== false,
77911
77925
  textxalign: "center"
77912
77926
  });
77927
+ if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, alt: `Barcode ${String(value ?? "")}` });
77913
77928
  return svgDataUri(svg);
77914
77929
  }
77915
77930
  },
@@ -77926,7 +77941,7 @@ var codeHelpers = [
77926
77941
  // hash (`{{epcQr name=… iban=… size=200}}`), and Liquid, which cannot build an object literal,
77927
77942
  // keyword arguments (`{{ company.iban | epcQr: name: company.name, amount: invoice.total }}`,
77928
77943
  // where a string input is the IBAN). They are merged and split by field name.
77929
- fn: (_ctx, input, options) => {
77944
+ fn: (ctx, input, options) => {
77930
77945
  const fields = {
77931
77946
  ...input && typeof input === "object" ? input : typeof input === "string" && input ? { iban: input } : {},
77932
77947
  ...options ?? {}
@@ -77935,12 +77950,10 @@ var codeHelpers = [
77935
77950
  const qr = {};
77936
77951
  for (const [key, value] of Object.entries(fields))
77937
77952
  (EPC_FIELDS.has(key) ? payment : qr)[key] = value;
77938
- return svgDataUri(
77939
- qrSvg(epcPayload(payment), {
77940
- ecc: "M",
77941
- ...qr
77942
- })
77943
- );
77953
+ const o = { ecc: "M", ...qr };
77954
+ const svg = qrSvg(epcPayload(payment), o);
77955
+ if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "GiroCode" });
77956
+ return svgDataUri(svg);
77944
77957
  }
77945
77958
  }
77946
77959
  ];
@@ -78130,6 +78143,29 @@ var collectionHelpers = [
78130
78143
  ];
78131
78144
 
78132
78145
  // packages/engine/src/lib/helpers/document.ts
78146
+ var CHART_PALETTE = ["#3E63DD", "#12A594", "#E5484D", "#F76B15", "#8E4EC6", "#FFC53D", "#0090FF", "#978365"];
78147
+ var SLICED = /* @__PURE__ */ new Set(["pie", "doughnut", "polarArea"]);
78148
+ var colour = (value) => typeof value === "string" && value ? value : Array.isArray(value) && value.length ? value.map(String) : void 0;
78149
+ function chartDataFromPlain(spec) {
78150
+ const type = spec.type ?? "bar";
78151
+ const series = Array.isArray(spec.series) ? spec.series.map((s) => asObject(s)) : [{ label: spec.label, values: spec.values, color: spec.color }];
78152
+ const labels = Array.isArray(spec.labels) ? spec.labels : [];
78153
+ const datasets = series.map((s, index) => {
78154
+ const data = (Array.isArray(s.values) ? s.values : []).map((v2) => {
78155
+ const n = toNumber(v2);
78156
+ return Number.isFinite(n) ? n : null;
78157
+ });
78158
+ const own = colour(s.color);
78159
+ const fill = SLICED.has(type) ? own ?? labels.map((_2, i) => CHART_PALETTE[i % CHART_PALETTE.length]) : own ?? CHART_PALETTE[index % CHART_PALETTE.length];
78160
+ const dataset = { data, backgroundColor: fill, borderColor: fill };
78161
+ if (s.label !== void 0 && s.label !== null && s.label !== "") dataset["label"] = String(s.label);
78162
+ if (type === "line") dataset["fill"] = false;
78163
+ return dataset;
78164
+ });
78165
+ return { labels, datasets };
78166
+ }
78167
+ var isPlain = (spec) => spec.data === void 0 && (Array.isArray(spec.values) || Array.isArray(spec.series));
78168
+ var asObject = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
78133
78169
  var css = (value) => {
78134
78170
  if (value === void 0 || value === null || value === "") return void 0;
78135
78171
  const n = toNumber(value);
@@ -78139,7 +78175,13 @@ function chartPayload(spec) {
78139
78175
  const options = { ...spec.options ?? {} };
78140
78176
  options["animation"] = false;
78141
78177
  options["responsive"] = false;
78142
- return { type: spec.type ?? "bar", data: spec.data ?? {}, options };
78178
+ if (!isPlain(spec)) return { type: spec.type ?? "bar", data: spec.data ?? {}, options };
78179
+ const data = chartDataFromPlain(spec);
78180
+ const unnamed = data.datasets.length === 1 && data.datasets[0]?.["label"] === void 0;
78181
+ const plugins = asObject(options["plugins"]);
78182
+ if (unnamed && !SLICED.has(spec.type ?? "bar") && plugins["legend"] === void 0)
78183
+ options["plugins"] = { ...plugins, legend: { display: false } };
78184
+ return { type: spec.type ?? "bar", data, options };
78143
78185
  }
78144
78186
  function chartMarkup(spec, id) {
78145
78187
  const width = toNumber(spec.width ?? 480) || 480;
@@ -78158,18 +78200,24 @@ function imageMarkup(url, options = {}) {
78158
78200
  const style = styles ? ` style="${styles}"` : "";
78159
78201
  return `<img src="${escapeHtml(url)}" alt="${escapeHtml(options.alt ?? "")}"${cls}${style}>`;
78160
78202
  }
78161
- var asObject = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
78203
+ function resolveAsset(base, url) {
78204
+ if (!base || /^[a-z][a-z0-9+.-]*:/i.test(url)) return url;
78205
+ return `${base.replace(/\/$/, "")}/${url.replace(/^\//, "")}`;
78206
+ }
78162
78207
  var documentHelpers = [
78163
78208
  {
78164
78209
  name: "chart",
78165
78210
  html: true,
78166
78211
  doc: {
78167
- signature: "chart({ type, data, options, width, height })",
78168
- description: "Draws a Chart.js chart (bar, line, pie, doughnut, radar, \u2026) from a chart configuration; animations are disabled so PDF and preview match.",
78169
- example: "{{ chart({ type: 'bar', data: sales, width: 480, height: 240 }) }}",
78212
+ signature: "chart({ type, labels, values, label, color } | { type, labels, series } | { type, data, options }, { width, height })",
78213
+ description: "Draws a Chart.js chart (bar, line, pie, doughnut, radar, \u2026) from plain data, `labels` with `values` or several `series` of `{ label, values, color }`, or from a full Chart.js `data` and `options`; animations are disabled so PDF and preview match.",
78214
+ example: "{{ chart({ type: 'line', labels: ['Q1', 'Q2', 'Q3'], values: sales.quarters, width: 480 }) }}",
78170
78215
  category: "document"
78171
78216
  },
78172
- fn: (_ctx, spec, extra) => chartMarkup({ ...asObject(spec), ...asObject(extra) })
78217
+ fn: (ctx, spec, extra) => {
78218
+ if (ctx.mode === "office") throw new Error(officeUnsupported("chart"));
78219
+ return chartMarkup({ ...asObject(spec), ...asObject(extra) });
78220
+ }
78173
78221
  },
78174
78222
  {
78175
78223
  name: "image",
@@ -78180,7 +78228,12 @@ var documentHelpers = [
78180
78228
  example: "{{ image(product.photo, { width: 120, height: 80, fit: 'cover' }) }}",
78181
78229
  category: "document"
78182
78230
  },
78183
- fn: (_ctx, url, options) => imageMarkup(String(url ?? ""), asObject(options))
78231
+ fn: (ctx, url, options) => {
78232
+ const o = asObject(options);
78233
+ if (ctx.drawing)
78234
+ return ctx.drawing({ kind: "url", url: resolveAsset(ctx.assetBaseUrl, String(url ?? "")), width: o.width, height: o.height, alt: o.alt });
78235
+ return imageMarkup(String(url ?? ""), o);
78236
+ }
78184
78237
  }
78185
78238
  ];
78186
78239
 
@@ -78528,6 +78581,8 @@ var builtinHelpers2 = /* @__PURE__ */ new Set([
78528
78581
  "helperMissing"
78529
78582
  ]);
78530
78583
  var builtinRoots = /* @__PURE__ */ new Set([
78584
+ // the organisation's brand kit (spec 18), merged under the data
78585
+ "brand",
78531
78586
  "this",
78532
78587
  "@index",
78533
78588
  "@key",
@@ -78536,6 +78591,11 @@ var builtinRoots = /* @__PURE__ */ new Set([
78536
78591
  "@root",
78537
78592
  "lookup"
78538
78593
  ]);
78594
+ function withBrand(data, ctx) {
78595
+ const root = data ?? {};
78596
+ if (!ctx.brand || typeof root !== "object" || Array.isArray(root)) return root;
78597
+ return { brand: ctx.brand, ...root };
78598
+ }
78539
78599
  function createInstance(ctx) {
78540
78600
  const hb = import_handlebars.default.create();
78541
78601
  for (const [name, { fn, html }] of ctx.helpers.bind(ctx)) {
@@ -78806,7 +78866,8 @@ var HandlebarsCompiled = class {
78806
78866
  hb,
78807
78867
  template: hb.compile(this.source, {
78808
78868
  strict: false,
78809
- noEscape: false,
78869
+ // office text is escaped when its markup is restored (office/template-text.ts)
78870
+ noEscape: ctx.mode === "office",
78810
78871
  preventIndent: true
78811
78872
  })
78812
78873
  };
@@ -78835,7 +78896,7 @@ var HandlebarsCompiled = class {
78835
78896
  };
78836
78897
  collect(this.source, 0);
78837
78898
  try {
78838
- const out = template(data ?? {}, {
78899
+ const out = template(withBrand(data, ctx), {
78839
78900
  partials,
78840
78901
  allowProtoPropertiesByDefault: false,
78841
78902
  allowProtoMethodsByDefault: false
@@ -79060,6 +79121,7 @@ var nunjucksBuiltinFilters = /* @__PURE__ */ new Set([
79060
79121
  "urlize",
79061
79122
  "wordcount"
79062
79123
  ]);
79124
+ var jinjaLiterals = /* @__PURE__ */ new Set(["True", "False", "None"]);
79063
79125
  var builtinRoots2 = /* @__PURE__ */ new Set([
79064
79126
  "loop",
79065
79127
  "caller",
@@ -79071,6 +79133,8 @@ var builtinRoots2 = /* @__PURE__ */ new Set([
79071
79133
  "varargs",
79072
79134
  "kwargs",
79073
79135
  "namespace",
79136
+ // the organisation's brand kit (spec 18), a global beside the data
79137
+ "brand",
79074
79138
  // Jinja2's spelling of the literals, which the compat layer resolves at runtime
79075
79139
  "True",
79076
79140
  "False",
@@ -79271,7 +79335,8 @@ function createEnvironment(ctx) {
79271
79335
  const env = new import_nunjucks.default.Environment(
79272
79336
  new PartialLoader(ctx.partials),
79273
79337
  {
79274
- autoescape: true,
79338
+ // office text is escaped when its markup is restored, so escaping here would double it
79339
+ autoescape: ctx.mode !== "office",
79275
79340
  throwOnUndefined: false,
79276
79341
  trimBlocks: false,
79277
79342
  lstripBlocks: false
@@ -79286,6 +79351,7 @@ function createEnvironment(ctx) {
79286
79351
  env.addGlobal(name, wrapped);
79287
79352
  }
79288
79353
  env.addGlobal("namespace", namespace);
79354
+ if (ctx.brand) env.addGlobal("brand", ctx.brand);
79289
79355
  installJinjaFilters(env);
79290
79356
  return env;
79291
79357
  }
@@ -79340,6 +79406,7 @@ function walk2(node, w) {
79340
79406
  case "Symbol":
79341
79407
  case "LookupVal": {
79342
79408
  const p = pathOf(node);
79409
+ if (node.typename === "Symbol" && jinjaLiterals.has(String(node.value))) return;
79343
79410
  if (p) {
79344
79411
  const root = p.path[0] ?? "";
79345
79412
  const bound = inScope(w, root);
@@ -79706,6 +79773,8 @@ var liquidBuiltinFilters = /* @__PURE__ */ new Set([
79706
79773
  "time_zone"
79707
79774
  ]);
79708
79775
  var builtinRoots3 = /* @__PURE__ */ new Set([
79776
+ // the organisation's brand kit (spec 18), a global beside the data
79777
+ "brand",
79709
79778
  "forloop",
79710
79779
  "tablerowloop",
79711
79780
  "true",
@@ -79771,6 +79840,8 @@ function createLiquid(ctx) {
79771
79840
  extname: "",
79772
79841
  root: [""],
79773
79842
  fs: partialFs(ctx.partials),
79843
+ // scope data shadows globals, so `data.brand` still wins
79844
+ globals: ctx.brand ? { brand: ctx.brand } : {},
79774
79845
  renderLimit: ctx.limits.ms,
79775
79846
  memoryLimit: ctx.limits.outputBytes * 4,
79776
79847
  parseLimit: 2e6
@@ -80041,6 +80112,26 @@ function getEngine(id) {
80041
80112
  return engine;
80042
80113
  }
80043
80114
 
80115
+ // packages/engine/src/lib/schema-diff.ts
80116
+ var LOWER_BOUNDS = ["minimum", "exclusiveMinimum", "minLength", "minItems", "minProperties"];
80117
+ var UPPER_BOUNDS = ["maximum", "exclusiveMaximum", "maxLength", "maxItems", "maxProperties"];
80118
+ var ANALYSED = /* @__PURE__ */ new Set([
80119
+ "type",
80120
+ "properties",
80121
+ "required",
80122
+ "additionalProperties",
80123
+ "items",
80124
+ "enum",
80125
+ "const",
80126
+ "format",
80127
+ "pattern",
80128
+ "$ref",
80129
+ "$defs",
80130
+ "definitions",
80131
+ ...LOWER_BOUNDS,
80132
+ ...UPPER_BOUNDS
80133
+ ]);
80134
+
80044
80135
  // packages/engine/src/lib/schema.ts
80045
80136
  var isoDateTime = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
80046
80137
  var isoDate = /^\d{4}-\d{2}-\d{2}$/;
@@ -80131,9 +80222,12 @@ function inferSchema(sample) {
80131
80222
  };
80132
80223
  }
80133
80224
 
80225
+ // packages/engine/src/lib/output.ts
80226
+ var outputFormats = ["pdf", "png", "jpg", "webp", "docx", "pptx"];
80227
+
80134
80228
  // packages/engine/src/lib/snippets.ts
80135
80229
  var loop = (engine, item, list2, body) => engine === "handlebars" ? `{{#each ${list2}}}
80136
- ${body.replace(/\{\{ item\./g, "{{ ")}
80230
+ ${body.replace(new RegExp(`(?<![\\w.])${item}\\.`, "g"), "")}
80137
80231
  {{/each}}` : `{% for ${item} in ${list2} %}
80138
80232
  ${body}
80139
80233
  {% endfor %}`;
@@ -80143,6 +80237,15 @@ var val = (engine, expr, filter) => {
80143
80237
  return `{{${filter.split("(")[0]} ${expr}${filter.includes("(") ? " " + filter.slice(filter.indexOf("(") + 1, -1) : ""}}}`;
80144
80238
  return engine === "liquid" ? `{{ ${expr} | ${filter.replace("(", ": ").replace(")", "")} }}` : `{{ ${expr} | ${filter} }}`;
80145
80239
  };
80240
+ var ifElse = (engine, cond, then, otherwise) => engine === "handlebars" ? `{{#if ${cond}}}${then}{{else}}${otherwise}{{/if}}` : `{% if ${cond} %}${then}{% else %}${otherwise}{% endif %}`;
80241
+ var tr = (engine, key, params = {}) => {
80242
+ const entries = Object.entries(params);
80243
+ if (engine === "handlebars")
80244
+ return `{{t '${key}'${entries.map(([k2, v2]) => ` ${k2}=${v2}`).join("")}}}`;
80245
+ if (engine === "liquid")
80246
+ return `{{ '${key}' | t${entries.length ? ": " + entries.map(([k2, v2]) => `${k2}: ${v2}`).join(", ") : ""} }}`;
80247
+ return `{{ t('${key}'${entries.length ? `, { ${entries.map(([k2, v2]) => `${k2}: ${v2}`).join(", ")} }` : ""}) }}`;
80248
+ };
80146
80249
  function forAll(build) {
80147
80250
  return {
80148
80251
  jinja2: build("jinja2"),
@@ -80150,14 +80253,101 @@ function forAll(build) {
80150
80253
  handlebars: build("handlebars")
80151
80254
  };
80152
80255
  }
80256
+ var rule = "var(--brand-color-primary, #222)";
80153
80257
  var snippets = [
80258
+ {
80259
+ id: "letterhead",
80260
+ group: "letter",
80261
+ title: "Letterhead",
80262
+ description: "Logo from the brand kit, or the organisation name when no logo is set.",
80263
+ source: forAll(
80264
+ (e) => `<header class="ff-letterhead">
80265
+ ${ifElse(e, "brand.logo.primary", `<img class="logo" src="${val(e, "brand.logo.primary")}" alt="${val(e, "brand.name")}">`, `<strong class="name">${val(e, "brand.name")}</strong>`)}
80266
+ </header>$0`
80267
+ ),
80268
+ css: `.ff-letterhead { display: flex; justify-content: flex-end; align-items: center; min-height: 48px; margin-bottom: 24px; padding-bottom: 8px; border-bottom: 2px solid ${rule}; }
80269
+ .ff-letterhead .logo { max-height: 56px; max-width: 200px; }
80270
+ .ff-letterhead .name { font-family: var(--brand-font-heading, inherit); font-size: 18pt; color: var(--brand-color-primary, inherit); }`
80271
+ },
80272
+ {
80273
+ id: "window-address",
80274
+ group: "letter",
80275
+ kinds: ["pdf"],
80276
+ title: "Window envelope address",
80277
+ description: "Return address and recipient in the 85 \xD7 45 mm field of a DIN 5008 window envelope.",
80278
+ source: forAll(
80279
+ (e) => `<div class="ff-window">
80280
+ <div class="sender">${val(e, "company.name")} \xB7 ${val(e, "company.street")} \xB7 ${val(e, "company.zip")} ${val(e, "company.city")}</div>
80281
+ <address>
80282
+ <strong>${val(e, "customer.name")}</strong><br>
80283
+ ${val(e, "customer.street")}<br>
80284
+ ${val(e, "customer.zip")} ${val(e, "customer.city")}
80285
+ </address>
80286
+ </div>$0`
80287
+ ),
80288
+ sampleData: {
80289
+ company: { name: "Fennlor Studio GmbH", street: "Musterstra\xDFe 1", zip: "12345", city: "Musterstadt" },
80290
+ customer: { name: "Olvarest GmbH", street: "Beispielweg 2", zip: "54321", city: "Beispielstadt" }
80291
+ },
80292
+ // Form B puts the field 45 mm below the top edge and 20 mm from the left; the page margins decide where it lands
80293
+ css: `.ff-window { width: 85mm; height: 45mm; box-sizing: border-box; padding: 0 5mm; overflow: hidden; }
80294
+ .ff-window .sender { height: 5mm; line-height: 5mm; margin-bottom: 3mm; font-size: 7pt; color: #555; border-bottom: 0.5pt solid #999; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
80295
+ .ff-window address { font-style: normal; font-size: 10pt; line-height: 1.35; }`
80296
+ },
80297
+ {
80298
+ id: "reference-block",
80299
+ group: "letter",
80300
+ title: "Reference block",
80301
+ description: "Customer number, invoice number and date as a right-aligned table; put it in a two-column layout to sit beside the address.",
80302
+ source: forAll(
80303
+ (e) => `<table class="ff-reference">
80304
+ <tr><th>${tr(e, "reference.customer")}</th><td>${val(e, "customer.number")}</td></tr>
80305
+ <tr><th>${tr(e, "reference.invoice")}</th><td>${val(e, "invoice.number")}</td></tr>
80306
+ <tr><th>${tr(e, "reference.date")}</th><td>${val(e, "invoice.date", "date('P')")}</td></tr>
80307
+ </table>$0`
80308
+ ),
80309
+ sampleData: {
80310
+ customer: { number: "K-1042" },
80311
+ invoice: { number: "2026-0042", date: "2026-09-13" }
80312
+ },
80313
+ css: `.ff-reference { margin-left: auto; border-collapse: collapse; font-size: 9pt; }
80314
+ .ff-reference th { text-align: left; font-weight: normal; color: #555; padding: 1px 12px 1px 0; }
80315
+ .ff-reference td { text-align: right; padding: 1px 0; }`,
80316
+ i18n: {
80317
+ en: { "reference.customer": "Customer no.", "reference.invoice": "Invoice no.", "reference.date": "Date" },
80318
+ de: { "reference.customer": "Kundennummer", "reference.invoice": "Rechnungsnummer", "reference.date": "Datum" }
80319
+ }
80320
+ },
80321
+ {
80322
+ id: "invoice-heading",
80323
+ group: "invoice",
80324
+ title: "Invoice heading",
80325
+ description: "Invoice number as the title and the service period below it.",
80326
+ source: forAll(
80327
+ (e) => `<div class="ff-heading">
80328
+ <h1>${tr(e, "heading.title", { number: "invoice.number" })}</h1>
80329
+ <p>${tr(e, "heading.period")} ${val(e, "invoice.period_start", "date('P')")} \u2013 ${val(e, "invoice.period_end", "date('P')")}</p>
80330
+ </div>$0`
80331
+ ),
80332
+ sampleData: {
80333
+ invoice: { number: "2026-0042", period_start: "2026-09-01", period_end: "2026-09-30" }
80334
+ },
80335
+ css: `.ff-heading { margin: 24px 0 16px; }
80336
+ .ff-heading h1 { font-family: var(--brand-font-heading, inherit); font-size: 18pt; color: var(--brand-color-primary, inherit); margin: 0 0 4px; }
80337
+ .ff-heading p { margin: 0; color: #555; }`,
80338
+ i18n: {
80339
+ en: { "heading.title": "Invoice {number}", "heading.period": "Service period:" },
80340
+ de: { "heading.title": "Rechnung {number}", "heading.period": "Leistungszeitraum:" }
80341
+ }
80342
+ },
80154
80343
  {
80155
80344
  id: "invoice-table",
80345
+ group: "invoice",
80156
80346
  title: "Invoice table",
80157
80347
  description: "Line items with description, quantity, unit price and line total.",
80158
80348
  source: forAll(
80159
80349
  (e) => `<table class="ff-lines">
80160
- <thead><tr><th>Description</th><th class="num">Qty</th><th class="num">Unit price</th><th class="num">Total</th></tr></thead>
80350
+ <thead><tr><th>${tr(e, "lines.description")}</th><th class="num">${tr(e, "lines.qty")}</th><th class="num">${tr(e, "lines.price")}</th><th class="num">${tr(e, "lines.total")}</th></tr></thead>
80161
80351
  <tbody>
80162
80352
  ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</td><td class="num">${val(e, "line.qty", "number(0)")}</td><td class="num">${val(e, "line.price", "money")}</td><td class="num">${val(e, "line.total", "money")}</td></tr>`)}
80163
80353
  </tbody>
@@ -80174,17 +80364,32 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80174
80364
  css: `.ff-lines { width: 100%; border-collapse: collapse; font-size: 10pt; }
80175
80365
  .ff-lines th, .ff-lines td { padding: 6px 8px; border-bottom: 1px solid #ddd; text-align: left; }
80176
80366
  .ff-lines .num { text-align: right; white-space: nowrap; }
80177
- .ff-lines thead th { border-bottom: 2px solid #222; }`
80367
+ .ff-lines thead th { border-bottom: 2px solid ${rule}; }`,
80368
+ i18n: {
80369
+ en: {
80370
+ "lines.description": "Description",
80371
+ "lines.qty": "Qty",
80372
+ "lines.price": "Unit price",
80373
+ "lines.total": "Total"
80374
+ },
80375
+ de: {
80376
+ "lines.description": "Beschreibung",
80377
+ "lines.qty": "Menge",
80378
+ "lines.price": "Einzelpreis",
80379
+ "lines.total": "Gesamt"
80380
+ }
80381
+ }
80178
80382
  },
80179
80383
  {
80180
80384
  id: "totals",
80385
+ group: "invoice",
80181
80386
  title: "Totals block",
80182
80387
  description: "Net, VAT and gross totals right-aligned under a table.",
80183
80388
  source: forAll(
80184
80389
  (e) => `<table class="ff-totals">
80185
- <tr><td>Net</td><td class="num">${val(e, "invoice.net", "money")}</td></tr>
80186
- <tr><td>VAT ${val(e, "invoice.vat_rate")} %</td><td class="num">${val(e, "invoice.vat", "money")}</td></tr>
80187
- <tr class="grand"><td>Total</td><td class="num">${val(e, "invoice.total", "money")}</td></tr>
80390
+ <tr><td>${tr(e, "totals.net")}</td><td class="num">${val(e, "invoice.net", "money")}</td></tr>
80391
+ <tr><td>${tr(e, "totals.vat", { rate: "invoice.vat_rate" })}</td><td class="num">${val(e, "invoice.vat", "money")}</td></tr>
80392
+ <tr class="grand"><td>${tr(e, "totals.total")}</td><td class="num">${val(e, "invoice.total", "money")}</td></tr>
80188
80393
  </table>$0`
80189
80394
  ),
80190
80395
  sampleData: {
@@ -80193,10 +80398,103 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80193
80398
  css: `.ff-totals { margin-left: auto; margin-top: 12px; border-collapse: collapse; font-size: 10pt; }
80194
80399
  .ff-totals td { padding: 4px 8px; }
80195
80400
  .ff-totals .num { text-align: right; min-width: 90px; }
80196
- .ff-totals .grand td { border-top: 2px solid #222; font-weight: 600; }`
80401
+ .ff-totals .grand td { border-top: 2px solid ${rule}; font-weight: 600; }`,
80402
+ i18n: {
80403
+ en: {
80404
+ "totals.net": "Net",
80405
+ "totals.vat": "VAT {rate} %",
80406
+ "totals.total": "Total"
80407
+ },
80408
+ de: {
80409
+ "totals.net": "Netto",
80410
+ "totals.vat": "USt. {rate} %",
80411
+ "totals.total": "Gesamtbetrag"
80412
+ }
80413
+ }
80414
+ },
80415
+ {
80416
+ id: "payment-terms",
80417
+ group: "invoice",
80418
+ title: "Payment terms and bank details",
80419
+ description: "Amount, due date and the account to transfer it to.",
80420
+ source: forAll(
80421
+ (e) => `<div class="ff-payment">
80422
+ <p>${tr(e, "payment.transfer")} <strong>${val(e, "payment.amount", "money")}</strong> ${tr(e, "payment.until")} <strong>${val(e, "invoice.due_date", "date('P')")}</strong> ${tr(e, "payment.account")}</p>
80423
+ <table>
80424
+ <tr><th>${tr(e, "payment.holder")}</th><td>${val(e, "payment.name")}</td></tr>
80425
+ <tr><th>IBAN</th><td>${val(e, "payment.iban")}</td></tr>
80426
+ <tr><th>BIC</th><td>${val(e, "payment.bic")}</td></tr>
80427
+ <tr><th>${tr(e, "payment.reference")}</th><td>${val(e, "payment.reference")}</td></tr>
80428
+ </table>
80429
+ </div>$0`
80430
+ ),
80431
+ sampleData: {
80432
+ invoice: { due_date: "2026-10-13" },
80433
+ payment: {
80434
+ name: "Fennlor Studio GmbH",
80435
+ iban: "DE00000000000000000000",
80436
+ bic: "XXXXDEXXXXX",
80437
+ amount: 3760.4,
80438
+ reference: "2026-0042"
80439
+ }
80440
+ },
80441
+ css: `.ff-payment { margin-top: 16px; font-size: 9.5pt; break-inside: avoid; }
80442
+ .ff-payment p { margin: 0 0 6px; }
80443
+ .ff-payment table { border-collapse: collapse; }
80444
+ .ff-payment th { text-align: left; font-weight: normal; color: #555; padding: 1px 16px 1px 0; }`,
80445
+ i18n: {
80446
+ en: {
80447
+ "payment.transfer": "Please transfer",
80448
+ "payment.until": "by",
80449
+ "payment.account": "to the following account:",
80450
+ "payment.holder": "Account holder",
80451
+ "payment.reference": "Reference"
80452
+ },
80453
+ de: {
80454
+ "payment.transfer": "Bitte \xFCberweisen Sie",
80455
+ "payment.until": "bis zum",
80456
+ "payment.account": "auf folgendes Konto:",
80457
+ "payment.holder": "Kontoinhaber",
80458
+ "payment.reference": "Verwendungszweck"
80459
+ }
80460
+ }
80461
+ },
80462
+ {
80463
+ id: "small-business-note",
80464
+ group: "invoice",
80465
+ title: "Small business VAT note",
80466
+ description: "States that no VAT is charged under \xA7 19 UStG (Kleinunternehmerregelung).",
80467
+ source: forAll((e) => `<p class="ff-note">${tr(e, "vat.small_business")}</p>$0`),
80468
+ css: `.ff-note { margin-top: 12px; font-size: 9pt; color: #444; }`,
80469
+ i18n: {
80470
+ en: { "vat.small_business": "No VAT is charged under the small business scheme of \xA7 19 UStG." },
80471
+ de: { "vat.small_business": "Gem\xE4\xDF \xA7 19 UStG wird keine Umsatzsteuer berechnet." }
80472
+ }
80473
+ },
80474
+ {
80475
+ id: "reverse-charge-note",
80476
+ group: "invoice",
80477
+ title: "Reverse charge note",
80478
+ description: "EU business customers: the recipient owes the VAT, with their VAT ID.",
80479
+ source: forAll(
80480
+ (e) => `<p class="ff-note">${tr(e, "vat.reverse_charge")} ${tr(e, "vat.customer_id")} ${val(e, "customer.vat_id")}</p>$0`
80481
+ ),
80482
+ sampleData: { customer: { vat_id: "ATU00000000" } },
80483
+ css: `.ff-note { margin-top: 12px; font-size: 9pt; color: #444; }`,
80484
+ i18n: {
80485
+ en: {
80486
+ "vat.reverse_charge": "Reverse charge: the recipient of the service is liable for VAT.",
80487
+ "vat.customer_id": "Customer VAT ID:"
80488
+ },
80489
+ de: {
80490
+ "vat.reverse_charge": "Steuerschuldnerschaft des Leistungsempf\xE4ngers.",
80491
+ "vat.customer_id": "USt-IdNr. des Leistungsempf\xE4ngers:"
80492
+ }
80493
+ }
80197
80494
  },
80198
80495
  {
80199
80496
  id: "address-block",
80497
+ group: "letter",
80200
80498
  title: "Address block",
80201
80499
  description: "Recipient name and postal address lines.",
80202
80500
  source: forAll(
@@ -80209,10 +80507,10 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80209
80507
  ),
80210
80508
  sampleData: {
80211
80509
  customer: {
80212
- name: "Muster GmbH",
80213
- street: "Musterstra\xDFe 1",
80214
- zip: "10115",
80215
- city: "Berlin",
80510
+ name: "Olvarest GmbH",
80511
+ street: "Beispielweg 2",
80512
+ zip: "54321",
80513
+ city: "Beispielstadt",
80216
80514
  country: "Deutschland"
80217
80515
  }
80218
80516
  },
@@ -80220,18 +80518,38 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80220
80518
  },
80221
80519
  {
80222
80520
  id: "page-footer",
80521
+ group: "page",
80522
+ kinds: ["pdf"],
80223
80523
  title: "Footer with page numbers",
80224
- description: "Paste into the Footer tab: company line and \u201CPage x of y\u201D.",
80524
+ description: "Company line and \u201CPage x of y\u201D in the page footer.",
80525
+ target: "footer",
80225
80526
  source: forAll(
80226
80527
  (e) => `<div style="width:100%;display:flex;justify-content:space-between;font-size:9px;color:#666">
80227
80528
  <span>${val(e, "company.name")} \xB7 ${val(e, "company.email")}</span>
80228
- <span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
80529
+ <span>${tr(e, "footer.page")} <span class="pageNumber"></span> ${tr(e, "footer.of")} <span class="totalPages"></span></span>
80229
80530
  </div>$0`
80230
80531
  ),
80231
- sampleData: { company: { name: "Acme GmbH", email: "hello@acme.example" } }
80532
+ sampleData: { company: { name: "Fennlor Studio GmbH", email: "hello@fennlor.example" } },
80533
+ i18n: {
80534
+ en: { "footer.page": "Page", "footer.of": "of" },
80535
+ de: { "footer.page": "Seite", "footer.of": "von" }
80536
+ }
80537
+ },
80538
+ {
80539
+ id: "legal-footer",
80540
+ group: "page",
80541
+ kinds: ["pdf"],
80542
+ title: "Legal footer",
80543
+ description: "The legal footer from the brand kit, centred in the page footer.",
80544
+ target: "footer",
80545
+ source: forAll(
80546
+ (e) => `<div style="width:100%;text-align:center;font-size:8px;color:#666">${val(e, "brand.legal_footer")}</div>$0`
80547
+ )
80232
80548
  },
80233
80549
  {
80234
80550
  id: "cover-page",
80551
+ group: "report",
80552
+ kinds: ["pdf"],
80235
80553
  title: "Cover page",
80236
80554
  description: "Title, subtitle and date on a page of its own.",
80237
80555
  source: forAll(
@@ -80250,12 +80568,13 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80250
80568
  }
80251
80569
  },
80252
80570
  css: `.ff-cover { min-height: 60vh; display: flex; flex-direction: column; justify-content: center; }
80253
- .ff-cover h1 { font-size: 32pt; margin: 0 0 8px; }
80571
+ .ff-cover h1 { font-family: var(--brand-font-heading, inherit); font-size: 32pt; color: var(--brand-color-primary, inherit); margin: 0 0 8px; }
80254
80572
  .ff-cover .subtitle { font-size: 16pt; color: #444; margin: 0; }
80255
80573
  .ff-cover .date { margin-top: 24px; color: #666; }`
80256
80574
  },
80257
80575
  {
80258
80576
  id: "signature",
80577
+ group: "letter",
80259
80578
  title: "Signature line",
80260
80579
  description: "Place, date and a signature line.",
80261
80580
  source: forAll(
@@ -80268,7 +80587,7 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80268
80587
  signature: {
80269
80588
  place: "Berlin",
80270
80589
  date: "2026-09-07",
80271
- name: "Philipp Staudt"
80590
+ name: "Erika Mustermann"
80272
80591
  }
80273
80592
  },
80274
80593
  css: `.ff-signature { margin-top: 48px; width: 260px; font-size: 9pt; color: #444; }
@@ -80276,33 +80595,32 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80276
80595
  },
80277
80596
  {
80278
80597
  id: "epc-qr",
80598
+ group: "invoice",
80279
80599
  title: "SEPA payment QR code",
80280
80600
  description: "GiroCode that banking apps scan to prefill the transfer.",
80281
80601
  source: {
80282
80602
  jinja2: `<figure class="ff-qr">
80283
- <img src="{{ epcQr({ name: company.name, iban: company.iban, bic: company.bic, amount: invoice.total, reference: invoice.number }, { size: 120 }) }}" alt="Payment QR code">
80284
- <figcaption>Scan to pay</figcaption>
80603
+ <img src="{{ epcQr(payment, { size: 120 }) }}" alt="${tr("jinja2", "qr.alt")}">
80604
+ <figcaption>${tr("jinja2", "qr.caption")}</figcaption>
80285
80605
  </figure>$0`,
80286
80606
  liquid: `<figure class="ff-qr">
80287
- <img src="{{ payment | epcQr }}" alt="Payment QR code">
80288
- <figcaption>Scan to pay</figcaption>
80607
+ <img src="{{ payment | epcQr: size: 120 }}" alt="${tr("liquid", "qr.alt")}">
80608
+ <figcaption>${tr("liquid", "qr.caption")}</figcaption>
80289
80609
  </figure>$0`,
80290
80610
  handlebars: `<figure class="ff-qr">
80291
- <img src="{{epcQr payment size=120}}" alt="Payment QR code">
80292
- <figcaption>Scan to pay</figcaption>
80611
+ <img src="{{epcQr payment size=120}}" alt="${tr("handlebars", "qr.alt")}">
80612
+ <figcaption>${tr("handlebars", "qr.caption")}</figcaption>
80293
80613
  </figure>$0`
80294
80614
  },
80615
+ i18n: {
80616
+ en: { "qr.alt": "Payment QR code", "qr.caption": "Scan to pay" },
80617
+ de: { "qr.alt": "QR-Code zur Zahlung", "qr.caption": "Zum Bezahlen scannen" }
80618
+ },
80295
80619
  sampleData: {
80296
- company: {
80297
- name: "Acme GmbH",
80298
- iban: "DE02120300000000202051",
80299
- bic: "BYLADEM1001"
80300
- },
80301
- invoice: { number: "2026-0042", total: 3760.4 },
80302
80620
  payment: {
80303
- name: "Acme GmbH",
80304
- iban: "DE02120300000000202051",
80305
- bic: "BYLADEM1001",
80621
+ name: "Fennlor Studio GmbH",
80622
+ iban: "DE00000000000000000000",
80623
+ bic: "XXXXDEXXXXX",
80306
80624
  amount: 3760.4,
80307
80625
  reference: "2026-0042"
80308
80626
  }
@@ -80312,38 +80630,29 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80312
80630
  },
80313
80631
  {
80314
80632
  id: "chart",
80633
+ group: "report",
80315
80634
  title: "Chart",
80316
- description: "Bar chart drawn with Chart.js from a chart configuration in the data.",
80635
+ description: "Bar chart from labels and values in the data, in the brand colour.",
80636
+ // the colour comes from the brand kit; without one the helper takes its palette
80317
80637
  source: {
80318
80638
  jinja2: `<figure class="ff-chart-box">
80319
- {{ chart(salesChart) }}
80320
- <figcaption>{{ salesChart.caption }}</figcaption>
80639
+ {{ chart(sales, { type: '\${1:bar}', width: 480, height: 240, color: brand.colors.primary }) }}
80640
+ <figcaption>{{ sales.caption }}</figcaption>
80321
80641
  </figure>$0`,
80322
80642
  liquid: `<figure class="ff-chart-box">
80323
- {{ salesChart | chart }}
80324
- <figcaption>{{ salesChart.caption }}</figcaption>
80643
+ {{ sales | chart: type: '\${1:bar}', width: 480, height: 240, color: brand.colors.primary }}
80644
+ <figcaption>{{ sales.caption }}</figcaption>
80325
80645
  </figure>$0`,
80326
80646
  handlebars: `<figure class="ff-chart-box">
80327
- {{chart salesChart}}
80328
- <figcaption>{{salesChart.caption}}</figcaption>
80647
+ {{chart sales type='\${1:bar}' width=480 height=240 color=brand.colors.primary}}
80648
+ <figcaption>{{sales.caption}}</figcaption>
80329
80649
  </figure>$0`
80330
80650
  },
80331
80651
  sampleData: {
80332
- salesChart: {
80333
- type: "bar",
80652
+ sales: {
80334
80653
  caption: "Revenue per quarter",
80335
- width: 480,
80336
- height: 240,
80337
- data: {
80338
- labels: ["Q1", "Q2", "Q3", "Q4"],
80339
- datasets: [
80340
- {
80341
- label: "Revenue",
80342
- data: [12e3, 15500, 14200, 18900],
80343
- backgroundColor: "#3E63DD"
80344
- }
80345
- ]
80346
- }
80654
+ labels: ["Q1", "Q2", "Q3", "Q4"],
80655
+ values: [12e3, 15500, 14200, 18900]
80347
80656
  }
80348
80657
  },
80349
80658
  css: `.ff-chart-box { margin: 16px 0; break-inside: avoid; }
@@ -80351,6 +80660,7 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80351
80660
  },
80352
80661
  {
80353
80662
  id: "two-columns",
80663
+ group: "page",
80354
80664
  title: "Two-column layout",
80355
80665
  description: "Two equal columns that stay side by side in print.",
80356
80666
  source: forAll(
@@ -80361,6 +80671,50 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
80361
80671
  ),
80362
80672
  css: `.ff-columns { display: flex; gap: 24px; }
80363
80673
  .ff-columns > div { flex: 1 1 0; min-width: 0; }`
80674
+ },
80675
+ {
80676
+ id: "keep-together",
80677
+ group: "page",
80678
+ kinds: ["pdf"],
80679
+ title: "Keep together",
80680
+ description: "Content that is never split across two pages.",
80681
+ source: forAll(() => `<div class="avoid-break">
80682
+ $0
80683
+ </div>`)
80684
+ },
80685
+ {
80686
+ id: "page-break",
80687
+ group: "page",
80688
+ kinds: ["pdf"],
80689
+ title: "Page break",
80690
+ description: "Starts a new page in the PDF.",
80691
+ source: forAll(() => `<div class="page-break"></div>
80692
+ $0`)
80693
+ },
80694
+ {
80695
+ id: "kpi-cards",
80696
+ group: "report",
80697
+ title: "Key figures",
80698
+ description: "A row of cards with label, value and change for each figure.",
80699
+ source: forAll(
80700
+ (e) => `<div class="ff-kpis">
80701
+ ${loop(e, "kpi", "report.kpis", ` <div class="kpi"><span class="label">${val(e, "kpi.label")}</span><strong>${val(e, "kpi.value")}</strong><span class="change">${val(e, "kpi.change")}</span></div>`)}
80702
+ </div>$0`
80703
+ ),
80704
+ sampleData: {
80705
+ report: {
80706
+ kpis: [
80707
+ { label: "Revenue", value: "\u20AC 61,600", change: "+12 % on Q2" },
80708
+ { label: "New customers", value: "48", change: "+9 on Q2" },
80709
+ { label: "Churn", value: "1.8 %", change: "\u22120.4 pt on Q2" }
80710
+ ]
80711
+ }
80712
+ },
80713
+ css: `.ff-kpis { display: flex; gap: 12px; margin: 16px 0; break-inside: avoid; }
80714
+ .ff-kpis .kpi { flex: 1 1 0; padding: 10px 12px; border: 1px solid #e5e5e5; border-top: 3px solid ${rule}; border-radius: 4px; }
80715
+ .ff-kpis .label { display: block; font-size: 8pt; color: #666; text-transform: uppercase; letter-spacing: 0.04em; }
80716
+ .ff-kpis strong { display: block; margin: 2px 0; font-size: 18pt; }
80717
+ .ff-kpis .change { font-size: 8pt; color: #555; }`
80364
80718
  }
80365
80719
  ];
80366
80720
 
@@ -80369,7 +80723,50 @@ var SEGMENT = "[A-Za-z0-9][A-Za-z0-9._-]*";
80369
80723
  var ASSET_NAME_PATTERN = new RegExp(`^${SEGMENT}(?:/${SEGMENT})*$`);
80370
80724
  var ASSET_MAX_BYTES = 10 * 1024 * 1024;
80371
80725
 
80726
+ // packages/engine/src/lib/office/errors.ts
80727
+ var officeLimits = {
80728
+ /** Entries in one container. */
80729
+ maxEntries: 2e3,
80730
+ /** Sum of the uncompressed sizes the central directory declares. */
80731
+ maxDeclaredBytes: 200 * 1024 * 1024,
80732
+ /** One part we inflate; enforced while inflating, because the declared size can lie. */
80733
+ maxPartBytes: 20 * 1024 * 1024,
80734
+ /** A conversion upload or a template file. */
80735
+ maxFileBytes: 20 * 1024 * 1024
80736
+ };
80737
+
80738
+ // packages/engine/src/lib/office/drawings.ts
80739
+ var OPEN = String.fromCharCode(57346);
80740
+ var CLOSE = String.fromCharCode(57347);
80741
+ var officeDrawingLimits = {
80742
+ /** Drawings per render. */
80743
+ maxDrawings: 50,
80744
+ /** Image bytes placed per render. */
80745
+ maxImageBytes: 20 * 1024 * 1024,
80746
+ /** Largest raster edge in pixels. */
80747
+ maxPixels: 2400
80748
+ };
80749
+
80750
+ // packages/engine/src/lib/office/render.ts
80751
+ var OFFICE_FILLED_MAX_BYTES = 50 * 1024 * 1024;
80752
+
80372
80753
  // packages/sdk-ts/src/lib/client.ts
80754
+ var isOfficeInput = (input) => "file" in input;
80755
+ function blobOf(file) {
80756
+ const blob = file.data instanceof Blob ? file.data : new Blob([file.data]);
80757
+ const own = typeof File !== "undefined" && file.data instanceof File ? file.data.name : void 0;
80758
+ return { blob, name: file.name ?? own ?? "document" };
80759
+ }
80760
+ function multipart(fields, file) {
80761
+ const form = new FormData();
80762
+ const { blob, name } = blobOf(file);
80763
+ form.set("file", blob, name);
80764
+ for (const [key, value] of Object.entries(fields)) {
80765
+ if (value === void 0) continue;
80766
+ form.set(key, typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value));
80767
+ }
80768
+ return form;
80769
+ }
80373
80770
  var FormfeedError = class extends Error {
80374
80771
  constructor(code, message2, status, problem = null, requestId = null) {
80375
80772
  super(message2);
@@ -80461,6 +80858,11 @@ var Formfeed = class {
80461
80858
  },
80462
80859
  /** Removes the stored files of a render before they expire. Needs the `file:delete` scope. */
80463
80860
  deleteOutputs: (id, options = {}) => this.request("DELETE", `/renders/${encodeURIComponent(id)}/outputs`, void 0, options),
80861
+ /**
80862
+ * The stored request of a render, to render it again. Needs `render:input`; throws
80863
+ * `render_input_expired` (410) when the workspace keeps no requests or the period ended.
80864
+ */
80865
+ input: (id, options = {}) => this.request("GET", `/renders/${encodeURIComponent(id)}/input`, void 0, options),
80464
80866
  batch: (request, options = {}) => this.request("POST", "/renders/batch", request, {
80465
80867
  ...options,
80466
80868
  idempotencyKey: options.idempotencyKey ?? randomKey()
@@ -80475,7 +80877,18 @@ var Formfeed = class {
80475
80877
  merge: (sources, output = {}, options = {}) => this.request("POST", "/pdf/merge", { ...output, sources: sources.map(idOf) }, withKey(options)),
80476
80878
  protect: (source, protect, options = {}) => this.request("POST", "/pdf/protect", { ...protect, source: idOf(source) }, withKey(options)),
80477
80879
  watermark: (source, watermark, options = {}) => this.request("POST", "/pdf/watermark", { ...watermark, source: idOf(source) }, withKey(options)),
80478
- info: (source, options = {}) => this.request("POST", "/pdf/info", { source: idOf(source) }, options)
80880
+ info: (source, options = {}) => this.request("POST", "/pdf/info", { source: idOf(source) }, options),
80881
+ /**
80882
+ * Converts an office document to a PDF render (Starter plan and above): an uploaded file (Word,
80883
+ * Excel, PowerPoint, OpenDocument, RTF or HTML, up to 20 MB, not kept), or the output of a Word or
80884
+ * PowerPoint template render. Costs follow the PDF rule. A busy converter answers 503, which is
80885
+ * retried like any other.
80886
+ */
80887
+ convert: (source, convert = {}, options = {}) => {
80888
+ if (typeof source === "object" && "file" in source)
80889
+ return this.request("POST", "/pdf/convert", multipart({ ...convert }, source.file), withKey(options));
80890
+ return this.request("POST", "/pdf/convert", { ...convert, source: idOf(source) }, withKey(options));
80891
+ }
80479
80892
  };
80480
80893
  /**
80481
80894
  * The workspace file library (`/files`): images and PDFs a template references by name with
@@ -80514,6 +80927,27 @@ var Formfeed = class {
80514
80927
  /** Removes the file and its bytes; templates that name it render a missing image afterwards. */
80515
80928
  delete: (file, options = {}) => this.request("DELETE", `/files/${encodeURIComponent(idOf(file))}`, void 0, options)
80516
80929
  };
80930
+ /** The organisation's brand kit, read-only through the API (it is edited in the app). Needs `template:read`. */
80931
+ brand = {
80932
+ get: (options = {}) => this.request("GET", "/brand", void 0, options)
80933
+ };
80934
+ /**
80935
+ * Shared partials of the organisation. Renders use the current source, so a change reaches every
80936
+ * template that includes the partial without a new version. Needs `template:read` or `template:write`.
80937
+ */
80938
+ partials = {
80939
+ /** Every shared partial, without its source. */
80940
+ list: async (options = {}) => (await this.request("GET", "/partials", void 0, options)).data,
80941
+ /** One partial with its source. */
80942
+ get: (name, options = {}) => this.request("GET", `/partials/${encodeURIComponent(name)}`, void 0, options),
80943
+ /** Creates or replaces a partial; pass `base_version` to fail with 409 when it changed since you read it. */
80944
+ put: async (name, input, options = {}) => {
80945
+ const { status, body } = await this.send("PUT", `/partials/${encodeURIComponent(name)}`, input, options);
80946
+ return { partial: body, created: status === 201 };
80947
+ },
80948
+ /** Removes a partial; templates that still include it fail to render afterwards. */
80949
+ delete: (name, options = {}) => this.request("DELETE", `/partials/${encodeURIComponent(name)}`, void 0, options)
80950
+ };
80517
80951
  jobs = {
80518
80952
  get: (id, options = {}) => this.request("GET", `/jobs/${encodeURIComponent(id)}`, void 0, options),
80519
80953
  waitFor: async (id, options = {}) => {
@@ -80533,7 +80967,20 @@ var Formfeed = class {
80533
80967
  create: (input, options = {}) => this.request("POST", "/webhooks", input, options),
80534
80968
  update: (id, patch, options = {}) => this.request("PUT", `/webhooks/${encodeURIComponent(id)}`, patch, options),
80535
80969
  delete: (id, options = {}) => this.request("DELETE", `/webhooks/${encodeURIComponent(id)}`, void 0, options),
80536
- test: (id, options = {}) => this.request("POST", `/webhooks/${encodeURIComponent(id)}/test`, void 0, options)
80970
+ test: (id, options = {}) => this.request("POST", `/webhooks/${encodeURIComponent(id)}/test`, void 0, options),
80971
+ /**
80972
+ * Sends a stored event (`evt_…`) again as a new delivery to a registered endpoint (`webhook:manage`)
80973
+ * or a running listen session (`webhook:listen`).
80974
+ */
80975
+ resend: (eventId, endpointId, options = {}) => this.request("POST", `/webhooks/events/${encodeURIComponent(eventId)}/resend`, { endpoint_id: endpointId }, options),
80976
+ /**
80977
+ * Listen sessions, what `formfeed listen` uses. Needs `webhook:listen`. Starting a session ends the
80978
+ * previous one of the same key; the WebSocket itself is up to the caller.
80979
+ */
80980
+ listen: {
80981
+ start: (input = {}, options = {}) => this.request("POST", "/webhooks/listen", input, options),
80982
+ end: (id, options = {}) => this.request("DELETE", `/webhooks/listen/${encodeURIComponent(id)}`, void 0, options)
80983
+ }
80537
80984
  };
80538
80985
  templates = {
80539
80986
  list: (query = {}, options = {}) => {
@@ -80554,15 +81001,79 @@ var Formfeed = class {
80554
81001
  return out;
80555
81002
  },
80556
81003
  get: (idOrSlug, options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}`, void 0, options),
80557
- create: (input, options = {}) => this.request("POST", "/templates", input, options),
81004
+ /** A Word or PowerPoint template (`kind` `docx` or `pptx`) is created from its `file`, sent as multipart. */
81005
+ create: (input, options = {}) => {
81006
+ if (isOfficeInput(input) && input.file) {
81007
+ const { file, ...fields } = input;
81008
+ return this.request("POST", "/templates", multipart(fields, file), options);
81009
+ }
81010
+ return this.request("POST", "/templates", input, options);
81011
+ },
80558
81012
  update: (idOrSlug, patch, options = {}) => this.request("PUT", `/templates/${encodeURIComponent(idOrSlug)}`, patch, options),
80559
81013
  archive: (idOrSlug, options = {}) => this.request("DELETE", `/templates/${encodeURIComponent(idOrSlug)}`, void 0, options),
80560
81014
  versions: {
80561
81015
  list: async (idOrSlug, options = {}) => (await this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/versions`, void 0, options)).data,
80562
- /** `which`: 'published', 'latest' or a version number. Carries the files. */
80563
- get: (idOrSlug, which = "published", options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/versions/${which}`, void 0, options),
80564
- create: (idOrSlug, input, options = {}) => this.request("POST", `/templates/${encodeURIComponent(idOrSlug)}/versions`, input, options),
80565
- publish: (idOrSlug, number, options = {}) => this.request("POST", `/templates/${encodeURIComponent(idOrSlug)}/versions/${number}/publish`, void 0, options)
81016
+ /** `which`: 'published', 'latest', a version number or a channel name (its main version). Carries the files. */
81017
+ get: (idOrSlug, which = "published", options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/versions/${encodeURIComponent(String(which))}`, void 0, options),
81018
+ /** For a Word or PowerPoint template, pass `file` to replace the document; without it the latest file is kept. */
81019
+ create: (idOrSlug, input, options = {}) => {
81020
+ const path = `/templates/${encodeURIComponent(idOrSlug)}/versions`;
81021
+ if (isOfficeInput(input)) {
81022
+ const { file, ...fields } = input;
81023
+ if (file) return this.request("POST", path, multipart(fields, file), options);
81024
+ return this.request("POST", path, fields, options);
81025
+ }
81026
+ return this.request("POST", path, input, options);
81027
+ },
81028
+ /** The Word or PowerPoint file of a version (`which` as for `get`); needs `template:read`. */
81029
+ file: async (idOrSlug, which = "published", options = {}) => {
81030
+ const path = `/templates/${encodeURIComponent(idOrSlug)}/versions/${encodeURIComponent(String(which))}/file`;
81031
+ return (await this.send("GET", path, void 0, options, "bytes")).body;
81032
+ },
81033
+ /**
81034
+ * Publishes a version. When both versions store a data schema and the new one breaks callers of the
81035
+ * published one, the API refuses with `schema_breaking_change` unless `allowBreaking` is set.
81036
+ */
81037
+ publish: (idOrSlug, number, options = {}) => {
81038
+ const { allowBreaking, ...rest } = options;
81039
+ return this.request(
81040
+ "POST",
81041
+ `/templates/${encodeURIComponent(idOrSlug)}/versions/${number}/publish`,
81042
+ allowBreaking ? { allow_breaking: true } : void 0,
81043
+ rest
81044
+ );
81045
+ }
81046
+ },
81047
+ /**
81048
+ * Release channels (`published` and named ones such as `staging`): a render with `version: 'staging'`
81049
+ * uses the version the channel points at. Moves reach renders within a minute.
81050
+ */
81051
+ channels: {
81052
+ list: (idOrSlug, options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/channels`, void 0, options),
81053
+ /** Creates or moves a channel; on `published` this publishes. */
81054
+ set: (idOrSlug, name, move, options = {}) => {
81055
+ const { allowBreaking, ...rest } = options;
81056
+ return this.request(
81057
+ "PUT",
81058
+ `/templates/${encodeURIComponent(idOrSlug)}/channels/${encodeURIComponent(name)}`,
81059
+ { ...move, ...allowBreaking ? { allow_breaking: true } : {} },
81060
+ rest
81061
+ );
81062
+ },
81063
+ /** The canary becomes the main version. */
81064
+ promote: (idOrSlug, name, options = {}) => this.channelAction(idOrSlug, name, "promote", options),
81065
+ /** Back to the version before the last move. */
81066
+ rollback: (idOrSlug, name, options = {}) => this.channelAction(idOrSlug, name, "rollback", options),
81067
+ /** Refused with `channel_in_use` while renders of the last hour used the channel, unless `force`. */
81068
+ delete: (idOrSlug, name, options = {}) => {
81069
+ const { force, ...rest } = options;
81070
+ return this.request(
81071
+ "DELETE",
81072
+ `/templates/${encodeURIComponent(idOrSlug)}/channels/${encodeURIComponent(name)}${force ? "?force=true" : ""}`,
81073
+ void 0,
81074
+ rest
81075
+ );
81076
+ }
80566
81077
  },
80567
81078
  schema: (idOrSlug, options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/schema`, void 0, options),
80568
81079
  /**
@@ -80577,13 +81088,37 @@ var Formfeed = class {
80577
81088
  /** Units of the current period, or of `YYYY-MM`, with a daily series and a breakdown per template. */
80578
81089
  usage: (period = "current", options = {}) => this.request("GET", `/usage?period=${encodeURIComponent(period)}`, void 0, options)
80579
81090
  };
81091
+ /** Workspaces of the key's organisation. Needs `workspace:delete`, which no key has by default. */
81092
+ workspaces = {
81093
+ /**
81094
+ * Deletes a workspace: it leaves the app and the API at once, its keys are revoked and its content
81095
+ * is removed after 30 days. The organisation's last workspace is refused with `last_workspace`.
81096
+ */
81097
+ delete: (id, options = {}) => this.request("DELETE", `/workspaces/${encodeURIComponent(id)}`, void 0, options)
81098
+ };
81099
+ channelAction(idOrSlug, name, action, options) {
81100
+ const { allowBreaking, ...rest } = options;
81101
+ return this.request(
81102
+ "POST",
81103
+ `/templates/${encodeURIComponent(idOrSlug)}/channels/${encodeURIComponent(name)}/${action}`,
81104
+ allowBreaking ? { allow_breaking: true } : void 0,
81105
+ rest
81106
+ );
81107
+ }
80580
81108
  /** Raw request with auth, retries and problem mapping; use it for endpoints without a helper. */
80581
81109
  async request(method, path, body, options = {}) {
81110
+ return (await this.send(method, path, body, options)).body;
81111
+ }
81112
+ /**
81113
+ * `request` with the HTTP status, for endpoints whose answer depends on it (201 created, 200 updated).
81114
+ * `bytes` returns a successful body as bytes (file downloads); problems are JSON either way.
81115
+ */
81116
+ async send(method, path, body, options = {}, as = "json") {
80582
81117
  const url = `${this.baseUrl}${path}`;
80583
81118
  const headers = {
80584
81119
  authorization: `Bearer ${this.apiKey}`,
80585
- accept: "application/json",
80586
- "user-agent": "formfeed-sdk-ts/0.2"
81120
+ accept: as === "bytes" ? "*/*" : "application/json",
81121
+ "user-agent": "formfeed-sdk-ts/0.3"
80587
81122
  };
80588
81123
  const isForm = typeof FormData !== "undefined" && body instanceof FormData;
80589
81124
  if (body !== void 0 && !isForm) headers["content-type"] = "application/json";
@@ -80616,9 +81151,15 @@ var Formfeed = class {
80616
81151
  await sleep(this.backoff(attempt, res.headers.get("retry-after")), options.signal);
80617
81152
  continue;
80618
81153
  }
80619
- if (res.status === 204) return void 0;
81154
+ if (res.status === 204) return { status: 204, body: void 0 };
81155
+ if (as === "bytes" && res.ok) return { status: res.status, body: new Uint8Array(await res.arrayBuffer()) };
80620
81156
  const text2 = await res.text();
80621
- const json = text2 ? JSON.parse(text2) : null;
81157
+ let json = null;
81158
+ try {
81159
+ json = text2 ? JSON.parse(text2) : null;
81160
+ } catch {
81161
+ if (res.ok) throw new FormfeedError("invalid_response", `the API answered HTTP ${res.status} with a body that is not JSON`, res.status);
81162
+ }
80622
81163
  if (!res.ok) {
80623
81164
  const problem = json ?? {};
80624
81165
  throw new FormfeedError(
@@ -80629,7 +81170,7 @@ var Formfeed = class {
80629
81170
  res.headers.get("x-request-id")
80630
81171
  );
80631
81172
  }
80632
- return json;
81173
+ return { status: res.status, body: json };
80633
81174
  }
80634
81175
  }
80635
81176
  backoff(attempt, retryAfter) {
@@ -80644,9 +81185,9 @@ var encoder2 = new TextEncoder();
80644
81185
 
80645
81186
  // packages/mcp/src/lib/server.ts
80646
81187
  import { z as z2 } from "zod";
80647
- var SERVER_INFO = { name: "formfeed", version: "0.2.0" };
81188
+ var SERVER_INFO = { name: "formfeed", version: "0.3.0" };
80648
81189
  var engineSchema = z2.enum(["jinja2", "liquid", "handlebars"]);
80649
- var outputSchema = z2.enum(["pdf", "png", "jpg"]);
81190
+ var outputSchema = z2.enum(outputFormats);
80650
81191
  var templateSummary = (t) => ({
80651
81192
  id: t.id,
80652
81193
  slug: t.slug,
@@ -80782,7 +81323,7 @@ function createFormfeedServer(options) {
80782
81323
  html: z2.string().optional().describe("Raw HTML instead of a template"),
80783
81324
  engine: engineSchema.optional().describe("Engine for html that contains template syntax"),
80784
81325
  data: z2.record(z2.string(), z2.unknown()).optional(),
80785
- output: outputSchema.default("pdf"),
81326
+ output: outputSchema.optional().describe("Left out: PDF for PDF templates and html, the template's image format for image templates"),
80786
81327
  filename: z2.string().optional(),
80787
81328
  locale: z2.string().optional().describe("BCP 47 tag for helpers and translations, e.g. de-DE"),
80788
81329
  wait: z2.boolean().default(true).describe("Wait for the render to finish (sync renders finish immediately)")
@@ -80797,7 +81338,7 @@ function createFormfeedServer(options) {
80797
81338
  ...html ? { html } : {},
80798
81339
  ...engine ? { engine } : {},
80799
81340
  data: data ?? {},
80800
- output,
81341
+ ...output ? { output } : {},
80801
81342
  ...filename ? { filename } : {},
80802
81343
  ...locale ? { locale } : {},
80803
81344
  meta: { source: "mcp" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formfeed/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Model Context Protocol server for Formfeed: list templates, read data schemas, validate and render documents from AI agents.",
5
5
  "license": "MIT",
6
6
  "author": {