@bendyline/squisq-formats 2.7.1 → 2.7.3

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.
@@ -19,7 +19,7 @@ import {
19
19
 
20
20
  // src/pdf/export.ts
21
21
  import { PDFDocument, StandardFonts, rgb, PDFString } from "pdf-lib";
22
- import fontkit from "@pdf-lib/fontkit";
22
+ import fontkit2 from "@pdf-lib/fontkit";
23
23
  import { contrastRatio } from "@bendyline/squisq/schemas";
24
24
  import { docToMarkdown, resolveThemeForDoc } from "@bendyline/squisq/doc";
25
25
  import { readFrontmatterThemeId, sanitizeUrl } from "@bendyline/squisq/markdown";
@@ -269,6 +269,10 @@ var WHITESPACE_LIKE = /* @__PURE__ */ new Set([
269
269
  8288,
270
270
  65279
271
271
  ]);
272
+ var INVISIBLE_OR_SPACE = /^[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}]$/u;
273
+ function isInvisibleOrSpace(codePoint) {
274
+ return WHITESPACE_LIKE.has(codePoint) || INVISIBLE_OR_SPACE.test(String.fromCodePoint(codePoint));
275
+ }
272
276
  var MAX_SAMPLES = 5;
273
277
  var ASCII_ONLY = /^[\x20-\x7e]*$/;
274
278
  var COMBINING_MARKS = /[̀-ͯ]/g;
