@broadpaper/forme 0.3.0 → 0.4.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.
@@ -902,6 +902,146 @@ var FormeMeasurer = class {
902
902
  }
903
903
  };
904
904
 
905
+ // src/fonts.ts
906
+ var localReader = null;
907
+ function setFontFileReader(reader) {
908
+ localReader = reader;
909
+ }
910
+ var MAX_FONT_BYTES = 32 * 1024 * 1024;
911
+ var FETCH_TIMEOUT_MS = 15e3;
912
+ var MAX_CACHED = 64;
913
+ var cache = /* @__PURE__ */ new Map();
914
+ async function resolveFonts(input) {
915
+ const fonts = [];
916
+ const warnings = [];
917
+ const seen = /* @__PURE__ */ new Set();
918
+ const add = (font) => {
919
+ const weight = font.weight ?? 400;
920
+ const style = font.style ?? "normal";
921
+ const key = `${font.family.trim().toLowerCase()}|${weight}|${style}`;
922
+ if (seen.has(key)) return;
923
+ seen.add(key);
924
+ fonts.push({ family: font.family, src: font.src, weight, style });
925
+ };
926
+ for (const font of input.fonts ?? []) add(font);
927
+ const faces = await Promise.all((input.theme?.fonts ?? []).map((face) => themeFace(face, input.sources ?? {})));
928
+ for (const face of faces) {
929
+ warnings.push(...face.warnings);
930
+ if (face.font) add(face.font);
931
+ }
932
+ return { fonts, warnings };
933
+ }
934
+ async function themeFace(face, policy) {
935
+ const { weight, range } = weightOf(face.weight);
936
+ const style = face.style === "italic" ? "italic" : "normal";
937
+ const name = `"${face.family}" (${weight}${style === "italic" ? " italic" : ""})`;
938
+ const sources = (Array.isArray(face.src) ? face.src : [face.src]).filter((s) => typeof s === "string" && s.trim() !== "");
939
+ const reasons = [];
940
+ for (const src of sources) {
941
+ const loaded = await load(src.trim(), policy);
942
+ if ("reason" in loaded) {
943
+ reasons.push(loaded.reason);
944
+ continue;
945
+ }
946
+ 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.`] : [];
947
+ return { font: { family: face.family, src: loaded.src, weight, style }, warnings };
948
+ }
949
+ return {
950
+ warnings: [
951
+ `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("; ")})` : ""}.`
952
+ ]
953
+ };
954
+ }
955
+ function weightOf(value) {
956
+ if (typeof value === "number" && Number.isFinite(value)) return { weight: Math.round(value), range: false };
957
+ const text = String(value ?? "").trim().toLowerCase();
958
+ if (text === "bold") return { weight: 700, range: false };
959
+ const numbers = text.split(/\s+/).map(Number).filter((n) => Number.isFinite(n) && n > 0);
960
+ if (!numbers.length) return { weight: 400, range: false };
961
+ return { weight: Math.round(numbers[0]), range: numbers.length > 1 && numbers[1] !== numbers[0] };
962
+ }
963
+ async function load(src, policy) {
964
+ if (/^data:(font|application)\/(x-)?woff2?[;,]/i.test(src) || /\.woff2?([?#]|$)/i.test(src.startsWith("data:") ? "" : src)) {
965
+ return { reason: `${describe(src)} is WOFF, which only a browser reads` };
966
+ }
967
+ if (src.startsWith("data:")) return fromDataUri(src);
968
+ const remote = /^https?:\/\//i.test(src);
969
+ if (remote) {
970
+ let host;
971
+ try {
972
+ host = new URL(src).hostname;
973
+ } catch {
974
+ return { reason: `${describe(src)} is not a valid address` };
975
+ }
976
+ const allowed = policy.allowedHosts;
977
+ if (allowed && !allowed.includes("*") && !allowed.includes(host)) return { reason: `${host} is not an allowed host` };
978
+ } else if (policy.local === false) {
979
+ return { reason: `${describe(src)} is neither a data: URI nor a web address, and files are not read here` };
980
+ } else if (!localReader) {
981
+ return { src };
982
+ }
983
+ const key = src;
984
+ const hit = cache.get(key);
985
+ if (hit) return hit;
986
+ const pending = (async () => {
987
+ const bytes = remote ? await download(src, Boolean(policy.allowedHosts && !policy.allowedHosts.includes("*"))) : await localReader(src);
988
+ if (bytes.byteLength > MAX_FONT_BYTES) return { reason: `${describe(src)} is larger than ${MAX_FONT_BYTES / 1024 / 1024} MB` };
989
+ const problem = notEmbeddable(bytes, src);
990
+ return problem ? { reason: problem } : { src: `data:font/ttf;base64,${toBase64(bytes)}` };
991
+ })().catch((e) => {
992
+ cache.delete(key);
993
+ return { reason: `${describe(src)} could not be read (${e.message || String(e)})` };
994
+ });
995
+ cache.set(key, pending);
996
+ while (cache.size > MAX_CACHED) cache.delete(cache.keys().next().value);
997
+ return pending;
998
+ }
999
+ async function download(src, restricted) {
1000
+ const signal = typeof AbortSignal !== "undefined" && "timeout" in AbortSignal ? AbortSignal.timeout(FETCH_TIMEOUT_MS) : void 0;
1001
+ const response = await fetch(src, { redirect: restricted ? "error" : "follow", ...signal ? { signal } : {} });
1002
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1003
+ const length2 = Number(response.headers.get("content-length"));
1004
+ if (length2 > MAX_FONT_BYTES) throw new Error(`larger than ${MAX_FONT_BYTES / 1024 / 1024} MB`);
1005
+ return new Uint8Array(await response.arrayBuffer());
1006
+ }
1007
+ function fromDataUri(src) {
1008
+ const match = /^data:([^,]*),(.*)$/s.exec(src);
1009
+ if (!match) return { reason: "a data: URI with no data" };
1010
+ const base64 = /;base64$/i.test(match[1]);
1011
+ let head;
1012
+ try {
1013
+ head = base64 ? fromBase64(match[2].slice(0, 8)) : Uint8Array.from(decodeURIComponent(match[2].slice(0, 64)), (c) => c.charCodeAt(0) & 255);
1014
+ } catch {
1015
+ return { reason: "a data: URI that does not decode" };
1016
+ }
1017
+ const problem = notEmbeddable(head, "a data: URI");
1018
+ if (problem) return { reason: problem };
1019
+ if (base64) return { src };
1020
+ const bytes = Uint8Array.from(decodeURIComponent(match[2]), (c) => c.charCodeAt(0) & 255);
1021
+ return { src: `data:font/ttf;base64,${toBase64(bytes)}` };
1022
+ }
1023
+ function notEmbeddable(bytes, src) {
1024
+ if (bytes.length < 4) return `${describe(src)} is not a font file`;
1025
+ const tag = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
1026
+ if (bytes[0] === 0 && bytes[1] === 1 && bytes[2] === 0 && bytes[3] === 0 || tag === "OTTO" || tag === "true") return null;
1027
+ if (tag === "wOFF" || tag === "wOF2") return `${describe(src)} is WOFF, which only a browser reads`;
1028
+ if (tag === "ttcf") return `${describe(src)} is a font collection; give the single face instead`;
1029
+ return `${describe(src)} is not a TrueType or OpenType file`;
1030
+ }
1031
+ function describe(src) {
1032
+ return src.startsWith("data:") ? "a data: URI" : src.length > 80 ? `${src.slice(0, 77)}\u2026` : src;
1033
+ }
1034
+ function toBase64(bytes) {
1035
+ let binary = "";
1036
+ for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
1037
+ return btoa(binary);
1038
+ }
1039
+ function fromBase64(text) {
1040
+ const clean = text.replace(/[^A-Za-z0-9+/]/g, "");
1041
+ const binary = atob(clean.slice(0, clean.length - clean.length % 4));
1042
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
1043
+ }
1044
+
905
1045
  // src/editor.ts
906
1046
  function hash(s) {
907
1047
  let h = 2166136261;
@@ -928,8 +1068,8 @@ var FormeEditorMeasurer = class {
928
1068
  destroy() {
929
1069
  this.cache.clear();
930
1070
  }
931
- key(section, width, mode) {
932
- return hash(JSON.stringify(section)) + ":" + Math.round(width * 100) + ":" + mode;
1071
+ key(section, width, mode, faces) {
1072
+ return hash(JSON.stringify(section)) + ":" + Math.round(width * 100) + ":" + mode + ":" + faces;
933
1073
  }
934
1074
  async measure(sections, width) {
935
1075
  const opts = this.opts;
@@ -937,8 +1077,9 @@ var FormeEditorMeasurer = class {
937
1077
  const out = {};
938
1078
  const pending = [];
939
1079
  const keys = /* @__PURE__ */ new Map();
1080
+ const faces = facesKey(opts.theme.fonts);
940
1081
  for (const section of sections) {
941
- const k = this.key(section, width, opts.mode);
1082
+ const k = this.key(section, width, opts.mode, faces);
942
1083
  const hit = this.cache.get(k);
943
1084
  if (hit) out[section.key] = hit;
944
1085
  else {
@@ -947,7 +1088,9 @@ var FormeEditorMeasurer = class {
947
1088
  }
948
1089
  }
949
1090
  if (!pending.length) return out;
950
- const fonts = await this.config.fonts;
1091
+ const given = await this.config.fonts;
1092
+ const { fonts, warnings } = await resolveFonts({ fonts: given, theme: opts.theme });
1093
+ for (const w of warnings) this.warn(w);
951
1094
  const measurer = new FormeMeasurer({
952
1095
  registry: opts.registry,
953
1096
  theme: opts.theme,
@@ -965,14 +1108,21 @@ var FormeEditorMeasurer = class {
965
1108
  const k = keys.get(sectionKey);
966
1109
  if (k) this.cache.set(k, geometry);
967
1110
  }
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
- }
1111
+ for (const w of measurer.warnings) this.warn(w);
973
1112
  return out;
974
1113
  }
1114
+ warn(message) {
1115
+ if (this.seen.has(message)) return;
1116
+ this.seen.add(message);
1117
+ this.config.onWarning?.(message);
1118
+ }
975
1119
  };
1120
+ function facesKey(faces) {
1121
+ if (!faces?.length) return "";
1122
+ return hash(
1123
+ 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(";")
1124
+ );
1125
+ }
976
1126
 
977
1127
  // src/api.ts
978
1128
  import {
@@ -1313,7 +1463,7 @@ function emitPages(input) {
1313
1463
  const width = page.contentWidth;
1314
1464
  const bleed = page.margins.right;
1315
1465
  const bodyDependsOnPage = isPageDependent(resolved.body);
1316
- const cache = /* @__PURE__ */ new Map();
1466
+ const cache2 = /* @__PURE__ */ new Map();
1317
1467
  const rendered = /* @__PURE__ */ new Map();
1318
1468
  const treeOf = (section, opts) => {
1319
1469
  if (bodyDependsOnPage) return renderNode2(section, opts, width);
@@ -1322,7 +1472,7 @@ function emitPages(input) {
1322
1472
  };
1323
1473
  const translateSection = (section, opts, from, to) => {
1324
1474
  const cacheKey = `${section.key}|${from}|${to}`;
1325
- if (!bodyDependsOnPage && cache.has(cacheKey)) return cache.get(cacheKey);
1475
+ if (!bodyDependsOnPage && cache2.has(cacheKey)) return cache2.get(cacheKey);
1326
1476
  const v = treeOf(section, opts);
1327
1477
  if (!v) return null;
1328
1478
  const geom = geometry[section.key];
@@ -1330,7 +1480,7 @@ function emitPages(input) {
1330
1480
  const t = translate(cropped.node, ctx);
1331
1481
  if (!t) return null;
1332
1482
  const out = { node: t.node, offset: cropped.offset };
1333
- if (!bodyDependsOnPage) cache.set(cacheKey, out);
1483
+ if (!bodyDependsOnPage) cache2.set(cacheKey, out);
1334
1484
  return out;
1335
1485
  };
1336
1486
  const headers = /* @__PURE__ */ new Map();
@@ -1487,11 +1637,12 @@ async function buildFormeDocument(opts) {
1487
1637
  defaultPage: { size: "A4", margin: { top: 54, right: 54, bottom: 54, left: 54 }, wrap: true },
1488
1638
  defaultStyle: mapStyle4({ fontSize: pxToPt(theme.typography.baseFontSize ?? 11), fontFamily: theme.typography.bodyFont.split(",")[0].trim().replace(/^['"]|['"]$/g, "") })
1489
1639
  };
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" }));
1640
+ const fonts = await resolveFonts({ fonts: opts.fonts, theme, sources: opts.fontSources });
1641
+ if (fonts.fonts.length) doc.fonts = fontEntries(fonts.fonts);
1491
1642
  if (opts.tagged) doc.tagged = true;
1492
1643
  if (opts.pdfUa) doc.pdfUa = true;
1493
1644
  if (opts.pdfA) doc.pdfa = opts.pdfA;
1494
- const warnings = [...ctx.warnings, ...resolved.warnings.map((w) => w.message)];
1645
+ const warnings = [...ctx.warnings, ...resolved.warnings.map((w) => w.message), ...fonts.warnings];
1495
1646
  return { doc, warnings, license };
1496
1647
  }
1497
1648
  var engineLoader = null;
@@ -1572,6 +1723,7 @@ async function layoutPaginated(opts) {
1572
1723
  };
1573
1724
  const ctx = { warnings: /* @__PURE__ */ new Set(), suppressPageBreaks: true };
1574
1725
  const render = opts.renderer ?? defaultRenderer;
1726
+ const fonts = await resolveFonts({ fonts: opts.fonts, theme, sources: opts.fontSources });
1575
1727
  const layoutSections = async (list) => {
1576
1728
  if (!list.length) return {};
1577
1729
  const measured = [];
@@ -1599,7 +1751,7 @@ async function layoutPaginated(opts) {
1599
1751
  defaultPage: { size: "A4", margin: { top: 54, right: 54, bottom: 54, left: 54 }, wrap: true },
1600
1752
  defaultStyle: baseStyle(theme)
1601
1753
  };
1602
- if (opts.fonts?.length) doc.fonts = fontEntries(opts.fonts);
1754
+ if (fonts.fonts.length) doc.fonts = fontEntries(fonts.fonts);
1603
1755
  const result = await render(doc);
1604
1756
  const pages = result.layout?.pages ?? [];
1605
1757
  const scale2 = 1 / PX_TO_PT_LOCAL;
@@ -1663,7 +1815,7 @@ async function layoutPaginated(opts) {
1663
1815
  defaultPage: { size: "A4", margin: { top: 54, right: 54, bottom: 54, left: 54 }, wrap: true },
1664
1816
  defaultStyle: baseStyle(theme)
1665
1817
  };
1666
- if (opts.fonts?.length) doc.fonts = fontEntries(opts.fonts);
1818
+ if (fonts.fonts.length) doc.fonts = fontEntries(fonts.fonts);
1667
1819
  if (opts.tagged) doc.tagged = true;
1668
1820
  if (opts.pdfUa) doc.pdfUa = true;
1669
1821
  if (opts.pdfA) doc.pdfa = opts.pdfA;
@@ -1673,7 +1825,7 @@ async function layoutPaginated(opts) {
1673
1825
  pdf: result.pdf,
1674
1826
  pages: layout?.pages.length ?? to - from,
1675
1827
  layout,
1676
- warnings: [...ctx.warnings, ...resolved.warnings.map((w) => w.message), ...paged.warnings.map((w) => w.message), ...result.warnings ?? []]
1828
+ warnings: [...ctx.warnings, ...resolved.warnings.map((w) => w.message), ...paged.warnings.map((w) => w.message), ...fonts.warnings, ...result.warnings ?? []]
1677
1829
  };
1678
1830
  }
1679
1831
  };
@@ -1711,6 +1863,8 @@ export {
1711
1863
  UNBOUNDED_PAGE_HEIGHT,
1712
1864
  geometryFromLayout,
1713
1865
  FormeMeasurer,
1866
+ setFontFileReader,
1867
+ resolveFonts,
1714
1868
  FormeEditorMeasurer,
1715
1869
  buildFormeDocument,
1716
1870
  setEngineLoader,
@@ -1724,4 +1878,4 @@ export {
1724
1878
  verifyLicense,
1725
1879
  parseLicense
1726
1880
  };
1727
- //# sourceMappingURL=chunk-2C3MAW2U.js.map
1881
+ //# sourceMappingURL=chunk-QA4APTWM.js.map