@smallpen/local-package 0.1.0-alpha.3 → 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,13 +5,14 @@ import opentype from "opentype.js";
5
5
 
6
6
  import {
7
7
  createSemanticTree,
8
+ layoutProjectionDiagnostics,
8
9
  projectDesignView,
9
10
  resolveDesignView,
10
11
  sha256Hex,
11
12
  SmallPenError,
12
13
  } from "@smallpen/core";
13
14
 
14
- import { initWebpDecoder, decodeWebpSync } from "./webp-decode.mjs";
15
+ import { decodeWebpSync, initWebpDecoder, webpDimensions } from "./webp-decode.mjs";
15
16
 
16
17
  const MAX_DIMENSION = 8192;
17
18
  const MAX_PIXELS = 32 * 1024 * 1024;
@@ -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]],
@@ -59,8 +71,27 @@ function parseHexColor(value) {
59
71
  ];
60
72
  }
61
73
 
74
+ const parsedColors = new WeakMap();
75
+
76
+ // Paints are sampled per pixel, so each color is parsed, and an unsupported
77
+ // one reported, once per diagnostics list rather than once per pixel.
62
78
  function parseColor(value, diagnostics, path) {
63
79
  if (typeof value !== "string") return [255, 0, 255, 255];
80
+ let cache = parsedColors.get(diagnostics);
81
+ if (!cache) {
82
+ cache = new Map();
83
+ parsedColors.set(diagnostics, cache);
84
+ }
85
+ const key = `${path}\0${value}`;
86
+ let color = cache.get(key);
87
+ if (!color) {
88
+ color = parseColorValue(value, diagnostics, path);
89
+ cache.set(key, color);
90
+ }
91
+ return [...color];
92
+ }
93
+
94
+ function parseColorValue(value, diagnostics, path) {
64
95
  const source = value.trim().toLowerCase();
65
96
  if (source.startsWith("#")) {
66
97
  const parsed = parseHexColor(source);
@@ -231,10 +262,11 @@ function nearestWeight(weight, choices) {
231
262
  )[0];
232
263
  }
233
264
 
234
- function parseFont(bytes) {
265
+ function parseFont(bytes, options) {
235
266
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
236
267
  return opentype.parse(
237
268
  view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength),
269
+ options,
238
270
  );
239
271
  }
240
272
 
@@ -327,9 +359,11 @@ function resolveFont(state, style, nodeId, path) {
327
359
  const key = `${nodeId}:${id || family}`;
328
360
  if (!state.fontFallbacks.has(key)) {
329
361
  state.fontFallbacks.add(key);
362
+ const requestedFont = style.fontFamily ?? style.fontId ?? "unknown font";
330
363
  state.diagnostics.push({
331
364
  code: "font_render_fallback",
332
- message: `Renderer substituted Source Sans Pro for ${style.fontFamily ?? style.fontId ?? "unknown font"}`,
365
+ details: { requestedFont, substituteFont: "Source Sans Pro" },
366
+ message: `Renderer substituted Source Sans Pro for ${requestedFont}`,
333
367
  nodeId,
334
368
  path,
335
369
  });
@@ -456,7 +490,6 @@ function contourWindingCount(contours, x, y) {
456
490
  return crossings;
457
491
  }
458
492
 
459
- const PATH_CURVE_STEPS = 16;
460
493
  const PATH_TOKEN =
461
494
  /[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g;
462
495
 
@@ -554,9 +587,24 @@ function arcToCubicSegments(current, rx, ry, angleDegrees, largeArc, sweep, next
554
587
  return segments;
555
588
  }
556
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
+
557
604
  // Parses the full SVG path grammar (M L H V C S Q T A Z, absolute and relative)
558
- // into flattened contours and polyline segments. Used for canonical PATH nodes
559
- // 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.
560
608
  function parsePathData(value) {
561
609
  if (typeof value !== "string") return undefined;
562
610
  const tokens = value.match(PATH_TOKEN);
@@ -564,168 +612,181 @@ function parsePathData(value) {
564
612
  const segments = [];
565
613
  const contours = [];
566
614
  const closedContours = [];
615
+ const subpaths = [];
567
616
  let contour = [];
617
+ let subpath;
568
618
  let command;
569
619
  let index = 0;
570
620
  let cursor = { x: 0, y: 0 };
571
621
  let subpathStart = cursor;
572
- let previousControl;
622
+ // Reflection sources for S and T: only a preceding C/S (or Q/T) counts.
623
+ let previousCubic;
624
+ let previousQuad;
573
625
  const readNumber = () => {
574
626
  const token = tokens[index++];
575
627
  const parsed = Number.parseFloat(token);
576
628
  if (!Number.isFinite(parsed)) throw new Error(`invalid path number ${token}`);
577
629
  return parsed;
578
630
  };
579
- 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) => {
580
650
  segments.push({ end: next, start: cursor });
581
651
  contour.push(next);
652
+ subpath.points.push(next);
582
653
  cursor = next;
583
654
  };
584
- const pushCubic = (cubic, current) => {
585
- for (let step = 1; step <= PATH_CURVE_STEPS; step += 1) {
586
- 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;
587
674
  const inverseAmount = 1 - amount;
588
- contour.push({
675
+ pushPoint(step === steps ? end : {
589
676
  x:
590
- inverseAmount ** 3 * current.x +
677
+ inverseAmount ** 3 * start.x +
591
678
  3 * inverseAmount * inverseAmount * amount * cubic.x1 +
592
679
  3 * inverseAmount * amount * amount * cubic.x2 +
593
680
  amount ** 3 * cubic.x,
594
681
  y:
595
- inverseAmount ** 3 * current.y +
682
+ inverseAmount ** 3 * start.y +
596
683
  3 * inverseAmount * inverseAmount * amount * cubic.y1 +
597
684
  3 * inverseAmount * amount * amount * cubic.y2 +
598
685
  amount ** 3 * cubic.y,
599
686
  });
600
687
  }
601
- segments.push({ end: contour.at(-1), start: current });
602
- return contour.at(-1);
603
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 };
604
703
  try {
605
704
  while (index < tokens.length) {
606
705
  if (/^[a-zA-Z]$/.test(tokens[index])) command = tokens[index++];
607
706
  else if (!command) return undefined;
608
707
  const relative = command === command.toLowerCase();
609
708
  const normalized = command.toUpperCase();
610
- const reflect = (x, y) =>
611
- previousControl
612
- ? { x: 2 * cursor.x - previousControl.x, y: 2 * cursor.y - previousControl.y }
613
- : { x: cursor.x, y: cursor.y };
709
+ let cubicControl;
710
+ let quadControl;
614
711
  if (normalized === "Z") {
615
712
  if (contour.length > 0) {
616
- 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
+ }
617
718
  if (contour.length > 2) {
618
719
  contours.push(contour);
619
720
  closedContours.push(true);
620
721
  }
621
722
  }
622
- 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;
623
727
  cursor = subpathStart;
624
728
  command = undefined;
625
- previousControl = undefined;
626
729
  } else if (normalized === "M") {
627
730
  if (contour.length > 2) {
628
731
  contours.push(contour);
629
732
  closedContours.push(false);
630
733
  }
631
- contour = [];
632
- const next = {
633
- x: readNumber() + (relative ? cursor.x : 0),
634
- y: readNumber() + (relative ? cursor.y : 0),
635
- };
636
- contour.push(next);
734
+ const next = readPoint(relative);
735
+ contour = [next];
736
+ subpath = undefined;
637
737
  cursor = next;
638
738
  subpathStart = next;
639
- previousControl = undefined;
640
739
  command = relative ? "l" : "L";
641
740
  } else if (normalized === "L") {
642
- pushLine({
643
- x: readNumber() + (relative ? cursor.x : 0),
644
- y: readNumber() + (relative ? cursor.y : 0),
645
- });
646
- previousControl = undefined;
741
+ pushLine(readPoint(relative));
647
742
  } else if (normalized === "H") {
648
743
  pushLine({ x: readNumber() + (relative ? cursor.x : 0), y: cursor.y });
649
- previousControl = undefined;
650
744
  } else if (normalized === "V") {
651
745
  pushLine({ x: cursor.x, y: readNumber() + (relative ? cursor.y : 0) });
652
- previousControl = undefined;
653
- } else if (normalized === "C") {
654
- const cubic = {
655
- x1: readNumber() + (relative ? cursor.x : 0),
656
- y1: readNumber() + (relative ? cursor.y : 0),
657
- x2: readNumber() + (relative ? cursor.x : 0),
658
- y2: readNumber() + (relative ? cursor.y : 0),
659
- x: readNumber() + (relative ? cursor.x : 0),
660
- y: readNumber() + (relative ? cursor.y : 0),
661
- };
662
- previousControl = { x: cubic.x2, y: cubic.y2 };
663
- cursor = pushCubic(cubic, cursor);
664
- } else if (normalized === "S") {
665
- const first = reflect(cursor.x, cursor.y);
666
- 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({
667
752
  x1: first.x,
668
753
  y1: first.y,
669
- x2: readNumber() + (relative ? cursor.x : 0),
670
- y2: readNumber() + (relative ? cursor.y : 0),
671
- x: readNumber() + (relative ? cursor.x : 0),
672
- y: readNumber() + (relative ? cursor.y : 0),
673
- };
674
- previousControl = { x: cubic.x2, y: cubic.y2 };
675
- cursor = pushCubic(cubic, cursor);
676
- } else if (normalized === "Q") {
677
- const control = {
678
- x: readNumber() + (relative ? cursor.x : 0),
679
- y: readNumber() + (relative ? cursor.y : 0),
680
- };
681
- const cubic = {
682
- x1: control.x,
683
- y1: control.y,
684
- x2: control.x,
685
- y2: control.y,
686
- x: readNumber() + (relative ? cursor.x : 0),
687
- y: readNumber() + (relative ? cursor.y : 0),
688
- };
689
- previousControl = control;
690
- cursor = pushCubic(cubic, cursor);
691
- } else if (normalized === "T") {
692
- const control = reflect(cursor.x, cursor.y);
693
- const cubic = {
694
- x1: control.x,
695
- y1: control.y,
696
- x2: control.x,
697
- y2: control.y,
698
- x: readNumber() + (relative ? cursor.x : 0),
699
- y: readNumber() + (relative ? cursor.y : 0),
700
- };
701
- previousControl = control;
702
- 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));
703
763
  } else if (normalized === "A") {
704
764
  const rx = readNumber();
705
765
  const ry = readNumber();
706
766
  const rotation = readNumber();
707
767
  const largeArc = readNumber() !== 0;
708
768
  const sweep = readNumber() !== 0;
709
- const next = {
710
- x: readNumber() + (relative ? cursor.x : 0),
711
- y: readNumber() + (relative ? cursor.y : 0),
712
- };
713
- for (const cubic of arcToCubicSegments(
714
- cursor,
715
- rx,
716
- ry,
717
- rotation,
718
- largeArc,
719
- sweep,
720
- next,
721
- )) {
722
- 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;
723
784
  }
724
- cursor = next;
725
- previousControl = undefined;
726
785
  } else {
727
786
  return undefined;
728
787
  }
788
+ previousCubic = cubicControl;
789
+ previousQuad = quadControl;
729
790
  }
730
791
  } catch {
731
792
  return undefined;
@@ -741,6 +802,7 @@ function parsePathData(value) {
741
802
  totalLength += Math.hypot(segment.end.x - segment.start.x, segment.end.y - segment.start.y);
742
803
  cumulative.push(totalLength);
743
804
  }
805
+ const drawn = subpaths.filter(({ points }) => points.length > 1);
744
806
  return {
745
807
  closedContours,
746
808
  contours,
@@ -748,6 +810,7 @@ function parsePathData(value) {
748
810
  end: segments.at(-1).end,
749
811
  segments,
750
812
  start: segments[0].start,
813
+ subpaths: drawn,
751
814
  totalLength,
752
815
  };
753
816
  }
@@ -770,24 +833,6 @@ function pathFillContains(geometry, x, y, evenOdd = false) {
770
833
  : windingNumber(geometry.contours, x, y) !== 0;
771
834
  }
772
835
 
773
- function dashPattern(stroke, width) {
774
- const style = stroke.style ?? "solid";
775
- if (style === "solid") return undefined;
776
- const scale = Math.max(1, width);
777
- if (style === "dashed") {
778
- return { on: stroke.dash ?? scale * 3, period: (stroke.dash ?? scale * 3) + (stroke.gap ?? scale * 3) };
779
- }
780
- if (style === "dotted") {
781
- const on = stroke.dash ?? scale;
782
- return { on, period: on + (stroke.gap ?? scale) };
783
- }
784
- // "mixed": dash, gap, dot, gap — deterministic alternating pattern.
785
- const dash = stroke.dash ?? scale * 3;
786
- const dot = scale;
787
- const gap = stroke.gap ?? scale * 2;
788
- return { on: dash, period: dash + gap + dot + gap, mixed: { dash, dot, gap } };
789
- }
790
-
791
836
  function dashOnAt(pattern, distance) {
792
837
  if (!pattern) return true;
793
838
  let offset = distance % pattern.period;
@@ -837,146 +882,408 @@ function pathStrokeContains(
837
882
  return false;
838
883
  }
839
884
 
840
- 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
+ }
903
+
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;
841
914
 
842
- function markerGeometry(cap, point, direction, width) {
843
- const size = Math.max(3, width * 2.5);
844
- const normal = { x: -direction.y, y: direction.x };
845
- const at = (along, across) => ({
846
- x: point.x + direction.x * along + normal.x * across,
847
- y: point.y + direction.y * along + normal.y * across,
848
- });
849
- if (cap === "round") {
850
- const radius = width / 2;
851
- const contour = [];
852
- for (let index = 0; index < MARKER_SIDES; index += 1) {
853
- const angle = (index / MARKER_SIDES) * Math.PI * 2;
854
- contour.push({
855
- x: point.x + Math.cos(angle) * radius,
856
- y: point.y + Math.sin(angle) * radius,
857
- });
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;
974
+ }
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));
858
981
  }
859
- return { contours: [contour], segments: [] };
982
+ return Math.max(beyond, across);
860
983
  }
861
- if (cap === "square") {
862
- const half = width / 2;
863
- return {
864
- contours: [[
865
- at(0, -half),
866
- at(half, -half),
867
- at(half, half),
868
- at(0, half),
869
- ]],
870
- segments: [],
871
- };
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));
872
990
  }
873
- if (cap === "triangle-arrow") {
874
- const length = Math.max(6, width * 4);
875
- const half = size * 0.9;
876
- return {
877
- contours: [[
878
- point,
879
- at(-length, half),
880
- at(-length, -half),
881
- ]],
882
- segments: [],
883
- };
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
+ );
884
998
  }
885
- if (cap === "circle-marker") {
886
- const contour = [];
887
- for (let index = 0; index < MARKER_SIDES; index += 1) {
888
- const angle = (index / MARKER_SIDES) * Math.PI * 2;
889
- contour.push({
890
- x: point.x + Math.cos(angle) * size,
891
- y: point.y + Math.sin(angle) * size,
892
- });
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;
893
1016
  }
894
- return { contours: [contour], segments: [] };
1017
+ distance = Math.min(distance, pieceDistance(piece, x, y));
1018
+ if (distance * unit <= -0.5) return 1;
895
1019
  }
896
- if (cap === "square-marker") {
897
- return {
898
- contours: [[
899
- at(-size, size),
900
- at(-size, -size),
901
- at(size, -size),
902
- at(size, size),
903
- ]],
904
- segments: [],
905
- };
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]);
906
1055
  }