@@ -320,13 +324,116 @@ var WinAnsiTracker = class {
320
324
  get substitutions() {
321
325
  return this.count;
322
326
  }
323
- /** One-shot summary for the export, or undefined when nothing was lost. */
324
- warning() {
327
+ /**
328
+ * One-shot summary for the export, or undefined when nothing was lost.
329
+ * `unicodeFont` says an embedded Unicode font already drew what it could, so
330
+ * what remains is beyond it too (CJK and emoji, with the bundled faces).
331
+ */
332
+ warning(options = {}) {
325
333
  if (this.count === 0) return void 0;
334
+ if (options.unicodeFont) {
335
+ return `PDF export replaced ${this.count} character(s) that its fonts cannot draw (${this.samples.join(", ")}). Scripts such as CJK and emoji are not available in PDF export.`;
336
+ }
326
337
  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.`;
327
338
  }
328
339
  };
329
340
 
341
+ // src/pdf/unicodeText.ts
342
+ import fontkit from "@pdf-lib/fontkit";
343
+ async function loadBundledUnicodeFonts() {
344
+ const { dejaVuFontData } = await import("./unicodeFontData.generated-YY6T2K5S.js");
345
+ return dejaVuFontData();
346
+ }
347
+ function hasUnencodableText(value) {
348
+ for (const ch of value) {
349
+ const codePoint = ch.codePointAt(0);
350
+ if (!isWinAnsiEncodable(codePoint) && !isInvisibleOrSpace(codePoint)) return true;
351
+ }
352
+ return false;
353
+ }
354
+ function needsUnicodeFont(root) {
355
+ const pending = [root];
356
+ while (pending.length > 0) {
357
+ const value = pending.pop();
358
+ if (typeof value === "string") {
359
+ if (hasUnencodableText(value)) return true;
360
+ } else if (Array.isArray(value)) {
361
+ pending.push(...value);
362
+ } else if (value && typeof value === "object") {
363
+ pending.push(...Object.values(value));
364
+ }
365
+ }
366
+ return false;
367
+ }
368
+ function asBytes(data) {
369
+ return data instanceof Uint8Array ? data : new Uint8Array(data);
370
+ }
371
+ async function embedUnicodeFaces(pdfDoc, fonts, standard) {
372
+ pdfDoc.registerFontkit(fontkit);
373
+ const faces = /* @__PURE__ */ new Map();
374
+ const faceFor = (data) => {
375
+ let face = faces.get(data);
376
+ if (!face) {
377
+ const bytes = asBytes(data);
378
+ const metrics = fontkit.create(bytes);
379
+ face = pdfDoc.embedFont(bytes, { subset: true }).then((font) => ({
380
+ font,
381
+ covers: (codePoint) => metrics.hasGlyphForCodePoint(codePoint)
382
+ }));
383
+ faces.set(data, face);
384
+ }
385
+ return face;
386
+ };
387
+ const [regular, bold, mono] = await Promise.all([
388
+ faceFor(fonts.regular),
389
+ faceFor(fonts.bold ?? fonts.regular),
390
+ faceFor(fonts.mono ?? fonts.regular)
391
+ ]);
392
+ return /* @__PURE__ */ new Map([
393
+ [standard.regular, regular],
394
+ [standard.italic, regular],
395
+ [standard.bold, bold],
396
+ [standard.boldItalic, bold],
397
+ [standard.mono, mono],
398
+ [standard.monoBold, mono]
399
+ ]);
400
+ }
401
+ var PdfTextRuns = class {
402
+ constructor(tracker, faces = /* @__PURE__ */ new Map()) {
403
+ this.tracker = tracker;
404
+ this.faces = faces;
405
+ }
406
+ split(text, font) {
407
+ const face = this.faces.get(font);
408
+ if (!face || !hasUnencodableText(text)) return [{ text: this.tracker.clean(text), font }];
409
+ const runs = [];
410
+ const push = (value, runFont) => {
411
+ if (value.length === 0) return;
412
+ const last = runs[runs.length - 1];
413
+ if (last && last.font === runFont) last.text += value;
414
+ else runs.push({ text: value, font: runFont });
415
+ };
416
+ const drawable = (codePoint) => !isInvisibleOrSpace(codePoint) && face.covers(codePoint);
417
+ for (const word of text.normalize("NFC").split(/(\s+)/u)) {
418
+ if (word.length === 0) continue;
419
+ const codePoints = Array.from(word, (ch) => ch.codePointAt(0));
420
+ if (codePoints.every(isWinAnsiEncodable)) {
421
+ push(word, font);
422
+ } else if (codePoints.every(drawable)) {
423
+ push(word, face.font);
424
+ } else {
425
+ for (const ch of word) {
426
+ const codePoint = ch.codePointAt(0);
427
+ if (isWinAnsiEncodable(codePoint)) push(ch, font);
428
+ else if (drawable(codePoint)) push(ch, face.font);
429
+ else push(this.tracker.clean(ch), font);
430
+ }
431
+ }
432
+ }
433
+ return runs.length > 0 ? runs : [{ text: "", font }];
434
+ }
435
+ };
436
+
330
437
  // src/pdf/styles.ts
331
438
  var PAGE_WIDTH_LETTER = 612;
332
439
  var PAGE_HEIGHT_LETTER = 792;
@@ -401,7 +508,7 @@ async function markdownDocToPdf(doc, options = {}) {
401
508
  const ctx = await createExportContext(pdfDoc, options, doc);
402
509
  ctx.images = await embedDocumentImages(pdfDoc, doc, options.images);
403
510
  renderBlocks(doc.children, ctx, 0);
404
- const warning = ctx.text.warning();
511
+ const warning = ctx.text.warning({ unicodeFont: ctx.unicodeFont });
405
512
  if (warning) {
406
513
  if (options.onWarning) options.onWarning(warning);
407
514
  else console.warn(warning);
@@ -455,10 +562,22 @@ async function createExportContext(pdfDoc, options, doc) {
455
562
  pdfDoc.embedFont(StandardFonts.Courier),
456
563
  pdfDoc.embedFont(StandardFonts.CourierBold)
457
564
  ]);
565
+ let unicodeFaces = /* @__PURE__ */ new Map();
566
+ if (options.unicodeFonts !== false && needsUnicodeFont(doc)) {
567
+ const faces = options.unicodeFonts ?? await loadBundledUnicodeFonts();
568
+ unicodeFaces = await embedUnicodeFaces(pdfDoc, faces, {
569
+ regular,
570
+ bold,
571
+ italic,
572
+ boldItalic,
573
+ mono,
574
+ monoBold
575
+ });
576
+ }
458
577
  const icons = {};
459
578
  const iconFamilies = collectInlineIconFamilies(doc);
460
579
  if (iconFamilies.size > 0) {
461
- pdfDoc.registerFontkit(fontkit);
580
+ pdfDoc.registerFontkit(fontkit2);
462
581
  await Promise.all(
463
582
  fontAwesomeFaces(iconFamilies).map(async (face) => {
464
583
  icons[face.family] = await pdfDoc.embedFont(face.data, { subset: true });
@@ -483,6 +602,7 @@ async function createExportContext(pdfDoc, options, doc) {
483
602
  colorLink = paperInk(theme.colors.highlight || theme.colors.secondary, COLOR_LINK);
484
603
  }
485
604
  }
605
+ const text = new WinAnsiTracker();
486
606
  return {
487
607
  pdfDoc,
488
608
  fonts: { regular, bold, italic, boldItalic, mono, monoBold, icons },
@@ -496,7 +616,9 @@ async function createExportContext(pdfDoc, options, doc) {
496
616
  bottomY: margin,
497
617
  colors: { text: colorText, heading: colorHeading, link: colorLink },
498
618
  footnotes: new FootnoteIndex(doc),
499
- text: new WinAnsiTracker(),
619
+ text,
620
+ runs: new PdfTextRuns(text, unicodeFaces),
621
+ unicodeFont: unicodeFaces.size > 0,
500
622
  signal: options.signal,
501
623
  images: /* @__PURE__ */ new Map()
502
624
  };
@@ -522,15 +644,15 @@ function flattenInlines(nodes, ctx, state) {
522
644
  case "text": {
523
645
  const font = state.code ? state.bold ? ctx.fonts.monoBold : ctx.fonts.mono : pickFont(ctx, state.bold, state.italic);
524
646
  const baseSize = state.code ? CODE_FONT_SIZE : ctx.fontSize;
525
- spans.push({
526
- text: ctx.text.clean(node.value),
527
- font,
528
- fontSize: state.vertAlign ? baseSize * VERT_ALIGN_SCALE : baseSize,
529
- color: state.code ? COLOR_CODE_TEXT : state.color ?? ctx.colors.text,
530
- link: state.link,
531
- strikethrough: state.strikethrough,
532
- ...state.vertAlign ? { baselineShift: vertAlignShift(state.vertAlign, baseSize) } : {}
533
- });
647
+ spans.push(
648
+ ...textSpans(ctx, node.value, font, {
649
+ fontSize: state.vertAlign ? baseSize * VERT_ALIGN_SCALE : baseSize,
650
+ color: state.code ? COLOR_CODE_TEXT : state.color ?? ctx.colors.text,
651
+ link: state.link,
652
+ strikethrough: state.strikethrough,
653
+ ...state.vertAlign ? { baselineShift: vertAlignShift(state.vertAlign, baseSize) } : {}
654
+ })
655
+ );
534
656
  break;
535
657
  }
536
658
  case "strong":
@@ -561,13 +683,13 @@ function flattenInlines(nodes, ctx, state) {
561
683
  );
562
684
  break;
563
685
  case "inlineCode": {
564
- spans.push({
565
- text: ctx.text.clean(node.value),
566
- font: ctx.fonts.mono,
567
- fontSize: CODE_FONT_SIZE,
568
- color: COLOR_CODE_TEXT,
569
- link: state.link
570
- });
686
+ spans.push(
687
+ ...textSpans(ctx, node.value, ctx.fonts.mono, {
688
+ fontSize: CODE_FONT_SIZE,
689
+ color: COLOR_CODE_TEXT,
690
+ link: state.link
691
+ })
692
+ );
571
693
  break;
572
694
  }
573
695
  case "link": {
@@ -584,12 +706,12 @@ function flattenInlines(nodes, ctx, state) {
584
706
  }
585
707
  case "image": {
586
708
  const imgNode = node;
587
- spans.push({
588
- text: ctx.text.clean(imgNode.alt ? `[Image: ${imgNode.alt}]` : "[Image]"),
589
- font: ctx.fonts.italic,
590
- fontSize: ctx.fontSize,
591
- color: COLOR_BLOCKQUOTE_TEXT
592
- });
709
+ spans.push(
710
+ ...textSpans(ctx, imgNode.alt ? `[Image: ${imgNode.alt}]` : "[Image]", ctx.fonts.italic, {
711
+ fontSize: ctx.fontSize,
712
+ color: COLOR_BLOCKQUOTE_TEXT
713
+ })
714
+ );
593
715
  break;
594
716
  }
595
717
  case "inlineIcon": {
@@ -606,14 +728,14 @@ function flattenInlines(nodes, ctx, state) {
606
728
  strikethrough: state.strikethrough
607
729
  });
608
730
  } else {
609
- spans.push({
610
- text: ctx.text.clean(`{[${iconNode.token}]}`),
611
- font: pickFont(ctx, state.bold, state.italic),
612
- fontSize: ctx.fontSize,
613
- color: state.color ?? ctx.colors.text,
614
- link: state.link,
615
- strikethrough: state.strikethrough
616
- });
731
+ spans.push(
732
+ ...textSpans(ctx, `{[${iconNode.token}]}`, pickFont(ctx, state.bold, state.italic), {
733
+ fontSize: ctx.fontSize,
734
+ color: state.color ?? ctx.colors.text,
735
+ link: state.link,
736
+ strikethrough: state.strikethrough
737
+ })
738
+ );
617
739
  }
618
740
  break;
619
741
  }
@@ -628,33 +750,33 @@ function flattenInlines(nodes, ctx, state) {
628
750
  case "htmlInline": {
629
751
  const html = node.rawHtml;
630
752
  if (html) {
631
- spans.push({
632
- text: ctx.text.clean(html),
633
- font: ctx.fonts.mono,
634
- fontSize: CODE_FONT_SIZE,
635
- color: COLOR_CODE_TEXT
636
- });
753
+ spans.push(
754
+ ...textSpans(ctx, html, ctx.fonts.mono, {
755
+ fontSize: CODE_FONT_SIZE,
756
+ color: COLOR_CODE_TEXT
757
+ })
758
+ );
637
759
  }
638
760
  break;
639
761
  }
640
762
  case "inlineMath":
641
- spans.push({
642
- text: ctx.text.clean(node.value),
643
- font: ctx.fonts.mono,
644
- fontSize: CODE_FONT_SIZE,
645
- color: COLOR_CODE_TEXT
646
- });
763
+ spans.push(
764
+ ...textSpans(ctx, node.value, ctx.fonts.mono, {
765
+ fontSize: CODE_FONT_SIZE,
766
+ color: COLOR_CODE_TEXT
767
+ })
768
+ );
647
769
  break;
648
770
  case "footnoteReference": {
649
771
  const ref = node;
650
772
  const marker = ctx.footnotes ? String(ctx.footnotes.cite(ref.identifier).number) : ref.label ?? ref.identifier;
651
- spans.push({
652
- text: ctx.text.clean(marker),
653
- font: ctx.fonts.regular,
654
- fontSize: ctx.fontSize * VERT_ALIGN_SCALE,
655
- color: ctx.colors.link,
656
- baselineShift: vertAlignShift("superscript", ctx.fontSize)
657
- });
773
+ spans.push(
774
+ ...textSpans(ctx, marker, ctx.fonts.regular, {
775
+ fontSize: ctx.fontSize * VERT_ALIGN_SCALE,
776
+ color: ctx.colors.link,
777
+ baselineShift: vertAlignShift("superscript", ctx.fontSize)
778
+ })
779
+ );
658
780
  break;
659
781
  }
660
782
  // linkReference, imageReference, textDirective — render children or identifier
@@ -663,12 +785,12 @@ function flattenInlines(nodes, ctx, state) {
663
785
  if (fallback.children) {
664
786
  spans.push(...flattenInlines(fallback.children, ctx, state));
665
787
  } else if (fallback.value) {
666
- spans.push({
667
- text: ctx.text.clean(fallback.value),
668
- font: pickFont(ctx, state.bold, state.italic),
669
- fontSize: ctx.fontSize,
670
- color: state.color ?? ctx.colors.text
671
- });
788
+ spans.push(
789
+ ...textSpans(ctx, fallback.value, pickFont(ctx, state.bold, state.italic), {
790
+ fontSize: ctx.fontSize,
791
+ color: state.color ?? ctx.colors.text
792
+ })
793
+ );
672
794
  }
673
795
  break;
674
796
  }
@@ -676,6 +798,23 @@ function flattenInlines(nodes, ctx, state) {
676
798
  }
677
799
  return spans;
678
800
  }
801
+ function textSpans(ctx, text, font, style) {
802
+ return ctx.runs.split(text, font).map((run) => ({ ...style, text: run.text, font: run.font }));
803
+ }
804
+ function drawTextLine(ctx, text, font, at) {
805
+ let x = at.x;
806
+ for (const run of ctx.runs.split(text, font)) {
807
+ if (run.text.length === 0) continue;
808
+ ctx.page.drawText(run.text, {
809
+ x,
810
+ y: at.y,
811
+ size: at.size,
812
+ font: run.font,
813
+ color: rgb(at.color.r, at.color.g, at.color.b)
814
+ });
815
+ x += run.font.widthOfTextAtSize(run.text, at.size);
816
+ }
817
+ }
679
818
  function pickFont(ctx, bold, italic) {
680
819
  if (bold && italic) return ctx.fonts.boldItalic;
681
820
  if (bold) return ctx.fonts.bold;
@@ -967,7 +1106,7 @@ function renderListItem(item, ctx, extraIndent, depth, ordered, counter) {
967
1106
  function renderCodeBlock(node, ctx, extraIndent) {
968
1107
  const x0 = ctx.margin + extraIndent;
969
1108
  const _w = ctx.contentWidth - extraIndent;
970
- const lines = node.value.split("\n").map((line) => ctx.text.clean(line));
1109
+ const lines = node.value.split("\n");
971
1110
  const lineH = CODE_FONT_SIZE * LINE_HEIGHT_FACTOR;
972
1111
  const totalHeight = lines.length * lineH + 12;
973
1112
  ensureSpace(ctx, Math.min(totalHeight, ctx.y - ctx.bottomY));
@@ -977,12 +1116,11 @@ function renderCodeBlock(node, ctx, extraIndent) {
977
1116
  for (const line of lines) {
978
1117
  ensureSpace(ctx, lineH);
979
1118
  if (line.length > 0) {
980
- ctx.page.drawText(line, {
1119
+ drawTextLine(ctx, line, ctx.fonts.mono, {
981
1120
  x: x0 + 8,
982
1121
  y: ctx.y - CODE_FONT_SIZE,
983
1122
  size: CODE_FONT_SIZE,
984
- font: ctx.fonts.mono,
985
- color: rgb(COLOR_CODE_TEXT.r, COLOR_CODE_TEXT.g, COLOR_CODE_TEXT.b)
1123
+ color: COLOR_CODE_TEXT
986
1124
  });
987
1125
  }
988
1126
  ctx.y -= lineH;
@@ -1083,36 +1221,34 @@ function renderThematicBreak(ctx, extraIndent) {
1083
1221
  }
1084
1222
  function renderHtmlBlock(node, ctx, extraIndent) {
1085
1223
  if (!node.rawHtml) return;
1086
- const lines = node.rawHtml.split("\n").map((line) => ctx.text.clean(line));
1224
+ const lines = node.rawHtml.split("\n");
1087
1225
  const x0 = ctx.margin + extraIndent;
1088
1226
  for (const line of lines) {
1089
1227
  if (line.trim().length === 0) continue;
1090
1228
  const lineH = CODE_FONT_SIZE * LINE_HEIGHT_FACTOR;
1091
1229
  ensureSpace(ctx, lineH);
1092
- ctx.page.drawText(line, {
1230
+ drawTextLine(ctx, line, ctx.fonts.mono, {
1093
1231
  x: x0,
1094
1232
  y: ctx.y - CODE_FONT_SIZE,
1095
1233
  size: CODE_FONT_SIZE,
1096
- font: ctx.fonts.mono,
1097
- color: rgb(COLOR_CODE_TEXT.r, COLOR_CODE_TEXT.g, COLOR_CODE_TEXT.b)
1234
+ color: COLOR_CODE_TEXT
1098
1235
  });
1099
1236
  ctx.y -= lineH;
1100
1237
  }
1101
1238
  ctx.y -= PARAGRAPH_SPACING;
1102
1239
  }
1103
1240
  function renderMathBlock(node, ctx, extraIndent) {
1104
- const lines = node.value.split("\n").map((line) => ctx.text.clean(line));
1241
+ const lines = node.value.split("\n");
1105
1242
  const x0 = ctx.margin + extraIndent;
1106
1243
  for (const line of lines) {
1107
1244
  const lineH = CODE_FONT_SIZE * LINE_HEIGHT_FACTOR;
1108
1245
  ensureSpace(ctx, lineH);
1109
1246
  if (line.length > 0) {
1110
- ctx.page.drawText(line, {
1247
+ drawTextLine(ctx, line, ctx.fonts.mono, {
1111
1248
  x: x0,
1112
1249
  y: ctx.y - CODE_FONT_SIZE,
1113
1250
  size: CODE_FONT_SIZE,
1114
- font: ctx.fonts.mono,
1115
- color: rgb(COLOR_CODE_TEXT.r, COLOR_CODE_TEXT.g, COLOR_CODE_TEXT.b)
1251
+ color: COLOR_CODE_TEXT
1116
1252
  });
1117
1253
  }
1118
1254
  ctx.y -= lineH;
@@ -1121,15 +1257,14 @@ function renderMathBlock(node, ctx, extraIndent) {
1121
1257
  }
1122
1258
  function renderFootnoteDefinition(node, ctx, extraIndent) {
1123
1259
  const number = ctx.footnotes?.numberFor(node.identifier);
1124
- const label = ctx.text.clean(number !== void 0 ? `${number}.` : `[${node.identifier}]`);
1260
+ const label = number !== void 0 ? `${number}.` : `[${node.identifier}]`;
1125
1261
  const lineH = ctx.fontSize * LINE_HEIGHT_FACTOR;
1126
1262
  ensureSpace(ctx, lineH);
1127
- ctx.page.drawText(label, {
1263
+ drawTextLine(ctx, label, ctx.fonts.bold, {
1128
1264
  x: ctx.margin + extraIndent,
1129
1265
  y: ctx.y - ctx.fontSize * 0.75,
1130
1266
  size: ctx.fontSize * 0.75,
1131
- font: ctx.fonts.bold,
1132
- color: rgb(ctx.colors.link.r, ctx.colors.link.g, ctx.colors.link.b)
1267
+ color: ctx.colors.link
1133
1268
  });
1134
1269
  renderBlocks(node.children, ctx, extraIndent + LIST_INDENT);
1135
1270
  }
@@ -1163,12 +1298,11 @@ function renderFallbackBlock(node, ctx, extraIndent) {
1163
1298
  } else if (fallback.value && typeof fallback.value === "string") {
1164
1299
  const lineH = ctx.fontSize * LINE_HEIGHT_FACTOR;
1165
1300
  ensureSpace(ctx, lineH);
1166
- ctx.page.drawText(ctx.text.clean(fallback.value), {
1301
+ drawTextLine(ctx, fallback.value, ctx.fonts.regular, {
1167
1302
  x: ctx.margin + extraIndent,
1168
1303
  y: ctx.y - ctx.fontSize,
1169
1304
  size: ctx.fontSize,
1170
- font: ctx.fonts.regular,
1171
- color: rgb(ctx.colors.text.r, ctx.colors.text.g, ctx.colors.text.b)
1305
+ color: ctx.colors.text
1172
1306
  });
1173
1307
  ctx.y -= lineH + PARAGRAPH_SPACING;
1174
1308
  }
@@ -2,6 +2,10 @@ import {
2
2
  extractFilename,
3
3
  inferMimeType
4
4
  } from "./chunk-YVCUNZB5.js";
5
+ import {
6
+ exportImageBytes,
7
+ svgIslandsAsImages
8
+ } from "./chunk-VCZUXQUL.js";
5
9
  import {
6
10
  extractPlainText
7
11
  } from "./chunk-AVOZAKGP.js";
@@ -23,8 +27,11 @@ import JSZip from "jszip";
23
27
  import { resolveFontFamily } from "@bendyline/squisq/schemas";
24
28
  import { resolveThemeForDoc } from "@bendyline/squisq/doc";
25
29
  import { readFrontmatterThemeId, sanitizeUrl } from "@bendyline/squisq/markdown";
26
- async function markdownDocToEpub(doc, options = {}) {
30
+ async function markdownDocToEpub(sourceDoc, options = {}) {
27
31
  options.signal?.throwIfAborted();
32
+ const themeId = options.themeId ?? readFrontmatterThemeId(sourceDoc.frontmatter);
33
+ const islandColor = themeId ? resolveThemeForDoc(sourceDoc, themeId, options.themeRegistry).colors.text : void 0;
34
+ const { doc, images: islandImages } = svgIslandsAsImages(sourceDoc, { color: islandColor });
28
35
  const fmTitle = doc.frontmatter?.title;
29
36
  const fmAuthor = doc.frontmatter?.author;
30
37
  const title = options.title ?? (typeof fmTitle === "string" ? fmTitle : "Untitled");
@@ -42,11 +49,12 @@ async function markdownDocToEpub(doc, options = {}) {
42
49
  const imageEntries = collectDocImages(doc.children);
43
50
  const resolvedImages = /* @__PURE__ */ new Map();
44
51
  const usedImageNames = /* @__PURE__ */ new Set();
45
- if (options.images) {
52
+ if (options.images || islandImages.size > 0) {
46
53
  let imageIndex = 0;
47
54
  for (const src of imageEntries) {
48
55
  if ((imageIndex++ & 63) === 0) options.signal?.throwIfAborted();
49
- const data = options.images.get(src);
56
+ const attached = options.images?.get(src);
57
+ const data = islandImages.get(src)?.data ?? (attached && exportImageBytes(src, attached));
50
58
  if (data) {
51
59
  const filename = uniqueFilename(safeArchiveBasename(src, "image"), usedImageNames);
52
60
  usedImageNames.add(filename);
@@ -54,12 +62,7 @@ async function markdownDocToEpub(doc, options = {}) {
54
62
  }
55
63
  }
56
64
  }
57
- const css = generateStylesheet(
58
- options.themeId ?? readFrontmatterThemeId(doc.frontmatter),
59
- doc,
60
- options.themeRegistry,
61
- embeddedIconFonts
62
- );
65
+ const css = generateStylesheet(themeId, doc, options.themeRegistry, embeddedIconFonts);
63
66
  const zip = new JSZip();
64
67
  zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
65
68
  zip.file("META-INF/container.xml", CONTAINER_XML);
@@ -0,0 +1,90 @@
1
+ // src/shared/svgIslands.ts
2
+ import {
3
+ isSvgImageSource,
4
+ isSvgIslandLang,
5
+ parseSvgIsland,
6
+ svgIslandMarkup,
7
+ svgSourceFromBytes
8
+ } from "@bendyline/squisq/markdown";
9
+ var SVG_ISLAND_FALLBACK_ALT = "Illustration";
10
+ function ownBuffer(encoded) {
11
+ const bytes = new Uint8Array(encoded.length);
12
+ bytes.set(encoded);
13
+ return bytes.buffer;
14
+ }
15
+ function svgIslandImage(source, options = {}) {
16
+ const result = parseSvgIsland(source);
17
+ if (!result.ok) return null;
18
+ const { island } = result;
19
+ const data = ownBuffer(
20
+ new TextEncoder().encode(
21
+ svgIslandMarkup(island, { color: options.color, replaceCurrentColor: true })
22
+ )
23
+ );
24
+ return {
25
+ data,
26
+ contentType: "image/svg+xml",
27
+ width: island.width,
28
+ height: island.height,
29
+ alt: island.title ?? SVG_ISLAND_FALLBACK_ALT
30
+ };
31
+ }
32
+ function docHasSvgIslands(doc) {
33
+ const visit = (node) => {
34
+ if (node.type === "code") return isSvgIslandLang(node.lang);
35
+ const nested = node.children;
36
+ return Array.isArray(nested) && nested.some(visit);
37
+ };
38
+ return visit(doc);
39
+ }
40
+ function svgIslandsAsImages(doc, options = {}) {
41
+ const prefix = options.prefix ?? "squisq-svg-island";
42
+ const images = /* @__PURE__ */ new Map();
43
+ const replace = (nodes) => {
44
+ let changed = false;
45
+ const next = nodes.map((node) => {
46
+ if (node.type === "code" && isSvgIslandLang(node.lang)) {
47
+ const image = svgIslandImage(node.value, options);
48
+ if (!image) return node;
49
+ const url = `${prefix}-${images.size + 1}.svg`;
50
+ images.set(url, image);
51
+ changed = true;
52
+ const picture = { type: "image", url, alt: image.alt, title: null };
53
+ const paragraph = { type: "paragraph", children: [picture] };
54
+ return paragraph;
55
+ }
56
+ const nested = node.children;
57
+ if (Array.isArray(nested)) {
58
+ const replaced = replace(nested);
59
+ if (replaced !== nested) {
60
+ changed = true;
61
+ return { ...node, children: replaced };
62
+ }
63
+ }
64
+ return node;
65
+ });
66
+ return changed ? next : nodes;
67
+ };
68
+ if (!docHasSvgIslands(doc)) return { doc, images };
69
+ const children = replace(doc.children);
70
+ return { doc: children === doc.children ? doc : { ...doc, children }, images };
71
+ }
72
+ function isSvgImageEntry(path, contentType) {
73
+ return isSvgImageSource(path) || /^image\/svg\+xml\b/i.test(contentType ?? "");
74
+ }
75
+ function svgImageFromBytes(data) {
76
+ return svgIslandImage(svgSourceFromBytes(data));
77
+ }
78
+ function exportImageBytes(path, data, contentType) {
79
+ if (!isSvgImageEntry(path, contentType)) return data;
80
+ return svgImageFromBytes(data)?.data ?? null;
81
+ }
82
+
83
+ export {
84
+ svgIslandImage,
85
+ docHasSvgIslands,
86
+ svgIslandsAsImages,
87
+ isSvgImageEntry,
88
+ svgImageFromBytes,
89
+ exportImageBytes
90
+ };
@@ -117,11 +117,20 @@ function inlineNodeToRuns(node, handlers, format) {
117
117
  }
118
118
  }
119
119
 
120
+ // src/shared/officeSvgBlip.ts
121
+ var SVG_BLIP_EXT_URI = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}";
122
+ var NS_ASVG = "http://schemas.microsoft.com/office/drawing/2016/SVG/main";
123
+ function blipXml(rasterRelId, svgRelId) {
124
+ if (!svgRelId) return `<a:blip r:embed="${rasterRelId}"/>`;
125
+ return `<a:blip r:embed="${rasterRelId}"><a:extLst><a:ext uri="${SVG_BLIP_EXT_URI}"><asvg:svgBlip xmlns:asvg="${NS_ASVG}" r:embed="${svgRelId}"/></a:ext></a:extLst></a:blip>`;
126
+ }
127
+
120
128
  export {
121
129
  RelIdAllocator,
122
130
  toOoxmlHex,
123
131
  normalizeOoxmlHex,
124
132
  sanitizeOfficeHyperlink,
133
+ blipXml,
125
134
  inlineNodesToRuns,
126
135
  inlineNodeToRuns
127
136
  };