@bendyline/squisq-formats 2.8.14 → 2.8.16

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.
@@ -21,7 +21,6 @@ import {
21
21
 
22
22
  // src/pdf/export.ts
23
23
  import { PDFDocument, StandardFonts, rgb, PDFString } from "pdf-lib";
24
- import fontkit2 from "@pdf-lib/fontkit";
25
24
  import { contrastRatio } from "@bendyline/squisq/schemas";
26
25
  import { docToMarkdown, resolveThemeForDoc } from "@bendyline/squisq/doc";
27
26
  import { isHtmlLineBreak, readFrontmatterThemeId, sanitizeUrl } from "@bendyline/squisq/markdown";
@@ -328,56 +327,168 @@ var WinAnsiTracker = class {
328
327
  }
329
328
  /**
330
329
  * One-shot summary for the export, or undefined when nothing was lost.
331
- * `unicodeFont` says an embedded Unicode font already drew what it could, so
332
- * what remains is beyond it too (CJK and emoji, with the bundled faces).
330
+ * `unicodeFont` says embedded Unicode fonts already drew what they could, so
331
+ * what remains is beyond them too (Thai and the Indic scripts, with the
332
+ * bundled faces).
333
333
  */
334
334
  warning(options = {}) {
335
335
  if (this.count === 0) return void 0;
336
336
  if (options.unicodeFont) {
337
- return `PDF export replaced ${this.count} character(s) that its fonts cannot draw (${this.samples.join(", ")}). The PDF fonts do not cover CJK scripts or most emoji.`;
337
+ return `PDF export replaced ${this.count} character(s) that its fonts cannot draw (${this.samples.join(", ")}). The PDF fonts do not cover every script, such as Thai or Devanagari.`;
338
338
  }
339
339
  return `PDF export replaced ${this.count} character(s) that the standard PDF fonts cannot encode (${this.samples.join(", ")}). Scripts such as emoji, CJK and Cyrillic require an embedded Unicode font.`;
340
340
  }
341
341
  };
342
342
 
343
- // src/pdf/unicodeText.ts
343
+ // src/pdf/fontkitSubset.ts
344
344
  import fontkit from "@pdf-lib/fontkit";
