polotno-node 2.15.18 → 3.0.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.
package/instance.js CHANGED
@@ -1,12 +1,13 @@
1
- const path = require('path');
2
- const fs = require('fs');
3
- const os = require('os');
4
- const crypto = require('crypto');
5
- const { execSync } = require('child_process');
6
- const { pathToFileURL } = require('url');
7
- const { Readable } = require('stream');
8
- const { pipeline } = require('stream/promises');
9
- const DEFAULT_CLIENT = `file:${path.join(__dirname, 'dist', 'index.html')}`;
1
+ const path = require("path");
2
+ const fs = require("fs");
3
+ const os = require("os");
4
+ const crypto = require("crypto");
5
+ const { execSync } = require("child_process");
6
+ const { pathToFileURL } = require("url");
7
+ const { Readable } = require("stream");
8
+ const { pipeline } = require("stream/promises");
9
+ const fontCache = require("./font-cache");
10
+ const DEFAULT_CLIENT = `file:${path.join(__dirname, "dist", "index.html")}`;
10
11
 
11
12
  // Walk Polotno nodes recursively (depth-first)
12
13
  // cb(node) may return true to stop traversing the current branch early
@@ -29,12 +30,12 @@ const collectMediaSourcePointers = (json) => {
29
30
  // videos in elements tree
30
31
  for (const page of json?.pages || []) {
31
32
  for (const el of page?.children || []) {
32
- if (el?.type === 'video' && typeof el.src === 'string') {
33
- pointers.push({ kind: 'video', target: el, key: 'src' });
33
+ if (el?.type === "video" && typeof el.src === "string") {
34
+ pointers.push({ kind: "video", target: el, key: "src" });
34
35
  }
35
36
  forEveryChild(el, (child) => {
36
- if (child?.type === 'video' && typeof child.src === 'string') {
37
- pointers.push({ kind: 'video', target: child, key: 'src' });
37
+ if (child?.type === "video" && typeof child.src === "string") {
38
+ pointers.push({ kind: "video", target: child, key: "src" });
38
39
  }
39
40
  });
40
41
  }
@@ -42,8 +43,8 @@ const collectMediaSourcePointers = (json) => {
42
43
 
43
44
  // audios array
44
45
  for (const audio of json?.audios || []) {
45
- if (audio && typeof audio.src === 'string') {
46
- pointers.push({ kind: 'audio', target: audio, key: 'src' });
46
+ if (audio && typeof audio.src === "string") {
47
+ pointers.push({ kind: "audio", target: audio, key: "src" });
47
48
  }
48
49
  }
49
50
 
@@ -51,45 +52,45 @@ const collectMediaSourcePointers = (json) => {
51
52
  };
52
53
 
53
54
  const isDataOrFileSrc = (src) => {
54
- if (typeof src !== 'string') return true;
55
+ if (typeof src !== "string") return true;
55
56
  return (
56
- src.startsWith('data:') ||
57
- src.startsWith('file:') ||
58
- src.startsWith('blob:')
57
+ src.startsWith("data:") ||
58
+ src.startsWith("file:") ||
59
+ src.startsWith("blob:")
59
60
  );
60
61
  };
61
62
 
62
63
  const guessExtension = (urlString, contentType) => {
63
64
  try {
64
65
  const u = new URL(urlString);
65
- const ext = path.extname(u.pathname || '');
66
+ const ext = path.extname(u.pathname || "");
66
67
  if (ext) return ext;
67
68
  } catch (e) {
68
69
  // ignore; fallback to content-type
69
70
  }
70
71
 
71
- const ct = String(contentType || '')
72
- .split(';')[0]
72
+ const ct = String(contentType || "")
73
+ .split(";")[0]
73
74
  .trim()
74
75
  .toLowerCase();
75
76
  const map = {
76
- 'video/mp4': '.mp4',
77
- 'video/webm': '.webm',
78
- 'video/quicktime': '.mov',
79
- 'audio/mpeg': '.mp3',
80
- 'audio/mp4': '.m4a',
81
- 'audio/wav': '.wav',
82
- 'audio/x-wav': '.wav',
83
- 'audio/ogg': '.ogg',
84
- 'audio/webm': '.webm',
77
+ "video/mp4": ".mp4",
78
+ "video/webm": ".webm",
79
+ "video/quicktime": ".mov",
80
+ "audio/mpeg": ".mp3",
81
+ "audio/mp4": ".m4a",
82
+ "audio/wav": ".wav",
83
+ "audio/x-wav": ".wav",
84
+ "audio/ogg": ".ogg",
85
+ "audio/webm": ".webm",
85
86
  };
86
- return map[ct] || '.bin';
87
+ return map[ct] || ".bin";
87
88
  };
88
89
 
89
90
  const downloadUrlToFile = async (urlString, destinationPath) => {
90
- if (typeof fetch !== 'function') {
91
+ if (typeof fetch !== "function") {
91
92
  throw new Error(
92
- 'Global fetch API is not available in this Node.js runtime. Please use Node 18+.',
93
+ "Global fetch API is not available in this Node.js runtime. Please use Node 18+.",
93
94
  );
94
95
  }
95
96
  const MAX_ATTEMPTS = 3;
@@ -104,11 +105,11 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
104
105
  const res = await fetch(urlString);
105
106
  if (!res.ok) {
106
107
  // Best-effort read of error body (small) for debugging.
107
- let bodyPreview = '';
108
+ let bodyPreview = "";
108
109
  try {
109
110
  bodyPreview = await res.text();
110
111
  if (bodyPreview.length > 500) {
111
- bodyPreview = bodyPreview.slice(0, 500) + '...';
112
+ bodyPreview = bodyPreview.slice(0, 500) + "...";
112
113
  }
113
114
  } catch (e) {
114
115
  // ignore
@@ -118,12 +119,12 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
118
119
  bodyPreview ? `body: ${JSON.stringify(bodyPreview)}` : null,
119
120
  ]
120
121
  .filter(Boolean)
121
- .join(', ');
122
+ .join(", ");
122
123
 
123
124
  throw new Error(`HTTP error while downloading: ${details}`);
124
125
  }
125
126
  if (!res.body) {
126
- throw new Error('Empty response body');
127
+ throw new Error("Empty response body");
127
128
  }
128
129
 
129
130
  await pipeline(
@@ -131,7 +132,7 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
131
132
  fs.createWriteStream(destinationPath),
132
133
  );
133
134
 
134
- return res.headers.get('content-type') || '';
135
+ return res.headers.get("content-type") || "";
135
136
  } catch (e) {
136
137
  lastError = e;
137
138
  if (attempt < MAX_ATTEMPTS) {
@@ -155,19 +156,19 @@ const getVideoNormalizationReason = (filePath) => {
155
156
  try {
156
157
  const raw = execSync(
157
158
  `ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,codec_tag_string,pix_fmt -of json "${filePath}"`,
158
- { encoding: 'utf8' },
159
+ { encoding: "utf8" },
159
160
  );
160
161
  const info = JSON.parse(raw);
161
162
  const vs = info?.streams?.[0];
162
163
  if (!vs) return null;
163
164
 
164
- const codec = (vs.codec_name || '').toLowerCase();
165
- const tag = (vs.codec_tag_string || '').toLowerCase();
166
- if (['hevc', 'h265'].includes(codec) || ['hvc1', 'hev1'].includes(tag)) {
167
- return 'HEVC/H.265 is not supported by Chromium headless';
165
+ const codec = (vs.codec_name || "").toLowerCase();
166
+ const tag = (vs.codec_tag_string || "").toLowerCase();
167
+ if (["hevc", "h265"].includes(codec) || ["hvc1", "hev1"].includes(tag)) {
168
+ return "HEVC/H.265 is not supported by Chromium headless";
168
169
  }
169
- const pix = (vs.pix_fmt || '').toLowerCase();
170
- if (pix && pix !== 'yuv420p') {
170
+ const pix = (vs.pix_fmt || "").toLowerCase();
171
+ if (pix && pix !== "yuv420p") {
171
172
  return `pixel format "${pix}" is not the preferred yuv420p`;
172
173
  }
173
174
  return null;
@@ -178,7 +179,7 @@ const getVideoNormalizationReason = (filePath) => {
178
179
 
179
180
  const isFfmpegAvailable = () => {
180
181
  try {
181
- execSync('ffmpeg -version', { stdio: 'ignore' });
182
+ execSync("ffmpeg -version", { stdio: "ignore" });
182
183
  return true;
183
184
  } catch {
184
185
  return false;
@@ -189,7 +190,7 @@ const getAudioCodec = (filePath) => {
189
190
  try {
190
191
  const result = execSync(
191
192
  `ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`,
192
- { encoding: 'utf8' },
193
+ { encoding: "utf8" },
193
194
  );
194
195
  return result.trim();
195
196
  } catch {
@@ -199,7 +200,7 @@ const getAudioCodec = (filePath) => {
199
200
 
200
201
  const convertAudioToAac = (inputPath, outputPath) => {
201
202
  execSync(`ffmpeg -y -i "${inputPath}" -c:v copy -c:a aac "${outputPath}"`, {
202
- stdio: 'ignore',
203
+ stdio: "ignore",
203
204
  });
204
205
  };
205
206
 
@@ -216,7 +217,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
216
217
  if (isDataOrFileSrc(src)) continue;
217
218
  try {
218
219
  const u = new URL(src);
219
- if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
220
+ if (u.protocol !== "http:" && u.protocol !== "https:") continue;
220
221
  } catch (e) {
221
222
  continue;
222
223
  }
@@ -224,7 +225,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
224
225
  rewriteMap.set(src, null);
225
226
  urlsToDownload.push(src);
226
227
  }
227
- if (p.kind === 'video') videoUrls.add(src);
228
+ if (p.kind === "video") videoUrls.add(src);
228
229
  }
229
230
 
230
231
  if (urlsToDownload.length === 0) {
@@ -232,7 +233,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
232
233
  }
233
234
 
234
235
  const tempDir = await fs.promises.mkdtemp(
235
- path.join(os.tmpdir(), 'polotno-node-assets-'),
236
+ path.join(os.tmpdir(), "polotno-node-assets-"),
236
237
  );
237
238
 
238
239
  let didCleanup = false;
@@ -247,7 +248,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
247
248
  urlsToDownload.map(async (src) => {
248
249
  const tmpBase = crypto.randomUUID
249
250
  ? crypto.randomUUID()
250
- : crypto.randomBytes(16).toString('hex');
251
+ : crypto.randomBytes(16).toString("hex");
251
252
  const tmpPath = path.join(tempDir, tmpBase);
252
253
  const contentType = await downloadUrlToFile(src, tmpPath);
253
254
  const ext = guessExtension(src, contentType);
@@ -257,10 +258,10 @@ const prepareLocalMediaForVideoExport = async (json) => {
257
258
  if (videoUrls.has(src)) {
258
259
  const reason = getVideoNormalizationReason(finalPath);
259
260
  if (reason) {
260
- const normalizedPath = finalPath + '.normalized.mp4';
261
+ const normalizedPath = finalPath + ".normalized.mp4";
261
262
  execSync(
262
263
  `ffmpeg -y -i "${finalPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${normalizedPath}"`,
263
- { stdio: 'ignore' },
264
+ { stdio: "ignore" },
264
265
  );
265
266
  finalPath = normalizedPath;
266
267
  }
@@ -285,13 +286,30 @@ const prepareLocalMediaForVideoExport = async (json) => {
285
286
  }
286
287
  };
287
288
 
288
- module.exports.createPage = async (browser, url, requestInterceptor) => {
289
+ module.exports.createPage = async (
290
+ browser,
291
+ url,
292
+ requestInterceptor,
293
+ useFontCache = true,
294
+ ) => {
289
295
  const page = await browser.newPage();
290
296
 
291
297
  // Attach request interceptor if provided
292
- if (typeof requestInterceptor === 'function') {
298
+ if (useFontCache || typeof requestInterceptor === "function") {
293
299
  await page.setRequestInterception(true);
294
- page.on('request', requestInterceptor);
300
+ page.on("request", (request) => {
301
+ if (useFontCache && fontCache.tryRespond(request)) {
302
+ return;
303
+ }
304
+ if (typeof requestInterceptor === "function") {
305
+ requestInterceptor(request);
306
+ } else {
307
+ request.continue().catch(() => {});
308
+ }
309
+ });
310
+ if (useFontCache) {
311
+ page.on("response", (response) => fontCache.capture(response));
312
+ }
295
313
  }
296
314
 
297
315
  // we need to make sure that headless is inside user agent
@@ -299,32 +317,32 @@ module.exports.createPage = async (browser, url, requestInterceptor) => {
299
317
  // but also lets NOT just overwrite default one, because Google Fonts may send "reduced" version of font
300
318
  // so some languages may not work
301
319
  const userAgent = await page.evaluate(() => navigator.userAgent);
302
- if (userAgent.indexOf('Headless') === -1) {
303
- await page.setUserAgent(userAgent + ' Chrome Headless');
320
+ if (userAgent.indexOf("Headless") === -1) {
321
+ await page.setUserAgent(userAgent + " Chrome Headless");
304
322
  }
305
323
 
306
- page.on('console', (msg) => {
324
+ page.on("console", (msg) => {
307
325
  msg.args().forEach(async (message) => {
308
326
  // skip style information
309
- if (message.toString().indexOf('margin') >= 0) {
327
+ if (message.toString().indexOf("margin") >= 0) {
310
328
  return;
311
329
  }
312
330
 
313
331
  const { subtype, description } = message.remoteObject();
314
332
 
315
- if (subtype === 'error') {
333
+ if (subtype === "error") {
316
334
  console.error(description);
317
335
  return;
318
336
  }
319
337
 
320
338
  const text = message
321
339
  .toString()
322
- .replace('JSHandle:', '')
323
- .replace('%c', '');
340
+ .replace("JSHandle:", "")
341
+ .replace("%c", "");
324
342
  console.log(text);
325
343
  });
326
344
  });
327
- page.on('pageerror', (msg) => {
345
+ page.on("pageerror", (msg) => {
328
346
  console.error(msg);
329
347
  });
330
348
  await page.goto(url);
@@ -335,42 +353,81 @@ module.exports.run = async (page, func, args) => {
335
353
  return await page.evaluate(func, ...args);
336
354
  };
337
355
 
356
+ // In-page export recipes. These run inside the browser (via page.evaluate / run),
357
+ // so they may only reference page globals (`store`, `window`) — not module scope.
358
+ // Defining them once lets the instance methods and the module-level page-taking
359
+ // exports share a single recipe per export format.
360
+ const recipeToDataURL = async (json, attrs) => {
361
+ const pixelRatio = attrs.pixelRatio || 1;
362
+ store.loadJSON(json);
363
+ store.setElementsPixelRatio(pixelRatio);
364
+ await store.waitLoading();
365
+ return store.toDataURL({ ...attrs, pixelRatio });
366
+ };
367
+ const recipeToPDFDataURL = async (json, attrs) => {
368
+ store.loadJSON(json);
369
+ // Honor an `onProgress` callback exposed by the instance `run()`. When it is
370
+ // not exposed (e.g. the module-level page-taking export) this is a no-op.
371
+ if (window.onProgress) {
372
+ attrs.onProgress = (progress) => {
373
+ window.onProgress(progress);
374
+ };
375
+ }
376
+ await store.waitLoading();
377
+ return await store.toPDFDataURL(attrs);
378
+ };
379
+ const recipeToBlob = async (json, attrs) => {
380
+ store.loadJSON(json);
381
+ await store.waitLoading();
382
+ return await store.toBlob(attrs);
383
+ };
384
+ const recipeToGIFDataURL = async (json, attrs) => {
385
+ store.loadJSON(json);
386
+ return await store.toGIFDataURL(attrs);
387
+ };
388
+
338
389
  module.exports.jsonToDataURL = async (page, json, attrs) => {
339
- return await page.evaluate(
340
- async (json, attrs) => {
341
- const pixelRatio = attrs.pixelRatio || 1;
342
- store.loadJSON(json);
343
- store.setElementsPixelRatio(pixelRatio);
344
- await store.waitLoading();
345
- return store.toDataURL({ ...attrs, pixelRatio });
346
- },
347
- json,
348
- attrs || {},
349
- );
390
+ return await page.evaluate(recipeToDataURL, json, attrs || {});
350
391
  };
351
392
 
352
393
  module.exports.jsonToPDFDataURL = async (page, json, attrs) => {
353
- return page.evaluate(
354
- async (json, attrs) => {
355
- store.loadJSON(json);
356
- await store.waitLoading();
357
- return await store.toPDFDataURL(attrs);
358
- },
359
- json,
360
- attrs || {},
361
- );
394
+ return page.evaluate(recipeToPDFDataURL, json, attrs || {});
362
395
  };
363
396
 
364
397
  module.exports.jsonToBlob = async (page, json, attrs) => {
365
- return page.evaluate(
366
- async (json, attrs) => {
367
- store.loadJSON(json);
368
- await store.waitLoading();
369
- return await store.toBlob(attrs);
370
- },
371
- json,
372
- attrs || {},
373
- );
398
+ return page.evaluate(recipeToBlob, json, attrs || {});
399
+ };
400
+
401
+ // Render options that map directly to a `window.config.*` setter on the client.
402
+ // `arg: "value"` passes the option value through; `arg: true` calls the setter
403
+ // with a literal `true` (the option is only a boolean flag).
404
+ const CONFIG_OPTION_SETTERS = [
405
+ { option: "assetLoadTimeout", method: "setAssetLoadTimeout", arg: "value" },
406
+ { option: "fontLoadTimeout", method: "setFontLoadTimeout", arg: "value" },
407
+ { option: "legacyRichTextEnabled", method: "setLegacyRichTextEnabled", arg: true },
408
+ { option: "textVerticalResizeEnabled", method: "setTextVerticalResizeEnabled", arg: true },
409
+ { option: "textSplitAllowed", method: "unstable_setTextSplitAllowed", arg: true },
410
+ { option: "textOverflow", method: "setTextOverflow", arg: "value" },
411
+ ];
412
+
413
+ // Apply the option→config setters above to a page in a single round-trip.
414
+ const applyConfigOptions = async (page, attrs = {}) => {
415
+ const calls = [];
416
+ for (const { option, method, arg } of CONFIG_OPTION_SETTERS) {
417
+ if (attrs[option]) {
418
+ calls.push({ method, value: arg === "value" ? attrs[option] : arg });
419
+ }
420
+ }
421
+ if (calls.length === 0) return;
422
+ await page.evaluate((calls) => {
423
+ for (const { method, value } of calls) {
424
+ if (window.config && window.config[method]) {
425
+ window.config[method](value);
426
+ } else {
427
+ console.error(`${method} function is not defined in the client.`);
428
+ }
429
+ }
430
+ }, calls);
374
431
  };
375
432
 
376
433
  const busyPages = [];
@@ -383,12 +440,18 @@ module.exports.createInstance = async ({
383
440
  useParallelPages = true,
384
441
  browser,
385
442
  requestInterceptor,
443
+ useFontCache = true,
386
444
  } = {}) => {
387
445
  const visitPage = url || `${DEFAULT_CLIENT}?key=${key}`;
388
446
  const hasCustomClientUrl = Boolean(url);
389
447
  const firstPage = useParallelPages
390
448
  ? null
391
- : await module.exports.createPage(browser, visitPage, requestInterceptor);
449
+ : await module.exports.createPage(
450
+ browser,
451
+ visitPage,
452
+ requestInterceptor,
453
+ useFontCache,
454
+ );
392
455
 
393
456
  async function ensureAssetErrorHelpers(page) {
394
457
  await page.evaluate(() => {
@@ -403,10 +466,10 @@ module.exports.createInstance = async ({
403
466
  window._polotnoError = null;
404
467
 
405
468
  const message = String(error);
406
- const isFontError = message.indexOf('Timeout for loading font') >= 0;
469
+ const isFontError = message.indexOf("Timeout for loading font") >= 0;
407
470
  const skipFontError = isFontError && attrs && attrs.skipFontError;
408
471
 
409
- const isImageError = message.indexOf('image ') >= 0;
472
+ const isImageError = message.indexOf("image ") >= 0;
410
473
  const skipImageError = isImageError && attrs && attrs.skipImageError;
411
474
 
412
475
  if (skipFontError || skipImageError) {
@@ -443,12 +506,17 @@ module.exports.createInstance = async ({
443
506
 
444
507
  const run = async (func, ...args) => {
445
508
  const page = useParallelPages
446
- ? await module.exports.createPage(browser, visitPage, requestInterceptor)
509
+ ? await module.exports.createPage(
510
+ browser,
511
+ visitPage,
512
+ requestInterceptor,
513
+ useFontCache,
514
+ )
447
515
  : firstPage;
448
516
 
449
517
  if (busyPages.indexOf(page) >= 0) {
450
518
  throw new Error(
451
- 'Current rendering context is busy with another task. Please use `useParallelPages: true` option to run multiple tasks in parallel or make sure previous task is finished before starting a new one.',
519
+ "Current rendering context is busy with another task. Please use `useParallelPages: true` option to run multiple tasks in parallel or make sure previous task is finished before starting a new one.",
452
520
  );
453
521
  }
454
522
 
@@ -459,69 +527,19 @@ module.exports.createInstance = async ({
459
527
  const profilePath = args[1]?.profilePath;
460
528
  if (profilePath) {
461
529
  cdpSession = await page.target().createCDPSession();
462
- await cdpSession.send('Profiler.enable');
463
- await cdpSession.send('Profiler.start');
530
+ await cdpSession.send("Profiler.enable");
531
+ await cdpSession.send("Profiler.start");
464
532
  }
465
533
 
466
534
  try {
467
- if (args[1]?.assetLoadTimeout) {
468
- await page.evaluate((timeout) => {
469
- if (window.config && window.config.setAssetLoadTimeout) {
470
- window.config.setAssetLoadTimeout(timeout);
471
- } else {
472
- console.error(
473
- 'setAssetLoadTimeout function is not defined in the client.',
474
- );
475
- }
476
- }, args[1].assetLoadTimeout);
477
- }
478
- if (args[1]?.fontLoadTimeout) {
479
- await page.evaluate((timeout) => {
480
- if (window.config && window.config.setFontLoadTimeout) {
481
- window.config.setFontLoadTimeout(timeout);
482
- } else {
483
- console.error(
484
- 'setFontLoadTimeout function is not defined in the client.',
485
- );
486
- }
487
- }, args[1].fontLoadTimeout);
488
- }
489
- if (args[1]?.htmlTextRenderEnabled || args[1]?.richTextEnabled) {
490
- await page.evaluate(() => {
491
- if (window.config && window.config.setRichTextEnabled) {
492
- window.config.setRichTextEnabled(true);
493
- } else {
494
- console.error(
495
- 'setRichTextEnabled function is not defined in the client.',
496
- );
497
- }
498
- });
499
- }
500
- if (args[1]?.textVerticalResizeEnabled) {
501
- await page.evaluate(() => {
502
- if (window.config && window.config.setTextVerticalResizeEnabled) {
503
- window.config.setTextVerticalResizeEnabled(true);
504
- } else {
505
- console.error(
506
- 'setTextVerticalResizeEnabled function is not defined in the client.',
507
- );
508
- }
509
- });
510
- }
511
- if (args[1]?.textSplitAllowed) {
512
- await page.evaluate(() => {
513
- if (window.config && window.config.unstable_setTextSplitAllowed) {
514
- window.config.unstable_setTextSplitAllowed(true);
515
- } else {
516
- console.error(
517
- 'unstable_setTextSplitAllowed function is not defined in the client.',
518
- );
519
- }
520
- });
521
- }
535
+ // `htmlTextRenderEnabled`, `richTextEnabled` and `renderTagTextEnabled` options
536
+ // are no-op since polotno 4 - the new text rendering engine is always on.
537
+ // They are still accepted for backwards compatibility.
538
+ // All direct `window.config.*` setters are applied from one table.
539
+ await applyConfigOptions(page, args[1]);
522
540
  if (args[1]?.onProgress) {
523
541
  const originalProgress = args[1].onProgress;
524
- await page.exposeFunction('onProgress', async (progress, frameTime) => {
542
+ await page.exposeFunction("onProgress", async (progress, frameTime) => {
525
543
  originalProgress(progress, frameTime);
526
544
  });
527
545
  }
@@ -531,17 +549,6 @@ module.exports.createInstance = async ({
531
549
  await page.exposeFunction(name, fn);
532
550
  }
533
551
  }
534
- if (args[1]?.textOverflow) {
535
- await page.evaluate((overflow) => {
536
- if (window.config && window.config.setTextOverflow) {
537
- window.config.setTextOverflow(overflow);
538
- } else {
539
- console.error(
540
- 'setTextOverflow function is not defined in the client.',
541
- );
542
- }
543
- }, args[1].textOverflow);
544
- }
545
552
  await page.evaluate(() => {
546
553
  if (window.config?.onLoadError) {
547
554
  window.config.onLoadError((error) => {
@@ -549,7 +556,7 @@ module.exports.createInstance = async ({
549
556
  });
550
557
  } else {
551
558
  console.error(
552
- 'onLoadError function is not defined in the client. Error handling will not work.',
559
+ "onLoadError function is not defined in the client. Error handling will not work.",
553
560
  );
554
561
  }
555
562
  });
@@ -558,7 +565,7 @@ module.exports.createInstance = async ({
558
565
 
559
566
  // Stop profiler and save profile if enabled
560
567
  if (cdpSession && profilePath) {
561
- const { profile } = await cdpSession.send('Profiler.stop');
568
+ const { profile } = await cdpSession.send("Profiler.stop");
562
569
  fs.writeFileSync(profilePath, JSON.stringify(profile));
563
570
  console.log(`CPU profile saved to: ${profilePath}`);
564
571
  }
@@ -579,13 +586,13 @@ module.exports.createInstance = async ({
579
586
  } catch (e) {
580
587
  // Stop profiler and save profile even on error
581
588
  if (cdpSession && profilePath) {
582
- console.log('saving profile');
589
+ console.log("saving profile");
583
590
  try {
584
- const { profile } = await cdpSession.send('Profiler.stop');
591
+ const { profile } = await cdpSession.send("Profiler.stop");
585
592
  fs.writeFileSync(profilePath, JSON.stringify(profile));
586
593
  console.log(`CPU profile saved to: ${profilePath}`);
587
594
  } catch (profileError) {
588
- console.error('Failed to save profile:', profileError.message);
595
+ console.error("Failed to save profile:", profileError.message);
589
596
  }
590
597
  }
591
598
 
@@ -608,65 +615,35 @@ module.exports.createInstance = async ({
608
615
  };
609
616
 
610
617
  const jsonToDataURL = async (json, attrs) => {
611
- return await run(
612
- async (json, attrs) => {
613
- const pixelRatio = attrs.pixelRatio || 1;
614
- store.loadJSON(json);
615
- store.setElementsPixelRatio(pixelRatio);
616
- await store.waitLoading();
617
- return store.toDataURL({ ...attrs, pixelRatio });
618
- },
619
- json,
620
- attrs || {},
621
- );
618
+ return await run(recipeToDataURL, json, attrs || {});
622
619
  };
623
620
 
624
621
  const jsonToGIFDataURL = async (json, attrs) => {
625
- return await run(
626
- async (json, attrs) => {
627
- store.loadJSON(json);
628
- return await store.toGIFDataURL(attrs);
629
- },
630
- json,
631
- attrs || {},
632
- );
622
+ return await run(recipeToGIFDataURL, json, attrs || {});
633
623
  };
634
624
 
635
625
  const jsonToGIFBase64 = async (json, attrs) => {
636
626
  const url = await jsonToGIFDataURL(json, attrs);
637
- return url.split('base64,')[1];
627
+ return url.split("base64,")[1];
638
628
  };
639
629
 
640
630
  const jsonToImageBase64 = async (json, attrs) => {
641
631
  const url = await jsonToDataURL(json, attrs);
642
- return url.split('base64,')[1];
632
+ return url.split("base64,")[1];
643
633
  };
644
634
 
645
635
  const jsonToPDFDataURL = async (json, attrs) => {
646
- return await run(
647
- async (json, attrs) => {
648
- store.loadJSON(json);
649
- if (window.onProgress) {
650
- attrs.onProgress = (progress) => {
651
- window.onProgress(progress);
652
- };
653
- }
654
- await store.waitLoading();
655
- return await store.toPDFDataURL(attrs);
656
- },
657
- json,
658
- attrs || {},
659
- );
636
+ return await run(recipeToPDFDataURL, json, attrs || {});
660
637
  };
661
638
 
662
639
  const jsonToPDFBase64 = async (json, attrs) => {
663
640
  const url = await jsonToPDFDataURL(json, attrs);
664
- return url.split('base64,')[1];
641
+ return url.split("base64,")[1];
665
642
  };
666
643
 
667
644
  const jsonToBlob = async (json, attrs) => {
668
645
  const base64 = await jsonToImageBase64(json, attrs);
669
- const blob = Buffer.from(base64, 'base64');
646
+ const blob = Buffer.from(base64, "base64");
670
647
  return blob;
671
648
  };
672
649
 
@@ -705,11 +682,11 @@ module.exports.createInstance = async ({
705
682
 
706
683
  // For video export, we always use 'resize' text overflow mode
707
684
  // to ensure text fits properly in animations. User preferences are ignored for now.
708
- window.config.setTextOverflow('resize');
685
+ window.config.setTextOverflow("resize");
709
686
 
710
687
  if (!window.__polotnoLoadVideoExport) {
711
688
  throw new Error(
712
- 'Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().',
689
+ "Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().",
713
690
  );
714
691
  }
715
692
 
@@ -772,7 +749,7 @@ module.exports.createInstance = async ({
772
749
  ) {
773
750
  const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
774
751
  const chunk = uint8Array.slice(offset, end);
775
- let binary = '';
752
+ let binary = "";
776
753
  for (let i = 0; i < chunk.length; i++) {
777
754
  binary += String.fromCharCode(chunk[i]);
778
755
  }
@@ -786,7 +763,7 @@ module.exports.createInstance = async ({
786
763
  ...attrs,
787
764
  exposeFunctions: {
788
765
  __polotnoWriteChunk: (base64Chunk) => {
789
- const buffer = Buffer.from(base64Chunk, 'base64');
766
+ const buffer = Buffer.from(base64Chunk, "base64");
790
767
  writeStream.write(buffer);
791
768
  },
792
769
  },
@@ -794,13 +771,13 @@ module.exports.createInstance = async ({
794
771
  );
795
772
 
796
773
  writeStream.end();
797
- await new Promise((resolve) => writeStream.on('finish', resolve));
774
+ await new Promise((resolve) => writeStream.on("finish", resolve));
798
775
 
799
776
  // Convert audio to AAC if needed
800
777
  if (isFfmpegAvailable()) {
801
778
  const codec = getAudioCodec(outputPath);
802
- if (codec && codec !== 'aac') {
803
- const tempPath = outputPath + '.tmp.mp4';
779
+ if (codec && codec !== "aac") {
780
+ const tempPath = outputPath + ".tmp.mp4";
804
781
  convertAudioToAac(outputPath, tempPath);
805
782
  await fs.promises.rename(tempPath, outputPath);
806
783
  }
@@ -820,8 +797,8 @@ module.exports.createInstance = async ({
820
797
  try {
821
798
  const mimeType = await jsonToVideoFile(json, tempPath, attrs);
822
799
  const buffer = await fs.promises.readFile(tempPath);
823
- return `data:${mimeType || 'video/mp4'};base64,${buffer.toString(
824
- 'base64',
800
+ return `data:${mimeType || "video/mp4"};base64,${buffer.toString(
801
+ "base64",
825
802
  )}`;
826
803
  } finally {
827
804
  await fs.promises.rm(tempPath, { force: true });
@@ -836,7 +813,7 @@ module.exports.createInstance = async ({
836
813
  try {
837
814
  await jsonToVideoFile(json, tempPath, attrs);
838
815
  const buffer = await fs.promises.readFile(tempPath);
839
- return buffer.toString('base64');
816
+ return buffer.toString("base64");
840
817
  } finally {
841
818
  await fs.promises.rm(tempPath, { force: true });
842
819
  }
@@ -861,7 +838,12 @@ module.exports.createInstance = async ({
861
838
  jsonToVideoBase64,
862
839
  jsonToVideoFile,
863
840
  createPage: async () =>
864
- await module.exports.createPage(browser, visitPage, requestInterceptor),
841
+ await module.exports.createPage(
842
+ browser,
843
+ visitPage,
844
+ requestInterceptor,
845
+ useFontCache,
846
+ ),
865
847
  };
866
848
 
867
849
  createdInstances.push(instance);
@@ -874,7 +856,7 @@ const closeAllInstances = async () => {
874
856
  };
875
857
 
876
858
  // Also handle other termination signals
877
- ['SIGINT', 'SIGTERM', 'SIGQUIT', 'exit', 'exit'].forEach((signal) => {
859
+ ["SIGINT", "SIGTERM", "SIGQUIT", "exit", "exit"].forEach((signal) => {
878
860
  process.on(signal, () => {
879
861
  closeAllInstances();
880
862
  });