907
- if (cap === "diamond-marker") {
908
- return {
909
- contours: [[
910
- at(0, size),
911
- at(-size, 0),
912
- at(0, -size),
913
- at(size, 0),
914
- ]],
915
- segments: [],
916
- };
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;
917
1085
  }
918
- if (cap === "line-arrow") {
919
- const length = Math.max(6, width * 4);
920
- const spread = Math.PI / 7;
921
- const back = {
922
- x: -direction.x * Math.cos(spread) - -direction.y * Math.sin(spread),
923
- y: -direction.y * Math.cos(spread) + -direction.x * Math.sin(spread),
924
- };
925
- const backOther = {
926
- x: -direction.x * Math.cos(spread) - direction.y * Math.sin(spread),
927
- y: -direction.y * Math.cos(spread) + direction.x * Math.sin(spread),
928
- };
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;
929
1213
  return {
930
- contours: [],
931
- segments: [
932
- {
933
- end: { x: point.x + back.x * length, y: point.y + back.y * length },
934
- start: point,
935
- },
936
- {
937
- end: {
938
- x: point.x + backOther.x * length,
939
- y: point.y + backOther.y * length,
940
- },
941
- start: point,
942
- },
943
- ],
1214
+ x: at.x + outward.x * along - outward.y * across,
1215
+ y: at.y + outward.y * along + outward.x * across,
944
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
+ )];
945
1224
  }
