@smallpen/local-package 0.1.0-alpha.5 → 0.1.0-alpha.8

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/src/render.mjs CHANGED
@@ -5,6 +5,7 @@ import opentype from "opentype.js";
5
5
 
6
6
  import {
7
7
  createSemanticTree,
8
+ layoutProjectionDiagnostics,
8
9
  projectDesignView,
9
10
  resolveDesignView,
10
11
  sha256Hex,
@@ -26,6 +27,17 @@ const BUILTIN_FONT_FILES = new Map([
26
27
  ]);
27
28
  const builtinFonts = new Map();
28
29
  let symbolFont;
30
+ // A glyph the primary font lacks, from the symbol font. Other scripts need a
31
+ // font imported into the package; until then they are missing glyphs.
32
+ function fallbackGlyph(fonts, character) {
33
+ if (fonts.symbols.charToGlyphIndex(character) !== 0)
34
+ return {
35
+ advanceWidth: fonts.symbols.unitsPerEm,
36
+ font: fonts.symbols,
37
+ glyph: fonts.symbols.charToGlyph(character),
38
+ };
39
+ return null;
40
+ }
29
41
 
30
42
  const NAMED_COLORS = new Map([
31
43
  ["black", [0, 0, 0, 255]],
@@ -250,10 +262,11 @@ function nearestWeight(weight, choices) {
250
262
  )[0];
251
263
  }
252
264
 
253
- function parseFont(bytes) {
265
+ function parseFont(bytes, options) {
254
266
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
255
267
  return opentype.parse(
256
268
  view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength),
269
+ options,
257
270
  );
258
271
  }
259
272
 
@@ -477,7 +490,6 @@ function contourWindingCount(contours, x, y) {
477
490
  return crossings;
478
491
  }
479
492
 
480
- const PATH_CURVE_STEPS = 16;
481
493
  const PATH_TOKEN =
482
494
  /[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g;
483
495
 
@@ -575,9 +587,24 @@ function arcToCubicSegments(current, rx, ry, angleDegrees, largeArc, sweep, next
575
587
  return segments;
576
588
  }
577
589
 
590
+ // Line segments a cubic Bézier flattens to: Wang's bound keeps every chord
591
+ // within PATH_FLATNESS local units of the curve, so curves stay smooth at
592
+ // any size while straight-ish ones stay cheap.
593
+ const PATH_FLATNESS = 0.02;
594
+ const PATH_MAX_STEPS = 512;
595
+
596
+ function cubicSteps(start, cubic) {
597
+ const second = Math.max(
598
+ Math.hypot(start.x - 2 * cubic.x1 + cubic.x2, start.y - 2 * cubic.y1 + cubic.y2),
599
+ Math.hypot(cubic.x1 - 2 * cubic.x2 + cubic.x, cubic.y1 - 2 * cubic.y2 + cubic.y),
600
+ );
601
+ return clamp(Math.ceil(Math.sqrt((0.75 * second) / PATH_FLATNESS)), 1, PATH_MAX_STEPS);
602
+ }
603
+
578
604
  // Parses the full SVG path grammar (M L H V C S Q T A Z, absolute and relative)
579
- // into flattened contours and polyline segments. Used for canonical PATH nodes
580
- // and SVG media paths.
605
+ // into flattened contours (for fills), stroke subpaths with their end
606
+ // tangents (for strokes and markers) and polyline segments. Used for
607
+ // canonical PATH nodes and SVG media paths.
581
608
  function parsePathData(value) {
582
609
  if (typeof value !== "string") return undefined;
583
610
  const tokens = value.match(PATH_TOKEN);
@@ -585,168 +612,181 @@ function parsePathData(value) {
585
612
  const segments = [];
586
613
  const contours = [];
587
614
  const closedContours = [];
615
+ const subpaths = [];
588
616
  let contour = [];
617
+ let subpath;
589
618
  let command;
590
619
  let index = 0;
591
620
  let cursor = { x: 0, y: 0 };
592
621
  let subpathStart = cursor;
593
- let previousControl;
622
+ // Reflection sources for S and T: only a preceding C/S (or Q/T) counts.
623
+ let previousCubic;
624
+ let previousQuad;
594
625
  const readNumber = () => {
595
626
  const token = tokens[index++];
596
627
  const parsed = Number.parseFloat(token);
597
628
  if (!Number.isFinite(parsed)) throw new Error(`invalid path number ${token}`);
598
629
  return parsed;
599
630
  };
600
- const pushLine = (next) => {
631
+ const readPoint = (relative) => ({
632
+ x: readNumber() + (relative ? cursor.x : 0),
633
+ y: readNumber() + (relative ? cursor.y : 0),
634
+ });
635
+ const startSubpath = (at) => {
636
+ subpath = { closed: false, points: [at] };
637
+ subpaths.push(subpath);
638
+ };
639
+ // The direction a drawing command leaves `from` with, for markers.
640
+ const tangent = (from, ...candidates) => {
641
+ for (const candidate of candidates) {
642
+ const dx = candidate.x - from.x;
643
+ const dy = candidate.y - from.y;
644
+ const length = Math.hypot(dx, dy);
645
+ if (length > 1e-9) return { x: dx / length, y: dy / length };
646
+ }
647
+ return undefined;
648
+ };
649
+ const pushPoint = (next) => {
601
650
  segments.push({ end: next, start: cursor });
602
651
  contour.push(next);
652
+ subpath.points.push(next);
603
653
  cursor = next;
604
654
  };
605
- const pushCubic = (cubic, current) => {
606
- for (let step = 1; step <= PATH_CURVE_STEPS; step += 1) {
607
- const amount = step / PATH_CURVE_STEPS;
655
+ const pushLine = (next) => {
656
+ if (!subpath) startSubpath(cursor);
657
+ const direction = tangent(cursor, next);
658
+ subpath.startTangent ??= direction;
659
+ if (direction) subpath.endTangent = direction;
660
+ pushPoint(next);
661
+ };
662
+ const pushCubic = (cubic) => {
663
+ if (!subpath) startSubpath(cursor);
664
+ const start = cursor;
665
+ const end = { x: cubic.x, y: cubic.y };
666
+ const control1 = { x: cubic.x1, y: cubic.y1 };
667
+ const control2 = { x: cubic.x2, y: cubic.y2 };
668
+ subpath.startTangent ??= tangent(start, control1, control2, end);
669
+ const endTangent = tangent(end, control2, control1, start);
670
+ if (endTangent) subpath.endTangent = { x: -endTangent.x, y: -endTangent.y };
671
+ const steps = cubicSteps(start, cubic);
672
+ for (let step = 1; step <= steps; step += 1) {
673
+ const amount = step / steps;
608
674
  const inverseAmount = 1 - amount;
609
- contour.push({
675
+ pushPoint(step === steps ? end : {
610
676
  x:
611
- inverseAmount ** 3 * current.x +
677
+ inverseAmount ** 3 * start.x +
612
678
  3 * inverseAmount * inverseAmount * amount * cubic.x1 +
613
679
  3 * inverseAmount * amount * amount * cubic.x2 +
614
680
  amount ** 3 * cubic.x,
615
681
  y:
616
- inverseAmount ** 3 * current.y +
682
+ inverseAmount ** 3 * start.y +
617
683
  3 * inverseAmount * inverseAmount * amount * cubic.y1 +
618
684
  3 * inverseAmount * amount * amount * cubic.y2 +
619
685
  amount ** 3 * cubic.y,
620
686
  });
621
687
  }
622
- segments.push({ end: contour.at(-1), start: current });
623
- return contour.at(-1);
624
688
  };
689
+ const pushQuad = (control, end) => {
690
+ pushCubic({
691
+ x1: cursor.x + (2 / 3) * (control.x - cursor.x),
692
+ y1: cursor.y + (2 / 3) * (control.y - cursor.y),
693
+ x2: end.x + (2 / 3) * (control.x - end.x),
694
+ y2: end.y + (2 / 3) * (control.y - end.y),
695
+ x: end.x,
696
+ y: end.y,
697
+ });
698
+ };
699
+ const reflect = (control) =>
700
+ control
701
+ ? { x: 2 * cursor.x - control.x, y: 2 * cursor.y - control.y }
702
+ : { x: cursor.x, y: cursor.y };
625
703
  try {
626
704
  while (index < tokens.length) {
627
705
  if (/^[a-zA-Z]$/.test(tokens[index])) command = tokens[index++];
628
706
  else if (!command) return undefined;
629
707
  const relative = command === command.toLowerCase();
630
708
  const normalized = command.toUpperCase();
631
- const reflect = (x, y) =>
632
- previousControl
633
- ? { x: 2 * cursor.x - previousControl.x, y: 2 * cursor.y - previousControl.y }
634
- : { x: cursor.x, y: cursor.y };
709
+ let cubicControl;
710
+ let quadControl;
635
711
  if (normalized === "Z") {
636
712
  if (contour.length > 0) {
637
- segments.push({ end: subpathStart, start: cursor });
713
+ if (cursor.x !== subpathStart.x || cursor.y !== subpathStart.y) {
714
+ pushLine(subpathStart);
715
+ } else {
716
+ segments.push({ end: subpathStart, start: cursor });
717
+ }
638
718
  if (contour.length > 2) {
639
719
  contours.push(contour);
640
720
  closedContours.push(true);
641
721
  }
642
722
  }
643
- contour = [];
723
+ if (subpath) subpath.closed = true;
724
+ // A drawing command after Z starts a new subpath at the same point.
725
+ contour = [subpathStart];
726
+ subpath = undefined;
644
727
  cursor = subpathStart;
645
728
  command = undefined;
646
- previousControl = undefined;
647
729
  } else if (normalized === "M") {
648
730
  if (contour.length > 2) {
649
731
  contours.push(contour);
650
732
  closedContours.push(false);
651
733
  }
652
- contour = [];
653
- const next = {
654
- x: readNumber() + (relative ? cursor.x : 0),
655
- y: readNumber() + (relative ? cursor.y : 0),
656
- };
657
- contour.push(next);
734
+ const next = readPoint(relative);
735
+ contour = [next];
736
+ subpath = undefined;
658
737
  cursor = next;
659
738
  subpathStart = next;
660
- previousControl = undefined;
661
739
  command = relative ? "l" : "L";
662
740
  } else if (normalized === "L") {
663
- pushLine({
664
- x: readNumber() + (relative ? cursor.x : 0),
665
- y: readNumber() + (relative ? cursor.y : 0),
666
- });
667
- previousControl = undefined;
741
+ pushLine(readPoint(relative));
668
742
  } else if (normalized === "H") {
669
743
  pushLine({ x: readNumber() + (relative ? cursor.x : 0), y: cursor.y });
670
- previousControl = undefined;
671
744
  } else if (normalized === "V") {
672
745
  pushLine({ x: cursor.x, y: readNumber() + (relative ? cursor.y : 0) });
673
- previousControl = undefined;
674
- } else if (normalized === "C") {
675
- const cubic = {
676
- x1: readNumber() + (relative ? cursor.x : 0),
677
- y1: readNumber() + (relative ? cursor.y : 0),
678
- x2: readNumber() + (relative ? cursor.x : 0),
679
- y2: readNumber() + (relative ? cursor.y : 0),
680
- x: readNumber() + (relative ? cursor.x : 0),
681
- y: readNumber() + (relative ? cursor.y : 0),
682
- };
683
- previousControl = { x: cubic.x2, y: cubic.y2 };
684
- cursor = pushCubic(cubic, cursor);
685
- } else if (normalized === "S") {
686
- const first = reflect(cursor.x, cursor.y);
687
- const cubic = {
746
+ } else if (normalized === "C" || normalized === "S") {
747
+ const first = normalized === "C" ? readPoint(relative) : reflect(previousCubic);
748
+ const second = readPoint(relative);
749
+ const end = readPoint(relative);
750
+ cubicControl = second;
751
+ pushCubic({
688
752
  x1: first.x,
689
753
  y1: first.y,
690
- x2: readNumber() + (relative ? cursor.x : 0),
691
- y2: readNumber() + (relative ? cursor.y : 0),
692
- x: readNumber() + (relative ? cursor.x : 0),
693
- y: readNumber() + (relative ? cursor.y : 0),
694
- };
695
- previousControl = { x: cubic.x2, y: cubic.y2 };
696
- cursor = pushCubic(cubic, cursor);
697
- } else if (normalized === "Q") {
698
- const control = {
699
- x: readNumber() + (relative ? cursor.x : 0),
700
- y: readNumber() + (relative ? cursor.y : 0),
701
- };
702
- const cubic = {
703
- x1: control.x,
704
- y1: control.y,
705
- x2: control.x,
706
- y2: control.y,
707
- x: readNumber() + (relative ? cursor.x : 0),
708
- y: readNumber() + (relative ? cursor.y : 0),
709
- };
710
- previousControl = control;
711
- cursor = pushCubic(cubic, cursor);
712
- } else if (normalized === "T") {
713
- const control = reflect(cursor.x, cursor.y);
714
- const cubic = {
715
- x1: control.x,
716
- y1: control.y,
717
- x2: control.x,
718
- y2: control.y,
719
- x: readNumber() + (relative ? cursor.x : 0),
720
- y: readNumber() + (relative ? cursor.y : 0),
721
- };
722
- previousControl = control;
723
- cursor = pushCubic(cubic, cursor);
754
+ x2: second.x,
755
+ y2: second.y,
756
+ x: end.x,
757
+ y: end.y,
758
+ });
759
+ } else if (normalized === "Q" || normalized === "T") {
760
+ const control = normalized === "Q" ? readPoint(relative) : reflect(previousQuad);
761
+ quadControl = control;
762
+ pushQuad(control, readPoint(relative));
724
763
  } else if (normalized === "A") {
725
764
  const rx = readNumber();
726
765
  const ry = readNumber();
727
766
  const rotation = readNumber();
728
767
  const largeArc = readNumber() !== 0;
729
768
  const sweep = readNumber() !== 0;
730
- const next = {
731
- x: readNumber() + (relative ? cursor.x : 0),
732
- y: readNumber() + (relative ? cursor.y : 0),
733
- };
734
- for (const cubic of arcToCubicSegments(
735
- cursor,
736
- rx,
737
- ry,
738
- rotation,
739
- largeArc,
740
- sweep,
741
- next,
742
- )) {
743
- cursor = pushCubic(cubic, cursor);
769
+ const next = readPoint(relative);
770
+ if (next.x !== cursor.x || next.y !== cursor.y) {
771
+ const cubics = arcToCubicSegments(
772
+ cursor,
773
+ rx,
774
+ ry,
775
+ rotation,
776
+ largeArc,
777
+ sweep,
778
+ next,
779
+ );
780
+ // A zero radius draws a straight line (SVG 2 F.6.2).
781
+ if (cubics.length === 0) pushLine(next);
782
+ for (const cubic of cubics) pushCubic(cubic);
783
+ cursor = next;
744
784
  }
745
- cursor = next;
746
- previousControl = undefined;
747
785
  } else {
748
786
  return undefined;
749
787
  }
788
+ previousCubic = cubicControl;
789
+ previousQuad = quadControl;
750
790
  }
751
791
  } catch {
752
792
  return undefined;
@@ -762,6 +802,7 @@ function parsePathData(value) {
762
802
  totalLength += Math.hypot(segment.end.x - segment.start.x, segment.end.y - segment.start.y);
763
803
  cumulative.push(totalLength);
764
804
  }
805
+ const drawn = subpaths.filter(({ points }) => points.length > 1);
765
806
  return {
766
807
  closedContours,
767
808
  contours,
@@ -769,6 +810,7 @@ function parsePathData(value) {
769
810
  end: segments.at(-1).end,
770
811
  segments,
771
812
  start: segments[0].start,
813
+ subpaths: drawn,
772
814
  totalLength,
773
815
  };
774
816
  }
@@ -791,24 +833,6 @@ function pathFillContains(geometry, x, y, evenOdd = false) {
791
833
  : windingNumber(geometry.contours, x, y) !== 0;
792
834
  }
793
835
 
794
- function dashPattern(stroke, width) {
795
- const style = stroke.style ?? "solid";
796
- if (style === "solid") return undefined;
797
- const scale = Math.max(1, width);
798
- if (style === "dashed") {
799
- return { on: stroke.dash ?? scale * 3, period: (stroke.dash ?? scale * 3) + (stroke.gap ?? scale * 3) };
800
- }
801
- if (style === "dotted") {
802
- const on = stroke.dash ?? scale;
803
- return { on, period: on + (stroke.gap ?? scale) };
804
- }
805
- // "mixed": dash, gap, dot, gap — deterministic alternating pattern.
806
- const dash = stroke.dash ?? scale * 3;
807
- const dot = scale;
808
- const gap = stroke.gap ?? scale * 2;
809
- return { on: dash, period: dash + gap + dot + gap, mixed: { dash, dot, gap } };
810
- }
811
-
812
836
  function dashOnAt(pattern, distance) {
813
837
  if (!pattern) return true;
814
838
  let offset = distance % pattern.period;
@@ -858,146 +882,408 @@ function pathStrokeContains(
858
882
  return false;
859
883
  }
860
884
 
861
- const MARKER_SIDES = 24;
885
+ // Penpot's SVG dash arrays (ui/shapes/attrs.cljs calculate-dasharray), in
886
+ // local units: dashed defaults to w+10 on and off, dotted draws round dots
887
+ // w+5 apart, mixed alternates a long and a short dash.
888
+ function dashArray(stroke, width) {
889
+ const style = stroke.style ?? "solid";
890
+ let dashes;
891
+ if (style === "dashed") {
892
+ dashes = [stroke.dash ?? width + 10, stroke.gap ?? width + 10];
893
+ } else if (style === "dotted") {
894
+ dashes = [0, width + 5];
895
+ } else if (style === "mixed") {
896
+ dashes = [width + 5, width + 5, width + 1, width + 5];
897
+ } else {
898
+ return undefined;
899
+ }
900
+ if (dashes.some((value) => !Number.isFinite(value) || value < 0)) return undefined;
901
+ return dashes.reduce((sum, value) => sum + value, 0) > 0 ? dashes : undefined;
902
+ }
862
903
 
863
- function markerGeometry(cap, point, direction, width) {
864
- const size = Math.max(3, width * 2.5);
865
- const normal = { x: -direction.y, y: direction.x };
866
- const at = (along, across) => ({
867
- x: point.x + direction.x * along + normal.x * across,
868
- y: point.y + direction.y * along + normal.y * across,
869
- });
870
- if (cap === "round") {
871
- const radius = width / 2;
872
- const contour = [];
873
- for (let index = 0; index < MARKER_SIDES; index += 1) {
874
- const angle = (index / MARKER_SIDES) * Math.PI * 2;
875
- contour.push({
876
- x: point.x + Math.cos(angle) * radius,
877
- y: point.y + Math.sin(angle) * radius,
878
- });
904
+ const LINE_CAPS = new Set(["round", "square"]);
905
+ const MARKER_CAPS = new Set([
906
+ "circle-marker",
907
+ "diamond-marker",
908
+ "line-arrow",
909
+ "square-marker",
910
+ "triangle-arrow",
911
+ ]);
912
+ // SVG's default stroke-miterlimit, which Penpot never changes.
913
+ const MITER_LIMIT = 4;
914
+
915
+ function strokeBounds(points, reach) {
916
+ return {
917
+ bottom: Math.max(...points.map(({ y }) => y)) + reach,
918
+ left: Math.min(...points.map(({ x }) => x)) - reach,
919
+ right: Math.max(...points.map(({ x }) => x)) + reach,
920
+ top: Math.min(...points.map(({ y }) => y)) - reach,
921
+ };
922
+ }
923
+
924
+ // One stroked segment of a polyline, `half` either side of start→end. Each
925
+ // end is "round" where it joins a neighbour (so consecutive segments overlap
926
+ // without seams), or the polyline's linecap: "butt" or "square".
927
+ function segmentPiece(start, end, half, startCap = "round", endCap = "round") {
928
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
929
+ if (length < 1e-9) return undefined;
930
+ return {
931
+ bounds: strokeBounds([start, end], half * Math.SQRT2),
932
+ endCap,
933
+ half,
934
+ length,
935
+ start,
936
+ startCap,
937
+ type: "segment",
938
+ ux: (end.x - start.x) / length,
939
+ uy: (end.y - start.y) / length,
940
+ };
941
+ }
942
+
943
+ // A simple polygon (any winding, convex or not).
944
+ function polygonPiece(points) {
945
+ if (points.length < 3) return undefined;
946
+ return { bounds: strokeBounds(points, 0), contour: points, type: "polygon" };
947
+ }
948
+
949
+ // A disc; with `facing` only the half on that side of the centre.
950
+ function circlePiece(center, radius, facing) {
951
+ return {
952
+ bounds: strokeBounds([center], radius),
953
+ center,
954
+ facing,
955
+ radius,
956
+ type: "circle",
957
+ };
958
+ }
959
+
960
+ // Signed distance (negative inside) from a local point to a stroke piece.
961
+ // Exact outside every piece, so the union (the minimum) is exact outside
962
+ // the stroke too; inside, pieces overlap deeply wherever they meet.
963
+ function pieceDistance(piece, x, y) {
964
+ if (piece.type === "segment") {
965
+ const dx = x - piece.start.x;
966
+ const dy = y - piece.start.y;
967
+ const along = dx * piece.ux + dy * piece.uy;
968
+ const across = Math.abs(-dx * piece.uy + dy * piece.ux) - piece.half;
969
+ const startReach = piece.startCap === "square" ? piece.half : 0;
970
+ const endReach = piece.endCap === "square" ? piece.half : 0;
971
+ if (along < 0 && piece.startCap === "round") return Math.hypot(along, across + piece.half) - piece.half;
972
+ if (along > piece.length && piece.endCap === "round") {
973
+ return Math.hypot(along - piece.length, across + piece.half) - piece.half;
879
974
  }
880
- return { contours: [contour], segments: [] };
975
+ const beyond = Math.max(
976
+ piece.startCap === "round" ? -Infinity : -along - startReach,
977
+ piece.endCap === "round" ? -Infinity : along - piece.length - endReach,
978
+ );
979
+ if (beyond > 0 || across > 0) {
980
+ return Math.hypot(Math.max(beyond, 0), Math.max(across, 0));
981
+ }
982
+ return Math.max(beyond, across);
881
983
  }
882
- if (cap === "square") {
883
- const half = width / 2;
884
- return {
885
- contours: [[
886
- at(0, -half),
887
- at(half, -half),
888
- at(half, half),
889
- at(0, half),
890
- ]],
891
- segments: [],
892
- };
984
+ if (piece.type === "circle") {
985
+ const dx = x - piece.center.x;
986
+ const dy = y - piece.center.y;
987
+ const distance = Math.hypot(dx, dy) - piece.radius;
988
+ if (!piece.facing) return distance;
989
+ return Math.max(distance, -(dx * piece.facing.x + dy * piece.facing.y));
893
990
  }
894
- if (cap === "triangle-arrow") {
895
- const length = Math.max(6, width * 4);
896
- const half = size * 0.9;
897
- return {
898
- contours: [[
899
- point,
900
- at(-length, half),
901
- at(-length, -half),
902
- ]],
903
- segments: [],
904
- };
991
+ let distance = Infinity;
992
+ const contour = piece.contour;
993
+ for (let index = 0; index < contour.length; index += 1) {
994
+ distance = Math.min(
995
+ distance,
996
+ distanceToSegment(x, y, contour[index], contour[(index + 1) % contour.length]),
997
+ );
905
998
  }
906
- if (cap === "circle-marker") {
907
- const contour = [];
908
- for (let index = 0; index < MARKER_SIDES; index += 1) {
909
- const angle = (index / MARKER_SIDES) * Math.PI * 2;
910
- contour.push({
911
- x: point.x + Math.cos(angle) * size,
912
- y: point.y + Math.sin(angle) * size,
913
- });
999
+ return windingNumber([contour], x, y) !== 0 ? -distance : distance;
1000
+ }
1001
+
1002
+ // Coverage of a pixel at a local point by a set of pieces, given device
1003
+ // pixels per local unit.
1004
+ function piecesCoverage(pieces, x, y, unit) {
1005
+ const reach = 1 / unit;
1006
+ let distance = Infinity;
1007
+ for (const piece of pieces) {
1008
+ const { bounds } = piece;
1009
+ if (
1010
+ x < bounds.left - reach ||
1011
+ x > bounds.right + reach ||
1012
+ y < bounds.top - reach ||
1013
+ y > bounds.bottom + reach
1014
+ ) {
1015
+ continue;
914
1016
  }
915
- return { contours: [contour], segments: [] };
1017
+ distance = Math.min(distance, pieceDistance(piece, x, y));
1018
+ if (distance * unit <= -0.5) return 1;
916
1019
  }
917
- if (cap === "square-marker") {
918
- return {
919
- contours: [[
920
- at(-size, size),
921
- at(-size, -size),
922
- at(size, -size),
923
- at(size, size),
924
- ]],
925
- segments: [],
926
- };
1020
+ return edgeCoverage(distance * unit);
1021
+ }
1022
+
1023
+ function unitDirection(start, end) {
1024
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
1025
+ return length < 1e-9
1026
+ ? undefined
1027
+ : { x: (end.x - start.x) / length, y: (end.y - start.y) / length };
1028
+ }
1029
+
1030
+ // The wedge a miter join adds past the round overlap of two segments, on
1031
+ // the outer side of the turn at `vertex` from `incoming` to `outgoing`.
1032
+ // Past the miter limit SVG bevels, which the round overlap already covers
1033
+ // except for a sliver under a pixel wide at stroke widths that matter.
1034
+ function joinPiece(vertex, incoming, outgoing, half) {
1035
+ const cross = incoming.x * outgoing.y - incoming.y * outgoing.x;
1036
+ const dot = incoming.x * outgoing.x + incoming.y * outgoing.y;
1037
+ if (Math.abs(cross) < 1e-9) return undefined;
1038
+ const side = cross > 0 ? -1 : 1;
1039
+ const first = {
1040
+ x: vertex.x - side * incoming.y * half,
1041
+ y: vertex.y + side * incoming.x * half,
1042
+ };
1043
+ const second = {
1044
+ x: vertex.x - side * outgoing.y * half,
1045
+ y: vertex.y + side * outgoing.x * half,
1046
+ };
1047
+ // The miter ratio is 1 / sin(θ/2) for the angle θ between the segments.
1048
+ const sinHalf = Math.sqrt(Math.max(0, (1 + dot) / 2));
1049
+ const bisector = unitDirection(vertex, {
1050
+ x: (first.x + second.x) / 2,
1051
+ y: (first.y + second.y) / 2,
1052
+ });
1053
+ if (sinHalf * MITER_LIMIT < 1 || !bisector) {
1054
+ return polygonPiece([vertex, first, second]);
927
1055
  }
928
- if (cap === "diamond-marker") {
929
- return {
930
- contours: [[
931
- at(0, size),
932
- at(-size, 0),
933
- at(0, -size),
934
- at(size, 0),
935
- ]],
936
- segments: [],
937
- };
1056
+ const tipDistance = half / sinHalf;
1057
+ return polygonPiece([
1058
+ vertex,
1059
+ first,
1060
+ { x: vertex.x + bisector.x * tipDistance, y: vertex.y + bisector.y * tipDistance },
1061
+ second,
1062
+ ]);
1063
+ }
1064
+
1065
+ // Pieces that stroke one polyline like SVG: segments, miter joins and the
1066
+ // given linecap ("round", "square" or butt) on open ends. A single point (a
1067
+ // zero-length dash) draws only its caps, along `direction`.
1068
+ function pushPolylinePieces(pieces, points, closed, half, linecap, direction) {
1069
+ const add = (piece) => {
1070
+ if (piece) pieces.push(piece);
1071
+ };
1072
+ const cap = linecap ?? "butt";
1073
+ if (points.length === 1) {
1074
+ if (cap === "round") add(circlePiece(points[0], half));
1075
+ if (cap === "square" && direction) {
1076
+ add(segmentPiece(
1077
+ { x: points[0].x - direction.x * half, y: points[0].y - direction.y * half },
1078
+ { x: points[0].x + direction.x * half, y: points[0].y + direction.y * half },
1079
+ half,
1080
+ "butt",
1081
+ "butt",
1082
+ ));
1083
+ }
1084
+ return;
938
1085
  }
939
- if (cap === "line-arrow") {
940
- const length = Math.max(6, width * 4);
941
- const spread = Math.PI / 7;
942
- const back = {
943
- x: -direction.x * Math.cos(spread) - -direction.y * Math.sin(spread),
944
- y: -direction.y * Math.cos(spread) + -direction.x * Math.sin(spread),
945
- };
946
- const backOther = {
947
- x: -direction.x * Math.cos(spread) - direction.y * Math.sin(spread),
948
- y: -direction.y * Math.cos(spread) + direction.x * Math.sin(spread),
949
- };
1086
+ const count = closed ? points.length : points.length - 1;
1087
+ const directions = [];
1088
+ for (let index = 0; index < count; index += 1) {
1089
+ add(segmentPiece(
1090
+ points[index],
1091
+ points[(index + 1) % points.length],
1092
+ half,
1093
+ !closed && index === 0 ? cap : "round",
1094
+ !closed && index === count - 1 ? cap : "round",
1095
+ ));
1096
+ directions.push(unitDirection(points[index], points[(index + 1) % points.length]));
1097
+ }
1098
+ for (let index = closed ? 0 : 1; index < count; index += 1) {
1099
+ const incoming = directions[(index - 1 + count) % count];
1100
+ const outgoing = directions[index];
1101
+ if (incoming && outgoing) add(joinPiece(points[index], incoming, outgoing, half));
1102
+ }
1103
+ }
1104
+
1105
+ // Splits a polyline into its dashes (open polylines; a zero-length dash is a
1106
+ // single point) for an SVG dash array. The pattern restarts on every
1107
+ // subpath, like Skia.
1108
+ function dashPolyline(points, closed, dashes) {
1109
+ const vertices = closed ? [...points, points[0]] : points;
1110
+ const dashed = [];
1111
+ let dashIndex = 0;
1112
+ let remaining = dashes[0];
1113
+ let current = [vertices[0]];
1114
+ const finish = (direction) => {
1115
+ const unique = current.filter(
1116
+ (candidate, index) =>
1117
+ index === 0 ||
1118
+ Math.hypot(candidate.x - current[index - 1].x, candidate.y - current[index - 1].y) > 1e-9,
1119
+ );
1120
+ dashed.push({ direction, points: unique });
1121
+ current = undefined;
1122
+ };
1123
+ let direction;
1124
+ for (let index = 0; index < vertices.length - 1; index += 1) {
1125
+ const start = vertices[index];
1126
+ const end = vertices[index + 1];
1127
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
1128
+ if (length < 1e-9) continue;
1129
+ direction = { x: (end.x - start.x) / length, y: (end.y - start.y) / length };
1130
+ let travelled = 0;
1131
+ while (remaining <= length - travelled) {
1132
+ travelled += remaining;
1133
+ const at = {
1134
+ x: start.x + direction.x * travelled,
1135
+ y: start.y + direction.y * travelled,
1136
+ };
1137
+ if (current) {
1138
+ current.push(at);
1139
+ finish(direction);
1140
+ } else {
1141
+ current = [at];
1142
+ }
1143
+ dashIndex = (dashIndex + 1) % dashes.length;
1144
+ remaining = dashes[dashIndex];
1145
+ }
1146
+ remaining -= length - travelled;
1147
+ current?.push(end);
1148
+ }
1149
+ if (current && current.length > 1) finish(direction);
1150
+ return dashed;
1151
+ }
1152
+
1153
+ // Stroke subpaths of a PATH geometry: consecutive duplicate points dropped,
1154
+ // and a subpath ending on its start counts as closed, as Penpot's
1155
+ // close-subpaths does.
1156
+ function strokeSubpaths(geometry) {
1157
+ return geometry.subpaths.map((subpath) => {
1158
+ const points = subpath.points.filter(
1159
+ (candidate, index, all) =>
1160
+ index === 0 ||
1161
+ Math.hypot(candidate.x - all[index - 1].x, candidate.y - all[index - 1].y) > 1e-9,
1162
+ );
1163
+ const first = points[0];
1164
+ const last = points.at(-1);
1165
+ const meets =
1166
+ points.length > 2 && Math.hypot(last.x - first.x, last.y - first.y) <= 1e-9;
1167
+ if (subpath.closed || meets) {
1168
+ if (meets) points.pop();
1169
+ return { ...subpath, closed: true, points };
1170
+ }
1171
+ return { ...subpath, points };
1172
+ });
1173
+ }
1174
+
1175
+ // Marker shapes from Penpot's SVG cap markers (ui/shapes/custom_stroke.cljs):
1176
+ // viewBox polygons and discs, the reference point, and the marker scale
1177
+ // (viewBox fitted into markerWidth/Height stroke widths, aspect kept).
1178
+ const MARKER_SHAPES = new Map([
1179
+ ["line-arrow", {
1180
+ polygons: [[[0.5, 0.5], [3, 3], [0.5, 5.5], [0, 5], [2, 3], [0, 1]]],
1181
+ ref: [2, 3],
1182
+ scale: 8.5 / 6,
1183
+ }],
1184
+ ["triangle-arrow", { polygons: [[[0, 0], [3, 3], [0, 6]]], ref: [2, 3], scale: 8.5 / 6 }],
1185
+ ["square-marker", {
1186
+ polygons: [[[0, 0], [6, 0], [6, 6], [0, 6]]],
1187
+ ref: [3, 3],
1188
+ scale: 4.2426 / 6,
1189
+ }],
1190
+ ["circle-marker", { circle: { center: [3, 3], radius: 3 }, ref: [3, 3], scale: 4 / 6 }],
1191
+ ["diamond-marker", {
1192
+ polygons: [[[3, 0], [6, 3], [3, 6], [0, 3]]],
1193
+ ref: [3, 3],
1194
+ scale: 1,
1195
+ }],
1196
+ ["round", {
1197
+ circle: { center: [3, 3], facing: true, radius: 0.5 },
1198
+ ref: [3, 3],
1199
+ scale: 1,
1200
+ }],
1201
+ ["square", { polygons: [[[3, 2.5], [3.5, 2.5], [3.5, 3.5], [3, 3.5]]], ref: [3, 3], scale: 1 }],
1202
+ ]);
1203
+
1204
+ // A cap marker at `at`, its +x axis along `outward` (orient
1205
+ // auto-start-reverse), scaled by the stroke width (markerUnits strokeWidth).
1206
+ function markerPieces(cap, at, outward, width) {
1207
+ const shape = MARKER_SHAPES.get(cap);
1208
+ if (!shape || !outward) return [];
1209
+ const scale = shape.scale * width;
1210
+ const place = ([x, y]) => {
1211
+ const along = (x - shape.ref[0]) * scale;
1212
+ const across = (y - shape.ref[1]) * scale;
950
1213
  return {
951
- contours: [],
952
- segments: [
953
- {
954
- end: { x: point.x + back.x * length, y: point.y + back.y * length },
955
- start: point,
956
- },
957
- {
958
- end: {
959
- x: point.x + backOther.x * length,
960
- y: point.y + backOther.y * length,
961
- },
962
- start: point,
963
- },
964
- ],
1214
+ x: at.x + outward.x * along - outward.y * across,
1215
+ y: at.y + outward.y * along + outward.x * across,
965
1216
  };
1217
+ };
1218
+ if (shape.circle) {
1219
+ return [circlePiece(
1220
+ place(shape.circle.center),
1221
+ shape.circle.radius * scale,
1222
+ shape.circle.facing ? outward : undefined,
1223
+ )];
966
1224
  }
967
- return undefined;
1225
+ return shape.polygons.map((polygon) => polygonPiece(polygon.map(place))).filter(Boolean);
968
1226
  }
969
1227
 
970
- function capExtensions(geometry, width, pattern) {
971
- // "square" caps extend the open ends by half the stroke width; "round" caps
972
- // already fall out of the distance test.
973
- if (!pattern && width <= 0) return undefined;
974
- const segments = [];
975
- const half = width / 2;
976
- const first = geometry.segments[0];
977
- if (first) {
978
- const direction = directionAt(geometry, { segmentIndex: 0 });
979
- segments.push({
980
- end: first.start,
981
- start: {
982
- x: first.start.x - direction.x * half,
983
- y: first.start.y - direction.y * half,
984
- },
985
- });
1228
+ // How far stroke ink may reach past the stroked outline, in local units.
1229
+ function strokeReach(width) {
1230
+ return width * 4.25 + 1;
1231
+ }
1232
+
1233
+ // Penpot's stroke rendering for a PATH geometry or a dashed rectangle or
1234
+ // ellipse outline (`subpaths`), as SVG pieces: the stroke itself and its cap
1235
+ // markers, which SVG paints as separate elements. Open paths always stroke
1236
+ // centred; closed ones grow the stroke to twice the width for inner and
1237
+ // outer alignment, which the caller clips to the fill (ui/shapes/custom_stroke.cljs).
1238
+ function strokePieces(subpaths, stroke, width) {
1239
+ const alignment = stroke.alignment ?? "inner";
1240
+ const open = subpaths.some(({ closed }) => !closed);
1241
+ const aligned = !open && (alignment === "inner" || alignment === "outer");
1242
+ const half = aligned ? width : width / 2;
1243
+ const { capEnd, capStart } = stroke;
1244
+ const style = stroke.style ?? "solid";
1245
+ const linecap =
1246
+ style === "dotted"
1247
+ ? "round"
1248
+ : LINE_CAPS.has(capStart) && capStart === capEnd && !aligned
1249
+ ? capStart
1250
+ : undefined;
1251
+ const dashes = dashArray(stroke, width);
1252
+ const pieces = [];
1253
+ for (const subpath of subpaths) {
1254
+ if (subpath.points.length === 0) continue;
1255
+ if (!dashes) {
1256
+ pushPolylinePieces(pieces, subpath.points, subpath.closed, half, linecap);
1257
+ continue;
1258
+ }
1259
+ for (const dash of dashPolyline(subpath.points, subpath.closed, dashes)) {
1260
+ pushPolylinePieces(pieces, dash.points, false, half, linecap, dash.direction);
1261
+ }
986
1262
  }
987
- const last = geometry.segments.at(-1);
988
- if (last) {
989
- const direction = directionAt(geometry, {
990
- segmentIndex: geometry.segments.length - 1,
991
- });
992
- segments.push({
993
- end: {
994
- x: last.end.x + direction.x * half,
995
- y: last.end.y + direction.y * half,
996
- },
997
- start: last.end,
998
- });
1263
+ const markers = [];
1264
+ if (!aligned) {
1265
+ const markerCap = (cap) =>
1266
+ MARKER_CAPS.has(cap) || (LINE_CAPS.has(cap) && capStart !== capEnd) ? cap : undefined;
1267
+ const first = subpaths[0];
1268
+ const last = subpaths.at(-1);
1269
+ if (markerCap(capStart) && first?.startTangent) {
1270
+ markers.push(...markerPieces(
1271
+ capStart,
1272
+ first.points[0],
1273
+ { x: -first.startTangent.x, y: -first.startTangent.y },
1274
+ width,
1275
+ ));
1276
+ }
1277
+ if (markerCap(capEnd) && last?.endTangent) {
1278
+ markers.push(...markerPieces(
1279
+ capEnd,
1280
+ last.closed ? last.points[0] : last.points.at(-1),
1281
+ last.endTangent,
1282
+ width,
1283
+ ));
1284
+ }
999
1285
  }
1000
- return segments.length ? { contours: [], segments } : undefined;
1286
+ return { alignment: aligned ? alignment : "center", markers, pieces };
1001
1287
  }
1002
1288
 
1003
1289
  const WHITESPACE = /\s/u;
@@ -1010,7 +1296,7 @@ function spaceAdvance(font) {
1010
1296
 
1011
1297
  function lineGlyphs(
1012
1298
  font,
1013
- symbolFont,
1299
+ fonts,
1014
1300
  text,
1015
1301
  x,
1016
1302
  baseline,
@@ -1031,13 +1317,8 @@ function lineGlyphs(
1031
1317
  };
1032
1318
  }
1033
1319
  const glyph = font.charToGlyph(character);
1034
- if (glyph.index === 0 && symbolFont.charToGlyphIndex(character) !== 0) {
1035
- return {
1036
- advanceWidth: symbolFont.unitsPerEm,
1037
- font: symbolFont,
1038
- glyph: symbolFont.charToGlyph(character),
1039
- };
1040
- }
1320
+ const fallback = glyph.index === 0 ? fallbackGlyph(fonts, character) : null;
1321
+ if (fallback) return fallback;
1041
1322
  if (glyph.index === 0) {
1042
1323
  missing?.add(character);
1043
1324
  return {
@@ -1276,8 +1557,8 @@ function renderTextDecoration(
1276
1557
  function richTextRun(state, node, style, fills, path) {
1277
1558
  const font = resolveFont(state, style, node.id, path);
1278
1559
  const size = Number(style.fontSize ?? 14);
1279
- const height = Number(style.lineHeight ?? 1.2);
1280
- const lineHeight = height <= 4 ? size * height : height;
1560
+ // Penpot reads lineHeight as a multiple of the font size, whatever its size.
1561
+ const lineHeight = size * Number(style.lineHeight ?? 1.2);
1281
1562
  const ascent =
1282
1563
  (lineHeight - size) / 2 +
1283
1564
  (typographicAscender(font) * size) / font.unitsPerEm;
@@ -1395,13 +1676,9 @@ function richTextLines(state, node, path, missing) {
1395
1676
  let font = run.font;
1396
1677
  let glyph = font.charToGlyph(character);
1397
1678
  let advanceWidth = glyph.advanceWidth ?? font.unitsPerEm;
1398
- if (
1399
- glyph.index === 0 &&
1400
- state.fonts.symbols.charToGlyphIndex(character) !== 0
1401
- ) {
1402
- font = state.fonts.symbols;
1403
- glyph = font.charToGlyph(character);
1404
- advanceWidth = font.unitsPerEm;
1679
+ const fallback = glyph.index === 0 ? fallbackGlyph(state.fonts, character) : null;
1680
+ if (fallback) {
1681
+ ({ font, glyph, advanceWidth } = fallback);
1405
1682
  } else if (glyph.index === 0) {
1406
1683
  missing?.add(character);
1407
1684
  advanceWidth = spaceAdvance(font);
@@ -1569,8 +1846,8 @@ function renderText(state, node, matrix, opacity, path, clips) {
1569
1846
  const style = node.textStyle ?? {};
1570
1847
  const font = resolveFont(state, style, node.id, path);
1571
1848
  const fontSize = Number(style.fontSize ?? 14);
1572
- const lineHeightValue = Number(style.lineHeight ?? 1.2);
1573
- const lineHeight = lineHeightValue <= 4 ? fontSize * lineHeightValue : lineHeightValue;
1849
+ // Penpot reads lineHeight as a multiple of the font size, whatever its size.
1850
+ const lineHeight = fontSize * Number(style.lineHeight ?? 1.2);
1574
1851
  const letterSpacing = Number(style.letterSpacing ?? 0);
1575
1852
  const source =
1576
1853
  style.textTransform === "uppercase"
@@ -1588,7 +1865,7 @@ function renderText(state, node, matrix, opacity, path, clips) {
1588
1865
  (text, previous) =>
1589
1866
  lineGlyphs(
1590
1867
  font,
1591
- state.fonts.symbols,
1868
+ state.fonts,
1592
1869
  text,
1593
1870
  0,
1594
1871
  0,
@@ -1628,7 +1905,7 @@ function renderText(state, node, matrix, opacity, path, clips) {
1628
1905
  for (let index = 0; index < lines.length; index += 1) {
1629
1906
  const line = lineGlyphs(
1630
1907
  font,
1631
- state.fonts.symbols,
1908
+ state.fonts,
1632
1909
  lines[index],
1633
1910
  0,
1634
1911
  0,
@@ -1645,7 +1922,7 @@ function renderText(state, node, matrix, opacity, path, clips) {
1645
1922
  verticalOffset + index * lineHeight + (lineHeight - fontSize) / 2 + ascender;
1646
1923
  lineGlyphs(
1647
1924
  font,
1648
- state.fonts.symbols,
1925
+ state.fonts,
1649
1926
  lines[index],
1650
1927
  x,
1651
1928
  baseline,
@@ -2935,37 +3212,99 @@ function nodeContains(node, x, y) {
2935
3212
  return cornerContains(node, x, y);
2936
3213
  }
2937
3214
 
2938
- function nodeContainsGeometry(node, geometry, x, y) {
2939
- if (geometry) return pathFillContains(geometry, x, y);
2940
- return nodeContains(node, x, y);
3215
+ // Corner radii after growing the outline by `grow` (negative shrinks), in
3216
+ // TL, TR, BR, BL order and scaled down like CSS when adjacent radii overflow
3217
+ // a side. Square corners stay square.
3218
+ function grownCornerRadii(node, width, height, grow) {
3219
+ const values = Array.isArray(node.cornerRadius)
3220
+ ? node.cornerRadius
3221
+ : [node.cornerRadius ?? 0];
3222
+ const requested = (values.length === 4 ? values : Array(4).fill(values[0]))
3223
+ .map((value) => {
3224
+ const radius = Math.max(0, Number(value) || 0);
3225
+ return radius > 0 ? Math.max(0, radius + grow) : 0;
3226
+ });
3227
+ const [topLeft, topRight, bottomRight, bottomLeft] = requested;
3228
+ const scale = Math.min(
3229
+ 1,
3230
+ topLeft + topRight > 0 ? width / (topLeft + topRight) : 1,
3231
+ bottomLeft + bottomRight > 0 ? width / (bottomLeft + bottomRight) : 1,
3232
+ topLeft + bottomLeft > 0 ? height / (topLeft + bottomLeft) : 1,
3233
+ topRight + bottomRight > 0 ? height / (topRight + bottomRight) : 1,
3234
+ );
3235
+ return requested.map((radius) => radius * scale);
2941
3236
  }
2942
3237
 
2943
- function insetContains(node, x, y, inset) {
2944
- const width = node.width - inset * 2;
2945
- const height = node.height - inset * 2;
2946
- if (width <= 0 || height <= 0) return false;
2947
- const reduceRadius = (value) => Math.max(0, Number(value) - inset);
2948
- const cornerRadius = Array.isArray(node.cornerRadius)
2949
- ? node.cornerRadius.map(reduceRadius)
2950
- : reduceRadius(node.cornerRadius ?? 0);
2951
- return nodeContains(
2952
- { ...node, cornerRadius, height, width },
2953
- x - inset,
2954
- y - inset,
3238
+ // Signed distance (negative inside) from a node-local point to the outline
3239
+ // of a rectangle, rounded rectangle or ellipse grown by `grow` local units.
3240
+ // The ellipse distance uses the first-order gradient estimate, exact for
3241
+ // circles and accurate within a fraction of a pixel near any ellipse edge,
3242
+ // which is all edge coverage needs.
3243
+ function shapeDistance(node, x, y, grow = 0) {
3244
+ const width = node.width + grow * 2;
3245
+ const height = node.height + grow * 2;
3246
+ if (width <= 0 || height <= 0) return Infinity;
3247
+ const halfWidth = width / 2;
3248
+ const halfHeight = height / 2;
3249
+ const px = x + grow - halfWidth;
3250
+ const py = y + grow - halfHeight;
3251
+ if (node.type === "ELLIPSE") {
3252
+ if (Math.abs(halfWidth - halfHeight) < 1e-9) {
3253
+ return Math.hypot(px, py) - halfWidth;
3254
+ }
3255
+ const k0 = Math.hypot(px / halfWidth, py / halfHeight);
3256
+ const k1 = Math.hypot(
3257
+ px / (halfWidth * halfWidth),
3258
+ py / (halfHeight * halfHeight),
3259
+ );
3260
+ if (k1 === 0) return -Math.min(halfWidth, halfHeight);
3261
+ return (k0 * (k0 - 1)) / k1;
3262
+ }
3263
+ const radii = grownCornerRadii(node, width, height, grow);
3264
+ const radius = px < 0
3265
+ ? (py < 0 ? radii[0] : radii[3])
3266
+ : (py < 0 ? radii[1] : radii[2]);
3267
+ const qx = Math.abs(px) - halfWidth + radius;
3268
+ const qy = Math.abs(py) - halfHeight + radius;
3269
+ return (
3270
+ Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) +
3271
+ Math.min(Math.max(qx, qy), 0) -
3272
+ radius
2955
3273
  );
2956
3274
  }
2957
3275
 
2958
- function makeGeometry(segments) {
2959
- const cumulative = [];
2960
- let totalLength = 0;
2961
- for (const segment of segments) {
2962
- totalLength += Math.hypot(
2963
- segment.end.x - segment.start.x,
2964
- segment.end.y - segment.start.y,
2965
- );
2966
- cumulative.push(totalLength);
3276
+ // Pixel coverage of a shape edge from its signed distance in device pixels:
3277
+ // a box filter one pixel wide, so pixels the edge crosses get partial alpha.
3278
+ function edgeCoverage(deviceDistance) {
3279
+ return clamp(0.5 - deviceDistance, 0, 1);
3280
+ }
3281
+
3282
+ // Device pixels per node-local unit, for converting local distances.
3283
+ function deviceUnit(state, matrix) {
3284
+ return state.scale * Math.sqrt(Math.abs(matrix[0] * matrix[3] - matrix[1] * matrix[2]));
3285
+ }
3286
+
3287
+ // Signed distance (negative inside) from a node-local point to the outline
3288
+ // of PATH geometry, under the nonzero fill rule.
3289
+ function pathFillDistance(geometry, x, y) {
3290
+ let distance = Infinity;
3291
+ for (const contour of geometry.contours) {
3292
+ for (let index = 0; index < contour.length; index += 1) {
3293
+ distance = Math.min(
3294
+ distance,
3295
+ distanceToSegment(x, y, contour[index], contour[(index + 1) % contour.length]),
3296
+ );
3297
+ }
2967
3298
  }
2968
- return { contours: [], cumulative, segments, totalLength };
3299
+ return pathFillContains(geometry, x, y) ? -distance : distance;
3300
+ }
3301
+
3302
+ // Fill coverage of a node at a node-local point, antialiased. PATH geometry
3303
+ // is not clipped to the node box: Penpot derives a path's box from its
3304
+ // content and draws the content.
3305
+ function shapeCoverage(node, geometry, x, y, unit) {
3306
+ if (geometry) return edgeCoverage(pathFillDistance(geometry, x, y) * unit);
3307
+ return edgeCoverage(shapeDistance(node, x, y) * unit);
2969
3308
  }
2970
3309
 
2971
3310
  function clipsAllow(clips, x, y) {
@@ -2993,12 +3332,83 @@ function blurRadiusOf(value) {
2993
3332
  return Number.isFinite(radius) && radius > 0 ? radius : undefined;
2994
3333
  }
2995
3334
 
2996
- function blurAlphaRegion(buffer, width, height, radius) {
2997
- const boxRadius = Math.round(radius);
2998
- if (boxRadius < 1) return;
3335
+ // Radii of three successive box filters whose result approximates a Gaussian
3336
+ // with standard deviation `sigma` (the "boxes for Gauss" construction): odd
3337
+ // widths w_l and w_l + 2 mixed so the summed variance (w^2 - 1) / 12 matches
3338
+ // sigma^2. A single radius r per pass would give sigma ~ r + 0.5 instead.
3339
+ function gaussBoxRadii(sigma, passes = 3) {
3340
+ if (!(sigma > 0)) return [];
3341
+ const variance = 12 * sigma * sigma;
3342
+ const ideal = Math.sqrt(variance / passes + 1);
3343
+ let lower = Math.floor(ideal);
3344
+ if (lower % 2 === 0) lower -= 1;
3345
+ const upper = lower + 2;
3346
+ const lowerCount = Math.round(
3347
+ (variance - passes * lower * lower - 4 * passes * lower - 3 * passes) /
3348
+ (-4 * lower - 4),
3349
+ );
3350
+ return Array.from({ length: passes }, (_, index) =>
3351
+ ((index < lowerCount ? lower : upper) - 1) / 2,
3352
+ );
3353
+ }
3354
+
3355
+ // How far, in pixels, a Gaussian blur of `sigma` spreads ink.
3356
+ function gaussReach(sigma) {
3357
+ return gaussBoxRadii(sigma).reduce((sum, radius) => sum + radius, 0);
3358
+ }
3359
+
3360
+ // A small sigma has no good three-box approximation (the boxes round to
3361
+ // radius 0 or 1); browsers and Skia switch to a direct Gaussian kernel there.
3362
+ const DIRECT_GAUSSIAN_SIGMA = 2;
3363
+
3364
+ function gaussianKernel(sigma) {
3365
+ const radius = Math.ceil(3 * sigma);
3366
+ const weights = Array.from({ length: radius * 2 + 1 }, (_, index) =>
3367
+ Math.exp(-((index - radius) ** 2) / (2 * sigma * sigma)),
3368
+ );
3369
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
3370
+ return { radius, weights: weights.map((weight) => weight / total) };
3371
+ }
3372
+
3373
+ function gaussianBlurAlphaRegion(buffer, width, height, sigma) {
3374
+ const { radius, weights } = gaussianKernel(sigma);
2999
3375
  const temporary = new Float32Array(buffer.length);
3000
- const window = boxRadius * 2 + 1;
3001
- for (let pass = 0; pass < 3; pass += 1) {
3376
+ for (let row = 0; row < height; row += 1) {
3377
+ for (let column = 0; column < width; column += 1) {
3378
+ let sum = 0;
3379
+ for (let offset = -radius; offset <= radius; offset += 1) {
3380
+ const source = column + offset;
3381
+ if (source >= 0 && source < width) {
3382
+ sum += buffer[row * width + source] * weights[offset + radius];
3383
+ }
3384
+ }
3385
+ temporary[row * width + column] = sum;
3386
+ }
3387
+ }
3388
+ for (let column = 0; column < width; column += 1) {
3389
+ for (let row = 0; row < height; row += 1) {
3390
+ let sum = 0;
3391
+ for (let offset = -radius; offset <= radius; offset += 1) {
3392
+ const source = row + offset;
3393
+ if (source >= 0 && source < height) {
3394
+ sum += temporary[source * width + column] * weights[offset + radius];
3395
+ }
3396
+ }
3397
+ buffer[row * width + column] = sum;
3398
+ }
3399
+ }
3400
+ }
3401
+
3402
+ function blurAlphaRegion(buffer, width, height, sigma) {
3403
+ if (sigma > 0 && sigma < DIRECT_GAUSSIAN_SIGMA) {
3404
+ gaussianBlurAlphaRegion(buffer, width, height, sigma);
3405
+ return;
3406
+ }
3407
+ const radii = gaussBoxRadii(sigma).filter((radius) => radius >= 1);
3408
+ if (radii.length === 0) return;
3409
+ const temporary = new Float32Array(buffer.length);
3410
+ for (const boxRadius of radii) {
3411
+ const window = boxRadius * 2 + 1;
3002
3412
  for (let row = 0; row < height; row += 1) {
3003
3413
  let sum = 0;
3004
3414
  for (let offset = -boxRadius; offset <= boxRadius; offset += 1) {
@@ -3024,9 +3434,9 @@ function blurAlphaRegion(buffer, width, height, radius) {
3024
3434
  }
3025
3435
  }
3026
3436
 
3027
- function blurRgbaRegion(pixels, width, height, left, top, right, bottom, radius) {
3028
- const boxRadius = Math.round(radius);
3029
- if (boxRadius < 1 || right <= left || bottom <= top) return;
3437
+ function blurRgbaRegion(pixels, width, height, left, top, right, bottom, sigma) {
3438
+ const radii = gaussBoxRadii(sigma).filter((radius) => radius >= 1);
3439
+ if (radii.length === 0 || right <= left || bottom <= top) return;
3030
3440
  const regionWidth = right - left;
3031
3441
  const regionHeight = bottom - top;
3032
3442
  const buffer = new Float32Array(regionWidth * regionHeight * 4);
@@ -3042,10 +3452,10 @@ function blurRgbaRegion(pixels, width, height, left, top, right, bottom, radius)
3042
3452
  }
3043
3453
  }
3044
3454
  const temporary = new Float32Array(buffer.length);
3045
- const window = boxRadius * 2 + 1;
3046
3455
  const at = (buffer_, row, column, channel) =>
3047
3456
  buffer_[(clamp(row, 0, regionHeight - 1) * regionWidth + clamp(column, 0, regionWidth - 1)) * 4 + channel];
3048
- for (let pass = 0; pass < 3; pass += 1) {
3457
+ for (const boxRadius of radii) {
3458
+ const window = boxRadius * 2 + 1;
3049
3459
  for (let row = 0; row < regionHeight; row += 1) {
3050
3460
  for (let channel = 0; channel < 4; channel += 1) {
3051
3461
  let sum = 0;
@@ -3093,8 +3503,18 @@ function blurRgbaRegion(pixels, width, height, left, top, right, bottom, radius)
3093
3503
  }
3094
3504
  }
3095
3505
 
3096
- function pixelBounds(state, matrix, node) {
3097
- const bounds = transformedBounds(matrix, node.width, node.height);
3506
+ function pixelBounds(state, matrix, node, grow = 0) {
3507
+ return localPixelBounds(state, matrix, {
3508
+ bottom: node.height + grow,
3509
+ left: -grow,
3510
+ right: node.width + grow,
3511
+ top: -grow,
3512
+ });
3513
+ }
3514
+
3515
+ // Device pixel bounds of a node-local rectangle.
3516
+ function localPixelBounds(state, matrix, local) {
3517
+ const bounds = transformedRectangleBounds(matrix, local);
3098
3518
  return {
3099
3519
  bottom: clamp(Math.ceil(bounds.bottom * state.scale), 0, state.height),
3100
3520
  left: clamp(Math.floor(bounds.left * state.scale), 0, state.width),
@@ -3103,14 +3523,93 @@ function pixelBounds(state, matrix, node) {
3103
3523
  };
3104
3524
  }
3105
3525
 
3526
+ // Node-local bounds of PATH geometry: its contours and stroke points.
3527
+ function geometryBounds(geometry) {
3528
+ return contourBounds([
3529
+ ...geometry.contours,
3530
+ ...geometry.subpaths.map(({ points }) => points),
3531
+ [geometry.start, geometry.end],
3532
+ ]);
3533
+ }
3534
+
3535
+ function finiteNumber(value) {
3536
+ const number = Number(value);
3537
+ return value !== undefined && value !== null && Number.isFinite(number)
3538
+ ? number
3539
+ : undefined;
3540
+ }
3541
+
3542
+ // Shadow offsets in canonical (offsetX/offsetY), Penpot kebab-case
3543
+ // (offset-x/offset-y) or legacy (x/y) spelling. Older packages wrote x/y.
3544
+ function shadowOffsets(shadow) {
3545
+ const canonicalX = finiteNumber(shadow.offsetX ?? shadow["offset-x"]);
3546
+ const canonicalY = finiteNumber(shadow.offsetY ?? shadow["offset-y"]);
3547
+ const legacyX = canonicalX === undefined ? finiteNumber(shadow.x) : undefined;
3548
+ const legacyY = canonicalY === undefined ? finiteNumber(shadow.y) : undefined;
3549
+ return {
3550
+ legacy: legacyX !== undefined || legacyY !== undefined,
3551
+ offsetX: canonicalX ?? legacyX ?? 0,
3552
+ offsetY: canonicalY ?? legacyY ?? 0,
3553
+ };
3554
+ }
3555
+
3556
+ // Shadow source alpha at a node-local point, with the outline grown by
3557
+ // `spread` local units (negative shrinks). Rectangles, rounded rectangles and
3558
+ // ellipses grow exactly (radii follow the spread, square corners stay square)
3559
+ // and get antialiased edges. PATH geometry approximates the dilation with the
3560
+ // distance to its flattened outline: a point within `spread` of the outline
3561
+ // counts as inside when growing, and as outside when shrinking.
3562
+ function shadowSourceAlpha(node, geometry, x, y, spread, unit) {
3563
+ if (!geometry) return edgeCoverage(shapeDistance(node, x, y, spread) * unit);
3564
+ if (
3565
+ x < Math.min(0, -spread) ||
3566
+ y < Math.min(0, -spread) ||
3567
+ x >= node.width + Math.max(0, spread) ||
3568
+ y >= node.height + Math.max(0, spread)
3569
+ ) {
3570
+ return 0;
3571
+ }
3572
+ const inside = pathFillContains(geometry, x, y);
3573
+ if (spread === 0) return inside ? 1 : 0;
3574
+ if (inside === spread > 0) return inside ? 1 : 0;
3575
+ const reach = Math.abs(spread);
3576
+ const near = geometry.segments.some(
3577
+ ({ start, end }) => distanceToSegment(x, y, start, end) <= reach,
3578
+ );
3579
+ return near === spread > 0 ? 1 : 0;
3580
+ }
3581
+
3582
+ // Penpot's drop-shadow filter (ui/shapes/filters.cljs) passes SourceAlpha
3583
+ // through a x127 alpha matrix, so any coverage of the shape counts as opaque
3584
+ // before the blur; CSS-equivalent SVG output keeps that.
3585
+ const PENPOT_SHADOW_ALPHA_GAIN = 127;
3586
+
3587
+ // Shapes that paint nothing cast no shadow: the SVG filter has no alpha to
3588
+ // shadow. Frames shadow their content, which the outline stands for here.
3589
+ function castsShadow(node) {
3590
+ if (node.type === "FRAME" || node.type === "COMPONENT" || node.type === "INSTANCE" ||
3591
+ node.type === "TEXT" || node.type === "IMAGE" || node.type === "GROUP") {
3592
+ return true;
3593
+ }
3594
+ const paints = (paint) =>
3595
+ paint?.hidden !== true && Number(paint?.opacity ?? 1) > 0;
3596
+ return (node.fills ?? []).some(paints) || (node.strokes ?? []).some(paints);
3597
+ }
3598
+
3106
3599
  function renderNodeShadow(state, node, matrix, opacity, path, clips, geometry) {
3107
- const shadows = effectsOf(node.shadow);
3600
+ if (!castsShadow(node)) return;
3601
+ // The first shadow of the list is on top, as in CSS: paint from the last.
3602
+ const shadows = effectsOf(node.shadow).slice().reverse();
3603
+ let legacyReported = false;
3108
3604
  for (const shadow of shadows) {
3109
3605
  if (shadow.hidden === true) continue;
3110
- if (shadow.style !== undefined && shadow.style !== "drop-shadow") {
3606
+ if (
3607
+ shadow.inset === true ||
3608
+ (shadow.style !== undefined && shadow.style !== "drop-shadow")
3609
+ ) {
3111
3610
  state.diagnostics.push({
3112
3611
  code: "unsupported_render_shadow",
3113
- message: `Renderer ignored shadow style ${shadow.style}`,
3612
+ message: `Renderer ignored shadow style ${shadow.style ?? "inner-shadow"}`,
3114
3613
  nodeId: node.id,
3115
3614
  path,
3116
3615
  });
@@ -3118,9 +3617,19 @@ function renderNodeShadow(state, node, matrix, opacity, path, clips, geometry) {
3118
3617
  }
3119
3618
  // Token shadows are DTCG objects; shadows edited in Penpot keep its
3120
3619
  // kebab-case offsets and a {color, opacity} color.
3121
- const offsetX = Number(shadow.offsetX ?? shadow["offset-x"] ?? 0);
3122
- const offsetY = Number(shadow.offsetY ?? shadow["offset-y"] ?? 0);
3123
- const blur = Number(shadow.blur ?? 0);
3620
+ const { legacy, offsetX, offsetY } = shadowOffsets(shadow);
3621
+ if (legacy && !legacyReported) {
3622
+ legacyReported = true;
3623
+ state.diagnostics.push({
3624
+ code: "legacy_shadow_shape",
3625
+ message:
3626
+ "Shadow uses legacy x/y offsets; write offsetX/offsetY instead",
3627
+ nodeId: node.id,
3628
+ path,
3629
+ });
3630
+ }
3631
+ const blur = Math.max(0, finiteNumber(shadow.blur) ?? 0);
3632
+ const spread = finiteNumber(shadow.spread) ?? 0;
3124
3633
  const paint = isRecordLike(shadow.color) ? shadow.color : undefined;
3125
3634
  const color = parseColor(
3126
3635
  typeof shadow.color === "string"
@@ -3137,8 +3646,12 @@ function renderNodeShadow(state, node, matrix, opacity, path, clips, geometry) {
3137
3646
  (matrix[0] * offsetX + matrix[2] * offsetY) * state.scale;
3138
3647
  const offsetDeviceY =
3139
3648
  (matrix[1] * offsetX + matrix[3] * offsetY) * state.scale;
3649
+ const unit = deviceUnit(state, matrix);
3650
+ // Penpot and CSS treat blur as a radius: a Gaussian with sigma blur / 2.
3651
+ const sigma = (blur / 2) * state.scale;
3140
3652
  const bounds = pixelBounds(state, matrix, node);
3141
- const expand = Math.ceil(blur * state.scale * 3) + 2;
3653
+ const expand =
3654
+ Math.ceil(Math.max(gaussReach(sigma), 3 * sigma) + Math.max(0, spread) * unit) + 2;
3142
3655
  const left = clamp(bounds.left + Math.floor(offsetDeviceX - expand), 0, state.width);
3143
3656
  const right = clamp(bounds.right + Math.ceil(offsetDeviceX + expand), 0, state.width);
3144
3657
  const top = clamp(bounds.top + Math.floor(offsetDeviceY - expand), 0, state.height);
@@ -3148,29 +3661,30 @@ function renderNodeShadow(state, node, matrix, opacity, path, clips, geometry) {
3148
3661
  const alphaBuffer = new Float32Array(regionWidth * (bottom - top));
3149
3662
  const inverseMatrix = inverse(matrix);
3150
3663
  if (!inverseMatrix) continue;
3664
+ // The source is the node's own outline; ancestor clips act on where the
3665
+ // shadow lands, below.
3151
3666
  for (let pixelY = top; pixelY < bottom; pixelY += 1) {
3152
3667
  for (let pixelX = left; pixelX < right; pixelX += 1) {
3153
3668
  const canvasX = (pixelX + 0.5 - offsetDeviceX) / state.scale;
3154
3669
  const canvasY = (pixelY + 0.5 - offsetDeviceY) / state.scale;
3155
3670
  const local = point(inverseMatrix, canvasX, canvasY);
3156
- if (
3157
- local.x < 0 ||
3158
- local.y < 0 ||
3159
- local.x >= node.width ||
3160
- local.y >= node.height ||
3161
- !nodeContainsGeometry(node, geometry, local.x, local.y) ||
3162
- !clipsAllow(clips, canvasX, canvasY)
3163
- ) {
3164
- continue;
3671
+ const alpha = shadowSourceAlpha(node, geometry, local.x, local.y, spread, unit);
3672
+ if (alpha > 0) {
3673
+ alphaBuffer[(pixelY - top) * regionWidth + (pixelX - left)] = Math.min(
3674
+ 1,
3675
+ alpha * PENPOT_SHADOW_ALPHA_GAIN,
3676
+ );
3165
3677
  }
3166
- alphaBuffer[(pixelY - top) * regionWidth + (pixelX - left)] = 1;
3167
3678
  }
3168
3679
  }
3169
- blurAlphaRegion(alphaBuffer, regionWidth, bottom - top, blur * state.scale);
3680
+ blurAlphaRegion(alphaBuffer, regionWidth, bottom - top, sigma);
3170
3681
  for (let pixelY = top; pixelY < bottom; pixelY += 1) {
3171
3682
  for (let pixelX = left; pixelX < right; pixelX += 1) {
3172
3683
  const coverage = alphaBuffer[(pixelY - top) * regionWidth + (pixelX - left)];
3173
3684
  if (coverage <= 0.004) continue;
3685
+ if (!clipsAllow(clips, (pixelX + 0.5) / state.scale, (pixelY + 0.5) / state.scale)) {
3686
+ continue;
3687
+ }
3174
3688
  blend(
3175
3689
  state.pixels,
3176
3690
  (pixelY * state.width + pixelX) * 4,
@@ -3210,7 +3724,9 @@ function renderBackgroundBlur(state, node, matrix, clips) {
3210
3724
  bounds.top,
3211
3725
  bounds.right,
3212
3726
  bounds.bottom,
3213
- radius * state.scale,
3727
+ // Penpot's background blur is a radius: a Gaussian with sigma value / 2
3728
+ // (feGaussianBlur stdDeviation in its SVG renderer), like shadow blur.
3729
+ (radius / 2) * state.scale,
3214
3730
  );
3215
3731
  for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
3216
3732
  for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
@@ -3297,12 +3813,6 @@ function boundaryGeometry(node) {
3297
3813
  return { contours: [], segments };
3298
3814
  }
3299
3815
 
3300
- function strokeSegmentsFor(node, geometry, stroke, width, pattern) {
3301
- if (geometry) return [];
3302
- if (pattern) return boundaryGeometry(node).segments;
3303
- return [];
3304
- }
3305
-
3306
3816
  function renderStrokeColor(state, node, stroke, path) {
3307
3817
  return paintColor(
3308
3818
  stroke,
@@ -3336,10 +3846,19 @@ function createScratchState(state) {
3336
3846
  };
3337
3847
  }
3338
3848
 
3849
+ // Penpot clips every board (frames, components and their copies) unless it
3850
+ // shows its content (ui/shapes/frame.cljs).
3851
+ function clipsContent(node) {
3852
+ return (
3853
+ (node.type === "FRAME" || node.type === "COMPONENT" || node.type === "INSTANCE") &&
3854
+ node["show-content"] !== true
3855
+ );
3856
+ }
3857
+
3339
3858
  function childClips(state, nodes, node, matrix, clips) {
3340
3859
  const inverseMatrix = inverse(matrix);
3341
3860
  if (!inverseMatrix) return clips;
3342
- if (node.type === "FRAME" && node["show-content"] === false) {
3861
+ if (clipsContent(node)) {
3343
3862
  return [...(clips ?? []), { inverse: inverseMatrix, shape: node }];
3344
3863
  }
3345
3864
  if (
@@ -3412,7 +3931,7 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3412
3931
  matrix,
3413
3932
  opacity,
3414
3933
  stack,
3415
- masked || (node.type === "FRAME" && node["show-content"] === false)
3934
+ masked || clipsContent(node)
3416
3935
  ? childClips(state, nodes, node, matrix, clips)
3417
3936
  : clips,
3418
3937
  );
@@ -3420,12 +3939,13 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3420
3939
  stack.delete(nodeId);
3421
3940
  for (const entry of scratch.diagnostics) state.diagnostics.push(entry);
3422
3941
  if (scratch.missingGlyphs) mergeMissingGlyphs(state, scratch.missingGlyphs);
3423
- // Blur and composite only around the subtree's ink: three box passes
3424
- // spread it at most three radii, and transparent pixels blend to no-ops.
3425
- const region = inkBounds(
3426
- scratch,
3427
- 3 * Math.round(layerBlurRadius * state.scale) + 1,
3428
- );
3942
+ // Penpot's layer blur value is the Gaussian sigma itself (its SVG
3943
+ // renderer passes it to feGaussianBlur stdDeviation unhalved).
3944
+ const layerSigma = layerBlurRadius * state.scale;
3945
+ // Blur and composite only around the subtree's ink: the box passes
3946
+ // spread it at most the sum of their radii, and transparent pixels
3947
+ // blend to no-ops.
3948
+ const region = inkBounds(scratch, gaussReach(layerSigma) + 1);
3429
3949
  if (!region) return;
3430
3950
  blurRgbaRegion(
3431
3951
  scratch.pixels,
@@ -3435,7 +3955,7 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3435
3955
  region.top,
3436
3956
  region.right,
3437
3957
  region.bottom,
3438
- layerBlurRadius * state.scale,
3958
+ layerSigma,
3439
3959
  );
3440
3960
  for (let pixelY = region.top; pixelY < region.bottom; pixelY += 1) {
3441
3961
  for (let pixelX = region.left; pixelX < region.right; pixelX += 1) {
@@ -3459,8 +3979,7 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3459
3979
  renderNodeShape(state, node, matrix, opacity, path, clips);
3460
3980
  }
3461
3981
  stack.add(nodeId);
3462
- const clipsChildren =
3463
- masked || (node.type === "FRAME" && node["show-content"] === false);
3982
+ const clipsChildren = masked || clipsContent(node);
3464
3983
  const childClipsHere = clipsChildren
3465
3984
  ? childClips(state, nodes, node, matrix, clips)
3466
3985
  : clips;
@@ -3477,10 +3996,12 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3477
3996
 
3478
3997
  function renderNodeShape(state, node, matrix, opacity, path, clips) {
3479
3998
  const inverseMatrix = inverse(matrix);
3480
- if (!inverseMatrix || node.width <= 0 || node.height <= 0) return;
3999
+ if (!inverseMatrix) return;
3481
4000
  const geometry = node.type === "PATH" && typeof node.pathData === "string"
3482
4001
  ? parsePathData(node.pathData)
3483
4002
  : undefined;
4003
+ // A straight PATH has a zero-sized box but still draws its stroke.
4004
+ if (!geometry && (node.width <= 0 || node.height <= 0)) return;
3484
4005
  if (node.type === "PATH" && typeof node.pathData === "string" && !geometry) {
3485
4006
  state.diagnostics.push({
3486
4007
  code: "unsupported_render_path",
@@ -3491,7 +4012,10 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3491
4012
  }
3492
4013
  renderNodeShadow(state, node, matrix, opacity, path, clips, geometry);
3493
4014
  renderBackgroundBlur(state, node, matrix, clips);
3494
- const bounds = pixelBounds(state, matrix, node);
4015
+ const bounds = geometry
4016
+ ? localPixelBounds(state, matrix, geometryBounds(geometry))
4017
+ : pixelBounds(state, matrix, node);
4018
+ const unit = deviceUnit(state, matrix);
3495
4019
  // hide-fill-on-export suppresses fills in exported raster output while the
3496
4020
  // editing surface keeps showing them; the CLI render is the export surface.
3497
4021
  const suppressFill = node["hide-fill-on-export"] === true;
@@ -3539,26 +4063,22 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3539
4063
  const canvasX = (pixelX + 0.5) / state.scale;
3540
4064
  const canvasY = (pixelY + 0.5) / state.scale;
3541
4065
  const local = point(inverseMatrix, canvasX, canvasY);
3542
- if (
3543
- local.x < 0 ||
3544
- local.y < 0 ||
3545
- local.x >= node.width ||
3546
- local.y >= node.height ||
3547
- !nodeContainsGeometry(node, geometry, local.x, local.y) ||
3548
- !clipsAllow(clips, canvasX, canvasY)
3549
- ) {
3550
- continue;
3551
- }
4066
+ const coverage = shapeCoverage(node, geometry, local.x, local.y, unit);
4067
+ if (coverage <= 0 || !clipsAllow(clips, canvasX, canvasY)) continue;
4068
+ // Edge pixels whose centre lies just outside still sample the paint
4069
+ // at the nearest point inside the node.
4070
+ const sampleX = clamp(local.x, 0, node.width - 1e-6);
4071
+ const sampleY = clamp(local.y, 0, node.height - 1e-6);
3552
4072
  const color = raster
3553
- ? rasterSample(raster, local.x, local.y, node)
4073
+ ? rasterSample(raster, sampleX, sampleY, node)
3554
4074
  : media?.image
3555
- ? svgSample(media.image, local.x, local.y, node)
4075
+ ? svgSample(media.image, sampleX, sampleY, node)
3556
4076
  : uniformColor
3557
4077
  ? [...uniformColor]
3558
4078
  : paintColor(
3559
4079
  fill,
3560
- local.x,
3561
- local.y,
4080
+ sampleX,
4081
+ sampleY,
3562
4082
  node,
3563
4083
  state.colors,
3564
4084
  state.diagnostics,
@@ -3566,7 +4086,7 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3566
4086
  state.productPackageId,
3567
4087
  );
3568
4088
  if (fill.type === "image" && !media?.image && !raster) {
3569
- const band = (Math.floor(local.x / 8) + Math.floor(local.y / 8)) % 2;
4089
+ const band = (Math.floor(sampleX / 8) + Math.floor(sampleY / 8)) % 2;
3570
4090
  color[0] -= band * 18;
3571
4091
  color[1] -= band * 18;
3572
4092
  color[2] -= band * 18;
@@ -3575,7 +4095,7 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3575
4095
  state.pixels,
3576
4096
  (pixelY * state.width + pixelX) * 4,
3577
4097
  color,
3578
- opacity * (fill.opacity ?? 1),
4098
+ opacity * (fill.opacity ?? 1) * coverage,
3579
4099
  );
3580
4100
  }
3581
4101
  }
@@ -3584,96 +4104,125 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3584
4104
  if (stroke.hidden === true) continue;
3585
4105
  const width = Math.max(1, stroke.width ?? 1);
3586
4106
  const color = renderStrokeColor(state, node, stroke, path);
3587
- const pattern = dashPattern(stroke, width);
3588
- const boundary = geometry || (pattern ? makeGeometry(boundaryGeometry(node).segments) : undefined);
3589
- const markerStart = stroke.capStart && geometry
3590
- ? markerGeometry(
3591
- stroke.capStart,
3592
- geometry.start,
3593
- directionAt(geometry, { segmentIndex: 0 }),
3594
- width,
3595
- )
3596
- : undefined;
3597
- const markerEnd = stroke.capEnd && geometry
3598
- ? markerGeometry(
3599
- stroke.capEnd,
3600
- geometry.end,
3601
- directionAt(geometry, { segmentIndex: geometry.segments.length - 1 }),
3602
- width,
3603
- )
3604
- : undefined;
3605
- const markerStartOutline = markerStart?.segments.length
3606
- ? makeGeometry(markerStart.segments)
3607
- : undefined;
3608
- const markerEndOutline = markerEnd?.segments.length
3609
- ? makeGeometry(markerEnd.segments)
3610
- : undefined;
3611
- for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
3612
- for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
3613
- const canvasX = (pixelX + 0.5) / state.scale;
3614
- const canvasY = (pixelY + 0.5) / state.scale;
3615
- const local = point(inverseMatrix, canvasX, canvasY);
3616
- if (
3617
- local.x < 0 ||
3618
- local.y < 0 ||
3619
- local.x >= node.width ||
3620
- local.y >= node.height ||
3621
- !clipsAllow(clips, canvasX, canvasY)
3622
- ) {
3623
- continue;
4107
+ const strokeOpacity = opacity * (stroke.opacity ?? 1);
4108
+ const dashed = dashArray(stroke, width) !== undefined;
4109
+ const sides = strokeSides(stroke);
4110
+ if (sides && !geometry && !dashed && node.type !== "ELLIPSE") {
4111
+ // Per-side widths: the band between the box grown and shrunk side by
4112
+ // side per alignment, clipped to the shape's corners when inside.
4113
+ const alignment = stroke.alignment ?? "inner";
4114
+ const grow = alignment === "outer" ? 1 : alignment === "center" ? 0.5 : 0;
4115
+ const shrink = 1 - grow;
4116
+ const outer = { bottom: node.height + sides.bottom * grow, left: -sides.left * grow, right: node.width + sides.right * grow, top: -sides.top * grow };
4117
+ const inner = { bottom: node.height - sides.bottom * shrink, left: sides.left * shrink, right: node.width - sides.right * shrink, top: sides.top * shrink };
4118
+ const reach = Math.max(sides.top, sides.right, sides.bottom, sides.left) * grow + 1;
4119
+ const strokeBounds = pixelBounds(state, matrix, node, reach);
4120
+ for (let pixelY = strokeBounds.top; pixelY < strokeBounds.bottom; pixelY += 1) {
4121
+ for (let pixelX = strokeBounds.left; pixelX < strokeBounds.right; pixelX += 1) {
4122
+ const canvasX = (pixelX + 0.5) / state.scale;
4123
+ const canvasY = (pixelY + 0.5) / state.scale;
4124
+ if (!clipsAllow(clips, canvasX, canvasY)) continue;
4125
+ const local = point(inverseMatrix, canvasX, canvasY);
4126
+ let coverage = boxCoverage(outer, local, unit) - boxCoverage(inner, local, unit);
4127
+ if (coverage > 0 && alignment === "inner") coverage *= shapeCoverage(node, geometry, local.x, local.y, unit);
4128
+ if (coverage > 0) blend(state.pixels, (pixelY * state.width + pixelX) * 4, color, strokeOpacity * coverage);
3624
4129
  }
3625
- let covered;
3626
- if (boundary) {
3627
- const fillInside = nodeContainsGeometry(node, geometry, local.x, local.y);
3628
- covered = pathStrokeContains(
3629
- boundary,
3630
- local.x,
3631
- local.y,
3632
- width,
3633
- pattern,
3634
- stroke.alignment,
3635
- fillInside,
4130
+ }
4131
+ continue;
4132
+ }
4133
+ if (!geometry && !dashed) {
4134
+ // Solid strokes on rectangles, rounded rectangles and ellipses: the
4135
+ // band between the outline grown and shrunk per alignment, with
4136
+ // antialiased edges on both sides.
4137
+ const alignment = stroke.alignment ?? "inner";
4138
+ const outerGrow =
4139
+ alignment === "outer" ? width : alignment === "center" ? width / 2 : 0;
4140
+ const strokeBounds = pixelBounds(state, matrix, node, outerGrow + 1);
4141
+ for (let pixelY = strokeBounds.top; pixelY < strokeBounds.bottom; pixelY += 1) {
4142
+ for (let pixelX = strokeBounds.left; pixelX < strokeBounds.right; pixelX += 1) {
4143
+ const canvasX = (pixelX + 0.5) / state.scale;
4144
+ const canvasY = (pixelY + 0.5) / state.scale;
4145
+ const local = point(inverseMatrix, canvasX, canvasY);
4146
+ const coverage =
4147
+ edgeCoverage(shapeDistance(node, local.x, local.y, outerGrow) * unit) -
4148
+ edgeCoverage(shapeDistance(node, local.x, local.y, outerGrow - width) * unit);
4149
+ if (coverage <= 0 || !clipsAllow(clips, canvasX, canvasY)) continue;
4150
+ blend(
4151
+ state.pixels,
4152
+ (pixelY * state.width + pixelX) * 4,
4153
+ color,
4154
+ strokeOpacity * coverage,
3636
4155
  );
3637
- } else {
3638
- covered =
3639
- nodeContains(node, local.x, local.y) &&
3640
- !insetContains(node, local.x, local.y, width);
3641
4156
  }
3642
- if (!covered && markerStart) {
3643
- covered =
3644
- windingNumber(markerStart.contours, local.x, local.y) !== 0 ||
3645
- (markerStartOutline !== undefined &&
3646
- pathStrokeContains(
3647
- markerStartOutline,
3648
- local.x,
3649
- local.y,
3650
- width,
3651
- ));
4157
+ }
4158
+ continue;
4159
+ }
4160
+ const subpaths = geometry
4161
+ ? strokeSubpaths(geometry)
4162
+ : [{
4163
+ closed: true,
4164
+ points: boundaryGeometry(node).segments.map(({ start }) => start),
4165
+ }];
4166
+ const { alignment, markers, pieces } = strokePieces(subpaths, stroke, width);
4167
+ const strokeBounds = localPixelBounds(
4168
+ state,
4169
+ matrix,
4170
+ geometry
4171
+ ? (() => {
4172
+ const local = geometryBounds(geometry);
4173
+ const reach = strokeReach(width);
4174
+ return {
4175
+ bottom: local.bottom + reach,
4176
+ left: local.left - reach,
4177
+ right: local.right + reach,
4178
+ top: local.top - reach,
4179
+ };
4180
+ })()
4181
+ : { bottom: node.height + width + 1, left: -width - 1, right: node.width + width + 1, top: -width - 1 },
4182
+ );
4183
+ for (let pixelY = strokeBounds.top; pixelY < strokeBounds.bottom; pixelY += 1) {
4184
+ for (let pixelX = strokeBounds.left; pixelX < strokeBounds.right; pixelX += 1) {
4185
+ const canvasX = (pixelX + 0.5) / state.scale;
4186
+ const canvasY = (pixelY + 0.5) / state.scale;
4187
+ if (!clipsAllow(clips, canvasX, canvasY)) continue;
4188
+ const local = point(inverseMatrix, canvasX, canvasY);
4189
+ const index = (pixelY * state.width + pixelX) * 4;
4190
+ let coverage = piecesCoverage(pieces, local.x, local.y, unit);
4191
+ if (coverage > 0 && alignment !== "center") {
4192
+ // Inner and outer strokes are twice as wide, clipped to the
4193
+ // inside or the outside of the fill.
4194
+ const inside = shapeCoverage(node, geometry, local.x, local.y, unit);
4195
+ coverage *= alignment === "inner" ? inside : 1 - inside;
3652
4196
  }
3653
- if (!covered && markerEnd) {
3654
- covered =
3655
- windingNumber(markerEnd.contours, local.x, local.y) !== 0 ||
3656
- (markerEndOutline !== undefined &&
3657
- pathStrokeContains(
3658
- markerEndOutline,
3659
- local.x,
3660
- local.y,
3661
- width,
3662
- ));
4197
+ if (coverage > 0) blend(state.pixels, index, color, strokeOpacity * coverage);
4198
+ // Cap markers are separate SVG elements painted over the stroke.
4199
+ const markerCoverage = markers.length
4200
+ ? piecesCoverage(markers, local.x, local.y, unit)
4201
+ : 0;
4202
+ if (markerCoverage > 0) {
4203
+ blend(state.pixels, index, color, strokeOpacity * markerCoverage);
3663
4204
  }
3664
- if (!covered) continue;
3665
- blend(
3666
- state.pixels,
3667
- (pixelY * state.width + pixelX) * 4,
3668
- color,
3669
- opacity * (stroke.opacity ?? 1),
3670
- );
3671
4205
  }
3672
4206
  }
3673
4207
  }
3674
4208
  if (node.type === "TEXT") renderText(state, node, matrix, opacity, path, clips);
3675
4209
  }
3676
4210
 
4211
+ // A stroke's per-side widths, or null when it has one width.
4212
+ function strokeSides(stroke) {
4213
+ const fields = ["widthTop", "widthRight", "widthBottom", "widthLeft"];
4214
+ if (!fields.some((field) => stroke[field] !== undefined)) return null;
4215
+ const width = stroke.width ?? 1;
4216
+ return { bottom: stroke.widthBottom ?? width, left: stroke.widthLeft ?? width, right: stroke.widthRight ?? width, top: stroke.widthTop ?? width };
4217
+ }
4218
+
4219
+ // Antialiased coverage of an axis-aligned box at a local point.
4220
+ function boxCoverage(box, local, unit) {
4221
+ if (box.right <= box.left || box.bottom <= box.top) return 0;
4222
+ const axis = (value, low, high) => clamp(Math.min(value - low, high - value) * unit + 0.5, 0, 1);
4223
+ return axis(local.x, box.left, box.right) * axis(local.y, box.top, box.bottom);
4224
+ }
4225
+
3677
4226
  function flattenRegions(
3678
4227
  node,
3679
4228
  scale,
@@ -3710,6 +4259,43 @@ function flattenRegions(
3710
4259
  return result;
3711
4260
  }
3712
4261
 
4262
+ // Text QA uses exactly the fonts, wrapping and run metrics used by PNG output,
4263
+ // without allocating a raster or drawing glyphs.
4264
+ export async function measureProjectionText(product, projection, options = {}) {
4265
+ const textNodes = Object.values(projection.nodes).filter((node) => node.type === "TEXT" && node.visible !== false && node.text);
4266
+ if (!textNodes.length) return { items: [], diagnostics: [] };
4267
+ const diagnostics = [];
4268
+ const state = {
4269
+ colors: colorLibrary(product, options.foundation, options.libraries ?? [], diagnostics),
4270
+ diagnostics,
4271
+ fontFallbacks: new Set(),
4272
+ fonts: await createFontLibrary(product, options.foundation, options.libraries ?? [], diagnostics),
4273
+ missingGlyphs: new Map(),
4274
+ productPackageId: product.manifest.packageId,
4275
+ };
4276
+ const items = textNodes.map((node) => {
4277
+ const missing = new Set();
4278
+ let width, height;
4279
+ if (node.textBlocks?.length) {
4280
+ const lines = richTextLines(state, node, node.id, missing);
4281
+ width = Math.max(0, ...lines.map((line) => line.width));
4282
+ height = lines.reduce((sum, line) => sum + line.height, 0);
4283
+ } else {
4284
+ const style = node.textStyle ?? {};
4285
+ const font = resolveFont(state, style, node.id, node.id);
4286
+ const size = Number(style.fontSize ?? 14);
4287
+ const spacing = Number(style.letterSpacing ?? 0);
4288
+ const source = style.textTransform === "uppercase" ? node.text.toUpperCase() : style.textTransform === "lowercase" ? node.text.toLowerCase() : node.text;
4289
+ const measure = (text, previous) => lineGlyphs(font, state.fonts, text, 0, 0, size, spacing, undefined, missing, previous);
4290
+ const lines = node.growType === "auto-width" ? source.split("\n") : wrapTextLines(source, node.width, measure);
4291
+ width = Math.max(0, ...lines.map((line) => measure(line).width));
4292
+ height = lines.length * size * Number(style.lineHeight ?? 1.2);
4293
+ }
4294
+ return { nodeId: node.id, width, height, growType: node.growType ?? "fixed", missingGlyphs: [...missing] };
4295
+ });
4296
+ return { items, diagnostics };
4297
+ }
4298
+
3713
4299
  export async function renderProjection(product, projection, options = {}) {
3714
4300
  if (projection.fallbackUsed) {
3715
4301
  throw new SmallPenError(
@@ -3725,8 +4311,12 @@ export async function renderProjection(product, projection, options = {}) {
3725
4311
  }
3726
4312
  const root = projection.nodes[projection.rootId];
3727
4313
  if (!root) throw new SmallPenError("missing_root_node", "Projection root is unavailable");
3728
- const width = Math.ceil(root.width * scale);
3729
- const height = Math.ceil(root.height * scale);
4314
+ const crop = options.crop ?? { x: 0, y: 0, width: root.width, height: root.height };
4315
+ if (![crop.x, crop.y, crop.width, crop.height].every(Number.isFinite) || crop.width <= 0 || crop.height <= 0) {
4316
+ throw new SmallPenError("invalid_render_crop", "Crop must have finite x/y and positive width/height");
4317
+ }
4318
+ const width = Math.ceil(crop.width * scale);
4319
+ const height = Math.ceil(crop.height * scale);
3730
4320
  if (
3731
4321
  width < 1 ||
3732
4322
  height < 1 ||
@@ -3742,6 +4332,8 @@ export async function renderProjection(product, projection, options = {}) {
3742
4332
  });
3743
4333
  }
3744
4334
  const diagnostics = [...(projection.diagnostics ?? [])];
4335
+ for (const diagnostic of layoutProjectionDiagnostics(projection.nodes))
4336
+ if (!diagnostics.some(existing => existing.code === diagnostic.code && existing.nodeId === diagnostic.nodeId)) diagnostics.push(diagnostic);
3745
4337
  const hasText = Object.values(projection.nodes).some(
3746
4338
  (node) => node.type === "TEXT" && node.text,
3747
4339
  );
@@ -3790,7 +4382,7 @@ export async function renderProjection(product, projection, options = {}) {
3790
4382
  state,
3791
4383
  projection.nodes,
3792
4384
  projection.rootId,
3793
- translate(-root.x, -root.y),
4385
+ translate(-root.x - crop.x, -root.y - crop.y),
3794
4386
  1,
3795
4387
  new Set(),
3796
4388
  );
@@ -3828,6 +4420,7 @@ export async function createEvidence(product, options = {}) {
3828
4420
  rootY,
3829
4421
  );
3830
4422
  return {
4423
+ projection,
3831
4424
  evidence: {
3832
4425
  diagnostics: render.diagnostics,
3833
4426
  nodeRegions: regions,