@bendyline/squisq-formats 2.8.15 → 2.8.17

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.
@@ -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,
@@ -78,7 +79,8 @@ import {
78
79
  isGeometryBoundTemplate,
79
80
  isTemplateBlock,
80
81
  resolvePersistentLayers,
81
- resolveThemeForDoc
82
+ resolveThemeForDoc,
83
+ shapePath
82
84
  } from "@bendyline/squisq/doc";
83
85
  import { splitIconMarkers as splitIconMarkers3 } from "@bendyline/squisq/icon-marker";
84
86
  import {
@@ -89,11 +91,24 @@ import {
89
91
 
90
92
  // src/pptx/pictureLayers.ts
91
93
  import { svgSourceFromBytes } from "@bendyline/squisq/markdown";
92
- function buildImageShape(img, shapeId, left, top, maxWidth, maxHeight, hyperlinkRelId, svgRelId) {
94
+ function buildImageShape(img, shapeId, left, top, maxWidth, maxHeight, hyperlinkRelId, svgRelId, srcRect = "") {
93
95
  const name = escapeXml(img.alt || "Picture");
94
96
  const descr = img.alt ? ` descr="${escapeXml(img.alt)}"` : "";
95
97
  const link = hyperlinkRelId ? `<a:hlinkClick r:id="${hyperlinkRelId}"/>` : "";
96
- return `<p:pic><p:nvPicPr>` + (link ? `<p:cNvPr id="${shapeId}" name="${name}"${descr}>${link}</p:cNvPr>` : `<p:cNvPr id="${shapeId}" name="${name}"${descr}/>`) + `<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill>` + blipXml(img.relId, svgRelId) + `<a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="${left}" y="${top}"/><a:ext cx="${maxWidth}" cy="${maxHeight}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
98
+ return `<p:pic><p:nvPicPr>` + (link ? `<p:cNvPr id="${shapeId}" name="${name}"${descr}>${link}</p:cNvPr>` : `<p:cNvPr id="${shapeId}" name="${name}"${descr}/>`) + `<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill>` + blipXml(img.relId, svgRelId) + srcRect + `<a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="${left}" y="${top}"/><a:ext cx="${maxWidth}" cy="${maxHeight}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
99
+ }
100
+ function fitPicture(box, size, fit) {
101
+ if (!size || size.width <= 0 || size.height <= 0 || fit === "fill" || box.height <= 0)
102
+ return { rect: box, srcRect: "" };
103
+ if (fit !== "cover") return { rect: fitCentered(box, size.width, size.height), srcRect: "" };
104
+ const image = size.width / size.height;
105
+ const frame = box.width / box.height;
106
+ if (Math.abs(image - frame) < 1e-3) return { rect: box, srcRect: "" };
107
+ const edge = Math.round((image > frame ? 1 - frame / image : 1 - image / frame) / 2 * 1e5);
108
+ return {
109
+ rect: box,
110
+ srcRect: image > frame ? `<a:srcRect l="${edge}" r="${edge}"/>` : `<a:srcRect t="${edge}" b="${edge}"/>`
111
+ };
97
112
  }
98
113
  function fitCentered(area, width, height) {
99
114
  const scale = Math.min(area.width / width, area.height / height);
@@ -448,9 +463,30 @@ function cssGradientToString(gradient) {
448
463
  // src/pptx/drawingXml.ts
449
464
  function officeTypeface(family, fallback) {
450
465
  const resolved = resolveFontFamily(family, fallback);
451
- const first = resolved.split(",")[0]?.trim().replace(/^['"]|['"]$/g, "");
452
- return first || fallback;
453
- }
466
+ const names = resolved.split(",").map((name) => name.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
467
+ const named = names.find((name) => !GENERIC_FONT_FAMILIES.has(name.toLowerCase()));
468
+ if (named) return named;
469
+ const kind = names.map((name) => name.toLowerCase());
470
+ if (kind.includes("monospace") || kind.includes("ui-monospace")) return "Consolas";
471
+ if (kind.includes("serif") || kind.includes("ui-serif")) return "Georgia";
472
+ return names.length ? "Segoe UI" : fallback;
473
+ }
474
+ var GENERIC_FONT_FAMILIES = /* @__PURE__ */ new Set([
475
+ "system-ui",
476
+ "-apple-system",
477
+ "blinkmacsystemfont",
478
+ "ui-sans-serif",
479
+ "ui-serif",
480
+ "ui-monospace",
481
+ "ui-rounded",
482
+ "sans-serif",
483
+ "serif",
484
+ "monospace",
485
+ "cursive",
486
+ "fantasy",
487
+ "emoji",
488
+ "math"
489
+ ]);
454
490
  function solidColorXml(color) {
455
491
  const alpha = color.alpha < 1e5 ? `<a:alpha val="${color.alpha}"/>` : "";
456
492
  return `<a:solidFill><a:srgbClr val="${color.hex}">${alpha}</a:srgbClr></a:solidFill>`;
@@ -506,13 +542,17 @@ function patternFillXml(pattern, ground) {
506
542
  function buildLayerShape(layer, shapeId) {
507
543
  const rect = toEmuRect(resolveLayerRect(layer.position, 100, 100));
508
544
  const geometry = layer.content.shape === "circle" ? "ellipse" : layer.content.shape === "line" ? "line" : layer.content.borderRadius ? "roundRect" : "rect";
545
+ const box = resolveLayerRect(layer.position, 100, 100);
546
+ const shorter = Math.min(box.width, box.height);
547
+ const radius = Math.min(shorter / 2, Math.max(0, layer.content.borderRadius ?? 0));
548
+ const adjust = geometry === "roundRect" && shorter > 0 ? `<a:avLst><a:gd name="adj" fmla="val ${Math.round(radius / shorter * 1e5)}"/></a:avLst>` : "<a:avLst/>";
509
549
  const fill = layer.content.shape === "line" ? "<a:noFill/>" : layer.content.pattern ? patternFillXml(layer.content.pattern) : fillXml(layer.content.fill, layer.content.fillOpacity, layer.content.gradient);
510
550
  const stroke = lineXml(
511
551
  layer.content.stroke ?? (layer.content.shape === "line" ? "#ffffff" : void 0),
512
552
  layer.content.strokeWidth ?? (layer.content.shape === "line" ? 2 : void 0),
513
553
  layer.content.borderStyle
514
554
  );
515
- return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeId}" name="${escapeXml(layer.id)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="${rect.x}" y="${rect.y}"/><a:ext cx="${rect.width}" cy="${rect.height}"/></a:xfrm><a:prstGeom prst="${geometry}"><a:avLst/></a:prstGeom>` + fill + stroke + `</p:spPr></p:sp>`;
555
+ return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeId}" name="${escapeXml(layer.id)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="${rect.x}" y="${rect.y}"/><a:ext cx="${rect.width}" cy="${rect.height}"/></a:xfrm><a:prstGeom prst="${geometry}">${adjust}</a:prstGeom>` + fill + stroke + `</p:spPr></p:sp>`;
516
556
  }
517
557
 
518
558
  // src/pptx/backdrop.ts
@@ -751,6 +791,235 @@ function planDeckBackdrops(blocks) {
751
791
  return { master: { base: masterBase, overlays: masterOverlays }, layouts, slides };
752
792
  }
753
793
 
794
+ // src/pptx/pathGeometry.ts
795
+ var ARITY = {
796
+ M: 2,
797
+ L: 2,
798
+ H: 1,
799
+ V: 1,
800
+ C: 6,
801
+ S: 4,
802
+ Q: 4,
803
+ T: 2,
804
+ A: 7,
805
+ Z: 0
806
+ };
807
+ function arcToCubics(x0, y0, rx, ry, rotation, largeArc, sweep, x, y) {
808
+ if (x0 === x && y0 === y || !Number.isFinite(rx + ry)) return [];
809
+ rx = Math.abs(rx);
810
+ ry = Math.abs(ry);
811
+ if (rx === 0 || ry === 0) return [{ op: "L", x, y }];
812
+ const phi = rotation * Math.PI / 180;
813
+ const [cos, sin] = [Math.cos(phi), Math.sin(phi)];
814
+ const dx = (x0 - x) / 2;
815
+ const dy = (y0 - y) / 2;
816
+ const x1 = cos * dx + sin * dy;
817
+ const y1 = -sin * dx + cos * dy;
818
+ const lambda = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry);
819
+ if (lambda > 1) {
820
+ rx *= Math.sqrt(lambda);
821
+ ry *= Math.sqrt(lambda);
822
+ }
823
+ const numerator = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1;
824
+ const denominator = rx * rx * y1 * y1 + ry * ry * x1 * x1;
825
+ const factor = (largeArc === sweep ? -1 : 1) * Math.sqrt(Math.max(0, numerator / (denominator || 1)));
826
+ const cxp = factor * rx * y1 / ry;
827
+ const cyp = -factor * ry * x1 / rx;
828
+ const cx = cos * cxp - sin * cyp + (x0 + x) / 2;
829
+ const cy = sin * cxp + cos * cyp + (y0 + y) / 2;
830
+ const angle = (ux, uy, vx, vy) => {
831
+ const turn = Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
832
+ return turn;
833
+ };
834
+ const start = angle(1, 0, (x1 - cxp) / rx, (y1 - cyp) / ry);
835
+ let delta = angle((x1 - cxp) / rx, (y1 - cyp) / ry, (-x1 - cxp) / rx, (-y1 - cyp) / ry);
836
+ if (!sweep && delta > 0) delta -= 2 * Math.PI;
837
+ if (sweep && delta < 0) delta += 2 * Math.PI;
838
+ const pieces = Math.max(1, Math.ceil(Math.abs(delta) / (Math.PI / 2) - 1e-9));
839
+ const step = delta / pieces;
840
+ const handle = 4 / 3 * Math.tan(step / 4);
841
+ const point = (theta) => [
842
+ cx + rx * Math.cos(theta) * cos - ry * Math.sin(theta) * sin,
843
+ cy + rx * Math.cos(theta) * sin + ry * Math.sin(theta) * cos
844
+ ];
845
+ const tangent = (theta) => [
846
+ -rx * Math.sin(theta) * cos - ry * Math.cos(theta) * sin,
847
+ -rx * Math.sin(theta) * sin + ry * Math.cos(theta) * cos
848
+ ];
849
+ const segments = [];
850
+ for (let i = 0; i < pieces; i++) {
851
+ const from = start + step * i;
852
+ const to = from + step;
853
+ const [px, py] = point(from);
854
+ const [qx, qy] = point(to);
855
+ const [tx0, ty0] = tangent(from);
856
+ const [tx1, ty1] = tangent(to);
857
+ segments.push({
858
+ op: "C",
859
+ x1: px + handle * tx0,
860
+ y1: py + handle * ty0,
861
+ x2: qx - handle * tx1,
862
+ y2: qy - handle * ty1,
863
+ // The last piece ends exactly on the authored endpoint.
864
+ x: i === pieces - 1 ? x : qx,
865
+ y: i === pieces - 1 ? y : qy
866
+ });
867
+ }
868
+ return segments;
869
+ }
870
+ function parseSvgPath(d) {
871
+ const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/gu);
872
+ if (!tokens || !/^[Mm]$/u.test(tokens[0])) return null;
873
+ const segments = [];
874
+ let [cx, cy] = [0, 0];
875
+ let [startX, startY] = [0, 0];
876
+ let control;
877
+ let index = 0;
878
+ while (index < tokens.length) {
879
+ const command = tokens[index++];
880
+ const upper = command.toUpperCase();
881
+ const arity = ARITY[upper];
882
+ if (arity === void 0 || !/^[a-zA-Z]$/u.test(command)) return null;
883
+ const relative = command !== upper;
884
+ if (upper === "Z") {
885
+ segments.push({ op: "Z" });
886
+ [cx, cy] = [startX, startY];
887
+ control = void 0;
888
+ continue;
889
+ }
890
+ let first = true;
891
+ while (first || index < tokens.length && !/^[a-zA-Z]$/u.test(tokens[index])) {
892
+ const numbers = tokens.slice(index, index + arity).map(Number);
893
+ if (numbers.length < arity || numbers.some((value) => !Number.isFinite(value))) return null;
894
+ index += arity;
895
+ const [ox, oy] = relative ? [cx, cy] : [0, 0];
896
+ let next;
897
+ switch (upper) {
898
+ case "M": {
899
+ [cx, cy] = [ox + numbers[0], oy + numbers[1]];
900
+ if (first) {
901
+ segments.push({ op: "M", x: cx, y: cy });
902
+ [startX, startY] = [cx, cy];
903
+ } else segments.push({ op: "L", x: cx, y: cy });
904
+ break;
905
+ }
906
+ case "L":
907
+ [cx, cy] = [ox + numbers[0], oy + numbers[1]];
908
+ segments.push({ op: "L", x: cx, y: cy });
909
+ break;
910
+ case "H":
911
+ cx = ox + numbers[0];
912
+ segments.push({ op: "L", x: cx, y: cy });
913
+ break;
914
+ case "V":
915
+ cy = oy + numbers[0];
916
+ segments.push({ op: "L", x: cx, y: cy });
917
+ break;
918
+ case "C":
919
+ case "S": {
920
+ const smooth = upper === "S";
921
+ const x1 = smooth ? control?.kind === "C" ? 2 * cx - control.x : cx : ox + numbers[0];
922
+ const y1 = smooth ? control?.kind === "C" ? 2 * cy - control.y : cy : oy + numbers[1];
923
+ const rest = numbers.slice(smooth ? 0 : 2);
924
+ const [x2, y2, x, y] = [ox + rest[0], oy + rest[1], ox + rest[2], oy + rest[3]];
925
+ segments.push({ op: "C", x1, y1, x2, y2, x, y });
926
+ next = { kind: "C", x: x2, y: y2 };
927
+ [cx, cy] = [x, y];
928
+ break;
929
+ }
930
+ case "Q":
931
+ case "T": {
932
+ const smooth = upper === "T";
933
+ const x1 = smooth ? control?.kind === "Q" ? 2 * cx - control.x : cx : ox + numbers[0];
934
+ const y1 = smooth ? control?.kind === "Q" ? 2 * cy - control.y : cy : oy + numbers[1];
935
+ const rest = numbers.slice(smooth ? 0 : 2);
936
+ const [x, y] = [ox + rest[0], oy + rest[1]];
937
+ segments.push({ op: "Q", x1, y1, x, y });
938
+ next = { kind: "Q", x: x1, y: y1 };
939
+ [cx, cy] = [x, y];
940
+ break;
941
+ }
942
+ case "A": {
943
+ const [x, y] = [ox + numbers[5], oy + numbers[6]];
944
+ segments.push(
945
+ ...arcToCubics(
946
+ cx,
947
+ cy,
948
+ numbers[0],
949
+ numbers[1],
950
+ numbers[2],
951
+ numbers[3] !== 0,
952
+ numbers[4] !== 0,
953
+ x,
954
+ y
955
+ )
956
+ );
957
+ [cx, cy] = [x, y];
958
+ break;
959
+ }
960
+ }
961
+ control = next;
962
+ first = false;
963
+ }
964
+ }
965
+ return segments;
966
+ }
967
+ function pathBounds(segments) {
968
+ const xs = [];
969
+ const ys = [];
970
+ for (const segment of segments) {
971
+ if (segment.op === "Z") continue;
972
+ xs.push(segment.x);
973
+ ys.push(segment.y);
974
+ if (segment.op === "C" || segment.op === "Q") {
975
+ xs.push(segment.x1);
976
+ ys.push(segment.y1);
977
+ }
978
+ if (segment.op === "C") {
979
+ xs.push(segment.x2);
980
+ ys.push(segment.y2);
981
+ }
982
+ }
983
+ if (!xs.length) return null;
984
+ const [x, y] = [Math.min(...xs), Math.min(...ys)];
985
+ return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
986
+ }
987
+ function isStraightLine(segments) {
988
+ const points = segments.filter((segment) => segment.op !== "Z");
989
+ if (points.length < 2 || points.length !== segments.length) return false;
990
+ if (points.some((segment) => segment.op === "C" || segment.op === "Q")) return false;
991
+ const first = points[0];
992
+ const last = points[points.length - 1];
993
+ const [dx, dy] = [last.x - first.x, last.y - first.y];
994
+ const length = Math.hypot(dx, dy);
995
+ if (length === 0) return false;
996
+ return points.every((segment) => {
997
+ const point = segment;
998
+ return Math.abs((point.x - first.x) * dy - (point.y - first.y) * dx) / length < 0.5;
999
+ });
1000
+ }
1001
+ function customGeometryXml(segments, bounds, scale, filled) {
1002
+ const unit = (value, origin) => Math.round((value - origin) * scale);
1003
+ const pt = (x, y) => `<a:pt x="${unit(x, bounds.x)}" y="${unit(y, bounds.y)}"/>`;
1004
+ const body = segments.map((segment) => {
1005
+ switch (segment.op) {
1006
+ case "M":
1007
+ return `<a:moveTo>${pt(segment.x, segment.y)}</a:moveTo>`;
1008
+ case "L":
1009
+ return `<a:lnTo>${pt(segment.x, segment.y)}</a:lnTo>`;
1010
+ case "C":
1011
+ return `<a:cubicBezTo>${pt(segment.x1, segment.y1)}${pt(segment.x2, segment.y2)}${pt(segment.x, segment.y)}</a:cubicBezTo>`;
1012
+ case "Q":
1013
+ return `<a:quadBezTo>${pt(segment.x1, segment.y1)}${pt(segment.x, segment.y)}</a:quadBezTo>`;
1014
+ case "Z":
1015
+ return "<a:close/>";
1016
+ }
1017
+ }).join("");
1018
+ const width = Math.max(1, Math.round(bounds.width * scale));
1019
+ const height = Math.max(1, Math.round(bounds.height * scale));
1020
+ return `<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst><a:path w="${width}" h="${height}"${filled ? "" : ' fill="none"'}>${body}</a:path></a:pathLst></a:custGeom>`;
1021
+ }
1022
+
754
1023
  // src/pptx/layerText.ts
755
1024
  import { splitIconMarkers as splitIconMarkers2 } from "@bendyline/squisq/icon-marker";
756
1025
 
@@ -1130,7 +1399,8 @@ function buildLayerTextShape(layer, shapeId, maxBottom, listIndentPx, textScale
1130
1399
  const rect = toEmuRect(virtualRect);
1131
1400
  const align = style.textAlign === "center" ? "ctr" : style.textAlign === "right" ? "r" : "l";
1132
1401
  const inset = Math.max(0, Math.round(padding * EMU_PER_CANVAS_PX));
1133
- const bodyPr = `<a:bodyPr wrap="square" anchor="${vertical}" vertOverflow="clip" horzOverflow="clip" lIns="${inset}" rIns="${inset}" tIns="${inset}" bIns="${inset}"><a:noAutofit/></a:bodyPr>`;
1402
+ const unbreakable = !visibleBox && sourceText.trim() !== "" && sourceText.split("\n").every((line) => !/\s/u.test(line.trim()));
1403
+ const bodyPr = `<a:bodyPr wrap="${unbreakable ? "none" : "square"}" anchor="${vertical}" vertOverflow="clip" horzOverflow="${unbreakable ? "overflow" : "clip"}" lIns="${inset}" rIns="${inset}" tIns="${inset}" bIns="${inset}"><a:noAutofit/></a:bodyPr>`;
1134
1404
  const paragraphs = layer.content.paragraphs || blockGaps ? buildStructuredParagraphs(paragraphSource, renderedStyle, align, {
1135
1405
  // A list with no marker column of its own hangs its bullets by the
1136
1406
  // player's list indent.
@@ -1366,13 +1636,18 @@ async function docToPptx(doc, options = {}) {
1366
1636
  Math.max(1, expanded.length + 1),
1367
1637
  VIEWPORT_PRESETS.landscape
1368
1638
  );
1369
- slideBlocks.push({
1370
- id: "cover-block",
1371
- startTime: -1,
1372
- duration: 0,
1373
- audioSegment: -1,
1374
- layers: expandCoverBlock(doc.startBlock, coverContext)
1375
- });
1639
+ const coverLayers = expandCoverBlock(doc.startBlock, coverContext);
1640
+ if (coverRepeatsLeadingBlock(doc) && expanded[0] && expanded[0].id === doc.blocks[0]?.id) {
1641
+ expanded[0] = { ...expanded[0], layers: coverLayers };
1642
+ } else {
1643
+ slideBlocks.push({
1644
+ id: "cover-block",
1645
+ startTime: -1,
1646
+ duration: 0,
1647
+ audioSegment: -1,
1648
+ layers: coverLayers
1649
+ });
1650
+ }
1376
1651
  }
1377
1652
  slideBlocks.push(...expanded);
1378
1653
  if (slideBlocks.length === 0) {
@@ -1652,7 +1927,7 @@ function buildLayerSlideXml(block, ctx, plan) {
1652
1927
  }
1653
1928
  const textScale = isGeometryBoundTemplate(block.template) ? 1 : estimateSlideTextScale(layers);
1654
1929
  const units = mergeTextFlows(layers, textScale);
1655
- const textLayerY = units.filter(({ layer }) => layer.type === "text").map(({ layer }) => resolvePositionValue(layer.position.y, 1080));
1930
+ const textBoxes = units.filter(({ layer }) => layer.type === "text").map(({ layer }) => resolveLayerRect(layer.position, 1920, 1));
1656
1931
  for (const layer of layers) {
1657
1932
  if (layer.type !== "text") continue;
1658
1933
  for (const run of splitIconMarkers3(layer.content.text ?? "")) {
@@ -1662,8 +1937,11 @@ function buildLayerSlideXml(block, ctx, plan) {
1662
1937
  for (let index = 0; index < units.length; index++) {
1663
1938
  if ((index & 31) === 0) ctx.signal?.throwIfAborted();
1664
1939
  const { layer, listIndentPx } = units[index];
1665
- const currentY = layer.type === "text" ? resolvePositionValue(layer.position.y, 1080) : void 0;
1666
- const nextTextY = currentY === void 0 ? void 0 : textLayerY.filter((candidate) => candidate > currentY + 1).sort((a, b) => a - b)[0];
1940
+ const current = layer.type === "text" ? resolveLayerRect(layer.position, 1920, 1) : void 0;
1941
+ const currentY = current?.y;
1942
+ const nextTextY = current === void 0 ? void 0 : textBoxes.filter(
1943
+ (candidate) => candidate.y > current.y + 1 && candidate.x < current.x + current.width - TEXT_COLUMN_OVERLAP_PX && candidate.x + candidate.width > current.x + TEXT_COLUMN_OVERLAP_PX
1944
+ ).map((candidate) => candidate.y).sort((a, b) => a - b)[0];
1667
1945
  const textBottom = nextTextY === void 0 || currentY === void 0 ? void 0 : Math.max(currentY + 1, nextTextY - 8);
1668
1946
  shapes.push(
1669
1947
  ...layer.type === "text" ? [buildLayerTextShape(layer, ctx.allocShapeId(), textBottom, listIndentPx, textScale)] : convertLayerToShapes(layer, ctx, textBottom)
@@ -1688,10 +1966,24 @@ function convertLayerToShapes(layer, ctx, textBottom) {
1688
1966
  const data = svg ? void 0 : ctx.images?.get(layer.content.src);
1689
1967
  if (data) {
1690
1968
  const image = ctx.addImage(layer.content.src, data, layer.content.alt);
1691
- const rect = toEmuRect(resolveLayerRect(layer.position, 1920, 1080));
1969
+ const { rect, srcRect } = fitPicture(
1970
+ toEmuRect(resolveLayerRect(layer.position, 1920, 1080)),
1971
+ readImageDimensions(data),
1972
+ layer.content.fit
1973
+ );
1692
1974
  const embedded = ctx.embeddedImages.find((candidate) => candidate.relId === image);
1693
1975
  return [
1694
- buildImageShape(embedded, ctx.allocShapeId(), rect.x, rect.y, rect.width, rect.height)
1976
+ buildImageShape(
1977
+ embedded,
1978
+ ctx.allocShapeId(),
1979
+ rect.x,
1980
+ rect.y,
1981
+ rect.width,
1982
+ rect.height,
1983
+ null,
1984
+ void 0,
1985
+ srcRect
1986
+ )
1695
1987
  ];
1696
1988
  }
1697
1989
  return [
@@ -1891,46 +2183,74 @@ function treeItemsToText(items, depth = 0) {
1891
2183
  }
1892
2184
  return lines.filter(Boolean).join("\n");
1893
2185
  }
2186
+ function pathLineXml(layer) {
2187
+ const { stroke, strokeWidth } = layer.content;
2188
+ if (stroke === "none" || stroke === "transparent" || strokeWidth === 0)
2189
+ return "<a:ln><a:noFill/></a:ln>";
2190
+ const color = parseCssColor(stroke, "FFFFFF");
2191
+ const width = Math.max(1, Math.round((strokeWidth ?? 2) * EMU_PER_CANVAS_PX));
2192
+ const head = markerXml("headEnd", layer.content.startMarker);
2193
+ const tail = markerXml("tailEnd", layer.content.endMarker);
2194
+ const dash = dashXml(layer.content.borderStyle, layer.content.dasharray);
2195
+ return `<a:ln w="${width}">${solidColorXml(color)}${dash}${head}${tail}</a:ln>`;
2196
+ }
2197
+ var TEXT_COLUMN_OVERLAP_PX = 8;
1894
2198
  function buildPathLayerShapes(layer, ctx) {
2199
+ let d = layer.content.d;
1895
2200
  if (layer.content.shapeKind) {
1896
- const shape = {
1897
- id: layer.id,
1898
- type: "shape",
1899
- position: layer.position,
1900
- content: {
1901
- shape: layer.content.shapeKind === "circle" ? "circle" : "rect",
1902
- fill: layer.content.fill,
1903
- fillOpacity: layer.content.fillOpacity,
1904
- gradient: layer.content.gradient,
1905
- stroke: layer.content.stroke,
1906
- strokeWidth: layer.content.strokeWidth,
1907
- borderStyle: layer.content.borderStyle
1908
- }
1909
- };
1910
- return [buildLayerShape(shape, ctx.allocShapeId())];
1911
- }
1912
- const numbers = layer.content.d.match(/-?\d+(?:\.\d+)?/g)?.map(Number);
1913
- if (!numbers || numbers.length < 4) return [];
1914
- const x1 = numbers[0];
1915
- const y1 = numbers[1];
1916
- const x2 = numbers[numbers.length - 2];
1917
- const y2 = numbers[numbers.length - 1];
2201
+ const box = resolveLayerRect(layer.position, 100, 100);
2202
+ const named = shapePath(layer.content.shapeKind, box.x, box.y, box.width, box.height);
2203
+ if (!named) {
2204
+ const shape = {
2205
+ id: layer.id,
2206
+ type: "shape",
2207
+ position: layer.position,
2208
+ content: {
2209
+ shape: layer.content.shapeKind === "circle" ? "circle" : "rect",
2210
+ fill: layer.content.fill,
2211
+ fillOpacity: layer.content.fillOpacity,
2212
+ gradient: layer.content.gradient,
2213
+ stroke: layer.content.stroke,
2214
+ strokeWidth: layer.content.strokeWidth,
2215
+ borderStyle: layer.content.borderStyle
2216
+ }
2217
+ };
2218
+ return [buildLayerShape(shape, ctx.allocShapeId())];
2219
+ }
2220
+ d = named;
2221
+ }
2222
+ const segments = parseSvgPath(d);
2223
+ const bounds = segments ? pathBounds(segments) : null;
2224
+ if (!segments || !bounds) return [];
2225
+ const { fill, gradient, fillOpacity } = layer.content;
2226
+ const filled = !!gradient || !!fill && fill !== "none" && fill !== "transparent";
2227
+ const name = `<p:nvSpPr><p:cNvPr id="${ctx.allocShapeId()}" name="${escapeXml(layer.id)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>`;
2228
+ if (!filled && isStraightLine(segments)) {
2229
+ const points = segments.filter(
2230
+ (segment) => segment.op === "M" || segment.op === "L"
2231
+ );
2232
+ const from = points[0];
2233
+ const to = points[points.length - 1];
2234
+ const rect2 = toEmuRect({
2235
+ x: Math.min(from.x, to.x),
2236
+ y: Math.min(from.y, to.y),
2237
+ width: Math.max(1, Math.abs(to.x - from.x)),
2238
+ height: Math.max(1, Math.abs(to.y - from.y))
2239
+ });
2240
+ const flipH = to.x < from.x ? ' flipH="1"' : "";
2241
+ const flipV = to.y < from.y ? ' flipV="1"' : "";
2242
+ return [
2243
+ `<p:sp>${name}<p:spPr><a:xfrm${flipH}${flipV}><a:off x="${rect2.x}" y="${rect2.y}"/><a:ext cx="${rect2.width}" cy="${rect2.height}"/></a:xfrm><a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:noFill/>` + pathLineXml(layer) + `</p:spPr></p:sp>`
2244
+ ];
2245
+ }
1918
2246
  const rect = toEmuRect({
1919
- x: Math.min(x1, x2),
1920
- y: Math.min(y1, y2),
1921
- width: Math.max(1, Math.abs(x2 - x1)),
1922
- height: Math.max(1, Math.abs(y2 - y1))
2247
+ x: bounds.x,
2248
+ y: bounds.y,
2249
+ width: Math.max(1, bounds.width),
2250
+ height: Math.max(1, bounds.height)
1923
2251
  });
1924
- const flipH = x2 < x1 ? ' flipH="1"' : "";
1925
- const flipV = y2 < y1 ? ' flipV="1"' : "";
1926
- const stroke = parseCssColor(layer.content.stroke, "FFFFFF");
1927
- const width = Math.max(1, Math.round((layer.content.strokeWidth ?? 2) * EMU_PER_CANVAS_PX));
1928
- const head = markerXml("headEnd", layer.content.startMarker);
1929
- const tail = markerXml("tailEnd", layer.content.endMarker);
1930
- const dash = dashXml(layer.content.borderStyle, layer.content.dasharray);
1931
- const shapeId = ctx.allocShapeId();
1932
2252
  return [
1933
- `<p:sp><p:nvSpPr><p:cNvPr id="${shapeId}" name="${escapeXml(layer.id)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm${flipH}${flipV}><a:off x="${rect.x}" y="${rect.y}"/><a:ext cx="${rect.width}" cy="${rect.height}"/></a:xfrm><a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:noFill/><a:ln w="${width}">${solidColorXml(stroke)}${dash}${head}${tail}</a:ln></p:spPr></p:sp>`
2253
+ `<p:sp>${name}<p:spPr><a:xfrm><a:off x="${rect.x}" y="${rect.y}"/><a:ext cx="${rect.width}" cy="${rect.height}"/></a:xfrm>` + customGeometryXml(segments, bounds, EMU_PER_CANVAS_PX, filled) + (filled ? fillXml(fill, fillOpacity, gradient) : "<a:noFill/>") + pathLineXml(layer) + `</p:spPr></p:sp>`
1934
2254
  ];
1935
2255
  }
1936
2256
  function textLayerForPlaceholder(position, text, ctx) {
@@ -28,7 +28,7 @@ import {
28
28
 
29
29
  // src/epub/export.ts
30
30
  import JSZip from "jszip";
31
- import { resolveFontFamily } from "@bendyline/squisq/schemas";
31
+ import { contrastRatio, isHex, resolveFontFamily } from "@bendyline/squisq/schemas";
32
32
  import { resolveThemeForDoc } from "@bendyline/squisq/doc";
33
33
  import { isHtmlLineBreak, readFrontmatterThemeId, sanitizeUrl } from "@bendyline/squisq/markdown";
34
34
  async function markdownDocToEpub(authoredDoc, options = {}) {
@@ -669,14 +669,20 @@ ${navItems}
669
669
  </body>
670
670
  </html>`;
671
671
  }
672
+ function readsOnPaper(color) {
673
+ return !!color && isHex(color) && contrastRatio(color, "#ffffff") >= 4.5;
674
+ }
672
675
  function generateStylesheet(themeId, doc, registry, iconFonts = []) {
673
676
  let themeVars = "";
674
677
  if (themeId) {
675
678
  const theme = resolveThemeForDoc(doc, themeId, registry);
679
+ const ink = [theme.colors.primary, theme.colors.secondary, theme.colors.highlight].find(
680
+ (color) => readsOnPaper(color)
681
+ );
676
682
  themeVars = `
677
- --epub-bg: ${theme.colors.background};
678
- --epub-text: ${theme.colors.text};
679
- --epub-primary: ${theme.colors.primary};
683
+ --epub-bg: ${theme.colors.background};${readsOnPaper(theme.colors.text) ? `
684
+ --epub-text: ${theme.colors.text};` : ""}${ink ? `
685
+ --epub-primary: ${ink};` : ""}
680
686
  --epub-heading-font: ${resolveFontFamily(theme.typography.titleFont, "serif")};
681
687
  --epub-body-font: ${resolveFontFamily(theme.typography.bodyFont, "sans-serif")};`;
682
688
  }