946
- return undefined;
1225
+ return shape.polygons.map((polygon) => polygonPiece(polygon.map(place))).filter(Boolean);
947
1226
  }
948
1227
 
949
- function capExtensions(geometry, width, pattern) {
950
- // "square" caps extend the open ends by half the stroke width; "round" caps
951
- // already fall out of the distance test.
952
- if (!pattern && width <= 0) return undefined;
953
- const segments = [];
954
- const half = width / 2;
955
- const first = geometry.segments[0];
956
- if (first) {
957
- const direction = directionAt(geometry, { segmentIndex: 0 });
958
- segments.push({
959
- end: first.start,
960
- start: {
961
- x: first.start.x - direction.x * half,
962
- y: first.start.y - direction.y * half,
963
- },
964
- });
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
+ }
965
1262
  }
966
- const last = geometry.segments.at(-1);
967
- if (last) {
968
- const direction = directionAt(geometry, {
969
- segmentIndex: geometry.segments.length - 1,
970
- });
971
- segments.push({
972
- end: {
973
- x: last.end.x + direction.x * half,
974
- y: last.end.y + direction.y * half,
975
- },
976
- start: last.end,
977
- });
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
+ }
978
1285
  }
979
- return segments.length ? { contours: [], segments } : undefined;
1286
+ return { alignment: aligned ? alignment : "center", markers, pieces };
980
1287
  }
981
1288
 
982
1289
  const WHITESPACE = /\s/u;
