@broadpaper/forme 0.3.0 → 0.4.1

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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Browserless PDF generation for BroadPaper, via the Forme WebAssembly engine. The recommended backend.
8
8
 
9
- Part of [**BroadPaper**](https://www.broadpaper.com/developers/) — an embeddable visual report designer and PDF renderer.
9
+ Part of [**BroadPaper**](https://www.broadpaper.com/developers/?utm_source=npm&utm_medium=readme) — an embeddable visual report designer and PDF renderer.
10
10
  Your users design the report in your application; BroadPaper owns the layout, the branding,
11
11
  the pagination and the output.
12
12
 
@@ -43,7 +43,7 @@ production use. No seats, no activation, no machine binding, no telemetry, and n
43
43
  any point.
44
44
 
45
45
  - [How licensing works](https://www.broadpaper.com/docs/guide/licensing/)
46
- - [Pricing](https://www.broadpaper.com/developers/pricing/)
46
+ - [Pricing](https://www.broadpaper.com/developers/pricing/?utm_source=npm&utm_medium=readme)
47
47
  - [Licence agreement](https://www.broadpaper.com/legal/terms/)
48
48
 
49
49
  ## Documentation
@@ -51,6 +51,8 @@ any point.
51
51
  - [Browserless PDF](https://www.broadpaper.com/docs/guide/browserless-pdf)
52
52
  - [Themes and branding](https://www.broadpaper.com/docs/guide/themes)
53
53
  - [Full documentation](https://www.broadpaper.com/docs/)
54
+ - [Sample application](https://github.com/codingbadger/broadpaper-sample-application): the same app in React and Angular over one .NET back end
55
+ - [How BroadPaper compares](https://www.broadpaper.com/developers/compare/?utm_source=npm&utm_medium=readme) with react-pdf, pdfmake, Puppeteer and embedded report designers
54
56
 
55
57
  ## Support
56
58
 
@@ -38,6 +38,21 @@ function num(value) {
38
38
  const n = Number.parseFloat(value);
39
39
  return Number.isFinite(n) ? n : void 0;
40
40
  }
41
+ function flexShorthand(value) {
42
+ const v = value.trim().toLowerCase();
43
+ if (v === "none") return { grow: 0, shrink: 0 };
44
+ if (v === "auto") return { grow: 1, shrink: 1 };
45
+ if (v === "initial") return { grow: 0, shrink: 1 };
46
+ const parts = v.split(/\s+/).filter(Boolean);
47
+ const isCount = (p) => /^-?(\d+\.?\d*|\.\d+)$/.test(p);
48
+ const [a, b, c] = parts;
49
+ if (!a) return {};
50
+ if (parts.length === 1) return isCount(a) ? { grow: num(a), shrink: 1, basis: 0 } : { grow: 1, shrink: 1, basis: length(a) };
51
+ if (parts.length === 2) {
52
+ return isCount(b) ? { grow: num(a), shrink: num(b), basis: 0 } : { grow: num(a), shrink: 1, basis: length(b) };
53
+ }
54
+ return { grow: num(a), shrink: num(b), basis: length(c) };
55
+ }
41
56
  function colour(value) {
42
57
  const v = value.trim().toLowerCase();
43
58
  if (!v || v === "transparent" || v === "none" || v === "inherit" || v === "currentcolor") return void 0;
@@ -127,9 +142,13 @@ function translateStyle(css) {
127
142
  case "flex-wrap":
128
143
  set("flexWrap", value === "wrap" ? "wrap" : "nowrap");
129
144
  break;
130
- case "flex":
131
- set("flexGrow", num(value.split(/\s+/)[0] ?? "0"));
145
+ case "flex": {
146
+ const f = flexShorthand(value);
147
+ set("flexGrow", f.grow);
148
+ set("flexShrink", f.shrink);
149
+ set("flexBasis", f.basis);
132
150
  break;
151
+ }
133
152
  case "flex-grow":
134
153
  set("flexGrow", num(value));
135
154
  break;
@@ -701,7 +720,7 @@ function translate(v, ctx) {
701
720
  if (textual && (!blank2 || /bp-p--empty/.test(cls))) {
702
721
  const kind = heading ? { type: "Heading", level: Number(heading[1]), content } : { type: "Text", content };
703
722
  const node = { kind, style, children: [] };
704
- if (v.tag === "a" && typeof v.props.href === "string") node.href = v.props.href;
723
+ if (v.tag === "a" && typeof v.props.href === "string") kind.href = v.props.href;
705
724
  result = leaf(node);
706
725
  } else {
707
726
  const inner = childNodes(v, ctx);
@@ -902,6 +921,146 @@ var FormeMeasurer = class {
902
921
  }
903
922
  };
904
923
 
924
+ // src/fonts.ts
925
+ var localReader = null;
926
+ function setFontFileReader(reader) {
927
+ localReader = reader;
928
+ }
929
+ var MAX_FONT_BYTES = 32 * 1024 * 1024;
930
+ var FETCH_TIMEOUT_MS = 15e3;
931
+ var MAX_CACHED = 64;
932
+ var cache = /* @__PURE__ */ new Map();
933
+ async function resolveFonts(input) {
934
+ const fonts = [];
935
+ const warnings = [];
936
+ const seen = /* @__PURE__ */ new Set();
937
+ const add = (font) => {
938
+ const weight = font.weight ?? 400;
939
+ const style = font.style ?? "normal";
940
+ const key = `${font.family.trim().toLowerCase()}|${weight}|${style}`;
941
+ if (seen.has(key)) return;
942
+ seen.add(key);
943
+ fonts.push({ family: font.family, src: font.src, weight, style });
944
+ };
945
+ for (const font of input.fonts ?? []) add(font);
946
+ const faces = await Promise.all((input.theme?.fonts ?? []).map((face) => themeFace(face, input.sources ?? {})));
947
+ for (const face of faces) {
948
+ warnings.push(...face.warnings);
949
+ if (face.font) add(face.font);
950
+ }
951
+ return { fonts, warnings };
952
+ }
953
+ async function themeFace(face, policy) {
954
+ const { weight, range } = weightOf(face.weight);
955
+ const style = face.style === "italic" ? "italic" : "normal";
956
+ const name = `"${face.family}" (${weight}${style === "italic" ? " italic" : ""})`;
957
+ const sources = (Array.isArray(face.src) ? face.src : [face.src]).filter((s) => typeof s === "string" && s.trim() !== "");
958
+ const reasons = [];
959
+ for (const src of sources) {
960
+ const loaded = await load(src.trim(), policy);
961
+ if ("reason" in loaded) {
962
+ reasons.push(loaded.reason);
963
+ continue;
964
+ }
965
+ const warnings = range ? [`The font "${face.family}" declares the weights ${String(face.weight)} from one file; the PDF engine is given it at ${weight} only. Declare a file per weight for the others to be embedded.`] : [];
966
+ return { font: { family: face.family, src: loaded.src, weight, style }, warnings };
967
+ }
968
+ return {
969
+ warnings: [
970
+ `The font ${name} has no TrueType or OpenType source the PDF engine can read, so the PDF draws that text in a standard face and breaks its lines differently from the screen. Add a .ttf or .otf to its src${reasons.length ? ` (${reasons.join("; ")})` : ""}.`
971
+ ]
972
+ };
973
+ }
974
+ function weightOf(value) {
975
+ if (typeof value === "number" && Number.isFinite(value)) return { weight: Math.round(value), range: false };
976
+ const text = String(value ?? "").trim().toLowerCase();
977
+ if (text === "bold") return { weight: 700, range: false };
978
+ const numbers = text.split(/\s+/).map(Number).filter((n) => Number.isFinite(n) && n > 0);
979
+ if (!numbers.length) return { weight: 400, range: false };
980
+ return { weight: Math.round(numbers[0]), range: numbers.length > 1 && numbers[1] !== numbers[0] };
981
+ }
982
+ async function load(src, policy) {
983
+ if (/^data:(font|application)\/(x-)?woff2?[;,]/i.test(src) || /\.woff2?([?#]|$)/i.test(src.startsWith("data:") ? "" : src)) {
984
+ return { reason: `${describe(src)} is WOFF, which only a browser reads` };
985
+ }
986
+ if (src.startsWith("data:")) return fromDataUri(src);
987
+ const remote = /^https?:\/\//i.test(src);
988
+ if (remote) {
989
+ let host;
990
+ try {
991
+ host = new URL(src).hostname;
992
+ } catch {
993
+ return { reason: `${describe(src)} is not a valid address` };
994
+ }
995
+ const allowed = policy.allowedHosts;
996
+ if (allowed && !allowed.includes("*") && !allowed.includes(host)) return { reason: `${host} is not an allowed host` };
997
+ } else if (policy.local === false) {
998
+ return { reason: `${describe(src)} is neither a data: URI nor a web address, and files are not read here` };
999
+ } else if (!localReader) {
1000
+ return { src };
1001
+ }
1002
+ const key = src;
1003
+ const hit = cache.get(key);
1004
+ if (hit) return hit;
1005
+ const pending = (async () => {
1006
+ const bytes = remote ? await download(src, Boolean(policy.allowedHosts && !policy.allowedHosts.includes("*"))) : await localReader(src);
1007
+ if (bytes.byteLength > MAX_FONT_BYTES) return { reason: `${describe(src)} is larger than ${MAX_FONT_BYTES / 1024 / 1024} MB` };
1008
+ const problem = notEmbeddable(bytes, src);
1009
+ return problem ? { reason: problem } : { src: `data:font/ttf;base64,${toBase64(bytes)}` };
1010
+ })().catch((e) => {
1011
+ cache.delete(key);
1012
+ return { reason: `${describe(src)} could not be read (${e.message || String(e)})` };
1013
+ });
1014
+ cache.set(key, pending);
1015
+ while (cache.size > MAX_CACHED) cache.delete(cache.keys().next().value);
1016
+ return pending;
1017
+ }
1018
+ async function download(src, restricted) {
1019
+ const signal = typeof AbortSignal !== "undefined" && "timeout" in AbortSignal ? AbortSignal.timeout(FETCH_TIMEOUT_MS) : void 0;
1020
+ const response = await fetch(src, { redirect: restricted ? "error" : "follow", ...signal ? { signal } : {} });
1021
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1022
+ const length2 = Number(response.headers.get("content-length"));
1023
+ if (length2 > MAX_FONT_BYTES) throw new Error(`larger than ${MAX_FONT_BYTES / 1024 / 1024} MB`);
1024
+ return new Uint8Array(await response.arrayBuffer());
1025
+ }
1026
+ function fromDataUri(src) {
1027
+ const match = /^data:([^,]*),(.*)$/s.exec(src);
1028
+ if (!match) return { reason: "a data: URI with no data" };
1029
+ const base64 = /;base64$/i.test(match[1]);
1030
+ let head;
1031
+ try {
1032
+ head = base64 ? fromBase64(match[2].slice(0, 8)) : Uint8Array.from(decodeURIComponent(match[2].slice(0, 64)), (c) => c.charCodeAt(0) & 255);
1033
+ } catch {
1034
+ return { reason: "a data: URI that does not decode" };
1035
+ }
1036
+ const problem = notEmbeddable(head, "a data: URI");
1037
+ if (problem) return { reason: problem };
1038
+ if (base64) return { src };
1039
+ const bytes = Uint8Array.from(decodeURIComponent(match[2]), (c) => c.charCodeAt(0) & 255);
1040
+ return { src: `data:font/ttf;base64,${toBase64(bytes)}` };
1041
+ }
1042
+ function notEmbeddable(bytes, src) {
1043
+ if (bytes.length < 4) return `${describe(src)} is not a font file`;
1044
+ const tag = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
1045
+ if (bytes[0] === 0 && bytes[1] === 1 && bytes[2] === 0 && bytes[3] === 0 || tag === "OTTO" || tag === "true") return null;
1046
+ if (tag === "wOFF" || tag === "wOF2") return `${describe(src)} is WOFF, which only a browser reads`;
1047
+ if (tag === "ttcf") return `${describe(src)} is a font collection; give the single face instead`;
1048
+ return `${describe(src)} is not a TrueType or OpenType file`;
1049
+ }
1050
+ function describe(src) {
1051
+ return src.startsWith("data:") ? "a data: URI" : src.length > 80 ? `${src.slice(0, 77)}\u2026` : src;
1052
+ }
1053
+ function toBase64(bytes) {
1054
+ let binary = "";
1055
+ for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
1056
+ return btoa(binary);
1057
+ }
1058
+ function fromBase64(text) {
1059
+ const clean = text.replace(/[^A-Za-z0-9+/]/g, "");
1060
+ const binary = atob(clean.slice(0, clean.length - clean.length % 4));
1061
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
1062
+ }
1063
+
905
1064
  // src/editor.ts
906
1065
  function hash(s) {
907
1066
  let h = 2166136261;
@@ -928,8 +1087,8 @@ var FormeEditorMeasurer = class {
928
1087
  destroy() {
929
1088
  this.cache.clear();
930
1089
  }
931
- key(section, width, mode) {
932
- return hash(JSON.stringify(section)) + ":" + Math.round(width * 100) + ":" + mode;
1090
+ key(section, width, mode, faces) {
1091
+ return hash(JSON.stringify(section)) + ":" + Math.round(width * 100) + ":" + mode + ":" + faces;
933
1092
  }
934
1093
  async measure(sections, width) {
935
1094
  const opts = this.opts;
@@ -937,8 +1096,9 @@ var FormeEditorMeasurer = class {
937
1096
  const out = {};
938
1097
  const pending = [];
939
1098
  const keys = /* @__PURE__ */ new Map();
1099
+ const faces = facesKey(opts.theme.fonts);
940
1100
  for (const section of sections) {
941
- const k = this.key(section, width, opts.mode);
1101
+ const k = this.key(section, width, opts.mode, faces);
942
1102
  const hit = this.cache.get(k);
943
1103
  if (hit) out[section.key] = hit;
944
1104
  else {
@@ -947,7 +1107,9 @@ var FormeEditorMeasurer = class {
947
1107
  }
948
1108
  }
949
1109
  if (!pending.length) return out;
950
- const fonts = await this.config.fonts;
1110
+ const given = await this.config.fonts;
1111
+ const { fonts, warnings } = await resolveFonts({ fonts: given, theme: opts.theme });
1112
+ for (const w of warnings) this.warn(w);
951
1113
  const measurer = new FormeMeasurer({
952
1114
  registry: opts.registry,
953
1115
  theme: opts.theme,
@@ -965,14 +1127,21 @@ var FormeEditorMeasurer = class {
965
1127
  const k = keys.get(sectionKey);
966
1128
  if (k) this.cache.set(k, geometry);
967
1129
  }
968
- for (const w of measurer.warnings) {
969
- if (this.seen.has(w)) continue;
970
- this.seen.add(w);
971
- this.config.onWarning?.(w);
972
- }
1130
+ for (const w of measurer.warnings) this.warn(w);
973
1131
  return out;
974
1132
  }
1133
+ warn(message) {
1134
+ if (this.seen.has(message)) return;
1135
+ this.seen.add(message);
1136
+ this.config.onWarning?.(message);
1137
+ }
975
1138
  };
1139
+ function facesKey(faces) {
1140
+ if (!faces?.length) return "";
1141
+ return hash(
1142
+ faces.map((f) => [f.family, f.weight, f.style, ...(Array.isArray(f.src) ? f.src : [f.src]).map((s) => `${s.length}:${s.slice(0, 64)}${s.slice(-64)}`)].join("|")).join(";")
1143
+ );
1144
+ }
976
1145
 
977
1146
  // src/api.ts
978
1147
  import {
@@ -997,8 +1166,11 @@ function creditNode(spec, width, ctx) {
997
1166
  });
998
1167
  }
999
1168
  children.push({
1000
- tag: "span",
1001
- props: { style: { "font-size": `${size}px`, color: spec.color, opacity: String(spec.opacity), "line-height": "1.2" } },
1169
+ tag: spec.href ? "a" : "span",
1170
+ props: {
1171
+ ...spec.href ? { href: spec.href } : {},
1172
+ style: { "font-size": `${size}px`, color: spec.color, opacity: String(spec.opacity), "line-height": "1.2" }
1173
+ },
1002
1174
  children: [spec.text]
1003
1175
  });
1004
1176
  const line = {
@@ -1313,7 +1485,7 @@ function emitPages(input) {
1313
1485
  const width = page.contentWidth;
1314
1486
  const bleed = page.margins.right;
1315
1487
  const bodyDependsOnPage = isPageDependent(resolved.body);
1316
- const cache = /* @__PURE__ */ new Map();
1488
+ const cache2 = /* @__PURE__ */ new Map();
1317
1489
  const rendered = /* @__PURE__ */ new Map();
1318
1490
  const treeOf = (section, opts) => {
1319
1491
  if (bodyDependsOnPage) return renderNode2(section, opts, width);
@@ -1322,7 +1494,7 @@ function emitPages(input) {
1322
1494
  };
1323
1495
  const translateSection = (section, opts, from, to) => {
1324
1496
  const cacheKey = `${section.key}|${from}|${to}`;
1325
- if (!bodyDependsOnPage && cache.has(cacheKey)) return cache.get(cacheKey);
1497
+ if (!bodyDependsOnPage && cache2.has(cacheKey)) return cache2.get(cacheKey);
1326
1498
  const v = treeOf(section, opts);
1327
1499
  if (!v) return null;
1328
1500
  const geom = geometry[section.key];
@@ -1330,7 +1502,7 @@ function emitPages(input) {
1330
1502
  const t = translate(cropped.node, ctx);
1331
1503
  if (!t) return null;
1332
1504
  const out = { node: t.node, offset: cropped.offset };
1333
- if (!bodyDependsOnPage) cache.set(cacheKey, out);
1505
+ if (!bodyDependsOnPage) cache2.set(cacheKey, out);
1334
1506
  return out;
1335
1507
  };
1336
1508
  const headers = /* @__PURE__ */ new Map();
@@ -1487,11 +1659,12 @@ async function buildFormeDocument(opts) {
1487
1659
  defaultPage: { size: "A4", margin: { top: 54, right: 54, bottom: 54, left: 54 }, wrap: true },
1488
1660
  defaultStyle: mapStyle4({ fontSize: pxToPt(theme.typography.baseFontSize ?? 11), fontFamily: theme.typography.bodyFont.split(",")[0].trim().replace(/^['"]|['"]$/g, "") })
1489
1661
  };
1490
- if (opts.fonts?.length) doc.fonts = opts.fonts.map((f) => ({ family: f.family, src: f.src, weight: f.weight ?? 400, style: f.style ?? "normal" }));
1662
+ const fonts = await resolveFonts({ fonts: opts.fonts, theme, sources: opts.fontSources });
1663
+ if (fonts.fonts.length) doc.fonts = fontEntries(fonts.fonts);
1491
1664
  if (opts.tagged) doc.tagged = true;
1492
1665
  if (opts.pdfUa) doc.pdfUa = true;
1493
1666
  if (opts.pdfA) doc.pdfa = opts.pdfA;
1494
- const warnings = [...ctx.warnings, ...resolved.warnings.map((w) => w.message)];
1667
+ const warnings = [...ctx.warnings, ...resolved.warnings.map((w) => w.message), ...fonts.warnings];
1495
1668
  return { doc, warnings, license };
1496
1669
  }
1497
1670
  var engineLoader = null;
@@ -1572,6 +1745,7 @@ async function layoutPaginated(opts) {
1572
1745
  };
1573
1746
  const ctx = { warnings: /* @__PURE__ */ new Set(), suppressPageBreaks: true };
1574
1747
  const render = opts.renderer ?? defaultRenderer;
1748
+ const fonts = await resolveFonts({ fonts: opts.fonts, theme, sources: opts.fontSources });
1575
1749
  const layoutSections = async (list) => {
1576
1750
  if (!list.length) return {};
1577
1751
  const measured = [];
@@ -1599,7 +1773,7 @@ async function layoutPaginated(opts) {
1599
1773
  defaultPage: { size: "A4", margin: { top: 54, right: 54, bottom: 54, left: 54 }, wrap: true },
1600
1774
  defaultStyle: baseStyle(theme)
1601
1775
  };
1602
- if (opts.fonts?.length) doc.fonts = fontEntries(opts.fonts);
1776
+ if (fonts.fonts.length) doc.fonts = fontEntries(fonts.fonts);
1603
1777
  const result = await render(doc);
1604
1778
  const pages = result.layout?.pages ?? [];
1605
1779
  const scale2 = 1 / PX_TO_PT_LOCAL;
@@ -1663,7 +1837,7 @@ async function layoutPaginated(opts) {
1663
1837
  defaultPage: { size: "A4", margin: { top: 54, right: 54, bottom: 54, left: 54 }, wrap: true },
1664
1838
  defaultStyle: baseStyle(theme)
1665
1839
  };
1666
- if (opts.fonts?.length) doc.fonts = fontEntries(opts.fonts);
1840
+ if (fonts.fonts.length) doc.fonts = fontEntries(fonts.fonts);
1667
1841
  if (opts.tagged) doc.tagged = true;
1668
1842
  if (opts.pdfUa) doc.pdfUa = true;
1669
1843
  if (opts.pdfA) doc.pdfa = opts.pdfA;
@@ -1673,7 +1847,7 @@ async function layoutPaginated(opts) {
1673
1847
  pdf: result.pdf,
1674
1848
  pages: layout?.pages.length ?? to - from,
1675
1849
  layout,
1676
- warnings: [...ctx.warnings, ...resolved.warnings.map((w) => w.message), ...paged.warnings.map((w) => w.message), ...result.warnings ?? []]
1850
+ warnings: [...ctx.warnings, ...resolved.warnings.map((w) => w.message), ...paged.warnings.map((w) => w.message), ...fonts.warnings, ...result.warnings ?? []]
1677
1851
  };
1678
1852
  }
1679
1853
  };
@@ -1711,6 +1885,8 @@ export {
1711
1885
  UNBOUNDED_PAGE_HEIGHT,
1712
1886
  geometryFromLayout,
1713
1887
  FormeMeasurer,
1888
+ setFontFileReader,
1889
+ resolveFonts,
1714
1890
  FormeEditorMeasurer,
1715
1891
  buildFormeDocument,
1716
1892
  setEngineLoader,
@@ -1724,4 +1900,4 @@ export {
1724
1900
  verifyLicense,
1725
1901
  parseLicense
1726
1902
  };
1727
- //# sourceMappingURL=chunk-2C3MAW2U.js.map
1903
+ //# sourceMappingURL=chunk-FAKBQDQG.js.map