@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/assets/fonts/SourceSansPro-OFL.txt +46 -0
- package/assets/fonts/WorkSans-OFL.txt +46 -0
- package/package.json +2 -2
- package/src/draft-import.mjs +1 -11
- package/src/font-convert.mjs +23 -1
- package/src/fs-utils.mjs +95 -0
- package/src/index.mjs +18 -3
- package/src/initialize.mjs +363 -136
- package/src/kiwi-runtime.mjs +38 -36
- package/src/library-network.mjs +189 -0
- package/src/local-package-backend.mjs +215 -24
- package/src/local-package.mjs +589 -177
- package/src/local-workspace-session.mjs +278 -96
- package/src/migrate-themes.mjs +116 -0
- package/src/remote-library.mjs +167 -28
- package/src/render.mjs +1351 -510
- package/src/webp-decode.mjs +32 -0
- package/src/workspace.mjs +178 -17
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,
|
|
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
|
-
|
|
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
|
|
559
|
-
// 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.
|
|
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
|
-
|
|
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
|
|
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
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
675
|
+
pushPoint(step === steps ? end : {
|
|
589
676
|
x:
|
|
590
|
-
inverseAmount ** 3 *
|
|
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 *
|
|
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
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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:
|
|
670
|
-
y2:
|
|
671
|
-
x:
|
|
672
|
-
y:
|
|
673
|
-
};
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
|
982
|
+
return Math.max(beyond, across);
|
|
860
983
|
}
|
|
861
|
-
if (
|
|
862
|
-
const
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
1017
|
+
distance = Math.min(distance, pieceDistance(piece, x, y));
|
|
1018
|
+
if (distance * unit <= -0.5) return 1;
|
|
895
1019
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
931
|
-
|
|
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
|
|
1225
|
+
return shape.polygons.map((polygon) => polygonPiece(polygon.map(place))).filter(Boolean);
|
|
947
1226
|
}
|
|
948
1227
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
|
967
|
-
if (
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
|
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
|
-
|
|
1299
|
+
fonts,
|
|
993
1300
|
text,
|
|
994
1301
|
x,
|
|
995
1302
|
baseline,
|
|
996
1303
|
size,
|
|
997
1304
|
letterSpacing,
|
|
998
|
-
|
|
1305
|
+
onGlyph,
|
|
999
1306
|
missing,
|
|
1307
|
+
start,
|
|
1000
1308
|
) {
|
|
1001
|
-
const
|
|
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
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
1031
|
-
let cursor = x;
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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(
|
|
1341
|
+
(glyphFont.getKerningValue(previous.glyph, glyph) * size) /
|
|
1037
1342
|
glyphFont.unitsPerEm;
|
|
1038
1343
|
}
|
|
1039
|
-
if (
|
|
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 {
|
|
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 =
|
|
1059
|
-
if (line &&
|
|
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
|
|
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
|
-
|
|
1191
|
-
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);
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
|
1722
|
+
const candidateWidth = extendRichLineWidth(lineWidth, line.at(-1), word);
|
|
1334
1723
|
if (
|
|
1335
1724
|
line.length &&
|
|
1336
1725
|
node.growType !== "auto-width" &&
|
|
1337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1461
|
-
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);
|
|
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
|
|
1868
|
+
state.fonts,
|
|
1480
1869
|
text,
|
|
1481
1870
|
0,
|
|
1482
1871
|
0,
|
|
1483
1872
|
fontSize,
|
|
1484
1873
|
letterSpacing,
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
|
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
|
-
|
|
1923
|
+
lineGlyphs(
|
|
1534
1924
|
font,
|
|
1535
|
-
state.fonts
|
|
1925
|
+
state.fonts,
|
|
1536
1926
|
lines[index],
|
|
1537
1927
|
x,
|
|
1538
1928
|
baseline,
|
|
1539
1929
|
fontSize,
|
|
1540
1930
|
letterSpacing,
|
|
1541
|
-
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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)
|
|
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
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
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
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
return
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
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
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
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
|
|
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
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
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
|
-
|
|
2815
|
-
|
|
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,
|
|
2842
|
-
const
|
|
2843
|
-
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;
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
2934
|
-
|
|
2935
|
-
const
|
|
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"
|
|
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
|
|
2942
|
-
|
|
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 =
|
|
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
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3954
|
+
region.left,
|
|
3955
|
+
region.top,
|
|
3956
|
+
region.right,
|
|
3957
|
+
region.bottom,
|
|
3958
|
+
layerSigma,
|
|
3216
3959
|
);
|
|
3217
|
-
for (let
|
|
3218
|
-
|
|
3219
|
-
state.
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
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,
|
|
4073
|
+
? rasterSample(raster, sampleX, sampleY, node)
|
|
3314
4074
|
: media?.image
|
|
3315
|
-
? svgSample(media.image,
|
|
3316
|
-
:
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
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(
|
|
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
|
|
3346
|
-
const
|
|
3347
|
-
const
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
:
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
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
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
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
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
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 (
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
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
|
|
3481
|
-
|
|
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,
|