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