@@ -989,16 +1296,17 @@ function spaceAdvance(font) {
989
1296
 
990
1297
  function lineGlyphs(
991
1298
  font,
992
- symbolFont,
1299
+ fonts,
993
1300
  text,
994
1301
  x,
995
1302
  baseline,
996
1303
  size,
997
1304
  letterSpacing,
998
- outlines = true,
1305
+ onGlyph,
999
1306
  missing,
1307
+ start,
1000
1308
  ) {
1001
- const glyphs = [...text].map((character) => {
1309
+ const resolveGlyph = (character) => {
1002
1310
  if (WHITESPACE.test(character)) {
1003
1311
  // Whitespace never draws ink, even when the font lacks the codepoint.
1004
1312
  return {
@@ -1009,13 +1317,8 @@ function lineGlyphs(
1009
1317
  };
1010
1318
  }
1011
1319
  const glyph = font.charToGlyph(character);
1012
- if (glyph.index === 0 && symbolFont.charToGlyphIndex(character) !== 0) {
1013
- return {
1014
- advanceWidth: symbolFont.unitsPerEm,
1015
- font: symbolFont,
1016
- glyph: symbolFont.charToGlyph(character),
1017
- };
1018
- }
1320
+ const fallback = glyph.index === 0 ? fallbackGlyph(fonts, character) : null;
1321
+ if (fallback) return fallback;
1019
1322
  if (glyph.index === 0) {
1020
1323
  missing?.add(character);
1021
1324
  return {
@@ -1026,41 +1329,46 @@ function lineGlyphs(
1026
1329
  };
1027
1330
  }
1028
1331
  return { font, glyph };
1029
- });
1030
- const result = [];
1031
- let cursor = x;
1032
- for (let index = 0; index < glyphs.length; index += 1) {
1033
- const { advanceWidth, font: glyphFont, glyph, missing: isMissing, whitespace } = glyphs[index];
1034
- if (index > 0 && glyphs[index - 1].font === glyphFont) {
1332
+ };
1333
+ // `start` continues a previous result, so a line can be measured run by run.
1334
+ let cursor = start?.cursor ?? x;
1335
+ let previous = start?.previous;
1336
+ for (const character of text) {
1337
+ const entry = resolveGlyph(character);
1338
+ const { advanceWidth, font: glyphFont, glyph, missing: isMissing, whitespace } = entry;
1339
+ if (previous?.font === glyphFont) {
1035
1340
  cursor +=
1036
- (glyphFont.getKerningValue(glyphs[index - 1].glyph, glyph) * size) /
1341
+ (glyphFont.getKerningValue(previous.glyph, glyph) * size) /
1037
1342
  glyphFont.unitsPerEm;
1038
1343
  }
1039
- if (outlines && !isMissing && !whitespace) {
1040
- const contours = flattenPath(glyph.getPath(cursor, baseline, size));
1041
- if (contours.length > 0) result.push({ bounds: contourBounds(contours), contours });
1042
- }
1344
+ if (onGlyph && !isMissing && !whitespace) onGlyph(glyph, cursor);
1043
1345
  cursor +=
1044
1346
  ((advanceWidth ?? glyph.advanceWidth ?? glyphFont.unitsPerEm) * size) /
1045
1347
  glyphFont.unitsPerEm +
1046
1348
  letterSpacing;
1349
+ previous = entry;
1047
1350
  }
1048
- return { glyphs: result, width: Math.max(0, cursor - x - letterSpacing) };
1351
+ return { cursor, previous, width: Math.max(0, cursor - x - letterSpacing) };
1049
1352
  }
1050
1353
 
1354
+ // `measure(run, previous)` returns the width of the line so far plus `run`,
1355
+ // continuing from the result for the line so far.
1051
1356
  function wrapTextLines(source, width, measure) {
1052
1357
  const lines = [];
1053
1358
  for (const paragraph of source.split("\n")) {
1054
1359
  let line = "";
1360
+ let measured;
1055
1361
  // Preserve spaces and tabs, allowing a break after each (break-spaces).
1056
1362
  // Runs without these break opportunities keep their existing overflow.
1057
1363
  for (const run of paragraph.split(/(?<=[ \t])/u)) {
1058
- const candidate = line + run;
1059
- if (line && measure(candidate) > width) {
1364
+ const candidate = measure(run, measured);
1365
+ if (line && candidate.width > width) {
1060
1366
  lines.push(line);
1061
1367
  line = run;
1368
+ measured = measure(run);
1062
1369
  } else {
1063
- line = candidate;
1370
+ line += run;
1371
+ measured = candidate;
1064
1372
  }
1065
1373
  }
1066
1374
  lines.push(line);
@@ -1124,6 +1432,68 @@ function renderGlyph(
1124
1432
  }
1125
1433
  }
1126
1434
 
1435
+ const glyphUnitBoxes = new WeakMap();
1436
+
1437
+ // Outlines are flattened only for glyphs whose exact curve bounds can reach
1438
+ // both the node box and the canvas, and are dropped right after drawing, so
1439
+ // long text costs memory per visible glyph rather than per character.
1440
+ function renderGlyphOutline(
1441
+ state,
1442
+ node,
1443
+ matrix,
1444
+ inverseMatrix,
1445
+ glyph,
1446
+ x,
1447
+ baseline,
1448
+ size,
1449
+ color,
1450
+ opacity,
1451
+ clips,
1452
+ ) {
1453
+ let box = glyphUnitBoxes.get(glyph);
1454
+ if (!box) {
1455
+ box = glyph.getBoundingBox();
1456
+ glyphUnitBoxes.set(glyph, box);
1457
+ }
1458
+ // Same scale as Glyph.getPath; one unit of margin absorbs rounding.
1459
+ const scale = (1 / (glyph.path.unitsPerEm || 1000)) * size;
1460
+ const local = {
1461
+ bottom: baseline - box.y1 * scale + 1,
1462
+ left: x + box.x1 * scale - 1,
1463
+ right: x + box.x2 * scale + 1,
1464
+ top: baseline - box.y2 * scale - 1,
1465
+ };
1466
+ if (
1467
+ local.right < 0 ||
1468
+ local.bottom < 0 ||
1469
+ local.left > node.width ||
1470
+ local.top > node.height
1471
+ ) {
1472
+ return;
1473
+ }
1474
+ const device = transformedRectangleBounds(matrix, local);
1475
+ if (
1476
+ device.right * state.scale < 0 ||
1477
+ device.bottom * state.scale < 0 ||
1478
+ device.left * state.scale > state.width ||
1479
+ device.top * state.scale > state.height
1480
+ ) {
1481
+ return;
1482
+ }
1483
+ const contours = flattenPath(glyph.getPath(x, baseline, size));
1484
+ if (contours.length === 0) return;
1485
+ renderGlyph(
1486
+ state,
1487
+ node,
1488
+ matrix,
1489
+ inverseMatrix,
1490
+ { bounds: contourBounds(contours), contours },
1491
+ color,
1492
+ opacity,
1493
+ clips,
1494
+ );
1495
+ }
1496
+
1127
1497
  function renderTextDecoration(
1128
1498
  state,
1129
1499
  node,
@@ -1187,8 +1557,8 @@ function renderTextDecoration(
1187
1557
  function richTextRun(state, node, style, fills, path) {
1188
1558
  const font = resolveFont(state, style, node.id, path);
1189
1559
  const size = Number(style.fontSize ?? 14);
1190
- const height = Number(style.lineHeight ?? 1.2);
1191
- 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);
1192
1562
  const ascent =
1193
1563
  (lineHeight - size) / 2 +
1194
1564
  (typographicAscender(font) * size) / font.unitsPerEm;
@@ -1239,6 +1609,28 @@ function richLineLayout(characters) {
1239
1609
  return { placed, width: Math.max(0, width) };
1240
1610
  }
1241
1611
 
1612
+ // Continues richLineLayout's width sum, so wrapping a word costs its own
1613
+ // length instead of re-laying out the whole line.
1614
+ function extendRichLineWidth(width, previous, characters) {
1615
+ for (const character of characters) {
1616
+ if (previous) {
1617
+ width += previous.run.spacing;
1618
+ if (
1619
+ previous.font === character.font &&
1620
+ previous.run.size === character.run.size
1621
+ ) {
1622
+ width +=
1623
+ (character.font.getKerningValue(previous.glyph, character.glyph) *
1624
+ character.run.size) /
1625
+ character.font.unitsPerEm;
1626
+ }
1627
+ }
1628
+ width += character.advance;
1629
+ previous = character;
1630
+ }
1631
+ return width;
1632
+ }
1633
+
1242
1634
  function richTextLines(state, node, path, missing) {
1243
1635
  const lines = [];
1244
1636
  for (const block of node.textBlocks) {
@@ -1284,13 +1676,9 @@ function richTextLines(state, node, path, missing) {
1284
1676
  let font = run.font;
1285
1677
  let glyph = font.charToGlyph(character);
1286
1678
  let advanceWidth = glyph.advanceWidth ?? font.unitsPerEm;
1287
- if (
1288
- glyph.index === 0 &&
1289
- state.fonts.symbols.charToGlyphIndex(character) !== 0
1290
- ) {
1291
- font = state.fonts.symbols;
1292
- glyph = font.charToGlyph(character);
1293
- advanceWidth = font.unitsPerEm;
1679
+ const fallback = glyph.index === 0 ? fallbackGlyph(state.fonts, character) : null;
1680
+ if (fallback) {
1681
+ ({ font, glyph, advanceWidth } = fallback);
1294
1682
  } else if (glyph.index === 0) {
1295
1683
  missing?.add(character);
1296
1684
  advanceWidth = spaceAdvance(font);
@@ -1328,18 +1716,21 @@ function richTextLines(state, node, path, missing) {
1328
1716
  };
1329
1717
  for (const paragraph of paragraphs) {
1330
1718
  let line = [];
1719
+ let lineWidth = 0;
1331
1720
  let word = [];
1332
1721
  const appendWord = () => {
1333
- const candidate = line.concat(word);
1722
+ const candidateWidth = extendRichLineWidth(lineWidth, line.at(-1), word);
1334
1723
  if (
1335
1724
  line.length &&
1336
1725
  node.growType !== "auto-width" &&
1337
- richLineLayout(candidate).width > node.width
1726
+ Math.max(0, candidateWidth) > node.width
1338
1727
  ) {
1339
1728
  finish(line);
1340
1729
  line = word;
1730
+ lineWidth = extendRichLineWidth(0, undefined, word);
1341
1731
  } else {
1342
- line = candidate;
1732
+ for (const character of word) line.push(character);
1733
+ lineWidth = candidateWidth;
1343
1734
  }
1344
1735
  word = [];
1345
1736
  };
@@ -1386,21 +1777,19 @@ function renderRichText(state, node, matrix, opacity, path, clips) {
1386
1777
  pushRichSpan(spans, run, character.x, character.x + character.advance);
1387
1778
  continue;
1388
1779
  }
1389
- const contours = flattenPath(
1390
- character.glyph.getPath(x + character.x, baseline, run.size),
1780
+ renderGlyphOutline(
1781
+ state,
1782
+ node,
1783
+ matrix,
1784
+ inverseMatrix,
1785
+ character.glyph,
1786
+ x + character.x,
1787
+ baseline,
1788
+ run.size,
1789
+ run.color,
1790
+ opacity,
1791
+ clips,
1391
1792
  );
1392
- if (contours.length) {
1393
- renderGlyph(
1394
- state,
1395
- node,
1396
- matrix,
1397
- inverseMatrix,
1398
- { bounds: contourBounds(contours), contours },
1399
- run.color,
1400
- opacity,
1401
- clips,
1402
- );
1403
- }
1404
1793
  pushRichSpan(spans, run, character.x, character.x + character.advance);
1405
1794
  }
1406
1795
  for (const span of spans) {
@@ -1457,8 +1846,8 @@ function renderText(state, node, matrix, opacity, path, clips) {
1457
1846
  const style = node.textStyle ?? {};
1458
1847
  const font = resolveFont(state, style, node.id, path);
1459
1848
  const fontSize = Number(style.fontSize ?? 14);
1460
- const lineHeightValue = Number(style.lineHeight ?? 1.2);
1461
- 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);
1462
1851
  const letterSpacing = Number(style.letterSpacing ?? 0);
1463
1852
  const source =
1464
1853
  style.textTransform === "uppercase"
@@ -1473,17 +1862,19 @@ function renderText(state, node, matrix, opacity, path, clips) {
1473
1862
  : wrapTextLines(
1474
1863
  source,
1475
1864
  node.width,
1476
- (text) =>
1865
+ (text, previous) =>
1477
1866
  lineGlyphs(
1478
1867
  font,
1479
- state.fonts.symbols,
1868
+ state.fonts,
1480
1869
  text,
1481
1870
  0,
1482
1871
  0,
1483
1872
  fontSize,
1484
1873
  letterSpacing,
1485
- false,
1486
- ).width,
1874
+ undefined,
1875
+ undefined,
1876
+ previous,
1877
+ ),
1487
1878
  );
1488
1879
  if (node.growType === "auto-height") {
1489
1880
  const blockHeight = lines.length * lineHeight;
@@ -1514,13 +1905,12 @@ function renderText(state, node, matrix, opacity, path, clips) {
1514
1905
  for (let index = 0; index < lines.length; index += 1) {
1515
1906
  const line = lineGlyphs(
1516
1907
  font,
1517
- state.fonts.symbols,
1908
+ state.fonts,
1518
1909
  lines[index],
1519
1910
  0,
1520
1911
  0,
1521
1912
  fontSize,
1522
1913
  letterSpacing,
1523
- false,
1524
1914
  );
1525
1915
  const x =
1526
1916
  style.textAlign === "center"
@@ -1530,20 +1920,30 @@ function renderText(state, node, matrix, opacity, path, clips) {
1530
1920
  : 0;
1531
1921
  const baseline =
1532
1922
  verticalOffset + index * lineHeight + (lineHeight - fontSize) / 2 + ascender;
1533
- const placed = lineGlyphs(
1923
+ lineGlyphs(
1534
1924
  font,
1535
- state.fonts.symbols,
1925
+ state.fonts,
1536
1926
  lines[index],
1537
1927
  x,
1538
1928
  baseline,
1539
1929
  fontSize,
1540
1930
  letterSpacing,
1541
- true,
1931
+ (glyph, glyphX) =>
1932
+ renderGlyphOutline(
1933
+ state,
1934
+ node,
1935
+ matrix,
1936
+ inverseMatrix,
1937
+ glyph,
1938
+ glyphX,
1939
+ baseline,
1940
+ fontSize,
1941
+ textColor,
1942
+ opacity,
1943
+ clips,
1944
+ ),
1542
1945
  missing,
1543
1946
  );
1544
- for (const glyph of placed.glyphs) {
1545
- renderGlyph(state, node, matrix, inverseMatrix, glyph, textColor, opacity, clips);
1546
- }
1547
1947
  renderTextDecoration(
1548
1948
  state,
1549
1949
  node,
@@ -1635,6 +2035,14 @@ function paintColor(
1635
2035
  );
1636
2036
  }
1637
2037
 
2038
+ function paintVaries(paint, state) {
2039
+ if (paint.type === "solid" || paint.type === "image") return false;
2040
+ const colorKey = paint.colorRef
2041
+ ? assetReferenceKey(paint.colorRef, state.productPackageId)
2042
+ : undefined;
2043
+ return !(colorKey && state.colors.has(colorKey));
2044
+ }
2045
+
1638
2046
  function svgAttributes(source) {
1639
2047
  return Object.fromEntries(
1640
2048
  [...source.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)].map(
@@ -1795,6 +2203,29 @@ function pushDecodeDiagnostic(diagnostics, path, name, detail) {
1795
2203
  });
1796
2204
  }
1797
2205
 
2206
+ // Every raster decoder checks declared dimensions against the same limits
2207
+ // before allocating pixel buffers.
2208
+ function rasterSizeAllowed(width, height) {
2209
+ return (
2210
+ Number.isInteger(width) &&
2211
+ Number.isInteger(height) &&
2212
+ width > 0 &&
2213
+ height > 0 &&
2214
+ width <= MAX_DIMENSION &&
2215
+ height <= MAX_DIMENSION &&
2216
+ width * height <= MAX_PIXELS
2217
+ );
2218
+ }
2219
+
2220
+ function pushSizeDiagnostic(diagnostics, path, name, width, height) {
2221
+ pushDecodeDiagnostic(
2222
+ diagnostics,
2223
+ path,
2224
+ name,
2225
+ `image size ${width}x${height} exceeds the renderer limits (${MAX_DIMENSION}px per side, ${MAX_PIXELS} pixels)`,
2226
+ );
2227
+ }
2228
+
1798
2229
  function decodePng(bytes, diagnostics, path, name) {
1799
2230
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
1800
2231
  const signature = [137, 80, 78, 71, 13, 10, 26, 10];
@@ -1841,17 +2272,14 @@ function decodePng(bytes, diagnostics, path, name) {
1841
2272
  }
1842
2273
  offset += 12 + length;
1843
2274
  }
1844
- if (
1845
- !header ||
1846
- idat.length === 0 ||
1847
- header.width <= 0 ||
1848
- header.height <= 0 ||
1849
- header.width > MAX_DIMENSION ||
1850
- header.height > MAX_DIMENSION
1851
- ) {
2275
+ if (!header || idat.length === 0 || header.width <= 0 || header.height <= 0) {
1852
2276
  pushDecodeDiagnostic(diagnostics, path, name, "missing or invalid PNG header");
1853
2277
  return undefined;
1854
2278
  }
2279
+ if (!rasterSizeAllowed(header.width, header.height)) {
2280
+ pushSizeDiagnostic(diagnostics, path, name, header.width, header.height);
2281
+ return undefined;
2282
+ }
1855
2283
  if (
1856
2284
  header.depth !== 8 ||
1857
2285
  ![0, 2, 3, 4, 6].includes(header.colorType) ||
@@ -1868,14 +2296,25 @@ function decodePng(bytes, diagnostics, path, name) {
1868
2296
  return undefined;
1869
2297
  }
1870
2298
  const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[header.colorType];
2299
+ const stride = header.width * channels;
1871
2300
  let raw;
1872
2301
  try {
1873
- raw = inflateSync(Buffer.concat(idat));
1874
- } catch {
1875
- pushDecodeDiagnostic(diagnostics, path, name, "corrupt PNG pixel data");
2302
+ // Bound the output to the declared image size so a small compressed
2303
+ // stream cannot expand without limit.
2304
+ raw = inflateSync(Buffer.concat(idat), {
2305
+ maxOutputLength: (stride + 1) * header.height,
2306
+ });
2307
+ } catch (error) {
2308
+ pushDecodeDiagnostic(
2309
+ diagnostics,
2310
+ path,
2311
+ name,
2312
+ error?.code === "ERR_BUFFER_TOO_LARGE"
2313
+ ? "PNG pixel data is larger than its declared size"
2314
+ : "corrupt PNG pixel data",
2315
+ );
1876
2316
  return undefined;
1877
2317
  }
1878
- const stride = header.width * channels;
1879
2318
  if (raw.length < (stride + 1) * header.height) {
1880
2319
  pushDecodeDiagnostic(diagnostics, path, name, "truncated PNG pixel data");
1881
2320
  return undefined;
@@ -1968,6 +2407,10 @@ function decodeGif(bytes, diagnostics, path, name) {
1968
2407
  const data = new DataView(view.buffer, view.byteOffset, view.byteLength);
1969
2408
  const width = data.getUint16(6, true);
1970
2409
  const height = data.getUint16(8, true);
2410
+ if (!rasterSizeAllowed(width, height)) {
2411
+ pushSizeDiagnostic(diagnostics, path, name, width, height);
2412
+ return undefined;
2413
+ }
1971
2414
  const packed = view[10];
1972
2415
  let offset = 13;
1973
2416
  let globalTable;
@@ -2005,10 +2448,18 @@ function decodeGif(bytes, diagnostics, path, name) {
2005
2448
  pushDecodeDiagnostic(diagnostics, path, name, "corrupt GIF block structure");
2006
2449
  return undefined;
2007
2450
  }
2451
+ if (offset + 9 > view.length) {
2452
+ pushDecodeDiagnostic(diagnostics, path, name, "truncated GIF image descriptor");
2453
+ return undefined;
2454
+ }
2008
2455
  const frameX = data.getUint16(offset, true);
2009
2456
  const frameY = data.getUint16(offset + 2, true);
2010
2457
  const frameWidth = data.getUint16(offset + 4, true);
2011
2458
  const frameHeight = data.getUint16(offset + 6, true);
2459
+ if (!rasterSizeAllowed(frameWidth, frameHeight)) {
2460
+ pushSizeDiagnostic(diagnostics, path, name, frameWidth, frameHeight);
2461
+ return undefined;
2462
+ }
2012
2463
  const framePacked = view[offset + 8];
2013
2464
  offset += 9;
2014
2465
  let table = globalTable;
@@ -2227,7 +2678,12 @@ function decodeJpeg(bytes, diagnostics, path, name) {
2227
2678
  });
2228
2679
  cursor += 3;
2229
2680
  }
2230
- if (frame.width <= 0 || frame.height <= 0) frame = undefined;
2681
+ if (frame.width <= 0 || frame.height <= 0) {
2682
+ frame = undefined;
2683
+ } else if (!rasterSizeAllowed(frame.width, frame.height)) {
2684
+ pushSizeDiagnostic(diagnostics, path, name, frame.width, frame.height);
2685
+ return undefined;
2686
+ }
2231
2687
  } else if (marker === 0xc2 || marker === 0xc3 || (marker >= 0xc5 && marker <= 0xcf)) {
2232
2688
  pushDecodeDiagnostic(
2233
2689
  diagnostics,
@@ -2646,11 +3102,18 @@ function decodeMediaBytes(media, bytes, diagnostics, path) {
2646
3102
  kind: "raster",
2647
3103
  value: decodeJpeg(bytes, diagnostics, path, media.name),
2648
3104
  };
2649
- case "image/webp":
3105
+ case "image/webp": {
3106
+ // libwebp allocates the full canvas; check the header first.
3107
+ const size = webpDimensions(bytes);
3108
+ if (size && !rasterSizeAllowed(size.width, size.height)) {
3109
+ pushSizeDiagnostic(diagnostics, path, media.name, size.width, size.height);
3110
+ return { kind: "raster", value: undefined };
3111
+ }
2650
3112
  return {
2651
3113
  kind: "raster",
2652
3114
  value: decodeWebpSync(bytes, diagnostics, path, media.name),
2653
3115
  };
3116
+ }
2654
3117
  default:
2655
3118
  diagnostics.push({
2656
3119
  code: "media_decode_unsupported",
@@ -2749,37 +3212,99 @@ function nodeContains(node, x, y) {
2749
3212
  return cornerContains(node, x, y);
2750
3213
  }
2751
3214
 
2752
- function nodeContainsGeometry(node, geometry, x, y) {
2753
- if (geometry) return pathFillContains(geometry, x, y);
2754
- 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);
2755
3236
  }
2756
3237
 
2757
- function insetContains(node, x, y, inset) {
2758
- const width = node.width - inset * 2;
2759
- const height = node.height - inset * 2;
2760
- if (width <= 0 || height <= 0) return false;
2761
- const reduceRadius = (value) => Math.max(0, Number(value) - inset);
2762
- const cornerRadius = Array.isArray(node.cornerRadius)
2763
- ? node.cornerRadius.map(reduceRadius)
2764
- : reduceRadius(node.cornerRadius ?? 0);
2765
- return nodeContains(
2766
- { ...node, cornerRadius, height, width },
2767
- x - inset,
2768
- 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
2769
3273
  );
2770
3274
  }
2771
3275
 
2772
- function makeGeometry(segments) {
2773
- const cumulative = [];
2774
- let totalLength = 0;
2775
- for (const segment of segments) {
2776
- totalLength += Math.hypot(
2777
- segment.end.x - segment.start.x,
2778
- segment.end.y - segment.start.y,
2779
- );
2780
- 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
+ }
2781
3298
  }
2782
- 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);
2783
3308
  }
2784
3309
 
2785
3310
  function clipsAllow(clips, x, y) {
@@ -2807,12 +3332,83 @@ function blurRadiusOf(value) {
2807
3332
  return Number.isFinite(radius) && radius > 0 ? radius : undefined;
2808
3333
  }
2809
3334
 
2810
- function blurAlphaRegion(buffer, width, height, radius) {
2811
- const boxRadius = Math.round(radius);
2812
- 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);
2813
3375
  const temporary = new Float32Array(buffer.length);
2814
- const window = boxRadius * 2 + 1;
2815
- 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;
2816
3412
  for (let row = 0; row < height; row += 1) {
2817
3413
  let sum = 0;
2818
3414
  for (let offset = -boxRadius; offset <= boxRadius; offset += 1) {
@@ -2838,9 +3434,9 @@ function blurAlphaRegion(buffer, width, height, radius) {
2838
3434
  }
2839
3435
  }
2840
3436
 
2841
- function blurRgbaRegion(pixels, width, height, left, top, right, bottom, radius) {
2842
- const boxRadius = Math.round(radius);
2843
- 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;
2844
3440
  const regionWidth = right - left;
2845
3441
  const regionHeight = bottom - top;
2846
3442
  const buffer = new Float32Array(regionWidth * regionHeight * 4);
@@ -2856,10 +3452,10 @@ function blurRgbaRegion(pixels, width, height, left, top, right, bottom, radius)
2856
3452
  }
2857
3453
  }
2858
3454
  const temporary = new Float32Array(buffer.length);
2859
- const window = boxRadius * 2 + 1;
2860
3455
  const at = (buffer_, row, column, channel) =>
2861
3456
  buffer_[(clamp(row, 0, regionHeight - 1) * regionWidth + clamp(column, 0, regionWidth - 1)) * 4 + channel];
2862
- for (let pass = 0; pass < 3; pass += 1) {
3457
+ for (const boxRadius of radii) {
3458
+ const window = boxRadius * 2 + 1;
2863
3459
  for (let row = 0; row < regionHeight; row += 1) {
2864
3460
  for (let channel = 0; channel < 4; channel += 1) {
2865
3461
  let sum = 0;
@@ -2907,8 +3503,18 @@ function blurRgbaRegion(pixels, width, height, left, top, right, bottom, radius)
2907
3503
  }
2908
3504
  }
2909
3505
 
2910
- function pixelBounds(state, matrix, node) {
2911
- 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);
2912
3518
  return {
2913
3519
  bottom: clamp(Math.ceil(bounds.bottom * state.scale), 0, state.height),
2914
3520
  left: clamp(Math.floor(bounds.left * state.scale), 0, state.width),
@@ -2917,36 +3523,135 @@ function pixelBounds(state, matrix, node) {
2917
3523
  };
2918
3524
  }
2919
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
+
2920
3599
  function renderNodeShadow(state, node, matrix, opacity, path, clips, geometry) {
2921
- 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;
2922
3604
  for (const shadow of shadows) {
2923
3605
  if (shadow.hidden === true) continue;
2924
- if (shadow.style !== undefined && shadow.style !== "drop-shadow") {
3606
+ if (
3607
+ shadow.inset === true ||
3608
+ (shadow.style !== undefined && shadow.style !== "drop-shadow")
3609
+ ) {
2925
3610
  state.diagnostics.push({
2926
3611
  code: "unsupported_render_shadow",
2927
- message: `Renderer ignored shadow style ${shadow.style}`,
3612
+ message: `Renderer ignored shadow style ${shadow.style ?? "inner-shadow"}`,
2928
3613
  nodeId: node.id,
2929
3614
  path,
2930
3615
  });
2931
3616
  continue;
2932
3617
  }
2933
- const offsetX = Number(shadow.offsetX ?? 0);
2934
- const offsetY = Number(shadow.offsetY ?? 0);
2935
- const blur = Number(shadow.blur ?? 0);
3618
+ // Token shadows are DTCG objects; shadows edited in Penpot keep its
3619
+ // kebab-case offsets and a {color, opacity} color.
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;
3633
+ const paint = isRecordLike(shadow.color) ? shadow.color : undefined;
2936
3634
  const color = parseColor(
2937
- typeof shadow.color === "string" ? shadow.color : "#000000",
3635
+ typeof shadow.color === "string"
3636
+ ? shadow.color
3637
+ : typeof paint?.color === "string" ? paint.color : "#000000",
2938
3638
  state.diagnostics,
2939
3639
  `${path}.shadow`,
2940
3640
  );
2941
- const shadowOpacity = Number.isFinite(Number(shadow.opacity))
2942
- ? clamp(Number(shadow.opacity), 0, 1)
3641
+ const rawOpacity = shadow.opacity ?? paint?.opacity;
3642
+ const shadowOpacity = Number.isFinite(Number(rawOpacity))
3643
+ ? clamp(Number(rawOpacity), 0, 1)
2943
3644
  : 1;
2944
3645
  const offsetDeviceX =
2945
3646
  (matrix[0] * offsetX + matrix[2] * offsetY) * state.scale;
2946
3647
  const offsetDeviceY =
2947
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;
2948
3652
  const bounds = pixelBounds(state, matrix, node);
2949
- 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;
2950
3655
  const left = clamp(bounds.left + Math.floor(offsetDeviceX - expand), 0, state.width);
2951
3656
  const right = clamp(bounds.right + Math.ceil(offsetDeviceX + expand), 0, state.width);
2952
3657
  const top = clamp(bounds.top + Math.floor(offsetDeviceY - expand), 0, state.height);
@@ -2956,29 +3661,30 @@ function renderNodeShadow(state, node, matrix, opacity, path, clips, geometry) {
2956
3661
  const alphaBuffer = new Float32Array(regionWidth * (bottom - top));
2957
3662
  const inverseMatrix = inverse(matrix);
2958
3663
  if (!inverseMatrix) continue;
3664
+ // The source is the node's own outline; ancestor clips act on where the
3665
+ // shadow lands, below.
2959
3666
  for (let pixelY = top; pixelY < bottom; pixelY += 1) {
2960
3667
  for (let pixelX = left; pixelX < right; pixelX += 1) {
2961
3668
  const canvasX = (pixelX + 0.5 - offsetDeviceX) / state.scale;
2962
3669
  const canvasY = (pixelY + 0.5 - offsetDeviceY) / state.scale;
2963
3670
  const local = point(inverseMatrix, canvasX, canvasY);
2964
- if (
2965
- local.x < 0 ||
2966
- local.y < 0 ||
2967
- local.x >= node.width ||
2968
- local.y >= node.height ||
2969
- !nodeContainsGeometry(node, geometry, local.x, local.y) ||
2970
- !clipsAllow(clips, canvasX, canvasY)
2971
- ) {
2972
- 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
+ );
2973
3677
  }
2974
- alphaBuffer[(pixelY - top) * regionWidth + (pixelX - left)] = 1;
2975
3678
  }
2976
3679
  }
2977
- blurAlphaRegion(alphaBuffer, regionWidth, bottom - top, blur * state.scale);
3680
+ blurAlphaRegion(alphaBuffer, regionWidth, bottom - top, sigma);
2978
3681
  for (let pixelY = top; pixelY < bottom; pixelY += 1) {
2979
3682
  for (let pixelX = left; pixelX < right; pixelX += 1) {
2980
3683
  const coverage = alphaBuffer[(pixelY - top) * regionWidth + (pixelX - left)];
2981
3684
  if (coverage <= 0.004) continue;
3685
+ if (!clipsAllow(clips, (pixelX + 0.5) / state.scale, (pixelY + 0.5) / state.scale)) {
3686
+ continue;
3687
+ }
2982
3688
  blend(
2983
3689
  state.pixels,
2984
3690
  (pixelY * state.width + pixelX) * 4,
@@ -3018,7 +3724,9 @@ function renderBackgroundBlur(state, node, matrix, clips) {
3018
3724
  bounds.top,
3019
3725
  bounds.right,
3020
3726
  bounds.bottom,
3021
- 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,
3022
3730
  );
3023
3731
  for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
3024
3732
  for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
@@ -3105,12 +3813,6 @@ function boundaryGeometry(node) {
3105
3813
  return { contours: [], segments };
3106
3814
  }
3107
3815
 
3108
- function strokeSegmentsFor(node, geometry, stroke, width, pattern) {
3109
- if (geometry) return [];
3110
- if (pattern) return boundaryGeometry(node).segments;
3111
- return [];
3112
- }
3113
-
3114
3816
  function renderStrokeColor(state, node, stroke, path) {
3115
3817
  return paintColor(
3116
3818
  stroke,
@@ -3144,10 +3846,19 @@ function createScratchState(state) {
3144
3846
  };
3145
3847
  }
3146
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
+
3147
3858
  function childClips(state, nodes, node, matrix, clips) {
3148
3859
  const inverseMatrix = inverse(matrix);
3149
3860
  if (!inverseMatrix) return clips;
3150
- if (node.type === "FRAME" && node["show-content"] === false) {
3861
+ if (clipsContent(node)) {
3151
3862
  return [...(clips ?? []), { inverse: inverseMatrix, shape: node }];
3152
3863
  }
3153
3864
  if (
@@ -3167,6 +3878,30 @@ function childClips(state, nodes, node, matrix, clips) {
3167
3878
  return clips;
3168
3879
  }
3169
3880
 
3881
+ function inkBounds(state, margin) {
3882
+ let left = state.width;
3883
+ let right = 0;
3884
+ let top = state.height;
3885
+ let bottom = 0;
3886
+ for (let pixelY = 0; pixelY < state.height; pixelY += 1) {
3887
+ const row = pixelY * state.width;
3888
+ for (let pixelX = 0; pixelX < state.width; pixelX += 1) {
3889
+ if (state.pixels[(row + pixelX) * 4 + 3] === 0) continue;
3890
+ if (pixelX < left) left = pixelX;
3891
+ if (pixelX >= right) right = pixelX + 1;
3892
+ if (pixelY < top) top = pixelY;
3893
+ bottom = pixelY + 1;
3894
+ }
3895
+ }
3896
+ if (right <= left) return undefined;
3897
+ return {
3898
+ bottom: Math.min(state.height, bottom + margin),
3899
+ left: Math.max(0, left - margin),
3900
+ right: Math.min(state.width, right + margin),
3901
+ top: Math.max(0, top - margin),
3902
+ };
3903
+ }
3904
+
3170
3905
  function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, clips) {
3171
3906
  if (stack.has(nodeId)) throw new SmallPenError("node_cycle", `Render cycle includes ${nodeId}`);
3172
3907
  const node = nodes[nodeId];
@@ -3196,7 +3931,7 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3196
3931
  matrix,
3197
3932
  opacity,
3198
3933
  stack,
3199
- masked || (node.type === "FRAME" && node["show-content"] === false)
3934
+ masked || clipsContent(node)
3200
3935
  ? childClips(state, nodes, node, matrix, clips)
3201
3936
  : clips,
3202
3937
  );
@@ -3204,28 +3939,39 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3204
3939
  stack.delete(nodeId);
3205
3940
  for (const entry of scratch.diagnostics) state.diagnostics.push(entry);
3206
3941
  if (scratch.missingGlyphs) mergeMissingGlyphs(state, scratch.missingGlyphs);
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);
3949
+ if (!region) return;
3207
3950
  blurRgbaRegion(
3208
3951
  scratch.pixels,
3209
3952
  state.width,
3210
3953
  state.height,
3211
- 0,
3212
- 0,
3213
- state.width,
3214
- state.height,
3215
- layerBlurRadius * state.scale,
3954
+ region.left,
3955
+ region.top,
3956
+ region.right,
3957
+ region.bottom,
3958
+ layerSigma,
3216
3959
  );
3217
- for (let index = 0; index < state.pixels.length; index += 4) {
3218
- blend(
3219
- state.pixels,
3220
- index,
3221
- [
3222
- scratch.pixels[index],
3223
- scratch.pixels[index + 1],
3224
- scratch.pixels[index + 2],
3225
- scratch.pixels[index + 3],
3226
- ],
3227
- 1,
3228
- );
3960
+ for (let pixelY = region.top; pixelY < region.bottom; pixelY += 1) {
3961
+ for (let pixelX = region.left; pixelX < region.right; pixelX += 1) {
3962
+ const index = (pixelY * state.width + pixelX) * 4;
3963
+ blend(
3964
+ state.pixels,
3965
+ index,
3966
+ [
3967
+ scratch.pixels[index],
3968
+ scratch.pixels[index + 1],
3969
+ scratch.pixels[index + 2],
3970
+ scratch.pixels[index + 3],
3971
+ ],
3972
+ 1,
3973
+ );
3974
+ }
3229
3975
  }
3230
3976
  return;
3231
3977
  }
@@ -3233,8 +3979,7 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3233
3979
  renderNodeShape(state, node, matrix, opacity, path, clips);
3234
3980
  }
3235
3981
  stack.add(nodeId);
3236
- const clipsChildren =
3237
- masked || (node.type === "FRAME" && node["show-content"] === false);
3982
+ const clipsChildren = masked || clipsContent(node);
3238
3983
  const childClipsHere = clipsChildren
3239
3984
  ? childClips(state, nodes, node, matrix, clips)
3240
3985
  : clips;
@@ -3251,10 +3996,12 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3251
3996
 
3252
3997
  function renderNodeShape(state, node, matrix, opacity, path, clips) {
3253
3998
  const inverseMatrix = inverse(matrix);
3254
- if (!inverseMatrix || node.width <= 0 || node.height <= 0) return;
3999
+ if (!inverseMatrix) return;
3255
4000
  const geometry = node.type === "PATH" && typeof node.pathData === "string"
3256
4001
  ? parsePathData(node.pathData)
3257
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;
3258
4005
  if (node.type === "PATH" && typeof node.pathData === "string" && !geometry) {
3259
4006
  state.diagnostics.push({
3260
4007
  code: "unsupported_render_path",
@@ -3265,7 +4012,10 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3265
4012
  }
3266
4013
  renderNodeShadow(state, node, matrix, opacity, path, clips, geometry);
3267
4014
  renderBackgroundBlur(state, node, matrix, clips);
3268
- 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);
3269
4019
  // hide-fill-on-export suppresses fills in exported raster output while the
3270
4020
  // editing surface keeps showing them; the CLI render is the export surface.
3271
4021
  const suppressFill = node["hide-fill-on-export"] === true;
@@ -3294,37 +4044,49 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3294
4044
  path,
3295
4045
  });
3296
4046
  }
4047
+ // Only gradients vary per pixel; resolve any other paint once.
4048
+ const uniformColor =
4049
+ raster || media?.image || paintVaries(fill, state)
4050
+ ? undefined
4051
+ : paintColor(
4052
+ fill,
4053
+ 0,
4054
+ 0,
4055
+ node,
4056
+ state.colors,
4057
+ state.diagnostics,
4058
+ path,
4059
+ state.productPackageId,
4060
+ );
3297
4061
  for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
3298
4062
  for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
3299
4063
  const canvasX = (pixelX + 0.5) / state.scale;
3300
4064
  const canvasY = (pixelY + 0.5) / state.scale;
3301
4065
  const local = point(inverseMatrix, canvasX, canvasY);
3302
- if (
3303
- local.x < 0 ||
3304
- local.y < 0 ||
3305
- local.x >= node.width ||
3306
- local.y >= node.height ||
3307
- !nodeContainsGeometry(node, geometry, local.x, local.y) ||
3308
- !clipsAllow(clips, canvasX, canvasY)
3309
- ) {
3310
- continue;
3311
- }
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);
3312
4072
  const color = raster
3313
- ? rasterSample(raster, local.x, local.y, node)
4073
+ ? rasterSample(raster, sampleX, sampleY, node)
3314
4074
  : media?.image
3315
- ? svgSample(media.image, local.x, local.y, node)
3316
- : paintColor(
3317
- fill,
3318
- local.x,
3319
- local.y,
3320
- node,
3321
- state.colors,
3322
- state.diagnostics,
3323
- path,
3324
- state.productPackageId,
3325
- );
4075
+ ? svgSample(media.image, sampleX, sampleY, node)
4076
+ : uniformColor
4077
+ ? [...uniformColor]
4078
+ : paintColor(
4079
+ fill,
4080
+ sampleX,
4081
+ sampleY,
4082
+ node,
4083
+ state.colors,
4084
+ state.diagnostics,
4085
+ path,
4086
+ state.productPackageId,
4087
+ );
3326
4088
  if (fill.type === "image" && !media?.image && !raster) {
3327
- 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;
3328
4090
  color[0] -= band * 18;
3329
4091
  color[1] -= band * 18;
3330
4092
  color[2] -= band * 18;
@@ -3333,7 +4095,7 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3333
4095
  state.pixels,
3334
4096
  (pixelY * state.width + pixelX) * 4,
3335
4097
  color,
3336
- opacity * (fill.opacity ?? 1),
4098
+ opacity * (fill.opacity ?? 1) * coverage,
3337
4099
  );
3338
4100
  }
3339
4101
  }
@@ -3342,90 +4104,125 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3342
4104
  if (stroke.hidden === true) continue;
3343
4105
  const width = Math.max(1, stroke.width ?? 1);
3344
4106
  const color = renderStrokeColor(state, node, stroke, path);
3345
- const pattern = dashPattern(stroke, width);
3346
- const boundary = geometry || (pattern ? makeGeometry(boundaryGeometry(node).segments) : undefined);
3347
- const markerStart = stroke.capStart && geometry
3348
- ? markerGeometry(
3349
- stroke.capStart,
3350
- geometry.start,
3351
- directionAt(geometry, { segmentIndex: 0 }),
3352
- width,
3353
- )
3354
- : undefined;
3355
- const markerEnd = stroke.capEnd && geometry
3356
- ? markerGeometry(
3357
- stroke.capEnd,
3358
- geometry.end,
3359
- directionAt(geometry, { segmentIndex: geometry.segments.length - 1 }),
3360
- width,
3361
- )
3362
- : undefined;
3363
- for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
3364
- for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
3365
- const canvasX = (pixelX + 0.5) / state.scale;
3366
- const canvasY = (pixelY + 0.5) / state.scale;
3367
- const local = point(inverseMatrix, canvasX, canvasY);
3368
- if (
3369
- local.x < 0 ||
3370
- local.y < 0 ||
3371
- local.x >= node.width ||
3372
- local.y >= node.height ||
3373
- !clipsAllow(clips, canvasX, canvasY)
3374
- ) {
3375
- 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);
3376
4129
  }
3377
- let covered;
3378
- if (boundary) {
3379
- const fillInside = nodeContainsGeometry(node, geometry, local.x, local.y);
3380
- covered = pathStrokeContains(
3381
- boundary,
3382
- local.x,
3383
- local.y,
3384
- width,
3385
- pattern,
3386
- stroke.alignment,
3387
- 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,
3388
4155
  );
3389
- } else {
3390
- covered =
3391
- nodeContains(node, local.x, local.y) &&
3392
- !insetContains(node, local.x, local.y, width);
3393
4156
  }
3394
- if (!covered && markerStart) {
3395
- covered =
3396
- windingNumber(markerStart.contours, local.x, local.y) !== 0 ||
3397
- (markerStart.segments.length > 0 &&
3398
- pathStrokeContains(
3399
- makeGeometry(markerStart.segments),
3400
- local.x,
3401
- local.y,
3402
- width,
3403
- ));
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;
3404
4196
  }
3405
- if (!covered && markerEnd) {
3406
- covered =
3407
- windingNumber(markerEnd.contours, local.x, local.y) !== 0 ||
3408
- (markerEnd.segments.length > 0 &&
3409
- pathStrokeContains(
3410
- makeGeometry(markerEnd.segments),
3411
- local.x,
3412
- local.y,
3413
- width,
3414
- ));
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);
3415
4204
  }
3416
- if (!covered) continue;
3417
- blend(
3418
- state.pixels,
3419
- (pixelY * state.width + pixelX) * 4,
3420
- color,
3421
- opacity * (stroke.opacity ?? 1),
3422
- );
3423
4205
  }
3424
4206
  }
3425
4207
  }
3426
4208
  if (node.type === "TEXT") renderText(state, node, matrix, opacity, path, clips);
3427
4209
  }
3428
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
+
3429
4226
  function flattenRegions(
3430
4227
  node,
3431
4228
  scale,
@@ -3462,6 +4259,43 @@ function flattenRegions(
3462
4259
  return result;
3463
4260
  }
3464
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
+
3465
4299
  export async function renderProjection(product, projection, options = {}) {
3466
4300
  if (projection.fallbackUsed) {
3467
4301
  throw new SmallPenError(
@@ -3477,8 +4311,12 @@ export async function renderProjection(product, projection, options = {}) {
3477
4311
  }
3478
4312
  const root = projection.nodes[projection.rootId];
3479
4313
  if (!root) throw new SmallPenError("missing_root_node", "Projection root is unavailable");
3480
- const width = Math.ceil(root.width * scale);
3481
- 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);
3482
4320
  if (
3483
4321
  width < 1 ||
3484
4322
  height < 1 ||
@@ -3494,6 +4332,8 @@ export async function renderProjection(product, projection, options = {}) {
3494
4332
  });
3495
4333
  }
3496
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);
3497
4337
  const hasText = Object.values(projection.nodes).some(
3498
4338
  (node) => node.type === "TEXT" && node.text,
3499
4339
  );
@@ -3542,7 +4382,7 @@ export async function renderProjection(product, projection, options = {}) {
3542
4382
  state,
3543
4383
  projection.nodes,
3544
4384
  projection.rootId,
3545
- translate(-root.x, -root.y),
4385
+ translate(-root.x - crop.x, -root.y - crop.y),
3546
4386
  1,
3547
4387
  new Set(),
3548
4388
  );
@@ -3580,6 +4420,7 @@ export async function createEvidence(product, options = {}) {
3580
4420
  rootY,
3581
4421
  );
3582
4422
  return {
4423
+ projection,
3583
4424
  evidence: {
3584
4425
  diagnostics: render.diagnostics,
3585
4426
  nodeRegions: regions,