ntk 7.7.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.
- package/README.md +9 -10
- package/lib/app.js +4 -0
- package/lib/fontconfig.js +185 -61
- package/lib/index.js +2 -29
- package/lib/renderingcontext_2d.js +583 -19
- package/lib/shadow.js +199 -0
- package/lib/text/fontsource.js +47 -3
- package/lib/text/layout.js +4 -4
- package/lib/widgets/svgview.js +14 -16
- package/package.json +4 -9
- package/lib/widgets/css.js +0 -673
- package/lib/widgets/highlight.js +0 -97
- package/lib/widgets/htmlview.js +0 -893
- package/lib/widgets/markdown.js +0 -148
- package/lib/widgets/markdownview.js +0 -492
- package/lib/widgets/tex.js +0 -679
- package/lib/yoga.js +0 -119
|
@@ -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
|
|
202
|
-
*
|
|
203
|
-
* mapping
|
|
204
|
-
*
|
|
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
|
|
207
|
-
return src instanceof CanvasPattern
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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.
|
|
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
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
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
|
-
|
|
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
|
/**
|