ntk 8.0.0 → 8.1.1

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;
@@ -1662,7 +2131,9 @@ class RenderingContext2d {
1662
2131
  * @param {Picture} src source picture the glyphs paint with — a solid
1663
2132
  * (`ctx.createSolidPicture(r, g, b, a)`, premultiplied 0..1) or a gradient
1664
2133
  * @param {Array<{run, x, y, textRendering?}>} positioned runs in visual
1665
- * order; `x`/`y` is the run's baseline origin in device space. `run` is
2134
+ * order; `x`/`y` is the run's baseline origin in **user space** — the
2135
+ * current transform applies to it, as it does to every other drawing
2136
+ * call. `run` is
1666
2137
  * `{ font, size, glyphs }` — a `Font`, a pixel size, and glyphs
1667
2138
  * `{ id, ax, dx, dy }` in drawing order: `id` a font glyph id
1668
2139
  * (`Font.shape()`'s `glyphs[].id`, or `Font.glyphIdFor(cp)`), `ax` the
@@ -1673,6 +2144,32 @@ class RenderingContext2d {
1673
2144
  * (`codePoints`, `width`, …) are ignored. `textRendering` optionally
1674
2145
  * overrides the bitmap/vector routing per run (docs/text.md).
1675
2146
  *
2147
+ * The transform moves each run's origin, exactly as `fillText` moves its
2148
+ * anchor; the glyphs themselves are not rotated or scaled by it (size the
2149
+ * font via `ctx.font`, or `run.size`, instead). Advances and `dx`/`dy` are
2150
+ * therefore device pixels on both calls. Without this, a `TextLayout`
2151
+ * drawn into a translated context — which is every react-x11 canvas that
2152
+ * is not at the window's origin — landed at the untransformed coordinates
2153
+ * and was then cut by the clip, while the neighbouring `fillRect` and
2154
+ * `drawImage` moved (issue #280).
2155
+ */
2156
+ drawGlyphs(op, src, positioned) {
2157
+ const m = this._m;
2158
+ if (!matIsIdentity(m)) {
2159
+ positioned = positioned.map((p) => {
2160
+ const [x, y] = matApply(m, p.x, p.y);
2161
+ return { ...p, x, y };
2162
+ });
2163
+ }
2164
+ this._drawGlyphsDevice(op, src, positioned);
2165
+ }
2166
+
2167
+ /**
2168
+ * `drawGlyphs` with the origins already in device space — the primitive
2169
+ * under it, for callers that place glyphs themselves (`fillText`, which
2170
+ * has to add the alignment and baseline offsets *after* the transform,
2171
+ * because glyph advances are device pixels).
2172
+ *
1676
2173
  * CompositeGlyphs writes straight to the destination picture, so it has
1677
2174
  * no way to consult our clip mask — text drawn through it used to spill
1678
2175
  * out of clipped boxes while every fill and stroke stayed inside. With a
@@ -1681,10 +2178,10 @@ class RenderingContext2d {
1681
2178
  * through the result — the same shape as _fillPolys. A rectangular clip
1682
2179
  * takes the server-side fast path below instead of the mask.
1683
2180
  */
1684
- drawGlyphs(op, src, positioned) {
2181
+ _drawGlyphsDevice(op, src, positioned) {
1685
2182
  const app = this.window.app;
1686
2183
  const R = this.Render;
1687
- if (!preparePattern(src, this._m)) return;
2184
+ if (!prepareStyle(src, this._m)) return;
1688
2185
  if (!this._clips.length) {
1689
2186
  drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
1690
2187
  this._markDirty();
@@ -1905,8 +2402,15 @@ class RenderingContext2d {
1905
2402
  }
1906
2403
 
1907
2404
  fillRect(x, y, w, h) {
2405
+ if (this._shadowed()) this._shadowOfRect(x, y, w, h, false);
2406
+ this._fillRect(x, y, w, h);
2407
+ }
2408
+
2409
+ /** fillRect minus the shadow — the batch in `fillRects` paints one shadow
2410
+ * for the whole list and then draws the rectangles through here */
2411
+ _fillRect(x, y, w, h) {
1908
2412
  if (matIsIdentity(this._m)) {
1909
- if (!preparePattern(this._backgroundPicture, this._m)) return;
2413
+ if (!prepareStyle(this._backgroundPicture, this._m)) return;
1910
2414
  const mask = this._compositeMask();
1911
2415
  this.Render.Composite(
1912
2416
  this._op(),
@@ -1965,6 +2469,17 @@ class RenderingContext2d {
1965
2469
  }
1966
2470
  if (!flat.length) return;
1967
2471
 
2472
+ // one shadow for the batch, not one per rectangle: the shadow of a
2473
+ // group of rectangles is their combined coverage blurred once, and N
2474
+ // separate shadows would darken every overlap
2475
+ if (this._shadowed()) {
2476
+ const rectPath = new Path2D();
2477
+ for (let i = 0; i < flat.length; i += 4) {
2478
+ rectPath.rect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
2479
+ }
2480
+ this._shadowOfPolys(flattenPath(rectPath._cmds, this._m), {});
2481
+ }
2482
+
1968
2483
  const clip = this._clips.length ? this._clipRect() : null;
1969
2484
  if (
1970
2485
  !matIsIdentity(this._m) ||
@@ -1972,7 +2487,7 @@ class RenderingContext2d {
1972
2487
  (this._clips.length && !clip)
1973
2488
  ) {
1974
2489
  for (let i = 0; i < flat.length; i += 4)
1975
- this.fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
2490
+ this._fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
1976
2491
  return;
1977
2492
  }
1978
2493
  if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
@@ -2005,6 +2520,7 @@ class RenderingContext2d {
2005
2520
  }
2006
2521
 
2007
2522
  strokeRect(x, y, w, h) {
2523
+ if (this._shadowed()) this._shadowOfRect(x, y, w, h, true);
2008
2524
  const m = this._m;
2009
2525
  if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
2010
2526
  const zero = { x: 0, y: 0 };
@@ -2025,12 +2541,14 @@ class RenderingContext2d {
2025
2541
  }
2026
2542
 
2027
2543
  fill(...args) {
2544
+ if (this._shadowed()) this._shadowOfPath(args, false);
2028
2545
  if (this._tryRoundRectFill(args)) return;
2029
2546
  const { polys, rule } = this._polysFor(args);
2030
2547
  this._fillPolys(polys, rule);
2031
2548
  }
2032
2549
 
2033
2550
  stroke(...args) {
2551
+ if (this._shadowed()) this._shadowOfPath(args, true);
2034
2552
  const box = this._shapeBoxFor(args);
2035
2553
  if (box && this._tryStrokeBox(box)) return;
2036
2554
  const polys =
@@ -2803,6 +3321,7 @@ class RenderingContext2d {
2803
3321
  fillText(text, x, y) {
2804
3322
  text = String(text ?? "");
2805
3323
  if (!text) return;
3324
+ if (this._shadowed()) this._shadowOfText(text, x, y);
2806
3325
  const style = this._resolvedTextStyle();
2807
3326
  const app = this.window.app;
2808
3327
  // through the shaping memo TextLayout uses: a label repainted every
@@ -2823,7 +3342,9 @@ class RenderingContext2d {
2823
3342
  });
2824
3343
  cursor += run.width;
2825
3344
  }
2826
- this.drawGlyphs(
3345
+ // already device space: the anchor went through the matrix above, and
3346
+ // the offsets and advances added to it are device pixels
3347
+ this._drawGlyphsDevice(
2827
3348
  this.Render.PictOp.Over,
2828
3349
  this._backgroundPicture,
2829
3350
  positioned,
@@ -2835,9 +3356,13 @@ class RenderingContext2d {
2835
3356
  * `width` (advance), actual bounding box (ink extents relative to the
2836
3357
  * origin), and font bounding box (from font metrics).
2837
3358
  */
2838
- measureText(text) {
2839
- const style = this._resolvedTextStyle();
2840
- const shaped = this.window.app.fonts.shape(String(text ?? ""), style);
3359
+ /**
3360
+ * Ink extents of a shaped run, relative to its origin: the loop behind
3361
+ * `measureText`'s actual bounding box, shared with the shadow path so a
3362
+ * shadowed `fillText` does not shape its text a second time to find out
3363
+ * how big its coverage surface has to be.
3364
+ */
3365
+ _shapedInk(shaped) {
2841
3366
  let minX = 0;
2842
3367
  let maxX = 0;
2843
3368
  let minY = 0;
@@ -2855,6 +3380,13 @@ class RenderingContext2d {
2855
3380
  cursor += g.ax;
2856
3381
  }
2857
3382
  }
3383
+ return { minX, maxX, minY, maxY };
3384
+ }
3385
+
3386
+ measureText(text) {
3387
+ const style = this._resolvedTextStyle();
3388
+ const shaped = this.window.app.fonts.shape(String(text ?? ""), style);
3389
+ const { minX, maxX, minY, maxY } = this._shapedInk(shaped);
2858
3390
  const m = style.font.metrics(style.size);
2859
3391
  return {
2860
3392
  width: shaped.width,
@@ -3239,11 +3771,7 @@ class RenderingContext2d {
3239
3771
  _coverageSource() {
3240
3772
  const style = this._fillStyle;
3241
3773
  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);
3774
+ return this._backgroundPicture;
3247
3775
  }
3248
3776
  const c = parseColor(style);
3249
3777
  return this.createSolidPicture(c[0], c[1], c[2], c[3] * this.globalAlpha);
@@ -3260,6 +3788,9 @@ class RenderingContext2d {
3260
3788
  */
3261
3789
  _drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
3262
3790
  const R = this.Render;
3791
+ // the coverage is painted in the current fillStyle, on both branches
3792
+ // below — a gradient/pattern whose transform collapsed paints nothing
3793
+ if (!prepareStyle(this._backgroundPicture, this._m)) return;
3263
3794
  const scaled = dw !== sw || dh !== sh;
3264
3795
  if (scaled) {
3265
3796
  R.SetPictureTransform(picture.id, [
@@ -3419,6 +3950,7 @@ class RenderingContext2d {
3419
3950
  }
3420
3951
 
3421
3952
  drawImage(image, ...args) {
3953
+ if (this._shadowed()) this._shadowOfImage(image, args);
3422
3954
  if (isPictureSource(image)) {
3423
3955
  let sx = 0;
3424
3956
  let sy = 0;
@@ -3665,6 +4197,21 @@ class RenderingContext2d {
3665
4197
  }
3666
4198
  }
3667
4199
 
4200
+ /**
4201
+ * The fill/stroke style the `create*Gradient` methods return: colour stops
4202
+ * along a line, between two circles, or around a point, backed by one
4203
+ * XRender gradient picture.
4204
+ *
4205
+ * Its coordinates are **user space**, like every other coordinate a caller
4206
+ * gives the context, and are resolved against the transform in force when
4207
+ * the gradient is *painted* — the picture transform is that CTM's inverse,
4208
+ * installed by `_sync` before each use, exactly as a pattern's is. A
4209
+ * gradient made for a node's own coordinates therefore keeps painting in
4210
+ * them after the context is translated to that node's origin (issue #271).
4211
+ *
4212
+ * The picture is created on first use and freed by the GC, through
4213
+ * `Picture`'s finalizer.
4214
+ */
3668
4215
  class CanvasGradient {
3669
4216
  constructor(type, ctx, p0, p1, p2, p3, p4, p5) {
3670
4217
  this.type = type;
@@ -3672,6 +4219,9 @@ class CanvasGradient {
3672
4219
  this.ctx = ctx;
3673
4220
  this._id = null;
3674
4221
  this._picture = null;
4222
+ // what the server currently holds: a fresh gradient picture is
4223
+ // untransformed, so an untransformed fill costs no extra request
4224
+ this._applied = [1, 0, 0, 1, 0, 0];
3675
4225
 
3676
4226
  this.x0 = p0;
3677
4227
  this.y0 = p1;
@@ -3683,7 +4233,6 @@ class CanvasGradient {
3683
4233
  } else {
3684
4234
  this.angle = p2;
3685
4235
  }
3686
- // TODO: check if ChangePictureAttribute works on gradients ( set repeat edge pixels flag )
3687
4236
  }
3688
4237
 
3689
4238
  addColorStop(offset, color) {
@@ -3729,10 +4278,55 @@ class CanvasGradient {
3729
4278
  default:
3730
4279
  throw new Error("unknown gradient type");
3731
4280
  }
4281
+ // Past the outermost stop a gradient clamps to that stop's colour, as
4282
+ // the canvas and CSS specs say — which is XRender's RepeatPad, not the
4283
+ // RepeatNone (transparent) a gradient picture is born with. Without it
4284
+ // an app has to place its gradient exactly on the fill or lose the
4285
+ // corners. The in-process JS server pads unconditionally, so only a real
4286
+ // server can tell the difference.
4287
+ Render.ChangePicture(this._id, { repeat: 2 }); // Repeat.Pad
3732
4288
  // wrap with picture so FreePicture is invoked on gc via FinalizationRegistry
3733
4289
  this._picture = new Picture(this.ctx.window.app, { id: this._id });
3734
4290
  return this._id;
3735
4291
  }
4292
+
4293
+ /**
4294
+ * Make the server-side mapping match the CTM this paint runs under. The
4295
+ * gradient's own coordinates are user space and every fill samples the
4296
+ * source at device coordinates, so the picture transform — which takes a
4297
+ * source coordinate to a gradient one — is the CTM's inverse.
4298
+ *
4299
+ * Returns false when the CTM collapses (a zero scale), which paints
4300
+ * nothing, exactly as the canvas spec says.
4301
+ */
4302
+ _sync(ctm) {
4303
+ const inv = matInvert(ctm);
4304
+ if (!inv) return false;
4305
+ const id = this.id; // lazily creates the picture
4306
+ const a = this._applied;
4307
+ if (
4308
+ inv[0] !== a[0] ||
4309
+ inv[1] !== a[1] ||
4310
+ inv[2] !== a[2] ||
4311
+ inv[3] !== a[3] ||
4312
+ inv[4] !== a[4] ||
4313
+ inv[5] !== a[5]
4314
+ ) {
4315
+ this.ctx.Render.SetPictureTransform(id, [
4316
+ inv[0],
4317
+ inv[2],
4318
+ inv[4],
4319
+ inv[1],
4320
+ inv[3],
4321
+ inv[5],
4322
+ 0,
4323
+ 0,
4324
+ 1,
4325
+ ]);
4326
+ this._applied = inv;
4327
+ }
4328
+ return true;
4329
+ }
3736
4330
  }
3737
4331
 
3738
4332
  /**