ntk 8.0.0 → 8.1.0

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.
@@ -27,6 +27,13 @@ import {
27
27
  import Picture from "./picture.js";
28
28
  import Pixmap from "./pixmap.js";
29
29
  import { routeRaster } from "./rasterize.js";
30
+ import {
31
+ blurCoverage,
32
+ cachedShadow,
33
+ shadowPolicyOf,
34
+ shadowReach,
35
+ shadowSigma,
36
+ } from "./shadow.js";
30
37
  import { Surface } from "./surface.js";
31
38
  import {
32
39
  BL,
@@ -76,6 +83,37 @@ function intersectBox(a, b) {
76
83
  return { x, y, w: right - x, h: bottom - y };
77
84
  }
78
85
 
86
+ /** The device-space ink bounds of a flattened path, unclamped. */
87
+ function polysInk(polys) {
88
+ let minX = Infinity;
89
+ let minY = Infinity;
90
+ let maxX = -Infinity;
91
+ let maxY = -Infinity;
92
+ for (const poly of polys) {
93
+ const pts = poly.pts;
94
+ for (let i = 0; i < pts.length; i += 2) {
95
+ if (pts[i] < minX) minX = pts[i];
96
+ if (pts[i] > maxX) maxX = pts[i];
97
+ if (pts[i + 1] < minY) minY = pts[i + 1];
98
+ if (pts[i + 1] > maxY) maxY = pts[i + 1];
99
+ }
100
+ }
101
+ return maxX === -Infinity ? null : { minX, minY, maxX, maxY };
102
+ }
103
+
104
+ /** the same list, moved — the shadow's copy of a drawing is the same
105
+ * geometry in the coverage surface's coordinates */
106
+ function shiftPolys(polys, dx, dy) {
107
+ return polys.map((poly) => {
108
+ const pts = new Array(poly.pts.length);
109
+ for (let i = 0; i < pts.length; i += 2) {
110
+ pts[i] = poly.pts[i] + dx;
111
+ pts[i + 1] = poly.pts[i + 1] + dy;
112
+ }
113
+ return { ...poly, pts };
114
+ });
115
+ }
116
+
79
117
  const DEFAULT_FONT = "20px sans-serif";
80
118
 
81
119
  // `"wght" 460, "wdth" 87.5` — the CSS grammar, quotes optional. An object is
@@ -95,6 +133,17 @@ function parseVariationSettings(val) {
95
133
  return any ? out : null;
96
134
  }
97
135
 
136
+ /** a stable string for a variation setting, for cache keys — two Fonts of
137
+ * one file at different axis positions share a `key`, so the coordinates
138
+ * have to be part of anything keyed on the rendered result */
139
+ function variationsKey(variations) {
140
+ if (!variations) return "";
141
+ return Object.keys(variations)
142
+ .sort()
143
+ .map((tag) => `${tag}=${variations[tag]}`)
144
+ .join(",");
145
+ }
146
+
98
147
  /**
99
148
  * Fallback for a context dropped without `destroy()`, matching Pixmap,
100
149
  * Picture and GlyphSet. Only the GCs are freed here: everything else a
@@ -198,13 +247,21 @@ function patternSourceOf(app, source) {
198
247
  }
199
248
 
200
249
  /**
201
- * Point a pattern's picture transform at the transform in force for this
202
- * fill — patterns are painted in user space, so the CTM is part of the
203
- * mapping. Returns false when nothing would be painted (a singular matrix),
204
- * true for anything else, including every non-pattern style.
250
+ * Point a style's picture transform at the transform in force for this
251
+ * paint. Gradients and patterns are both defined in *user* space, so the CTM
252
+ * is part of the mapping — and per the canvas spec it is the CTM at paint
253
+ * time, not the one that happened to be current when the style was made
254
+ * (verified against browsers: a gradient created untransformed and filled
255
+ * after a `translate` moves with the fill, and one created under a translate
256
+ * and filled without it does not).
257
+ *
258
+ * Returns false when nothing would be painted (a singular matrix), true for
259
+ * anything else, including every plain-colour style.
205
260
  */
206
- function preparePattern(src, m) {
207
- return src instanceof CanvasPattern ? src._sync(m) : true;
261
+ function prepareStyle(src, m) {
262
+ return src instanceof CanvasPattern || src instanceof CanvasGradient
263
+ ? src._sync(m)
264
+ : true;
208
265
  }
209
266
 
210
267
  function isPictureSource(image) {
@@ -422,6 +479,15 @@ class RenderingContext2d {
422
479
  this._lineDash = [];
423
480
  this._lineDashOffset = 0;
424
481
 
482
+ this._shadowBlur = 0;
483
+ this._shadowOffsetX = 0;
484
+ this._shadowOffsetY = 0;
485
+ // "transparent black" — the spec's default, and the one value that
486
+ // skips the whole shadow path, so an app that never asks for a shadow
487
+ // never pays for one
488
+ this._shadowColor = "rgba(0, 0, 0, 0)";
489
+ this._shadowRgba = [0, 0, 0, 0];
490
+
425
491
  this.fillStyle = "white";
426
492
  this.strokeStyle = "black";
427
493
  this.lineWidth = 1;
@@ -660,6 +726,11 @@ class RenderingContext2d {
660
726
  textRendering: this._textRendering,
661
727
  textAlign: this.textAlign,
662
728
  textBaseline: this.textBaseline,
729
+ shadowBlur: this._shadowBlur,
730
+ shadowColor: this._shadowColor,
731
+ shadowRgba: this._shadowRgba,
732
+ shadowOffsetX: this._shadowOffsetX,
733
+ shadowOffsetY: this._shadowOffsetY,
663
734
  m: this._m.slice(),
664
735
  clips: this._clips,
665
736
  });
@@ -684,6 +755,11 @@ class RenderingContext2d {
684
755
  this._textRendering = s.textRendering;
685
756
  this.textAlign = s.textAlign;
686
757
  this.textBaseline = s.textBaseline;
758
+ this._shadowBlur = s.shadowBlur;
759
+ this._shadowColor = s.shadowColor;
760
+ this._shadowRgba = s.shadowRgba;
761
+ this._shadowOffsetX = s.shadowOffsetX;
762
+ this._shadowOffsetY = s.shadowOffsetY;
687
763
  this._m = s.m;
688
764
  if (s.clips !== this._clips) {
689
765
  this._clips = s.clips;
@@ -691,6 +767,398 @@ class RenderingContext2d {
691
767
  }
692
768
  }
693
769
 
770
+ // ------------------------------------------------------------------
771
+ // shadows (issue #272)
772
+ //
773
+ // The canvas properties, and the three server-side steps behind them: the
774
+ // drawing's coverage into a padded `a8` surface, a separable gaussian over
775
+ // it, and one masked composite in `shadowColor`. See lib/shadow.js for the
776
+ // blur and the cache, and docs/context-2d.md for what an app sees.
777
+
778
+ /**
779
+ * Gaussian blur applied to the shadow, in pixels. Canvas-spec units: this
780
+ * is a **diameter**, and the gaussian it names has σ = `shadowBlur / 2`,
781
+ * so a value here looks the same as the same value in a browser.
782
+ *
783
+ * Negative and non-finite values are ignored, as the spec requires.
784
+ */
785
+ set shadowBlur(value) {
786
+ const v = Number(value);
787
+ if (Number.isFinite(v) && v >= 0) this._shadowBlur = v;
788
+ }
789
+
790
+ get shadowBlur() {
791
+ return this._shadowBlur;
792
+ }
793
+
794
+ /**
795
+ * The shadow's colour. Defaults to fully transparent black, which is the
796
+ * spec's default and the switch that keeps every drawing operation on
797
+ * exactly the path it was on before shadows existed.
798
+ *
799
+ * A value that is not a colour is ignored (the spec's rule), so a
800
+ * mistyped colour leaves the previous shadow rather than throwing from
801
+ * inside a paint.
802
+ */
803
+ set shadowColor(value) {
804
+ let rgba;
805
+ try {
806
+ rgba = parseColor(value);
807
+ } catch {
808
+ return;
809
+ }
810
+ this._shadowColor = value;
811
+ this._shadowRgba = rgba;
812
+ }
813
+
814
+ get shadowColor() {
815
+ return this._shadowColor;
816
+ }
817
+
818
+ /**
819
+ * How far the shadow is offset. In **device** pixels: the spec puts
820
+ * shadow offsets outside the current transform, so a rotated drawing has
821
+ * an upright shadow, the same way a rotated element's box-shadow is
822
+ * upright in CSS.
823
+ *
824
+ * Offsets are rounded to whole pixels when the shadow is composited. The
825
+ * drawing's own sub-pixel position is preserved either way; what rounds
826
+ * is where its blurred copy lands.
827
+ */
828
+ set shadowOffsetX(value) {
829
+ const v = Number(value);
830
+ if (Number.isFinite(v)) this._shadowOffsetX = v;
831
+ }
832
+
833
+ get shadowOffsetX() {
834
+ return this._shadowOffsetX;
835
+ }
836
+
837
+ set shadowOffsetY(value) {
838
+ const v = Number(value);
839
+ if (Number.isFinite(v)) this._shadowOffsetY = v;
840
+ }
841
+
842
+ get shadowOffsetY() {
843
+ return this._shadowOffsetY;
844
+ }
845
+
846
+ /**
847
+ * Is there a shadow to paint at all?
848
+ *
849
+ * A shadow with no offset and no blur still paints — it lands exactly
850
+ * under the drawing, where it shows through anything translucent, which
851
+ * is what the spec says and what browsers do. Only a transparent
852
+ * `shadowColor` (the default) skips the work, so the answer is one array
853
+ * read on every fill of every app that never mentioned shadows.
854
+ */
855
+ _shadowed() {
856
+ return this._shadowRgba[3] > 0 && this.globalAlpha > 0;
857
+ }
858
+
859
+ /**
860
+ * The coverage surface's box in device space: the drawing's ink, padded by
861
+ * `reach` on every side so the blur has room to spread into, and clipped
862
+ * to the part of the drawing whose shadow could land on the target at all.
863
+ *
864
+ * That clip is exact rather than a heuristic. A source pixel at `s`
865
+ * spreads to shadow pixels `s + offset ± reach`, so ink further than
866
+ * `reach` outside the target (once the offset is undone) cannot contribute
867
+ * a single pixel of visible shadow — dropping it costs nothing and keeps a
868
+ * shape far off-screen from allocating a surface the size of its own
869
+ * bounding box.
870
+ */
871
+ _shadowBox(ink, reach) {
872
+ const ox = Math.round(this._shadowOffsetX);
873
+ const oy = Math.round(this._shadowOffsetY);
874
+ // a pixel of slack for the antialiased edge, as _clampBBox takes
875
+ const x0 = Math.max(Math.floor(ink.minX) - 1, -ox - reach);
876
+ const y0 = Math.max(Math.floor(ink.minY) - 1, -oy - reach);
877
+ const x1 = Math.min(Math.ceil(ink.maxX) + 1, this.width - ox + reach);
878
+ const y1 = Math.min(Math.ceil(ink.maxY) + 1, this.height - oy + reach);
879
+ if (x1 <= x0 || y1 <= y0) return null;
880
+ return {
881
+ x: x0 - reach,
882
+ y: y0 - reach,
883
+ w: x1 - x0 + reach * 2,
884
+ h: y1 - y0 + reach * 2,
885
+ };
886
+ }
887
+
888
+ /**
889
+ * Put this context's drawing state onto the coverage surface's context.
890
+ *
891
+ * `fillStyle` is opaque white and nothing else: an `a8` surface stores
892
+ * coverage, so what is drawn into it has to be at full alpha and takes its
893
+ * colour later, at the composite. `globalAlpha`, the composite op and the
894
+ * clip are deliberately *not* copied — they belong to that composite, not
895
+ * to the shape, and applying them twice would square the alpha.
896
+ */
897
+ _loadShadowState(sctx, dx, dy) {
898
+ // a device-space translation in front of the transform, so a user-space
899
+ // call replayed here lands where the surface expects it. Translation
900
+ // does not change the determinant, so the transform-aware line width
901
+ // in _strokePolys is the same one the real stroke will use.
902
+ sctx._m = matMultiply([1, 0, 0, 1, dx, dy], this._m);
903
+ sctx.fillStyle = "#fff";
904
+ sctx.strokeStyle = "#fff";
905
+ sctx.lineWidth = this.lineWidth;
906
+ sctx.lineCap = this.lineCap;
907
+ sctx.lineJoin = this.lineJoin;
908
+ sctx.miterLimit = this.miterLimit;
909
+ sctx._lineDash = this._lineDash;
910
+ sctx._lineDashOffset = this._lineDashOffset;
911
+ sctx._textStyle = this._textStyle;
912
+ sctx._lastFontString = this._lastFontString;
913
+ sctx._fontVariations = this._fontVariations;
914
+ sctx._textRendering = this._textRendering;
915
+ sctx.textAlign = this.textAlign;
916
+ sctx.textBaseline = this.textBaseline;
917
+ return sctx;
918
+ }
919
+
920
+ /**
921
+ * Composite finished shadow coverage: the surface is the mask,
922
+ * `shadowColor` is the source.
923
+ *
924
+ * Borrowing `fillStyle` for the length of the call is what puts the
925
+ * shadow through the same route a coverage `drawImage` takes — clip,
926
+ * `globalAlpha`, composite op and damage reporting all apply to it exactly
927
+ * as they do to the drawing it belongs to.
928
+ */
929
+ _paintShadow(surface, dx, dy) {
930
+ const style = this._fillStyle;
931
+ const picture = this._backgroundPicture;
932
+ this.fillStyle = this._shadowColor;
933
+ try {
934
+ this._drawCoverage(
935
+ surface.picture(this.window.app),
936
+ 0,
937
+ 0,
938
+ surface.width,
939
+ surface.height,
940
+ dx,
941
+ dy,
942
+ surface.width,
943
+ surface.height,
944
+ this._op(),
945
+ );
946
+ } finally {
947
+ this._fillStyle = style;
948
+ this._backgroundPicture = picture;
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Paint the shadow of one drawing, given its device-space ink bounds and
954
+ * a way to draw it again into the coverage surface.
955
+ *
956
+ * `replay(sctx, dx, dy)` gets a context on that surface with this one's
957
+ * state already on it, and the device offset that maps this context's
958
+ * coordinates into it. Nothing is cached: the geometry of a path has no
959
+ * short name to key it by. Text does, and takes `_shadowOfText` instead.
960
+ */
961
+ _shadowOfDrawing(ink, replay) {
962
+ if (!ink) return;
963
+ const app = this.window.app;
964
+ const policy = shadowPolicyOf(app);
965
+ const sigma = shadowSigma(this._shadowBlur, policy);
966
+ const reach = shadowReach(sigma);
967
+ const box = this._shadowBox(ink, reach);
968
+ if (!box || box.w * box.h > policy.maxPixels) return;
969
+
970
+ let surface = new Surface(app, {
971
+ width: box.w,
972
+ height: box.h,
973
+ format: "a8",
974
+ });
975
+ surface.render((sctx) => {
976
+ this._loadShadowState(sctx, -box.x, -box.y);
977
+ replay(sctx, -box.x, -box.y);
978
+ });
979
+ if (sigma > 0) surface = blurCoverage(surface, sigma);
980
+ this._paintShadow(
981
+ surface,
982
+ box.x + Math.round(this._shadowOffsetX),
983
+ box.y + Math.round(this._shadowOffsetY),
984
+ );
985
+ surface.destroy();
986
+ }
987
+
988
+ /** the shadow of a path fill or stroke, from its device-space polys */
989
+ _shadowOfPolys(polys, { rule = "nonzero", stroke = false } = {}) {
990
+ const ink = polysInk(polys);
991
+ if (!ink) return;
992
+ if (stroke) {
993
+ // the stroke's ink is the extruded outline, which is not built yet —
994
+ // over-estimate it. Half the width covers the band, the miter limit
995
+ // covers the spike a sharp corner can throw, and the cost of guessing
996
+ // high is blur over a few empty pixels.
997
+ const det = this._m[0] * this._m[3] - this._m[1] * this._m[2];
998
+ const scale = Math.sqrt(Math.abs(det)) || 1;
999
+ const half = (this.lineWidth * scale) / 2;
1000
+ const spike =
1001
+ this.lineJoin === "miter" ? Math.min(Math.max(this.miterLimit, 1), 10) : 1.5;
1002
+ const slack = half * spike + 1;
1003
+ ink.minX -= slack;
1004
+ ink.minY -= slack;
1005
+ ink.maxX += slack;
1006
+ ink.maxY += slack;
1007
+ }
1008
+ this._shadowOfDrawing(ink, (sctx, dx, dy) => {
1009
+ const moved = shiftPolys(polys, dx, dy);
1010
+ if (stroke) sctx._strokePolys(moved);
1011
+ else sctx._fillPolys(moved, rule);
1012
+ });
1013
+ }
1014
+
1015
+ /**
1016
+ * The shadow of a run of text, cached.
1017
+ *
1018
+ * Text is the one drawing with a short, stable name — the string, the
1019
+ * font and the blur — so its coverage is built once and composited on
1020
+ * every frame afterwards, which is the difference between a specimen that
1021
+ * rebuilds two surfaces and a blur per slider tick and one that does not.
1022
+ *
1023
+ * The cached copy is position-independent: the run's origin sits at a
1024
+ * whole pixel inside the surface, and the composite carries it to wherever
1025
+ * the text is. Glyph origins are rounded to whole pixels on the way to the
1026
+ * server anyway, so nothing is lost by it.
1027
+ */
1028
+ _shadowOfText(text, x, y) {
1029
+ const app = this.window.app;
1030
+ const policy = shadowPolicyOf(app);
1031
+ const sigma = shadowSigma(this._shadowBlur, policy);
1032
+ const reach = shadowReach(sigma);
1033
+ const style = this._resolvedTextStyle();
1034
+ // the same shaping memo fillText draws from, so a shadowed label shapes
1035
+ // once per frame rather than twice
1036
+ const shaped = app.fonts._shapeCachedWhole(text, style);
1037
+ const ink = this._shapedInk(shaped);
1038
+ // rounded outwards, with a pixel of antialiasing slack
1039
+ const left = Math.ceil(-ink.minX) + 1;
1040
+ const right = Math.ceil(ink.maxX) + 1;
1041
+ const ascent = Math.ceil(-ink.minY) + 1;
1042
+ const descent = Math.ceil(ink.maxY) + 1;
1043
+ const width = left + right + reach * 2;
1044
+ const height = ascent + descent + reach * 2;
1045
+ if (width <= 0 || height <= 0) return;
1046
+ // the run's origin inside the surface — whole pixels, so the coverage
1047
+ // is the same wherever on the target the text is drawn
1048
+ const originX = reach + left;
1049
+ const originY = reach + ascent;
1050
+
1051
+ // where the run's origin lands on the target, exactly as fillText puts it
1052
+ const [tx, ty] = matApply(this._m, x, y);
1053
+ const runX = tx + this._alignOffset(shaped);
1054
+ const runY = ty + this._baselineOffset(style.font.metrics(style.size));
1055
+
1056
+ // A run whose padded ink is larger than a shadow surface may be does not
1057
+ // get one: fall back to the clipped, uncached path, which sizes itself
1058
+ // to the part of the shadow that can actually be seen.
1059
+ if (width * height > policy.maxPixels) {
1060
+ this._shadowOfDrawing(
1061
+ {
1062
+ minX: runX + ink.minX,
1063
+ maxX: runX + ink.maxX,
1064
+ minY: runY + ink.minY,
1065
+ maxY: runY + ink.maxY,
1066
+ },
1067
+ (sctx) => sctx.fillText(text, x, y),
1068
+ );
1069
+ return;
1070
+ }
1071
+
1072
+ const key = [
1073
+ text,
1074
+ this._lastFontString,
1075
+ style.font.key,
1076
+ variationsKey(this._fontVariations),
1077
+ this._textRendering ?? "",
1078
+ sigma,
1079
+ ].join("\u0000");
1080
+ const surface = cachedShadow(app, key, () => {
1081
+ let coverage = new Surface(app, { width, height, format: "a8" });
1082
+ coverage.render((sctx) => {
1083
+ this._loadShadowState(sctx, 0, 0);
1084
+ // the origin is placed by hand, so neither alignment nor the
1085
+ // baseline may move it again
1086
+ sctx._m = [1, 0, 0, 1, 0, 0];
1087
+ sctx.textAlign = "left";
1088
+ sctx.textBaseline = "alphabetic";
1089
+ sctx.fillText(text, originX, originY);
1090
+ });
1091
+ if (sigma > 0) coverage = blurCoverage(coverage, sigma);
1092
+ return coverage;
1093
+ });
1094
+ if (!surface) return;
1095
+ this._paintShadow(
1096
+ surface,
1097
+ Math.round(runX + this._shadowOffsetX) - originX,
1098
+ Math.round(runY + this._shadowOffsetY) - originY,
1099
+ );
1100
+ }
1101
+
1102
+ /** the shadow of a `fill()`/`stroke()`-shaped call, from its arguments */
1103
+ _shadowOfPath(args, stroke) {
1104
+ if (stroke) {
1105
+ const polys =
1106
+ args[0] instanceof Path2D
1107
+ ? flattenPath(args[0]._cmds, this._m)
1108
+ : flattenPath(this._path._cmds, null);
1109
+ this._shadowOfPolys(polys, { stroke: true });
1110
+ return;
1111
+ }
1112
+ const { polys, rule } = this._polysFor(args);
1113
+ this._shadowOfPolys(polys, { rule });
1114
+ }
1115
+
1116
+ /**
1117
+ * The shadow of a `drawImage`, from the destination rectangle its
1118
+ * arguments describe.
1119
+ *
1120
+ * The image is drawn again into the coverage surface rather than its alpha
1121
+ * being read out: an `a8` destination *is* the alpha channel, so an
1122
+ * ordinary composite of the image onto one leaves exactly the coverage the
1123
+ * shadow needs — including a translucent image's soft edges, and whatever
1124
+ * scaling or transform the call asked for.
1125
+ */
1126
+ _shadowOfImage(image, args) {
1127
+ const iw = image?.width;
1128
+ const ih = image?.height;
1129
+ if (!Number.isFinite(iw) || !Number.isFinite(ih)) return;
1130
+ let rect;
1131
+ if (args.length >= 8) rect = args.slice(4, 8);
1132
+ else if (args.length >= 4) rect = args.slice(0, 4);
1133
+ else rect = [args[0] ?? 0, args[1] ?? 0, iw, ih];
1134
+ const [dx, dy, dw, dh] = rect;
1135
+ if (!(dw > 0) || !(dh > 0)) return;
1136
+ const corners = [
1137
+ matApply(this._m, dx, dy),
1138
+ matApply(this._m, dx + dw, dy),
1139
+ matApply(this._m, dx, dy + dh),
1140
+ matApply(this._m, dx + dw, dy + dh),
1141
+ ];
1142
+ const xs = corners.map((c) => c[0]);
1143
+ const ys = corners.map((c) => c[1]);
1144
+ this._shadowOfDrawing(
1145
+ {
1146
+ minX: Math.min(...xs),
1147
+ maxX: Math.max(...xs),
1148
+ minY: Math.min(...ys),
1149
+ maxY: Math.max(...ys),
1150
+ },
1151
+ (sctx) => sctx.drawImage(image, ...args),
1152
+ );
1153
+ }
1154
+
1155
+ /** the shadow of an axis-aligned rectangle in user space */
1156
+ _shadowOfRect(x, y, w, h, stroke) {
1157
+ const tmp = new Path2D();
1158
+ tmp.rect(x, y, w, h);
1159
+ this._shadowOfPolys(flattenPath(tmp._cmds, this._m), { stroke });
1160
+ }
1161
+
694
1162
  // ------------------------------------------------------------------
695
1163
  // transform
696
1164
 
@@ -1135,7 +1603,7 @@ class RenderingContext2d {
1135
1603
  op = op ?? this._op();
1136
1604
  alpha = alpha ?? this.globalAlpha;
1137
1605
  if (alpha <= 0) return;
1138
- if (!preparePattern(src, this._m)) return;
1606
+ if (!prepareStyle(src, this._m)) return;
1139
1607
 
1140
1608
  // one box per subpath, so a path holding disjoint ones can be masked as
1141
1609
  // the pieces it is rather than as the box around all of them
@@ -1178,6 +1646,7 @@ class RenderingContext2d {
1178
1646
  _strokePolys(polys, { src = null } = {}) {
1179
1647
  src = src ?? this._strokePicture;
1180
1648
  if (this.globalAlpha <= 0) return;
1649
+ if (!prepareStyle(src, this._m)) return;
1181
1650
  // approximate transform-aware line width by the average scale factor
1182
1651
  const det = this._m[0] * this._m[3] - this._m[1] * this._m[2];
1183
1652
  const scale = Math.sqrt(Math.abs(det)) || 1;
@@ -1684,7 +2153,7 @@ class RenderingContext2d {
1684
2153
  drawGlyphs(op, src, positioned) {
1685
2154
  const app = this.window.app;
1686
2155
  const R = this.Render;
1687
- if (!preparePattern(src, this._m)) return;
2156
+ if (!prepareStyle(src, this._m)) return;
1688
2157
  if (!this._clips.length) {
1689
2158
  drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
1690
2159
  this._markDirty();
@@ -1905,8 +2374,15 @@ class RenderingContext2d {
1905
2374
  }
1906
2375
 
1907
2376
  fillRect(x, y, w, h) {
2377
+ if (this._shadowed()) this._shadowOfRect(x, y, w, h, false);
2378
+ this._fillRect(x, y, w, h);
2379
+ }
2380
+
2381
+ /** fillRect minus the shadow — the batch in `fillRects` paints one shadow
2382
+ * for the whole list and then draws the rectangles through here */
2383
+ _fillRect(x, y, w, h) {
1908
2384
  if (matIsIdentity(this._m)) {
1909
- if (!preparePattern(this._backgroundPicture, this._m)) return;
2385
+ if (!prepareStyle(this._backgroundPicture, this._m)) return;
1910
2386
  const mask = this._compositeMask();
1911
2387
  this.Render.Composite(
1912
2388
  this._op(),
@@ -1965,6 +2441,17 @@ class RenderingContext2d {
1965
2441
  }
1966
2442
  if (!flat.length) return;
1967
2443
 
2444
+ // one shadow for the batch, not one per rectangle: the shadow of a
2445
+ // group of rectangles is their combined coverage blurred once, and N
2446
+ // separate shadows would darken every overlap
2447
+ if (this._shadowed()) {
2448
+ const rectPath = new Path2D();
2449
+ for (let i = 0; i < flat.length; i += 4) {
2450
+ rectPath.rect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
2451
+ }
2452
+ this._shadowOfPolys(flattenPath(rectPath._cmds, this._m), {});
2453
+ }
2454
+
1968
2455
  const clip = this._clips.length ? this._clipRect() : null;
1969
2456
  if (
1970
2457
  !matIsIdentity(this._m) ||
@@ -1972,7 +2459,7 @@ class RenderingContext2d {
1972
2459
  (this._clips.length && !clip)
1973
2460
  ) {
1974
2461
  for (let i = 0; i < flat.length; i += 4)
1975
- this.fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
2462
+ this._fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
1976
2463
  return;
1977
2464
  }
1978
2465
  if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
@@ -2005,6 +2492,7 @@ class RenderingContext2d {
2005
2492
  }
2006
2493
 
2007
2494
  strokeRect(x, y, w, h) {
2495
+ if (this._shadowed()) this._shadowOfRect(x, y, w, h, true);
2008
2496
  const m = this._m;
2009
2497
  if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
2010
2498
  const zero = { x: 0, y: 0 };
@@ -2025,12 +2513,14 @@ class RenderingContext2d {
2025
2513
  }
2026
2514
 
2027
2515
  fill(...args) {
2516
+ if (this._shadowed()) this._shadowOfPath(args, false);
2028
2517
  if (this._tryRoundRectFill(args)) return;
2029
2518
  const { polys, rule } = this._polysFor(args);
2030
2519
  this._fillPolys(polys, rule);
2031
2520
  }
2032
2521
 
2033
2522
  stroke(...args) {
2523
+ if (this._shadowed()) this._shadowOfPath(args, true);
2034
2524
  const box = this._shapeBoxFor(args);
2035
2525
  if (box && this._tryStrokeBox(box)) return;
2036
2526
  const polys =
@@ -2803,6 +3293,7 @@ class RenderingContext2d {
2803
3293
  fillText(text, x, y) {
2804
3294
  text = String(text ?? "");
2805
3295
  if (!text) return;
3296
+ if (this._shadowed()) this._shadowOfText(text, x, y);
2806
3297
  const style = this._resolvedTextStyle();
2807
3298
  const app = this.window.app;
2808
3299
  // through the shaping memo TextLayout uses: a label repainted every
@@ -2835,9 +3326,13 @@ class RenderingContext2d {
2835
3326
  * `width` (advance), actual bounding box (ink extents relative to the
2836
3327
  * origin), and font bounding box (from font metrics).
2837
3328
  */
2838
- measureText(text) {
2839
- const style = this._resolvedTextStyle();
2840
- const shaped = this.window.app.fonts.shape(String(text ?? ""), style);
3329
+ /**
3330
+ * Ink extents of a shaped run, relative to its origin: the loop behind
3331
+ * `measureText`'s actual bounding box, shared with the shadow path so a
3332
+ * shadowed `fillText` does not shape its text a second time to find out
3333
+ * how big its coverage surface has to be.
3334
+ */
3335
+ _shapedInk(shaped) {
2841
3336
  let minX = 0;
2842
3337
  let maxX = 0;
2843
3338
  let minY = 0;
@@ -2855,6 +3350,13 @@ class RenderingContext2d {
2855
3350
  cursor += g.ax;
2856
3351
  }
2857
3352
  }
3353
+ return { minX, maxX, minY, maxY };
3354
+ }
3355
+
3356
+ measureText(text) {
3357
+ const style = this._resolvedTextStyle();
3358
+ const shaped = this.window.app.fonts.shape(String(text ?? ""), style);
3359
+ const { minX, maxX, minY, maxY } = this._shapedInk(shaped);
2858
3360
  const m = style.font.metrics(style.size);
2859
3361
  return {
2860
3362
  width: shaped.width,
@@ -3239,11 +3741,7 @@ class RenderingContext2d {
3239
3741
  _coverageSource() {
3240
3742
  const style = this._fillStyle;
3241
3743
  if (this.globalAlpha >= 1 || !isPlainColor(style)) {
3242
- // a pattern whose transform collapsed paints nothing, and "nothing" as
3243
- // a source is transparent black — no call site here has to know
3244
- return preparePattern(this._backgroundPicture, this._m)
3245
- ? this._backgroundPicture
3246
- : this.createSolidPicture(0, 0, 0, 0);
3744
+ return this._backgroundPicture;
3247
3745
  }
3248
3746
  const c = parseColor(style);
3249
3747
  return this.createSolidPicture(c[0], c[1], c[2], c[3] * this.globalAlpha);
@@ -3260,6 +3758,9 @@ class RenderingContext2d {
3260
3758
  */
3261
3759
  _drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
3262
3760
  const R = this.Render;
3761
+ // the coverage is painted in the current fillStyle, on both branches
3762
+ // below — a gradient/pattern whose transform collapsed paints nothing
3763
+ if (!prepareStyle(this._backgroundPicture, this._m)) return;
3263
3764
  const scaled = dw !== sw || dh !== sh;
3264
3765
  if (scaled) {
3265
3766
  R.SetPictureTransform(picture.id, [
@@ -3419,6 +3920,7 @@ class RenderingContext2d {
3419
3920
  }
3420
3921
 
3421
3922
  drawImage(image, ...args) {
3923
+ if (this._shadowed()) this._shadowOfImage(image, args);
3422
3924
  if (isPictureSource(image)) {
3423
3925
  let sx = 0;
3424
3926
  let sy = 0;
@@ -3665,6 +4167,21 @@ class RenderingContext2d {
3665
4167
  }
3666
4168
  }
3667
4169
 
4170
+ /**
4171
+ * The fill/stroke style the `create*Gradient` methods return: colour stops
4172
+ * along a line, between two circles, or around a point, backed by one
4173
+ * XRender gradient picture.
4174
+ *
4175
+ * Its coordinates are **user space**, like every other coordinate a caller
4176
+ * gives the context, and are resolved against the transform in force when
4177
+ * the gradient is *painted* — the picture transform is that CTM's inverse,
4178
+ * installed by `_sync` before each use, exactly as a pattern's is. A
4179
+ * gradient made for a node's own coordinates therefore keeps painting in
4180
+ * them after the context is translated to that node's origin (issue #271).
4181
+ *
4182
+ * The picture is created on first use and freed by the GC, through
4183
+ * `Picture`'s finalizer.
4184
+ */
3668
4185
  class CanvasGradient {
3669
4186
  constructor(type, ctx, p0, p1, p2, p3, p4, p5) {
3670
4187
  this.type = type;
@@ -3672,6 +4189,9 @@ class CanvasGradient {
3672
4189
  this.ctx = ctx;
3673
4190
  this._id = null;
3674
4191
  this._picture = null;
4192
+ // what the server currently holds: a fresh gradient picture is
4193
+ // untransformed, so an untransformed fill costs no extra request
4194
+ this._applied = [1, 0, 0, 1, 0, 0];
3675
4195
 
3676
4196
  this.x0 = p0;
3677
4197
  this.y0 = p1;
@@ -3683,7 +4203,6 @@ class CanvasGradient {
3683
4203
  } else {
3684
4204
  this.angle = p2;
3685
4205
  }
3686
- // TODO: check if ChangePictureAttribute works on gradients ( set repeat edge pixels flag )
3687
4206
  }
3688
4207
 
3689
4208
  addColorStop(offset, color) {
@@ -3729,10 +4248,55 @@ class CanvasGradient {
3729
4248
  default:
3730
4249
  throw new Error("unknown gradient type");
3731
4250
  }
4251
+ // Past the outermost stop a gradient clamps to that stop's colour, as
4252
+ // the canvas and CSS specs say — which is XRender's RepeatPad, not the
4253
+ // RepeatNone (transparent) a gradient picture is born with. Without it
4254
+ // an app has to place its gradient exactly on the fill or lose the
4255
+ // corners. The in-process JS server pads unconditionally, so only a real
4256
+ // server can tell the difference.
4257
+ Render.ChangePicture(this._id, { repeat: 2 }); // Repeat.Pad
3732
4258
  // wrap with picture so FreePicture is invoked on gc via FinalizationRegistry
3733
4259
  this._picture = new Picture(this.ctx.window.app, { id: this._id });
3734
4260
  return this._id;
3735
4261
  }
4262
+
4263
+ /**
4264
+ * Make the server-side mapping match the CTM this paint runs under. The
4265
+ * gradient's own coordinates are user space and every fill samples the
4266
+ * source at device coordinates, so the picture transform — which takes a
4267
+ * source coordinate to a gradient one — is the CTM's inverse.
4268
+ *
4269
+ * Returns false when the CTM collapses (a zero scale), which paints
4270
+ * nothing, exactly as the canvas spec says.
4271
+ */
4272
+ _sync(ctm) {
4273
+ const inv = matInvert(ctm);
4274
+ if (!inv) return false;
4275
+ const id = this.id; // lazily creates the picture
4276
+ const a = this._applied;
4277
+ if (
4278
+ inv[0] !== a[0] ||
4279
+ inv[1] !== a[1] ||
4280
+ inv[2] !== a[2] ||
4281
+ inv[3] !== a[3] ||
4282
+ inv[4] !== a[4] ||
4283
+ inv[5] !== a[5]
4284
+ ) {
4285
+ this.ctx.Render.SetPictureTransform(id, [
4286
+ inv[0],
4287
+ inv[2],
4288
+ inv[4],
4289
+ inv[1],
4290
+ inv[3],
4291
+ inv[5],
4292
+ 0,
4293
+ 0,
4294
+ 1,
4295
+ ]);
4296
+ this._applied = inv;
4297
+ }
4298
+ return true;
4299
+ }
3736
4300
  }
3737
4301
 
3738
4302
  /**