345
- async function loadBundledUnicodeFonts() {
346
- const { dejaVuFontData } = await import("./unicodeFontData.generated-YY6T2K5S.js");
347
- return dejaVuFontData();
345
+ function padGlyphsToEvenLength(subset) {
346
+ const target = subset;
347
+ const addGlyph = target._addGlyph;
348
+ if (typeof addGlyph !== "function") return;
349
+ target._addGlyph = function(glyphId) {
350
+ const index = addGlyph.call(this, glyphId);
351
+ const data = this.glyf[index];
352
+ if (data && data.length % 2 === 1) {
353
+ const Buffer = data.constructor;
354
+ this.glyf[index] = Buffer.concat([data, Buffer.alloc(1)]);
355
+ this.offset += 1;
356
+ }
357
+ return index;
358
+ };
359
+ }
360
+ var subsetSafeFontkit = {
361
+ create(buffer, postscriptName) {
362
+ const font = fontkit.create(buffer, postscriptName);
363
+ if (typeof font.createSubset === "function") {
364
+ const createSubset = font.createSubset.bind(font);
365
+ font.createSubset = () => {
366
+ const subset = createSubset();
367
+ padGlyphsToEvenLength(subset);
368
+ return subset;
369
+ };
370
+ }
371
+ return font;
372
+ }
373
+ };
374
+
375
+ // src/pdf/lineBreaks.ts
376
+ var BREAKS_BETWEEN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\u3000-\u303f\uff00-\uffef]/u;
377
+ var NO_LINE_START = /[.,!?;:)\]}、。,.:;!?)]}」』】〉》〕・ー々ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ]/u;
378
+ var NO_LINE_END = /[([{「『【〈《〔]/u;
379
+ function breakBetweenCjk(word) {
380
+ if (!BREAKS_BETWEEN.test(word)) return [word];
381
+ const pieces = [];
382
+ let piece = "";
383
+ let previousBreaks = false;
384
+ let previousOpens = false;
385
+ for (const ch of word) {
386
+ const breaks = BREAKS_BETWEEN.test(ch);
387
+ if (piece && (breaks || previousBreaks) && !previousOpens && !NO_LINE_START.test(ch)) {
388
+ pieces.push(piece);
389
+ piece = "";
390
+ }
391
+ piece += ch;
392
+ previousBreaks = breaks;
393
+ previousOpens = NO_LINE_END.test(ch);
394
+ }
395
+ if (piece) pieces.push(piece);
396
+ return pieces;
397
+ }
398
+
399
+ // src/pdf/unicodeText.ts
400
+ import fontkit2 from "@pdf-lib/fontkit";
401
+ var EMOJI_TEXT = /[\p{Extended_Pictographic}\p{Regional_Indicator}]/u;
402
+ var HAN_OR_KANA_TEXT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}\u3000-\u303f\uff00-\uffef]/u;
403
+ var HANGUL_TEXT = /\p{Script=Hangul}/u;
404
+ async function loadBundledUnicodeFonts(root) {
405
+ const text = root === void 0 ? "" : unencodableCharacters(root);
406
+ const [{ dejaVuFontData }, emoji, cjk, hangul] = await Promise.all([
407
+ import("./unicodeFontData.generated-YY6T2K5S.js"),
408
+ EMOJI_TEXT.test(text) ? import("./emojiFontData.generated-EPQOUGVZ.js").then((m) => m.notoEmojiFontData()) : void 0,
409
+ HAN_OR_KANA_TEXT.test(text) ? import("./cjkFontData.generated-LJKMYRHP.js").then((m) => m.notoSansScFontData()) : void 0,
410
+ HANGUL_TEXT.test(text) ? import("./hangulFontData.generated-CVLPZH2N.js").then((m) => m.notoSansKrFontData()) : void 0
411
+ ]);
412
+ return {
413
+ ...dejaVuFontData(),
414
+ emoji,
415
+ fallbacks: [cjk, hangul].filter((face) => face !== void 0)
416
+ };
417
+ }
418
+ function isUnencodable(codePoint) {
419
+ return !isWinAnsiEncodable(codePoint) && !isInvisibleOrSpace(codePoint);
348
420
  }
349
421
  function hasUnencodableText(value) {
350
422
  for (const ch of value) {
351
- const codePoint = ch.codePointAt(0);
352
- if (!isWinAnsiEncodable(codePoint) && !isInvisibleOrSpace(codePoint)) return true;
423
+ if (isUnencodable(ch.codePointAt(0))) return true;
353
424
  }
354
425
  return false;
355
426
  }
356
- function needsUnicodeFont(root) {
427
+ function forEachString(root, visit) {
357
428
  const pending = [root];
358
429
  while (pending.length > 0) {
359
430
  const value = pending.pop();
360
431
  if (typeof value === "string") {
361
- if (hasUnencodableText(value)) return true;
432
+ if (visit(value) === true) return;
362
433
  } else if (Array.isArray(value)) {
363
434
  pending.push(...value);
364
435
  } else if (value && typeof value === "object") {
365
436
  pending.push(...Object.values(value));
366
437
  }
367
438
  }
368
- return false;
369
439
  }
440
+ function needsUnicodeFont(root) {
441
+ let found = false;
442
+ forEachString(root, (value) => found = hasUnencodableText(value));
443
+ return found;
444
+ }
445
+ function unencodableCharacters(root) {
446
+ const characters = /* @__PURE__ */ new Set();
447
+ forEachString(root, (value) => {
448
+ for (const ch of value) {
449
+ if (isUnencodable(ch.codePointAt(0))) characters.add(ch);
450
+ }
451
+ });
452
+ return [...characters].join("");
453
+ }
454
+ function continuesCluster(ch, codePoint) {
455
+ return /\p{M}/u.test(ch) || // combining marks, variation selectors, the keycap
456
+ codePoint === 8205 || // zero-width joiner
457
+ codePoint >= 127995 && codePoint <= 127999 || // emoji skin tones
458
+ codePoint >= 917536 && codePoint <= 917631;
459
+ }
460
+ function isRegionalIndicator(codePoint) {
461
+ return codePoint >= 127462 && codePoint <= 127487;
462
+ }
463
+ function characterClusters(text) {
464
+ const clusters = [];
465
+ let loneRegionalIndicator = false;
466
+ for (const ch of text) {
467
+ const codePoint = ch.codePointAt(0);
468
+ const last = clusters.length - 1;
469
+ const joins = last >= 0 && (continuesCluster(ch, codePoint) || clusters[last].endsWith("\u200D") || loneRegionalIndicator && isRegionalIndicator(codePoint));
470
+ if (joins) {
471
+ clusters[last] += ch;
472
+ loneRegionalIndicator = false;
473
+ } else {
474
+ clusters.push(ch);
475
+ loneRegionalIndicator = isRegionalIndicator(codePoint);
476
+ }
477
+ }
478
+ return clusters;
479
+ }
480
+ var EMOJI_STYLE = /[\p{Emoji_Presentation}\p{Emoji_Modifier}\p{Regional_Indicator}\u{fe0f}\u{20e3}\u{e0020}-\u{e007f}]/u;
370
481
  function asBytes(data) {
371
482
  return data instanceof Uint8Array ? data : new Uint8Array(data);
372
483
  }
373
484
  async function embedUnicodeFaces(pdfDoc, fonts, standard) {
374
- pdfDoc.registerFontkit(fontkit);
485
+ pdfDoc.registerFontkit(subsetSafeFontkit);
375
486
  const faces = /* @__PURE__ */ new Map();
376
487
  const faceFor = (data) => {
377
488
  let face = faces.get(data);
378
489
  if (!face) {
379
490
  const bytes = asBytes(data);
380
- const metrics = fontkit.create(bytes);
491
+ const metrics = fontkit2.create(bytes);
381
492
  face = pdfDoc.embedFont(bytes, { subset: true }).then((font) => ({
382
493
  font,
383
494
  covers: (codePoint) => metrics.hasGlyphForCodePoint(codePoint)
@@ -386,18 +497,21 @@ async function embedUnicodeFaces(pdfDoc, fonts, standard) {
386
497
  }
387
498
  return face;
388
499
  };
389
- const [regular, bold, mono] = await Promise.all([
500
+ const [regular, bold, mono, emoji, fallbacks] = await Promise.all([
390
501
  faceFor(fonts.regular),
391
502
  faceFor(fonts.bold ?? fonts.regular),
392
- faceFor(fonts.mono ?? fonts.regular)
503
+ faceFor(fonts.mono ?? fonts.regular),
504
+ fonts.emoji ? faceFor(fonts.emoji) : void 0,
505
+ Promise.all((fonts.fallbacks ?? []).map(faceFor))
393
506
  ]);
507
+ const withPrimary = (primary) => ({ primary, fallbacks, emoji });
394
508
  return /* @__PURE__ */ new Map([
395
- [standard.regular, regular],
396
- [standard.italic, regular],
397
- [standard.bold, bold],
398
- [standard.boldItalic, bold],
399
- [standard.mono, mono],
400
- [standard.monoBold, mono]
509
+ [standard.regular, withPrimary(regular)],
510
+ [standard.italic, withPrimary(regular)],
511
+ [standard.bold, withPrimary(bold)],
512
+ [standard.boldItalic, withPrimary(bold)],
513
+ [standard.mono, withPrimary(mono)],
514
+ [standard.monoBold, withPrimary(mono)]
401
515
  ]);
402
516
  }
403
517
  var PdfTextRuns = class {
@@ -406,8 +520,8 @@ var PdfTextRuns = class {
406
520
  this.faces = faces;
407
521
  }
408
522
  split(text, font) {
409
- const face = this.faces.get(font);
410
- if (!face || !hasUnencodableText(text)) return [{ text: this.tracker.clean(text), font }];
523
+ const faces = this.faces.get(font);
524
+ if (!faces || !hasUnencodableText(text)) return [{ text: this.tracker.clean(text), font }];
411
525
  const runs = [];
412
526
  const push = (value, runFont) => {
413
527
  if (value.length === 0) return;
@@ -415,25 +529,48 @@ var PdfTextRuns = class {
415
529
  if (last && last.font === runFont) last.text += value;
416
530
  else runs.push({ text: value, font: runFont });
417
531
  };
418
- const drawable = (codePoint) => !isInvisibleOrSpace(codePoint) && face.covers(codePoint);
532
+ const { primary } = faces;
533
+ const drawable = (codePoint) => !isInvisibleOrSpace(codePoint) && primary.covers(codePoint);
419
534
  for (const word of text.normalize("NFC").split(/(\s+)/u)) {
420
535
  if (word.length === 0) continue;
421
536
  const codePoints = Array.from(word, (ch) => ch.codePointAt(0));
422
537
  if (codePoints.every(isWinAnsiEncodable)) {
423
538
  push(word, font);
424
- } else if (codePoints.every(drawable)) {
425
- push(word, face.font);
539
+ } else if (!EMOJI_STYLE.test(word) && codePoints.every(drawable)) {
540
+ push(word, primary.font);
426
541
  } else {
427
- for (const ch of word) {
428
- const codePoint = ch.codePointAt(0);
429
- if (isWinAnsiEncodable(codePoint)) push(ch, font);
430
- else if (drawable(codePoint)) push(ch, face.font);
431
- else push(this.tracker.clean(ch), font);
432
- }
542
+ for (const cluster of characterClusters(word))
543
+ this.splitCluster(cluster, font, faces, push);
433
544
  }
434
545
  }
435
546
  return runs.length > 0 ? runs : [{ text: "", font }];
436
547
  }
548
+ /** One reader-visible character: drawn whole by one face where one can. */
549
+ splitCluster(cluster, font, faces, push) {
550
+ const codePoints = Array.from(cluster, (ch) => ch.codePointAt(0));
551
+ if (codePoints.every(isWinAnsiEncodable)) {
552
+ push(cluster, font);
553
+ return;
554
+ }
555
+ const order = EMOJI_STYLE.test(cluster) ? [faces.emoji, faces.primary, ...faces.fallbacks] : [faces.primary, ...faces.fallbacks, faces.emoji];
556
+ const candidates = order.filter((face) => face !== void 0);
557
+ const visible = codePoints.filter((codePoint) => !isInvisibleOrSpace(codePoint));
558
+ const whole = visible.length > 0 ? candidates.find((face) => visible.every((codePoint) => face.covers(codePoint))) : void 0;
559
+ if (whole) {
560
+ push(
561
+ Array.from(cluster).filter((ch) => whole.covers(ch.codePointAt(0))).join(""),
562
+ whole.font
563
+ );
564
+ return;
565
+ }
566
+ for (const ch of cluster) {
567
+ const codePoint = ch.codePointAt(0);
568
+ const face = isInvisibleOrSpace(codePoint) ? void 0 : candidates.find((candidate) => candidate.covers(codePoint));
569
+ if (isWinAnsiEncodable(codePoint)) push(ch, font);
570
+ else if (face) push(ch, face.font);
571
+ else push(this.tracker.clean(ch), font);
572
+ }
573
+ }
437
574
  };
438
575
 
439
576
  // src/pdf/styles.ts
@@ -567,7 +704,7 @@ async function createExportContext(pdfDoc, options, doc) {
567
704
  ]);
568
705
  let unicodeFaces = /* @__PURE__ */ new Map();
569
706
  if (options.unicodeFonts !== false && needsUnicodeFont(doc)) {
570
- const faces = options.unicodeFonts ?? await loadBundledUnicodeFonts();
707
+ const faces = options.unicodeFonts ?? await loadBundledUnicodeFonts(doc);
571
708
  unicodeFaces = await embedUnicodeFaces(pdfDoc, faces, {
572
709
  regular,
573
710
  bold,
@@ -580,7 +717,7 @@ async function createExportContext(pdfDoc, options, doc) {
580
717
  const icons = {};
581
718
  const iconFamilies = collectInlineIconFamilies(doc);
582
719
  if (iconFamilies.size > 0) {
583
- pdfDoc.registerFontkit(fontkit2);
720
+ pdfDoc.registerFontkit(subsetSafeFontkit);
584
721
  await Promise.all(
585
722
  fontAwesomeFaces(iconFamilies).map(async (face) => {
586
723
  icons[face.family] = await pdfDoc.embedFont(face.data, { subset: true });
@@ -938,22 +1075,6 @@ function breakLongWord(word, span, maxWidth) {
938
1075
  if (piece) pieces.push(piece);
939
1076
  return pieces;
940
1077
  }
941
- function continuesCluster(ch) {
942
- const codePoint = ch.codePointAt(0);
943
- return /\p{M}/u.test(ch) || codePoint === 8205 || codePoint === 65038 || codePoint === 65039 || codePoint >= 127995 && codePoint <= 127999;
944
- }
945
- function characterClusters(text) {
946
- const clusters = [];
947
- for (const ch of text) {
948
- const last = clusters.length - 1;
949
- if (last >= 0 && (continuesCluster(ch) || clusters[last].endsWith("\u200D"))) {
950
- clusters[last] += ch;
951
- } else {
952
- clusters.push(ch);
953
- }
954
- }
955
- return clusters;
956
- }
957
1078
  function splitIntoWords(text) {
958
1079
  const tokens = [];
959
1080
  let current = "";
@@ -968,7 +1089,7 @@ function splitIntoWords(text) {
968
1089
  inSpace = isSpace;
969
1090
  }
970
1091
  if (current.length > 0) tokens.push(current);
971
- return tokens;
1092
+ return tokens.flatMap(breakBetweenCjk);
972
1093
  }
973
1094
  function getLineHeight(line) {
974
1095
  let maxSize = 0;
@@ -70,6 +70,7 @@ import {
70
70
  import { VIEWPORT_PRESETS } from "@bendyline/squisq/schemas";
71
71
  import {
72
72
  buildPreviewDoc,
73
+ coverRepeatsLeadingBlock,
73
74
  createTemplateContext,
74
75
  docToMarkdown,
75
76
  expandCoverBlock,
@@ -1366,13 +1367,18 @@ async function docToPptx(doc, options = {}) {
1366
1367
  Math.max(1, expanded.length + 1),
1367
1368
  VIEWPORT_PRESETS.landscape
1368
1369
  );
1369
- slideBlocks.push({
1370
- id: "cover-block",
1371
- startTime: -1,
1372
- duration: 0,
1373
- audioSegment: -1,
1374
- layers: expandCoverBlock(doc.startBlock, coverContext)
1375
- });
1370
+ const coverLayers = expandCoverBlock(doc.startBlock, coverContext);
1371
+ if (coverRepeatsLeadingBlock(doc) && expanded[0] && expanded[0].id === doc.blocks[0]?.id) {
1372
+ expanded[0] = { ...expanded[0], layers: coverLayers };
1373
+ } else {
1374
+ slideBlocks.push({
1375
+ id: "cover-block",
1376
+ startTime: -1,
1377
+ duration: 0,
1378
+ audioSegment: -1,
1379
+ layers: coverLayers
1380
+ });
1381
+ }
1376
1382
  }
1377
1383
  slideBlocks.push(...expanded);
1378
1384
  if (slideBlocks.length === 0) {