@react-x11/components 0.13.0 → 0.14.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.
Files changed (128) hide show
  1. package/dist/html/controls.d.ts +29 -2
  2. package/dist/html/controls.d.ts.map +1 -1
  3. package/dist/html/controls.js +70 -2
  4. package/dist/html/controls.js.map +1 -1
  5. package/dist/html/css/cascade.d.ts.map +1 -1
  6. package/dist/html/css/cascade.js +26 -4
  7. package/dist/html/css/cascade.js.map +1 -1
  8. package/dist/html/css/color.d.ts +18 -0
  9. package/dist/html/css/color.d.ts.map +1 -1
  10. package/dist/html/css/color.js +27 -0
  11. package/dist/html/css/color.js.map +1 -1
  12. package/dist/html/css/parse.d.ts +18 -4
  13. package/dist/html/css/parse.d.ts.map +1 -1
  14. package/dist/html/css/parse.js +66 -28
  15. package/dist/html/css/parse.js.map +1 -1
  16. package/dist/html/css/style.d.ts +128 -15
  17. package/dist/html/css/style.d.ts.map +1 -1
  18. package/dist/html/css/style.js +561 -144
  19. package/dist/html/css/style.js.map +1 -1
  20. package/dist/html/css/transform.d.ts +85 -0
  21. package/dist/html/css/transform.d.ts.map +1 -0
  22. package/dist/html/css/transform.js +425 -0
  23. package/dist/html/css/transform.js.map +1 -0
  24. package/dist/html/css/ua.d.ts.map +1 -1
  25. package/dist/html/css/ua.js +14 -2
  26. package/dist/html/css/ua.js.map +1 -1
  27. package/dist/html/css/values.d.ts +4 -0
  28. package/dist/html/css/values.d.ts.map +1 -1
  29. package/dist/html/css/values.js.map +1 -1
  30. package/dist/html/fonts.d.ts +55 -5
  31. package/dist/html/fonts.d.ts.map +1 -1
  32. package/dist/html/fonts.js +284 -23
  33. package/dist/html/fonts.js.map +1 -1
  34. package/dist/html/layout/axes.d.ts +14 -0
  35. package/dist/html/layout/axes.d.ts.map +1 -0
  36. package/dist/html/layout/axes.js +28 -0
  37. package/dist/html/layout/axes.js.map +1 -0
  38. package/dist/html/layout/block.d.ts +93 -12
  39. package/dist/html/layout/block.d.ts.map +1 -1
  40. package/dist/html/layout/block.js +389 -39
  41. package/dist/html/layout/block.js.map +1 -1
  42. package/dist/html/layout/boxes.d.ts +59 -1
  43. package/dist/html/layout/boxes.d.ts.map +1 -1
  44. package/dist/html/layout/boxes.js +22 -2
  45. package/dist/html/layout/boxes.js.map +1 -1
  46. package/dist/html/layout/cache.d.ts +1 -1
  47. package/dist/html/layout/cache.d.ts.map +1 -1
  48. package/dist/html/layout/cache.js +6 -0
  49. package/dist/html/layout/cache.js.map +1 -1
  50. package/dist/html/layout/css-grid.d.ts.map +1 -1
  51. package/dist/html/layout/css-grid.js +90 -18
  52. package/dist/html/layout/css-grid.js.map +1 -1
  53. package/dist/html/layout/flex.d.ts.map +1 -1
  54. package/dist/html/layout/flex.js +570 -83
  55. package/dist/html/layout/flex.js.map +1 -1
  56. package/dist/html/layout/inline.d.ts +3 -0
  57. package/dist/html/layout/inline.d.ts.map +1 -1
  58. package/dist/html/layout/inline.js +144 -54
  59. package/dist/html/layout/inline.js.map +1 -1
  60. package/dist/html/layout/multicol.d.ts +42 -0
  61. package/dist/html/layout/multicol.d.ts.map +1 -0
  62. package/dist/html/layout/multicol.js +897 -0
  63. package/dist/html/layout/multicol.js.map +1 -0
  64. package/dist/html/layout/shaping.d.ts +7 -0
  65. package/dist/html/layout/shaping.d.ts.map +1 -1
  66. package/dist/html/layout/shaping.js +25 -20
  67. package/dist/html/layout/shaping.js.map +1 -1
  68. package/dist/html/layout/table.d.ts.map +1 -1
  69. package/dist/html/layout/table.js +62 -18
  70. package/dist/html/layout/table.js.map +1 -1
  71. package/dist/html/node.d.ts +3 -0
  72. package/dist/html/node.d.ts.map +1 -1
  73. package/dist/html/node.js +263 -36
  74. package/dist/html/node.js.map +1 -1
  75. package/dist/html/paint.d.ts +79 -4
  76. package/dist/html/paint.d.ts.map +1 -1
  77. package/dist/html/paint.js +1213 -141
  78. package/dist/html/paint.js.map +1 -1
  79. package/dist/html/svg.d.ts +15 -2
  80. package/dist/html/svg.d.ts.map +1 -1
  81. package/dist/html/svg.js +249 -17
  82. package/dist/html/svg.js.map +1 -1
  83. package/dist/html/widgets.js +87 -7
  84. package/dist/html/widgets.js.map +1 -1
  85. package/dist/html/woff2.d.ts +14 -0
  86. package/dist/html/woff2.d.ts.map +1 -0
  87. package/dist/html/woff2.js +608 -0
  88. package/dist/html/woff2.js.map +1 -0
  89. package/dist/maps/controller.d.ts +17 -1
  90. package/dist/maps/controller.d.ts.map +1 -1
  91. package/dist/maps/controller.js +27 -7
  92. package/dist/maps/controller.js.map +1 -1
  93. package/dist/richtext/node.d.ts +8 -1
  94. package/dist/richtext/node.d.ts.map +1 -1
  95. package/dist/richtext/node.js +5 -1
  96. package/dist/richtext/node.js.map +1 -1
  97. package/dist/richtext/runs.d.ts +25 -6
  98. package/dist/richtext/runs.d.ts.map +1 -1
  99. package/dist/richtext/runs.js +240 -24
  100. package/dist/richtext/runs.js.map +1 -1
  101. package/package.json +1 -1
  102. package/src/html/controls.ts +85 -2
  103. package/src/html/css/cascade.ts +23 -0
  104. package/src/html/css/color.ts +29 -0
  105. package/src/html/css/parse.ts +71 -31
  106. package/src/html/css/style.ts +664 -147
  107. package/src/html/css/transform.ts +459 -0
  108. package/src/html/css/ua.ts +15 -2
  109. package/src/html/css/values.ts +4 -0
  110. package/src/html/fonts.ts +312 -29
  111. package/src/html/layout/axes.ts +62 -0
  112. package/src/html/layout/block.ts +444 -37
  113. package/src/html/layout/boxes.ts +65 -3
  114. package/src/html/layout/cache.ts +6 -0
  115. package/src/html/layout/css-grid.ts +95 -22
  116. package/src/html/layout/flex.ts +671 -77
  117. package/src/html/layout/inline.ts +149 -55
  118. package/src/html/layout/multicol.ts +977 -0
  119. package/src/html/layout/shaping.ts +27 -18
  120. package/src/html/layout/table.ts +62 -19
  121. package/src/html/node.ts +300 -37
  122. package/src/html/paint.ts +1406 -206
  123. package/src/html/svg.ts +277 -19
  124. package/src/html/widgets.ts +95 -9
  125. package/src/html/woff2.ts +612 -0
  126. package/src/maps/controller.ts +28 -7
  127. package/src/richtext/node.ts +13 -2
  128. package/src/richtext/runs.ts +313 -35
package/src/html/paint.ts CHANGED
@@ -26,16 +26,25 @@ import {
26
26
  paintRunRules,
27
27
  } from '../richtext/runs.js';
28
28
  import type { FillContext } from '../richtext/runs.js';
29
- import { alphaOf, inkColor, isTransparent, resolve } from './css/values.js';
30
- import { blend, borderShades } from './css/color.js';
29
+ import {
30
+ AUTO,
31
+ alphaOf,
32
+ inkColor,
33
+ isTransparent,
34
+ resolve,
35
+ } from './css/values.js';
36
+ import { SCHEME_COLORS, blend, borderShades } from './css/color.js';
31
37
  import type { Len } from './css/values.js';
32
38
  import type {
33
39
  BackgroundClip,
34
40
  BackgroundRepeat,
35
41
  BoxShadow,
36
42
  ComputedStyle,
43
+ Gradient,
44
+ GradientStop,
37
45
  ImageRepeat,
38
46
  LinearGradient,
47
+ RadialGradient,
39
48
  RepeatMode,
40
49
  } from './css/style.js';
41
50
  import {
@@ -46,7 +55,10 @@ import {
46
55
  masked,
47
56
  scrolls,
48
57
  } from './css/style.js';
49
- import { contained } from './layout/block.js';
58
+ import { LARGEST } from './css/calc.js';
59
+ import { invert, mapRect, transformed } from './css/transform.js';
60
+ import type { Matrix } from './css/transform.js';
61
+ import { contained, placedMatrix } from './layout/block.js';
50
62
  import {
51
63
  BOX_RAISES,
52
64
  LINE_BOX_RAISES,
@@ -55,6 +67,10 @@ import {
55
67
  CLAMPED,
56
68
  CLIPPED_CELLS,
57
69
  COLLAPSED_CELLS,
70
+ COLUMN_LINES,
71
+ COLUMN_PIECES,
72
+ COLUMN_ROWS,
73
+ columned,
58
74
  FADED_BLOCKS,
59
75
  PAINT_ORDER,
60
76
  INLINE_OFFSETS,
@@ -66,6 +82,7 @@ import {
66
82
  } from './layout/boxes.js';
67
83
  import type {
68
84
  BoxTree,
85
+ ColumnPiece,
69
86
  EdgePlacement,
70
87
  LineBox,
71
88
  LineText,
@@ -124,6 +141,24 @@ export interface PaintContext extends FillContext {
124
141
  x1: number,
125
142
  y1: number,
126
143
  ): { addColorStop(offset: number, color: string): void };
144
+ createRadialGradient?(
145
+ x0: number,
146
+ y0: number,
147
+ r0: number,
148
+ x1: number,
149
+ y1: number,
150
+ r1: number,
151
+ ): { addColorStop(offset: number, color: string): void };
152
+ /** Multiplies the current matrix, which a fill's paint is sampled
153
+ * through and a path already laid down is not. */
154
+ transform?(
155
+ a: number,
156
+ b: number,
157
+ c: number,
158
+ d: number,
159
+ e: number,
160
+ f: number,
161
+ ): void;
127
162
  clip?(): void;
128
163
  /** Canvas shadows: ntk bakes and caches the blur, CoreGraphics draws it. */
129
164
  shadowColor?: string;
@@ -134,6 +169,20 @@ export interface PaintContext extends FillContext {
134
169
  /** ntk's X11 context has patterns; the Cocoa one does not, and tiles. */
135
170
  createPattern?(image: unknown, repetition: string): unknown;
136
171
  translate?(x: number, y: number): void;
172
+ /** Multiplies the context's matrix: what a transformed box is drawn
173
+ * through (`paintTransformed`). */
174
+ transform?(
175
+ a: number,
176
+ b: number,
177
+ c: number,
178
+ d: number,
179
+ e: number,
180
+ f: number,
181
+ ): void;
182
+ /** Whether text drawn under a matrix that turns or scales is drawn
183
+ * turned and scaled, glyphs and all: the native contexts', and not
184
+ * ntk's, whose glyphs are drawn as they were shaped. */
185
+ scalesText?: boolean;
137
186
  /** What every drawing is multiplied by: an element's `opacity`. */
138
187
  globalAlpha?: number;
139
188
  /** How a drawing meets what is under it; a value a backend does not
@@ -151,8 +200,9 @@ export interface PaintOptions {
151
200
  /** Where the document's origin sits in the window. Scrolling is this. */
152
201
  originX: number;
153
202
  originY: number;
154
- /** Device pixels per logical pixel, for the run rules — a link's
155
- * underline is a logical pixel thick, not a device one. Default 1. */
203
+ /** Device pixels per logical pixel, for the run rules — the two lines
204
+ * of a double one are a logical pixel apart, not a device one; a rule's
205
+ * thickness is its style's, in device pixels already. Default 1. */
156
206
  scale?: number;
157
207
  /** The rectangle being repainted, in window coordinates, or null for all. */
158
208
  damage: Rect | null;
@@ -228,6 +278,28 @@ const LINE_INK = new WeakMap<
228
278
  { above: number; below: number }
229
279
  >();
230
280
 
281
+ /**
282
+ * For a box painted through a matrix (`placedMatrix`), the bounds of what
283
+ * it and its descendants draw before the matrix is applied: what
284
+ * `boundsX`… would have been, which are where it puts them. The paint
285
+ * inside the box, and a point taken back into it, are in these.
286
+ */
287
+ const UNTRANSFORMED = new WeakMap<Box, Rect>();
288
+
289
+ /** The bounds of what a box painted through a matrix draws, in its own
290
+ * coordinates: asked of a box `placedMatrix` answers for, whose entry the
291
+ * last `computePaintBounds` wrote. */
292
+ export function ownBounds(box: Box): Rect {
293
+ return (
294
+ UNTRANSFORMED.get(box) ?? {
295
+ x: box.boundsX,
296
+ y: box.boundsY,
297
+ width: box.boundsWidth,
298
+ height: box.boundsHeight,
299
+ }
300
+ );
301
+ }
302
+
231
303
  export function computePaintBounds(box: Box, moved = false): number {
232
304
  // A box with no rectangle of its own gives only what it holds: nothing,
233
305
  // until a child with bounds is met.
@@ -398,6 +470,37 @@ export function computePaintBounds(box: Box, moved = false): number {
398
470
  }
399
471
  if (lines && (above > 0 || below > 0)) LINE_INK.set(lines, { above, below });
400
472
  else if (lines) LINE_INK.delete(lines);
473
+ // a `clip-path` cuts the box and all it holds: no ink past the path, and
474
+ // none where it leaves nothing — though the scrollable overflow is what
475
+ // it was, as a browser has it, which is why a page that hides a box with
476
+ // one makes it a pixel square as well
477
+ if (own && box.style.clipPath) {
478
+ const { rect } = clipPathOf(box, DOCUMENT);
479
+ x1 = Math.max(x1, rect.x);
480
+ y1 = Math.max(y1, rect.y);
481
+ x2 = Math.min(x2, rect.x + rect.w);
482
+ y2 = Math.min(y2, rect.y + rect.h);
483
+ if (!(x2 > x1 && y2 > y1)) x1 = Infinity;
484
+ }
485
+ // A box that turns, scales or skews draws what it holds through its
486
+ // matrix (`paintTransformed`): its ink is where that puts it, the
487
+ // rectangle around the one it would have filled, and the one it would
488
+ // have filled is kept for the paint inside it, which is in its own
489
+ // coordinates. One flattened to nothing draws nothing.
490
+ const matrix = own ? placedMatrix(box) : null;
491
+ if (matrix && x1 !== Infinity) {
492
+ UNTRANSFORMED.set(box, { x: x1, y: y1, width: x2 - x1, height: y2 - y1 });
493
+ if (invert(matrix)) {
494
+ // no further than a length reaches (`LARGEST`): a page's
495
+ // `scale(1e30)` is a box as large as a browser holds one, and no
496
+ // larger
497
+ const to = mapRect(matrix, x1, y1, x2 - x1, y2 - y1);
498
+ x1 = Math.max(to.x, -LARGEST);
499
+ y1 = Math.max(to.y, -LARGEST);
500
+ x2 = Math.min(to.x + to.width, LARGEST);
501
+ y2 = Math.min(to.y + to.height, LARGEST);
502
+ } else x1 = Infinity;
503
+ }
401
504
  if (x1 === Infinity) {
402
505
  // nothing to draw: no damage meets it, and no parent takes it in
403
506
  box.boundsX = Infinity;
@@ -420,12 +523,20 @@ export function computePaintBounds(box: Box, moved = false): number {
420
523
  // whether the box clips only matters where its content reaches past it,
421
524
  // and its style is one more object a walk of every box would read
422
525
  const end = empty ? -Infinity : box.y + box.height;
423
- if (bottom <= end) return end;
424
- const style = box.style;
425
- return box.parent &&
426
- (style.overflowX !== 'visible' || style.overflowY !== 'visible')
427
- ? end
428
- : bottom;
526
+ let reach = end;
527
+ if (bottom > end) {
528
+ const style = box.style;
529
+ reach =
530
+ box.parent &&
531
+ (style.overflowX !== 'visible' || style.overflowY !== 'visible')
532
+ ? end
533
+ : bottom;
534
+ }
535
+ // the scrollable overflow of a transformed box is where its transform
536
+ // puts it (CSS Overflow 3, 2.2)
537
+ if (!matrix || reach === -Infinity) return reach;
538
+ const to = mapRect(matrix, box.x, box.y, box.width, reach - box.y);
539
+ return Math.min(to.y + to.height, LARGEST);
429
540
  }
430
541
 
431
542
  /**
@@ -574,6 +685,20 @@ export function paintDocument(
574
685
  if (!canFill(ctx)) return;
575
686
  ctx.save();
576
687
  const canvas = canvasBackground(tree);
688
+ const scheme = rootScheme(tree);
689
+ if (scheme !== tree.paletteScheme) {
690
+ // A document is drawn on the window's own ground, the palette's, as an
691
+ // embedded one is on a transparent canvas. Where its root's colour
692
+ // scheme is not the scheme of what it is embedded in, its canvas is
693
+ // opaque, in the `Canvas` colour of its own (CSS Color Adjust 1, 2.2):
694
+ // a page that says it is light, and sets its text dark on no
695
+ // background, is not read against a dark window.
696
+ const area = canvasArea(tree.root, options);
697
+ if (area) {
698
+ ctx.fillStyle = SCHEME_COLORS[scheme].canvas;
699
+ ctx.fillRect(area.x, area.y, area.w, area.h);
700
+ }
701
+ }
577
702
  if (canvas) paintCanvas(ctx, canvas, tree.root, options);
578
703
  paintBox(ctx, tree.root, {
579
704
  ...options,
@@ -631,6 +756,34 @@ function canvasBackground(tree: BoxTree): CanvasBackground | null {
631
756
  : null;
632
757
  }
633
758
 
759
+ /** The used colour scheme of the document's root element: the `<html>`'s,
760
+ * or that of whichever box stands in for it (`canvasBackground`). */
761
+ function rootScheme(tree: BoxTree): 'light' | 'dark' {
762
+ if (tree.impliedHtml) return tree.impliedHtml.colorScheme;
763
+ return (childNamed(tree.root, 'html') ?? tree.root).style.colorScheme;
764
+ }
765
+
766
+ /** The canvas, cut to what is being painted: the whole element where the
767
+ * host says what that is, and else the root box. */
768
+ function canvasArea(
769
+ root: Box,
770
+ options: PaintOptions,
771
+ ): { x: number; y: number; w: number; h: number } | null {
772
+ const whole = options.canvas ?? {
773
+ x: root.x + options.originX,
774
+ y: root.y + options.originY,
775
+ width: root.width,
776
+ height: root.height,
777
+ };
778
+ return clampRect(
779
+ options,
780
+ Math.round(whole.x),
781
+ Math.round(whole.y),
782
+ Math.ceil(whole.width),
783
+ Math.ceil(whole.height),
784
+ );
785
+ }
786
+
634
787
  /** A box's child element of that name, or one inside the anonymous boxes
635
788
  * it is wrapped in: a `<body>` in an `<html>` set `display: table` is in
636
789
  * an anonymous row and cell. */
@@ -651,19 +804,7 @@ function paintCanvas(
651
804
  root: Box,
652
805
  options: PaintOptions,
653
806
  ): void {
654
- const whole = options.canvas ?? {
655
- x: root.x + options.originX,
656
- y: root.y + options.originY,
657
- width: root.width,
658
- height: root.height,
659
- };
660
- const area = clampRect(
661
- options,
662
- Math.round(whole.x),
663
- Math.round(whole.y),
664
- Math.ceil(whole.width),
665
- Math.ceil(whole.height),
666
- );
807
+ const area = canvasArea(root, options);
667
808
  if (!area) return;
668
809
  const layers = layersOf(source) ?? [source];
669
810
  const visible = source.visibility === 'visible';
@@ -673,7 +814,7 @@ function paintCanvas(
673
814
  ctx.fillStyle = inkColor(style.backgroundColor as string, style.color);
674
815
  ctx.fillRect(area.x, area.y, area.w, area.h);
675
816
  }
676
- if (style.backgroundGradient && ctx.createLinearGradient && visible) {
817
+ if (style.backgroundGradient && visible) {
677
818
  // sized by the root element's box and repeated down the canvas, as a
678
819
  // browser does — the stripes a short page with a gradient on its
679
820
  // body shows — or by the viewport, where it is fixed
@@ -936,28 +1077,312 @@ function paintBox(ctx: PaintContext, box: Box, options: PaintOptions): void {
936
1077
  return;
937
1078
  }
938
1079
  // `clip` shows the part of an absolutely positioned box it names, its own
939
- // background and borders among it (CSS 2.1 11.1.2) — written out here,
940
- // not called: a frame more a level and a deep document ran out of stack
941
- const clip = box.outOfFlow && box.style.clip ? clipOf(box, options) : null;
942
- if (!clip || (clip.w > 0 && clip.h > 0)) {
943
- const clipped = !!clip && pushClip(ctx, clip, null);
944
- paintContent(ctx, box, options);
945
- if (clipped) ctx.restore();
1080
+ // background and borders among it (CSS 2.1 11.1.2), and `clip-path` the
1081
+ // part of any box (`pushOwnClips`) — the content painted here, not in a
1082
+ // call of its own: a frame more a level and a deep document ran out of
1083
+ // stack
1084
+ const pushed = pushOwnClips(ctx, box, options);
1085
+ if (pushed >= 0) {
1086
+ paintContent(ctx, box, pathClips(box) ? underPath(options) : options);
1087
+ for (let i = 0; i < pushed; i += 1) ctx.restore();
946
1088
  }
947
1089
  if (fade) ctx.restore();
948
1090
  }
949
1091
 
950
- /** A box and what it holds, cut to its `clip` as `paintBox` cuts it. */
1092
+ /** How large a surface a transformed box is drawn on: a large window's
1093
+ * worth of pixels twice over, and no side longer than a pixmap's may be. */
1094
+ const RASTER_LIMIT = 16 * 1024 * 1024;
1095
+ const RASTER_SIDE = 16384;
1096
+
1097
+ /** What XRender carries a picture's transform in is 16.16 fixed point: a
1098
+ * number past this does not fit, and is thrown out of the request's
1099
+ * encoder. */
1100
+ const FIXED_LIMIT = 32000;
1101
+
1102
+ /**
1103
+ * A box that turns, scales or skews (CSS Transforms 1): painted as it was
1104
+ * laid out, through its matrix — about its `transform-origin`, where its
1105
+ * translation has already put it (`placedMatrix`). What it holds is
1106
+ * painted with it, as the stacking context it is (`stacksLayers`), culled
1107
+ * against the damage taken back through the matrix.
1108
+ *
1109
+ * The native contexts draw everything through a matrix, a text layout's
1110
+ * outlines among it, glyphs and all (`scalesText`). ntk's draws paths
1111
+ * through one; a glyph it draws as it was shaped, upright and at its size,
1112
+ * where the matrix puts its origin, and an image through a transform the
1113
+ * server holds in fixed point, which a picture drawn small far across a
1114
+ * window does not fit. So there only a box that is paths and flat colour —
1115
+ * an icon, a chevron, a spinner — is drawn through the matrix, and any
1116
+ * other on a surface of its own, as it was laid out, with the surface
1117
+ * drawn through the matrix (`paintRaster`): a picture of its text rather
1118
+ * than the text, resampled, and right about where it is and which way up.
1119
+ *
1120
+ * A context with no `transform` draws the box where it was laid out: the
1121
+ * headless mock, which draws nothing, and a recording one.
1122
+ */
1123
+ function paintTransformed(
1124
+ ctx: PaintContext,
1125
+ box: Box,
1126
+ options: PaintOptions,
1127
+ ): void {
1128
+ const matrix = placedMatrix(box);
1129
+ if (!matrix) {
1130
+ paintBox(ctx, box, options);
1131
+ return;
1132
+ }
1133
+ if (
1134
+ !intersects(box, options) ||
1135
+ COLLAPSED_CELLS.has(box) ||
1136
+ CLAMPED.has(box)
1137
+ ) {
1138
+ return;
1139
+ }
1140
+ if (typeof ctx.transform !== 'function') {
1141
+ paintPlaced(ctx, box, options);
1142
+ return;
1143
+ }
1144
+ // In the window's coordinates, where the document's origin is the
1145
+ // options': T(origin) · matrix · T(-origin). And about an origin moved
1146
+ // with the box's corner to the pixel it is drawn from: what is painted is
1147
+ // snapped to the pixel grid a box at a time, so a box a fraction of a
1148
+ // pixel down the page is drawn from a whole one, and turned about where
1149
+ // it was laid out it came back off the grid — a square mirrored onto
1150
+ // itself showed a line of what was under it along each side. A browser
1151
+ // snaps a transformed box's corner before its transform the same way.
1152
+ const left = box.x + options.originX;
1153
+ const top = box.y + options.originY;
1154
+ const ox = options.originX + Math.round(left) - left;
1155
+ const oy = options.originY + Math.round(top) - top;
1156
+ const [a, b, c, d] = matrix;
1157
+ const through: Matrix = [
1158
+ a,
1159
+ b,
1160
+ c,
1161
+ d,
1162
+ matrix[4] + ox - (a * ox + c * oy),
1163
+ matrix[5] + oy - (b * ox + d * oy),
1164
+ ];
1165
+ const back = invert(through);
1166
+ if (!back) return;
1167
+ const damage = options.damage;
1168
+ const inside: PaintOptions = {
1169
+ ...options,
1170
+ damage:
1171
+ damage && mapRect(back, damage.x, damage.y, damage.width, damage.height),
1172
+ };
1173
+ if (
1174
+ ctx.scalesText !== true &&
1175
+ !drawnAsPaths(box) &&
1176
+ paintRaster(ctx, box, inside, through)
1177
+ ) {
1178
+ return;
1179
+ }
1180
+ ctx.save();
1181
+ ctx.transform(a, b, c, d, through[4], through[5]);
1182
+ paintPlaced(ctx, box, inside);
1183
+ ctx.restore();
1184
+ }
1185
+
1186
+ /**
1187
+ * Whether a box's style turns, scales or skews it, which is asked before
1188
+ * every box that may be one is painted (`paintPositioned`, `paintLines`) —
1189
+ * there, and not in `paintBox`, whose frame is one of three a level of
1190
+ * nesting costs the stack: a name more in it, and a document a thousand
1191
+ * boxes deep ran out.
1192
+ */
1193
+ function turns(box: Box): boolean {
1194
+ const style = box.style;
1195
+ return (
1196
+ style.transform !== null || style.rotate !== null || style.scale !== null
1197
+ );
1198
+ }
1199
+
1200
+ /**
1201
+ * `paintBox` for a box whose ink bounds are not in the coordinates it is
1202
+ * being painted in, and so is not culled by them: one painted through a
1203
+ * matrix, in its own (`paintTransformed`).
1204
+ */
1205
+ function paintPlaced(ctx: PaintContext, box: Box, options: PaintOptions): void {
1206
+ const opacity = opacityOf(box);
1207
+ if (opacity <= 0) return;
1208
+ const fade = opacity < 1 && typeof ctx.globalAlpha === 'number';
1209
+ if (fade) {
1210
+ ctx.save();
1211
+ ctx.globalAlpha = ctx.globalAlpha! * opacity;
1212
+ }
1213
+ if (!masked(box.style) || !paintMasked(ctx, box, options)) {
1214
+ paintClipped(ctx, box, options);
1215
+ }
1216
+ if (fade) ctx.restore();
1217
+ }
1218
+
1219
+ /**
1220
+ * Whether all a box and what it holds draw is paths and flat colour:
1221
+ * backgrounds, borders, outlines and inline drawings — no text and no list
1222
+ * marker, whose glyphs a context may not turn, and no image, gradient,
1223
+ * shadow or mask, which one draws through a picture's transform
1224
+ * (`paintTransformed`).
1225
+ */
1226
+ function drawnAsPaths(box: Box): boolean {
1227
+ if (box.subtreeTextEnd > box.subtreeTextStart) return false;
1228
+ const stack: Box[] = [box];
1229
+ while (stack.length) {
1230
+ const at = stack.pop()!;
1231
+ if (at.marker || at.replaced === 'image') return false;
1232
+ const style = at.style;
1233
+ if (
1234
+ style.backgroundImage !== null ||
1235
+ style.backgroundGradient !== null ||
1236
+ style.backgroundImages !== null ||
1237
+ style.boxShadow !== null ||
1238
+ style.borderImage.source !== null ||
1239
+ masked(style)
1240
+ ) {
1241
+ return false;
1242
+ }
1243
+ for (const child of at.children) stack.push(child);
1244
+ }
1245
+ return true;
1246
+ }
1247
+
1248
+ /**
1249
+ * A transformed box painted on a surface of its own, as it was laid out,
1250
+ * and the surface drawn through its matrix: the part of what it draws that
1251
+ * the damage reaches. True where that is done — or is nothing to do, or
1252
+ * cannot be: a part too large for a surface, or a matrix that does not fit
1253
+ * the fixed point a picture's transform is sent in, which is one that draws
1254
+ * the box a thirtieth its size far across a window, or flatter than can be
1255
+ * seen. None of it is drawn then. False where the context has no surface to
1256
+ * draw on, and the caller draws the box through the matrix itself.
1257
+ */
1258
+ function paintRaster(
1259
+ ctx: PaintContext,
1260
+ box: Box,
1261
+ options: PaintOptions,
1262
+ through: Matrix,
1263
+ ): boolean {
1264
+ if (!options.surface || !ctx.drawImage) return false;
1265
+ const own = ownBounds(box);
1266
+ let x0 = Math.floor(own.x + options.originX);
1267
+ let y0 = Math.floor(own.y + options.originY);
1268
+ let x1 = Math.ceil(own.x + own.width + options.originX);
1269
+ let y1 = Math.ceil(own.y + own.height + options.originY);
1270
+ const damage = options.damage;
1271
+ if (damage) {
1272
+ // a pixel more each way: the edge of what is drawn is sampled from
1273
+ // beside it
1274
+ x0 = Math.max(x0, Math.floor(damage.x) - 1);
1275
+ y0 = Math.max(y0, Math.floor(damage.y) - 1);
1276
+ x1 = Math.min(x1, Math.ceil(damage.x + damage.width) + 1);
1277
+ y1 = Math.min(y1, Math.ceil(damage.y + damage.height) + 1);
1278
+ }
1279
+ const w = x1 - x0;
1280
+ const h = y1 - y0;
1281
+ if (!(w > 0 && h > 0)) return true;
1282
+ if (w > RASTER_SIDE || h > RASTER_SIDE || w * h > RASTER_LIMIT) return true;
1283
+ // what the context makes of the draw below: the picture's transform is
1284
+ // the inverse of its matrix after the surface's place in it
1285
+ const placed = invert([
1286
+ through[0],
1287
+ through[1],
1288
+ through[2],
1289
+ through[3],
1290
+ through[0] * x0 + through[2] * y0 + through[4],
1291
+ through[1] * x0 + through[3] * y0 + through[5],
1292
+ ]);
1293
+ if (!placed || placed.some((n) => Math.abs(n) > FIXED_LIMIT)) return true;
1294
+ const surface = options.surface(w, h);
1295
+ if (!surface) return false;
1296
+ try {
1297
+ // the surface's own coordinates, the window's moved to its corner
1298
+ const on: PaintOptions = {
1299
+ ...options,
1300
+ originX: options.originX - x0,
1301
+ originY: options.originY - y0,
1302
+ damage: { x: 0, y: 0, width: w, height: h },
1303
+ canvas: options.canvas && {
1304
+ ...options.canvas,
1305
+ x: options.canvas.x - x0,
1306
+ y: options.canvas.y - y0,
1307
+ },
1308
+ viewport: options.viewport && {
1309
+ ...options.viewport,
1310
+ x: options.viewport.x - x0,
1311
+ y: options.viewport.y - y0,
1312
+ },
1313
+ clips: [],
1314
+ };
1315
+ paintPlaced(surface.getContext('2d') as PaintContext, box, on);
1316
+ ctx.save();
1317
+ ctx.transform!(
1318
+ through[0],
1319
+ through[1],
1320
+ through[2],
1321
+ through[3],
1322
+ through[4],
1323
+ through[5],
1324
+ );
1325
+ ctx.drawImage(surface, x0, y0);
1326
+ ctx.restore();
1327
+ return true;
1328
+ } finally {
1329
+ surface.destroy?.();
1330
+ }
1331
+ }
1332
+
1333
+ /** A box and what it holds, cut to its `clip` and its `clip-path` as
1334
+ * `paintBox` cuts it. */
951
1335
  function paintClipped(
952
1336
  ctx: PaintContext,
953
1337
  box: Box,
954
1338
  options: PaintOptions,
955
1339
  ): void {
956
- const clip = box.outOfFlow && box.style.clip ? clipOf(box, options) : null;
957
- if (clip && !(clip.w > 0 && clip.h > 0)) return;
958
- const clipped = !!clip && pushClip(ctx, clip, null);
959
- paintContent(ctx, box, options);
960
- if (clipped) ctx.restore();
1340
+ const pushed = pushOwnClips(ctx, box, options);
1341
+ if (pushed < 0) return;
1342
+ paintContent(ctx, box, pathClips(box) ? underPath(options) : options);
1343
+ for (let i = 0; i < pushed; i += 1) ctx.restore();
1344
+ }
1345
+
1346
+ /**
1347
+ * Cut what follows to what a box shows of itself: the part its `clip`
1348
+ * names, where it is absolutely positioned, and the part its `clip-path`
1349
+ * does (CSS Masking 1, 5.1). How many clips were pushed, for the caller to
1350
+ * restore — none where the context cannot clip — or -1 where they leave
1351
+ * none of the box to show, and nothing was pushed.
1352
+ */
1353
+ function pushOwnClips(
1354
+ ctx: PaintContext,
1355
+ box: Box,
1356
+ options: PaintOptions,
1357
+ ): number {
1358
+ const style = box.style;
1359
+ const clip = box.outOfFlow && style.clip ? clipOf(box, options) : null;
1360
+ if (clip && !(clip.w > 0 && clip.h > 0)) return -1;
1361
+ const path = pathClips(box) ? clipPathOf(box, options) : null;
1362
+ if (path && !(path.rect.w > 0 && path.rect.h > 0)) return -1;
1363
+ let pushed = 0;
1364
+ if (clip && pushClip(ctx, clip, null)) pushed += 1;
1365
+ if (path && pushClip(ctx, path.rect, path.radii)) pushed += 1;
1366
+ return pushed;
1367
+ }
1368
+
1369
+ /** Whether a box is cut to a `clip-path`: one with a rectangle of its own
1370
+ * for the path to be measured in. An inline box is drawn on its block's
1371
+ * lines, and is not cut. */
1372
+ export function pathClips(box: Box): boolean {
1373
+ return box.style.clipPath !== null && hasRect(box);
1374
+ }
1375
+
1376
+ /**
1377
+ * The options what a box cut to a `clip-path` holds is painted with: no
1378
+ * clip around it for a positioned box to escape. The path cuts all the
1379
+ * element holds, whatever that is positioned from, where a box that clips
1380
+ * its overflow lets out a box whose containing block is outside it — put
1381
+ * off until that clip ended (`paintPositioned`), one in here was painted
1382
+ * with the path gone too.
1383
+ */
1384
+ function underPath(options: PaintOptions): PaintOptions {
1385
+ return { ...options, clips: [] };
961
1386
  }
962
1387
 
963
1388
  const MASK_LAYERS = new WeakMap<ComputedStyle, ComputedStyle[]>();
@@ -1200,6 +1625,13 @@ function paintOwnBackground(
1200
1625
  if (box.kind === 'table-row' || box.kind === 'table-row-group') return;
1201
1626
  if (hidden(box)) return;
1202
1627
  const style = box.style;
1628
+ if (columned.any) {
1629
+ const pieces = COLUMN_PIECES.get(box);
1630
+ if (pieces) {
1631
+ paintColumnPieces(ctx, box, pieces, options);
1632
+ return;
1633
+ }
1634
+ }
1203
1635
  if (style.boxShadow) paintShadows(ctx, box, options, false);
1204
1636
  if (box !== options.canvasSource) {
1205
1637
  paintLayers(ctx, box, options, frameImages(ctx, box, options));
@@ -1211,6 +1643,88 @@ function paintOwnBackground(
1211
1643
  if (box.kind === 'table') paintPartBackgrounds(ctx, box, options);
1212
1644
  }
1213
1645
 
1646
+ /**
1647
+ * The background and the borders of a block a column break falls inside
1648
+ * (CSS Multi-column 1), a piece at a time: drawn in each column as the
1649
+ * whole box standing there, and cut to the piece of it that is — which is
1650
+ * `box-decoration-break: slice`, CSS's default (CSS Fragmentation 3, 6.1):
1651
+ * no border and no padding at a break, and the background going on from
1652
+ * one piece to the next. Its shadow is not drawn: a sliced box casts one as
1653
+ * a whole and this has no whole box to cast it from.
1654
+ */
1655
+ function paintColumnPieces(
1656
+ ctx: PaintContext,
1657
+ box: Box,
1658
+ pieces: readonly ColumnPiece[],
1659
+ options: PaintOptions,
1660
+ ): void {
1661
+ if (!ctx.save || !ctx.restore || !ctx.beginPath || !ctx.rect || !ctx.clip)
1662
+ return;
1663
+ for (const piece of pieces) {
1664
+ if (!(piece.height > 0 && piece.width > 0)) continue;
1665
+ const frame: Frame = {
1666
+ x: piece.x,
1667
+ y: piece.wholeY,
1668
+ width: piece.width,
1669
+ height: piece.wholeHeight,
1670
+ captionTop: 0,
1671
+ captionBottom: 0,
1672
+ borderTop: box.borderTop,
1673
+ borderRight: box.borderRight,
1674
+ borderBottom: box.borderBottom,
1675
+ borderLeft: box.borderLeft,
1676
+ padTop: box.padTop,
1677
+ padRight: box.padRight,
1678
+ padBottom: box.padBottom,
1679
+ padLeft: box.padLeft,
1680
+ style: box.style,
1681
+ };
1682
+ const left = Math.round(piece.x + options.originX);
1683
+ const top = Math.round(piece.y + options.originY);
1684
+ const right = Math.round(piece.x + piece.width + options.originX);
1685
+ const bottom = Math.round(piece.y + piece.height + options.originY);
1686
+ ctx.save();
1687
+ ctx.beginPath();
1688
+ ctx.rect(left, top, right - left, bottom - top);
1689
+ ctx.clip();
1690
+ paintLayers(ctx, frame, options, frameImages(ctx, frame, options));
1691
+ paintBorders(ctx, frame, options);
1692
+ ctx.restore();
1693
+ }
1694
+ }
1695
+
1696
+ /**
1697
+ * Clip to the rows of a text's layout that are in the text's column
1698
+ * (`COLUMN_ROWS`), where the layout is drawn at `left` and `top`: the rest
1699
+ * of it is another column's, drawn there. Answers whether it clipped, and
1700
+ * the caller restores.
1701
+ */
1702
+ function clipToRows(
1703
+ ctx: PaintContext,
1704
+ text: LineText,
1705
+ rows: { top: number; bottom: number },
1706
+ left: number,
1707
+ top: number,
1708
+ ): boolean {
1709
+ if (!ctx.save || !ctx.restore || !ctx.beginPath || !ctx.rect || !ctx.clip)
1710
+ return false;
1711
+ const y1 = Math.round(top + rows.top);
1712
+ const y2 = Math.round(top + rows.bottom);
1713
+ ctx.save();
1714
+ ctx.beginPath();
1715
+ ctx.rect(
1716
+ Math.floor(left) - ROW_REACH,
1717
+ y1,
1718
+ Math.ceil(text.layout.width) + 2 * ROW_REACH,
1719
+ y2 - y1,
1720
+ );
1721
+ ctx.clip();
1722
+ return true;
1723
+ }
1724
+
1725
+ /** How far to either side of its layout a column's text may draw. */
1726
+ const ROW_REACH = 2048;
1727
+
1214
1728
  /**
1215
1729
  * How `paintLayers` draws a frame's image layers: each in the area its
1216
1730
  * `background-clip` names, from the box its `background-origin` names
@@ -1632,6 +2146,8 @@ function inFlow(parent: Box, child: Box, options: PaintOptions): boolean {
1632
2146
  style.overflowY === 'visible' &&
1633
2147
  opacityOf(child) >= 1 &&
1634
2148
  !masked(style) &&
2149
+ // cut to a path with all it holds, as one group
2150
+ !style.clipPath &&
1635
2151
  // containment makes it a stacking context, painted whole
1636
2152
  !contained(child, CONTAIN_LAYOUT | CONTAIN_PAINT)
1637
2153
  );
@@ -1660,6 +2176,7 @@ function flowFlex(parent: Box, child: Box, options: PaintOptions): boolean {
1660
2176
  style.overflowY === 'visible' &&
1661
2177
  !(child.outOfFlow && style.clip) &&
1662
2178
  !masked(style) &&
2179
+ !style.clipPath &&
1663
2180
  !contained(child, CONTAIN_LAYOUT | CONTAIN_PAINT)
1664
2181
  );
1665
2182
  }
@@ -1750,7 +2267,7 @@ function paintFlowLines(
1750
2267
  * beyond its background's edge. */
1751
2268
  function overflowClip(
1752
2269
  box: Box,
1753
- options: PaintOptions,
2270
+ options: ClipSpace,
1754
2271
  ): { x: number; y: number; w: number; h: number } {
1755
2272
  const left = box.x + options.originX;
1756
2273
  const top = frameY(box) + options.originY;
@@ -1776,7 +2293,7 @@ function overflowClip(
1776
2293
  */
1777
2294
  function clipEdge(
1778
2295
  box: Box,
1779
- options: PaintOptions,
2296
+ options: ClipSpace,
1780
2297
  ): {
1781
2298
  rect: { x: number; y: number; w: number; h: number };
1782
2299
  radii: Corners | null;
@@ -1932,7 +2449,9 @@ function paintPositioned(
1932
2449
  return;
1933
2450
  }
1934
2451
  }
1935
- paintBox(ctx, box, atViewport(box, options));
2452
+ // through its matrix, where it turns, scales or skews
2453
+ if (turns(box)) paintTransformed(ctx, box, atViewport(box, options));
2454
+ else paintBox(ctx, box, atViewport(box, options));
1936
2455
  }
1937
2456
 
1938
2457
  /**
@@ -1971,12 +2490,19 @@ export const FIXED_BOXES = new WeakMap<object, Box[]>();
1971
2490
  export function fixedToViewport(box: Box): boolean {
1972
2491
  if (box.style.position !== 'fixed') return false;
1973
2492
  for (let at = box.parent; at?.parent; at = at.parent) {
1974
- if (at.style.translate || at.style.transformTranslate) return false;
1975
- if (contained(at, CONTAIN_LAYOUT | CONTAIN_PAINT)) return false;
2493
+ if (holdsFixed(at)) return false;
1976
2494
  }
1977
2495
  return true;
1978
2496
  }
1979
2497
 
2498
+ /** Whether a box is the containing block of the fixed boxes in it: a
2499
+ * transformed box, or one with layout or paint containment. */
2500
+ function holdsFixed(box: Box): boolean {
2501
+ return (
2502
+ transformed(box.style) || contained(box, CONTAIN_LAYOUT | CONTAIN_PAINT)
2503
+ );
2504
+ }
2505
+
1980
2506
  function rootOf(box: Box): Box {
1981
2507
  let root = box;
1982
2508
  while (root.parent) root = root.parent;
@@ -1986,17 +2512,22 @@ function rootOf(box: Box): Box {
1986
2512
  /**
1987
2513
  * The box an out-of-flow box is positioned in, whose clips are the ones it
1988
2514
  * is under (`paintPositioned`, and the hit test's `deepestAt`): its nearest
1989
- * positioned or translated ancestor, or the root. Null for a fixed box,
1990
- * which is under none.
2515
+ * positioned or transformed ancestor, or the root. Null for a box fixed to
2516
+ * the viewport, which is under none — one in a transformed box is fixed to
2517
+ * that, and under what it is under.
1991
2518
  */
1992
2519
  export function containingBlockOf(box: Box): Box | null {
1993
- if (box.style.position === 'fixed') return null;
2520
+ if (box.style.position === 'fixed') {
2521
+ for (let at = box.parent; at?.parent; at = at.parent) {
2522
+ if (holdsFixed(at)) return at;
2523
+ }
2524
+ return null;
2525
+ }
1994
2526
  let containing = box.parent;
1995
2527
  while (
1996
2528
  containing?.parent &&
1997
2529
  containing.style.position === 'static' &&
1998
- !containing.style.translate &&
1999
- !containing.style.transformTranslate
2530
+ !transformed(containing.style)
2000
2531
  ) {
2001
2532
  containing = containing.parent;
2002
2533
  }
@@ -2080,7 +2611,7 @@ export function clipsOverflow(box: Box): boolean {
2080
2611
  /** A box's `clip` region, in window coordinates. */
2081
2612
  function clipOf(
2082
2613
  box: Box,
2083
- options: PaintOptions,
2614
+ options: ClipSpace,
2084
2615
  ): { x: number; y: number; w: number; h: number } {
2085
2616
  const clip = box.style.clip!;
2086
2617
  const left = clip.left ?? 0;
@@ -2097,6 +2628,212 @@ function clipOf(
2097
2628
  };
2098
2629
  }
2099
2630
 
2631
+ /** What placing a clip reads of a paint's options: where the document's
2632
+ * origin is, its scale, and how far an axis left open has to reach. */
2633
+ type ClipSpace = Pick<PaintOptions, 'originX' | 'originY' | 'scale' | 'damage'>;
2634
+
2635
+ /** The document's own coordinates, as a space to place a clip in. */
2636
+ const DOCUMENT: ClipSpace = { originX: 0, originY: 0, scale: 1, damage: null };
2637
+
2638
+ /**
2639
+ * A box's `clip-path` in window coordinates (CSS Masking 1, 5.1): the
2640
+ * rectangle its shape is (CSS Shapes 1, 3.1), each edge on the pixel it
2641
+ * falls nearest as a box's are, and its corners where it has any — of no
2642
+ * area where the shape leaves none of the box to show. Measured in the box
2643
+ * the path names, the border box unless it says: `inset()` in from its
2644
+ * edges, `rect()` and `xywh()` from its top left, and the box itself, with
2645
+ * the element's own corners, where no shape is written.
2646
+ */
2647
+ function clipPathOf(
2648
+ box: Box,
2649
+ options: ClipSpace,
2650
+ ): {
2651
+ rect: { x: number; y: number; w: number; h: number };
2652
+ radii: Corners | null;
2653
+ } {
2654
+ const path = box.style.clipPath!;
2655
+ // the box it is measured in, in from the border box — or out from it
2656
+ const within = path.box;
2657
+ const [it, ir, ib, il] =
2658
+ within === 'margin-box'
2659
+ ? [-box.marginTop, -box.marginRight, -box.marginBottom, -box.marginLeft]
2660
+ : edgeInsets(box, within);
2661
+ const width = Math.max(0, box.width - il - ir);
2662
+ const height = Math.max(0, frameHeight(box) - it - ib);
2663
+ // how far in from that box's edges the rectangle's are
2664
+ const [a, b, c, d] = path.lengths;
2665
+ let top = 0;
2666
+ let right = 0;
2667
+ let bottom = 0;
2668
+ let left = 0;
2669
+ if (path.shape === 'inset') {
2670
+ top = resolve(a, height);
2671
+ right = resolve(b, width);
2672
+ bottom = resolve(c, height);
2673
+ left = resolve(d, width);
2674
+ // a pair that adds up to more than the box is across is reduced to
2675
+ // it, each in its proportion, as overlapping radii are (3.1):
2676
+ // `inset(50%)` and `inset(75%)` are both the box's centre, and nothing
2677
+ if (left + right > width && left > 0 && right > 0) {
2678
+ const f = Math.max(0, width) / (left + right);
2679
+ left *= f;
2680
+ right *= f;
2681
+ }
2682
+ if (top + bottom > height && top > 0 && bottom > 0) {
2683
+ const f = Math.max(0, height) / (top + bottom);
2684
+ top *= f;
2685
+ bottom *= f;
2686
+ }
2687
+ } else if (path.shape === 'rect') {
2688
+ // four edges from the top and the left, `auto` the box's own; the
2689
+ // right and the bottom are no less than the left and the top
2690
+ top = a === AUTO ? 0 : resolve(a, height);
2691
+ left = d === AUTO ? 0 : resolve(d, width);
2692
+ right = b === AUTO ? 0 : width - Math.max(left, resolve(b, width));
2693
+ bottom = c === AUTO ? 0 : height - Math.max(top, resolve(c, height));
2694
+ } else if (path.shape === 'xywh') {
2695
+ left = resolve(a, width);
2696
+ top = resolve(b, height);
2697
+ right = width - left - Math.max(0, resolve(c, width));
2698
+ bottom = height - top - Math.max(0, resolve(d, height));
2699
+ }
2700
+ const x0 = box.x + options.originX + il;
2701
+ const y0 = frameY(box) + options.originY + it;
2702
+ const x = Math.round(x0 + left);
2703
+ const y = Math.round(y0 + top);
2704
+ const rect = {
2705
+ x,
2706
+ y,
2707
+ w: Math.max(0, Math.round(x0 + width - right) - x),
2708
+ h: Math.max(0, Math.round(y0 + height - bottom) - y),
2709
+ };
2710
+ let radii: Corners | null = null;
2711
+ if (path.shape === null) {
2712
+ // the box alone: its edge, shaped as the element's corners shape it
2713
+ const corners = cornersOf(box.style, box.width, frameHeight(box));
2714
+ if (corners) {
2715
+ radii =
2716
+ within === 'margin-box'
2717
+ ? spreadCorners(
2718
+ corners,
2719
+ box.width,
2720
+ frameHeight(box),
2721
+ -it,
2722
+ -ir,
2723
+ -ib,
2724
+ -il,
2725
+ )
2726
+ : roundedOf(insetCorners(corners, it, ir, ib, il));
2727
+ }
2728
+ } else if (path.radii) {
2729
+ // a percentage is of the box the shape is in, and the radii are fitted
2730
+ // to the rectangle they round
2731
+ radii = cornersFor(path.radii, path.radiiY, width, height, rect.w, rect.h);
2732
+ }
2733
+ return { rect, radii };
2734
+ }
2735
+
2736
+ /** Corners, or null where every one of them is square. */
2737
+ function roundedOf(c: Corners): Corners | null {
2738
+ for (let i = 0; i < 4; i += 1) if (c.x[i] > 0 && c.y[i] > 0) return c;
2739
+ return null;
2740
+ }
2741
+
2742
+ /**
2743
+ * Whether a point of the document is in what a box's `clip-path` leaves of
2744
+ * it: what a hit test asks, since nothing the path cuts away — of the box
2745
+ * or of anything in it — is under a pointer (`deepestAt`).
2746
+ */
2747
+ export function inClipPath(box: Box, x: number, y: number): boolean {
2748
+ const { rect, radii } = clipPathOf(box, DOCUMENT);
2749
+ const right = rect.x + rect.w;
2750
+ const bottom = rect.y + rect.h;
2751
+ if (x < rect.x || x >= right || y < rect.y || y >= bottom) return false;
2752
+ if (!radii) return true;
2753
+ // inside the rectangle, and past a corner's curve: each corner is a
2754
+ // quarter of an ellipse about a centre its radii in from the two edges
2755
+ for (let i = 0; i < 4; i += 1) {
2756
+ const rx = radii.x[i];
2757
+ const ry = radii.y[i];
2758
+ if (!(rx > 0 && ry > 0)) continue;
2759
+ const onLeft = i === 0 || i === 3;
2760
+ const cx = onLeft ? rect.x + rx : right - rx;
2761
+ const cy = i < 2 ? rect.y + ry : bottom - ry;
2762
+ if (onLeft ? x >= cx : x <= cx) continue;
2763
+ if (i < 2 ? y >= cy : y <= cy) continue;
2764
+ const dx = (x - cx) / rx;
2765
+ const dy = (y - cy) / ry;
2766
+ if (dx * dx + dy * dy > 1) return false;
2767
+ }
2768
+ return true;
2769
+ }
2770
+
2771
+ /**
2772
+ * Whether a point of the document is in the part of a box its `clip` shows
2773
+ * (CSS 2.1 11.1.2) — anywhere, for a box that is cut to none: what a hit
2774
+ * test asks, since what the clip cuts away is not drawn (`deepestAt`). What
2775
+ * it cuts away is the box and what is painted with it: all the box holds
2776
+ * where it is a stacking context (`stacksLayers`), and elsewhere all but a
2777
+ * fixed box, which the context around it paints under no clip
2778
+ * (`clipsFor`). Chrome cuts that one too.
2779
+ */
2780
+ export function inClip(box: Box, x: number, y: number): boolean {
2781
+ if (!box.outOfFlow || !box.style.clip) return true;
2782
+ const rect = clipOf(box, DOCUMENT);
2783
+ return (
2784
+ x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h
2785
+ );
2786
+ }
2787
+
2788
+ /**
2789
+ * The part of the document a box shows through, in the document's own
2790
+ * pixels, or null where nothing cuts it: the `clip` it is cut to (CSS 2.1
2791
+ * 11.1.2), and the clips of the boxes it is under — each that clips its
2792
+ * overflow or is cut to a `clip` of its own, from its containing block up
2793
+ * (11.1.1), so an absolute box is outside a box that clips where its
2794
+ * containing block is, and a fixed one outside them all. And every
2795
+ * `clip-path` from the box up, its own among them: a path cuts all its
2796
+ * element holds, whatever that is positioned from (CSS Masking 1, 5.1) —
2797
+ * to the rectangle a path's shape is, its corners left square. A rectangle
2798
+ * of no area where none of it shows. Painting cuts what it draws as it
2799
+ * walks down; this is for what is not painted here and has to be cut all
2800
+ * the same, a control's widget (`controlRectsOf`).
2801
+ */
2802
+ export function clipAround(box: Box, scale = 1): Rect | null {
2803
+ const space: ClipSpace = { originX: 0, originY: 0, scale, damage: null };
2804
+ let x0 = -Infinity;
2805
+ let y0 = -Infinity;
2806
+ let x1 = Infinity;
2807
+ let y1 = Infinity;
2808
+ let cut = false;
2809
+ const under = (rect: { x: number; y: number; w: number; h: number }) => {
2810
+ cut = true;
2811
+ x0 = Math.max(x0, rect.x);
2812
+ y0 = Math.max(y0, rect.y);
2813
+ x1 = Math.min(x1, rect.x + rect.w);
2814
+ y1 = Math.min(y1, rect.y + rect.h);
2815
+ };
2816
+ if (box.outOfFlow && box.style.clip) under(clipOf(box, space));
2817
+ // what holds a box, as clips go: its parent, or out of the flow its
2818
+ // containing block, and the boxes between them do not clip it
2819
+ const holder = (inner: Box): Box | null =>
2820
+ inner.outOfFlow ? containingBlockOf(inner) : inner.parent;
2821
+ for (let outer = holder(box); outer; outer = holder(outer)) {
2822
+ if (outer.outOfFlow && outer.style.clip) under(clipOf(outer, space));
2823
+ if (clipsOverflow(outer)) under(clipEdge(outer, space).rect);
2824
+ }
2825
+ for (let at: Box | null = box; at; at = at.parent) {
2826
+ if (pathClips(at)) under(clipPathOf(at, space).rect);
2827
+ }
2828
+ if (!cut) return null;
2829
+ return {
2830
+ x: x0,
2831
+ y: y0,
2832
+ width: Math.max(0, x1 - x0),
2833
+ height: Math.max(0, y1 - y0),
2834
+ };
2835
+ }
2836
+
2100
2837
  /**
2101
2838
  * The radii of a box's padding edge — its border radii less the borders —
2102
2839
  * for a clip, or null where a rectangle clips the same: padding at least a
@@ -2177,6 +2914,24 @@ function cornersOf(style: ComputedStyle, w: number, h: number): Corners | null {
2177
2914
  ) {
2178
2915
  return null;
2179
2916
  }
2917
+ return cornersFor(across, down, w, h, w, h);
2918
+ }
2919
+
2920
+ /**
2921
+ * Radii as corners in pixels: each a length or a percentage of a box `w`
2922
+ * by `h`, across and down, and all of them reduced together where two on a
2923
+ * side of the rectangle they round — `fitW` by `fitH`, which for a
2924
+ * `border-radius` is that box, and for a `clip-path`'s `round` is the
2925
+ * rectangle inside it — would overlap. Null where every corner is square.
2926
+ */
2927
+ function cornersFor(
2928
+ across: readonly Len[],
2929
+ down: readonly Len[] | null,
2930
+ w: number,
2931
+ h: number,
2932
+ fitW: number,
2933
+ fitH: number,
2934
+ ): Corners | null {
2180
2935
  const vertical = down ?? across;
2181
2936
  const x: Corners['x'] = [0, 0, 0, 0];
2182
2937
  const y: Corners['y'] = [0, 0, 0, 0];
@@ -2194,10 +2949,10 @@ function cornersOf(style: ComputedStyle, w: number, h: number): Corners | null {
2194
2949
  const fit = (side: number, sum: number): void => {
2195
2950
  if (sum > side) f = Math.min(f, Math.max(0, side) / sum);
2196
2951
  };
2197
- fit(w, x[0] + x[1]);
2198
- fit(h, y[1] + y[2]);
2199
- fit(w, x[2] + x[3]);
2200
- fit(h, y[3] + y[0]);
2952
+ fit(fitW, x[0] + x[1]);
2953
+ fit(fitH, y[1] + y[2]);
2954
+ fit(fitW, x[2] + x[3]);
2955
+ fit(fitH, y[3] + y[0]);
2201
2956
  if (f < 1) {
2202
2957
  for (let i = 0; i < 4; i += 1) {
2203
2958
  x[i] *= f;
@@ -2349,8 +3104,7 @@ function layered(parent: Box, child: Box): boolean {
2349
3104
  if (
2350
3105
  style.position !== 'relative' &&
2351
3106
  style.position !== 'sticky' &&
2352
- !style.translate &&
2353
- !style.transformTranslate
3107
+ !transformed(style)
2354
3108
  ) {
2355
3109
  return false;
2356
3110
  }
@@ -2364,7 +3118,7 @@ function layered(parent: Box, child: Box): boolean {
2364
3118
  * drawn twice — darker at every antialiased edge — and a translucent
2365
3119
  * background had its alpha doubled.
2366
3120
  */
2367
- function onLine(parent: Box, child: Box): boolean {
3121
+ export function onLine(parent: Box, child: Box): boolean {
2368
3122
  if (parent.lines === null && parent.kind !== 'inline') return false;
2369
3123
  if (child.isFloat || child.outOfFlow) return false;
2370
3124
  return (
@@ -2438,20 +3192,26 @@ function settleLayers(box: Box, list: Box[]): void {
2438
3192
  * it itself, and the ones below its flow: positioned with a `z-index`, or a
2439
3193
  * flex item with one; fixed or sticky with none (CSS Positioned Layout 3, as
2440
3194
  * browsers paint them — a fixed header's box set behind its content with
2441
- * `z-index: -1` went behind the page); or under full opacity (CSS Color 4
3195
+ * `z-index: -1` went behind the page); transformed (CSS Transforms 1, 3);
3196
+ * or under full opacity (CSS Color 4
2442
3197
  * 3.2), its own or the opacity it takes from an inline box it broke
2443
3198
  * (`FADED_BLOCKS`), which is painted as one group — faded with it, or at
2444
3199
  * `opacity: 0` not at all, where its root context drew a hover menu's
2445
3200
  * absolute children.
2446
3201
  */
2447
- function stacksLayers(box: Box): boolean {
3202
+ export function stacksLayers(box: Box): boolean {
2448
3203
  const style = box.style;
2449
3204
  if (style.position === 'fixed' || style.position === 'sticky') return true;
2450
3205
  if (style.opacity < 1 || FADED_BLOCKS.has(box)) return true;
2451
- // and so does a mask, which is applied to the group (CSS Masking 1, 7)
2452
- if (masked(style)) return true;
3206
+ // and so does a mask, which is applied to the group (CSS Masking 1, 7),
3207
+ // and a clip path, which cuts it (5.1)
3208
+ if (masked(style) || pathClips(box)) return true;
2453
3209
  // layout and paint containment make one (CSS Containment 2, 3.3, 3.5)
2454
3210
  if (contained(box, CONTAIN_LAYOUT | CONTAIN_PAINT)) return true;
3211
+ // and a transform (CSS Transforms 1, 3), on a box one applies to: what it
3212
+ // holds is painted with it, through its matrix, and not by a context
3213
+ // outside it
3214
+ if (transformed(style) && box.kind !== 'inline') return true;
2455
3215
  if (typeof style.zIndex !== 'number') return false;
2456
3216
  return style.position !== 'static' || flexItem(box);
2457
3217
  }
@@ -2475,7 +3235,7 @@ export function clipsFor(box: Box, context: Box): Box[] {
2475
3235
  const fixed = box.style.position === 'fixed';
2476
3236
  while (from && from !== context) {
2477
3237
  const style = from.style;
2478
- if (style.translate || style.transformTranslate) break;
3238
+ if (transformed(style)) break;
2479
3239
  if (!fixed && style.position !== 'static') break;
2480
3240
  from = from.parent;
2481
3241
  }
@@ -2571,9 +3331,19 @@ function hoistFrom(box: Box, root: boolean): Box[] | null {
2571
3331
  }
2572
3332
 
2573
3333
  function byZIndex(a: Box, b: Box): number {
2574
- const az = a.style.zIndex === 'auto' ? 0 : a.style.zIndex;
2575
- const bz = b.style.zIndex === 'auto' ? 0 : b.style.zIndex;
2576
- return az - bz;
3334
+ return layerOf(a) - layerOf(b);
3335
+ }
3336
+
3337
+ /** The `z-index` a box is ordered by among its stacking context's layers:
3338
+ * its own where one applies — to a positioned box, and to a flex or a grid
3339
+ * item (CSS 2.1 9.9.1, CSS Flexbox 5.4) — and none for a box that is
3340
+ * among them for its transform alone, which is painted in the document's
3341
+ * order whatever `z-index` it was given. */
3342
+ function layerOf(box: Box): number {
3343
+ const style = box.style;
3344
+ const z = style.zIndex;
3345
+ if (z === 'auto') return 0;
3346
+ return style.position !== 'static' || flexItem(box) ? z : 0;
2577
3347
  }
2578
3348
 
2579
3349
  /** Whether anything this box or its descendants draw is in the damage. */
@@ -2673,7 +3443,7 @@ function paintBackground(
2673
3443
  ctx.fillStyle = inkColor(color as string, style.color);
2674
3444
  fill();
2675
3445
  }
2676
- if (gradient && ctx.createLinearGradient) {
3446
+ if (gradient) {
2677
3447
  // over the colour, as the layer an image is: the size of the padding
2678
3448
  // box, which is where it starts, and repeated under the borders — or
2679
3449
  // the viewport's, where it is fixed; its line runs across the whole of
@@ -2695,7 +3465,7 @@ function paintBackground(
2695
3465
  // one fill in the rounded shape; under the borders it carries on
2696
3466
  // with its end colours where a browser would show the next tile
2697
3467
  if (!(at.width > 0 && at.height > 0)) return;
2698
- ctx.fillStyle = linearGradient(
3468
+ const paint = gradientFill(
2699
3469
  ctx,
2700
3470
  gradient,
2701
3471
  at.x,
@@ -2705,7 +3475,7 @@ function paintBackground(
2705
3475
  style.color,
2706
3476
  rect,
2707
3477
  );
2708
- fill();
3478
+ if (paint) fillGradient(ctx, paint, rect, rounded);
2709
3479
  } else paintGradient(ctx, style, gradient, rect, at);
2710
3480
  }
2711
3481
  }
@@ -2735,7 +3505,7 @@ const NO_SIZE: IntrinsicSize = { width: null, height: null, ratio: 0 };
2735
3505
  function paintGradient(
2736
3506
  ctx: PaintContext,
2737
3507
  style: ComputedStyle,
2738
- gradient: LinearGradient,
3508
+ gradient: Gradient,
2739
3509
  area: { x: number; y: number; w: number; h: number },
2740
3510
  at: Rect,
2741
3511
  ): void {
@@ -2784,17 +3554,8 @@ function paintGradient(
2784
3554
  ) {
2785
3555
  // a sliver of a root repeated down a long canvas: the one tile, and
2786
3556
  // its end colours on past it
2787
- ctx.fillStyle = linearGradient(
2788
- ctx,
2789
- gradient,
2790
- x0,
2791
- y0,
2792
- w,
2793
- h,
2794
- style.color,
2795
- area,
2796
- );
2797
- ctx.fillRect(area.x, area.y, area.w, area.h);
3557
+ const paint = gradientFill(ctx, gradient, x0, y0, w, h, style.color, area);
3558
+ if (paint) fillGradient(ctx, paint, area);
2798
3559
  return;
2799
3560
  }
2800
3561
  for (let y = fromY; y < toY; y += stepY) {
@@ -2806,17 +3567,8 @@ function paintGradient(
2806
3567
  const right = Math.min(x + w, area.x + area.w);
2807
3568
  if (right <= left) continue;
2808
3569
  const tile = { x: left, y: top, w: right - left, h: bottom - top };
2809
- ctx.fillStyle = linearGradient(
2810
- ctx,
2811
- gradient,
2812
- x,
2813
- y,
2814
- w,
2815
- h,
2816
- style.color,
2817
- tile,
2818
- );
2819
- ctx.fillRect(tile.x, tile.y, tile.w, tile.h);
3570
+ const paint = gradientFill(ctx, gradient, x, y, w, h, style.color, tile);
3571
+ if (paint) fillGradient(ctx, paint, tile);
2820
3572
  }
2821
3573
  }
2822
3574
  }
@@ -2856,32 +3608,11 @@ function linearGradient(
2856
3608
  const dy = -Math.cos(angle);
2857
3609
  const length = Math.abs(w * dx) + Math.abs(h * dy);
2858
3610
  const stops = gradient.stops;
2859
- const at: (number | null)[] = stops.map((stop) =>
2860
- stop.at === null
2861
- ? null
2862
- : length > 0
2863
- ? resolve(stop.at, length) / length
2864
- : 0,
2865
- );
2866
- if (at[0] === null) at[0] = 0;
2867
- if (at[at.length - 1] === null) at[at.length - 1] = 1;
2868
- for (let i = 1; i < at.length; i += 1) {
2869
- if (at[i] === null) {
2870
- // spread the ones without a position between the ones with
2871
- let j = i;
2872
- while (at[j] === null) j += 1;
2873
- const from = at[i - 1]!;
2874
- const to = at[j]!;
2875
- for (let k = i; k < j; k += 1) {
2876
- at[k] = from + ((to - from) * (k - i + 1)) / (j - i + 1);
2877
- }
2878
- }
2879
- at[i] = Math.max(at[i]!, at[i - 1]!);
2880
- }
3611
+ const at = stopOffsets(stops, length);
2881
3612
  // the context's offsets are 0 to 1, so a line that stops reach past runs
2882
3613
  // from the first to the last, and the box sees the part of it between
2883
- const from = Math.min(0, at[0]!);
2884
- const to = Math.max(1, at[at.length - 1]!);
3614
+ const from = Math.min(0, at[0]);
3615
+ const to = Math.max(1, at[at.length - 1]);
2885
3616
  const startX = x + w / 2 - (dx * length) / 2;
2886
3617
  const startY = y + h / 2 - (dy * length) / 2;
2887
3618
  const x0 = startX + dx * length * from;
@@ -2912,11 +3643,278 @@ function linearGradient(
2912
3643
  }
2913
3644
  const g = ctx.createLinearGradient!(x0, y0, x1, y1);
2914
3645
  for (let i = 0; i < stops.length; i += 1) {
2915
- g.addColorStop((at[i]! - from) / (to - from), colors[i]);
3646
+ g.addColorStop((at[i] - from) / (to - from), colors[i]);
2916
3647
  }
2917
3648
  return g;
2918
3649
  }
2919
3650
 
3651
+ /**
3652
+ * Where each of a gradient's stops is along a line `length` long, as a
3653
+ * fraction of it (CSS Images 3, 3.5.1): a stop without a position is
3654
+ * spread evenly between its neighbours', the first at 0 and the last at 1
3655
+ * where they have none, and none is before the one before it.
3656
+ */
3657
+ function stopOffsets(stops: GradientStop[], length: number): number[] {
3658
+ const at: (number | null)[] = stops.map((stop) =>
3659
+ stop.at === null
3660
+ ? null
3661
+ : length > 0
3662
+ ? resolve(stop.at, length) / length
3663
+ : 0,
3664
+ );
3665
+ if (at[0] === null) at[0] = 0;
3666
+ if (at[at.length - 1] === null) at[at.length - 1] = 1;
3667
+ for (let i = 1; i < at.length; i += 1) {
3668
+ if (at[i] === null) {
3669
+ // spread the ones without a position between the ones with
3670
+ let j = i;
3671
+ while (at[j] === null) j += 1;
3672
+ const from = at[i - 1]!;
3673
+ const to = at[j]!;
3674
+ for (let k = i; k < j; k += 1) {
3675
+ at[k] = from + ((to - from) * (k - i + 1)) / (j - i + 1);
3676
+ }
3677
+ }
3678
+ at[i] = Math.max(at[i]!, at[i - 1]!);
3679
+ }
3680
+ return at as number[];
3681
+ }
3682
+
3683
+ /** What a gradient fills with: the context's gradient, or the one colour
3684
+ * it comes to, and the matrix it is sampled through where it is an
3685
+ * ellipse — a context's radial gradients are circles, and an ellipse is
3686
+ * one stretched along its longer axis. */
3687
+ interface GradientFill {
3688
+ style: unknown;
3689
+ matrix: [number, number, number, number, number, number] | null;
3690
+ }
3691
+
3692
+ /** A gradient's fill for a box at (`x`, `y`), `w` by `h`: null where the
3693
+ * context draws no gradient of its kind. */
3694
+ function gradientFill(
3695
+ ctx: PaintContext,
3696
+ gradient: Gradient,
3697
+ x: number,
3698
+ y: number,
3699
+ w: number,
3700
+ h: number,
3701
+ currentColor: string,
3702
+ visible: { x: number; y: number; w: number; h: number } | null = null,
3703
+ ): GradientFill | null {
3704
+ if (gradient.kind === 'radial') {
3705
+ return radialGradient(ctx, gradient, x, y, w, h, currentColor, visible);
3706
+ }
3707
+ if (!ctx.createLinearGradient) return null;
3708
+ return {
3709
+ style: linearGradient(ctx, gradient, x, y, w, h, currentColor, visible),
3710
+ matrix: null,
3711
+ };
3712
+ }
3713
+
3714
+ /**
3715
+ * A `radial-gradient()` across a box (CSS Images 3, 3.2.2): centred where
3716
+ * its position puts it, and as large as its radii, or as its keyword says
3717
+ * — the side or the corner of the box nearest the centre or furthest from
3718
+ * it, and for an ellipse through a corner, the ellipse with the shape the
3719
+ * sides would give it. The stops are spaced along the ray from the centre
3720
+ * to the ending shape's edge, which for an ellipse is its horizontal
3721
+ * radius; one before the centre, where the ray starts, gives the colour
3722
+ * there and no more, and one past the edge carries the gradient on.
3723
+ *
3724
+ * A shape with no width or no height is drawn as its last colour, as
3725
+ * Chrome draws each (crbug.com/635727): the spec draws a zero width as a
3726
+ * gradient mirrored about the centre line, which Chrome does not.
3727
+ */
3728
+ function radialGradient(
3729
+ ctx: PaintContext,
3730
+ gradient: RadialGradient,
3731
+ x: number,
3732
+ y: number,
3733
+ w: number,
3734
+ h: number,
3735
+ currentColor: string,
3736
+ visible: { x: number; y: number; w: number; h: number } | null = null,
3737
+ ): GradientFill | null {
3738
+ const colors = gradient.stops.map((stop) =>
3739
+ inkColor(stop.color, currentColor),
3740
+ );
3741
+ const cx = resolve(gradient.at[0], w);
3742
+ const cy = resolve(gradient.at[1], h);
3743
+ let rx: number;
3744
+ let ry: number;
3745
+ if (gradient.radii) {
3746
+ rx = resolve(gradient.radii[0], w);
3747
+ ry = resolve(gradient.radii[1], h);
3748
+ } else {
3749
+ const extent = gradient.extent ?? 'farthest-corner';
3750
+ const side = extent.startsWith('closest') ? Math.min : Math.max;
3751
+ const sx = side(Math.abs(cx), Math.abs(w - cx));
3752
+ const sy = side(Math.abs(cy), Math.abs(h - cy));
3753
+ const corner = extent.endsWith('corner');
3754
+ if (gradient.circle) {
3755
+ rx = ry = corner ? Math.hypot(sx, sy) : side(sx, sy);
3756
+ } else {
3757
+ // through the corner, in the shape the sides give it
3758
+ rx = corner ? sx * Math.SQRT2 : sx;
3759
+ ry = corner ? sy * Math.SQRT2 : sy;
3760
+ }
3761
+ }
3762
+ if (!(rx > 0 && ry > 0)) {
3763
+ return { style: colors[colors.length - 1], matrix: null };
3764
+ }
3765
+ if (!hasRadialGradients(ctx)) return null;
3766
+ let at = stopOffsets(gradient.stops, rx);
3767
+ let inks = colors;
3768
+ if (at[0] < 0) {
3769
+ // the colour at the centre, between the stops either side of it
3770
+ const first = colorAlong(at, colors, 0);
3771
+ const i = at.findIndex((t) => t > 0);
3772
+ if (i < 0) return { style: first, matrix: null };
3773
+ at = [0, ...at.slice(i)];
3774
+ inks = [first, ...colors.slice(i)];
3775
+ }
3776
+ const ax = x + cx;
3777
+ const ay = y + cy;
3778
+ const seen = visible ?? { x, y, w, h };
3779
+ if (!(Math.abs(ax) < RADIAL_REACH && Math.abs(ay) < RADIAL_REACH)) {
3780
+ return farRadial(ctx, at, inks, ax, ay, rx, ry, seen);
3781
+ }
3782
+ let to = Math.max(1, at[at.length - 1]);
3783
+ if (Math.max(rx, ry) * to > GRADIENT_REACH) {
3784
+ // as far along the ray as what is seen of it reaches, and no further:
3785
+ // a circle a context cannot carry, cut to the part it draws
3786
+ let reach = 0;
3787
+ for (const px of [seen.x, seen.x + seen.w]) {
3788
+ for (const py of [seen.y, seen.y + seen.h]) {
3789
+ reach = Math.max(reach, Math.hypot((px - ax) / rx, (py - ay) / ry));
3790
+ }
3791
+ }
3792
+ if (reach > 0 && reach < to) {
3793
+ const last = colorAlong(at, inks, reach);
3794
+ const kept = at.filter((t) => t < reach).length;
3795
+ at = [...at.slice(0, kept), reach];
3796
+ inks = [...inks.slice(0, kept), last];
3797
+ to = reach;
3798
+ }
3799
+ }
3800
+ const circle = rx === ry;
3801
+ // a circle as wide as the ellipse is narrow, stretched along the other
3802
+ // axis: the matrix only ever widens, so its inverse, which a context
3803
+ // samples the gradient through, reaches no further than the centre does
3804
+ const r = Math.min(rx, ry);
3805
+ const g = circle
3806
+ ? ctx.createRadialGradient!(ax, ay, 0, ax, ay, r * to)
3807
+ : ctx.createRadialGradient!(0, 0, 0, 0, 0, r * to);
3808
+ for (let i = 0; i < at.length; i += 1) {
3809
+ g.addColorStop(at[i] / to, inks[i]);
3810
+ }
3811
+ return {
3812
+ style: g,
3813
+ matrix: circle ? null : [rx / r, 0, 0, ry / r, ax, ay],
3814
+ };
3815
+ }
3816
+
3817
+ /**
3818
+ * How far from the origin a radial gradient's centre may be and still be
3819
+ * the context's to draw: ntk makes a gradient past what X RENDER carries at
3820
+ * a scale that fits, and its 16.16 matrix holds that scale to a fraction of
3821
+ * a pixel to here.
3822
+ */
3823
+ const RADIAL_REACH = 1 << 18;
3824
+
3825
+ /**
3826
+ * A radial gradient whose centre is further off than a context carries —
3827
+ * `at 0 calc(infinity * 1px)`, which comes to the largest length there is —
3828
+ * across the part of it that is seen: from that far its rings are as good
3829
+ * as straight, so it is drawn as the linear gradient that runs the way the
3830
+ * rings spread there, as fast as they do, cut to what is seen as a linear
3831
+ * gradient's line is. The rings bend away from it by the square of what is
3832
+ * seen over eight times the distance, under a pixel from here.
3833
+ */
3834
+ function farRadial(
3835
+ ctx: PaintContext,
3836
+ at: number[],
3837
+ colors: string[],
3838
+ ax: number,
3839
+ ay: number,
3840
+ rx: number,
3841
+ ry: number,
3842
+ seen: { x: number; y: number; w: number; h: number },
3843
+ ): GradientFill | null {
3844
+ if (!ctx.createLinearGradient) return null;
3845
+ // the middle of what is seen, in the circle's own space, and how fast
3846
+ // the gradient runs there, a pixel
3847
+ const px = seen.x + seen.w / 2;
3848
+ const py = seen.y + seen.h / 2;
3849
+ const ux = (px - ax) / rx;
3850
+ const uy = (py - ay) / ry;
3851
+ const t = Math.hypot(ux, uy);
3852
+ const gx = ux / rx / t;
3853
+ const gy = uy / ry / t;
3854
+ const g2 = gx * gx + gy * gy;
3855
+ if (!(t > 0 && g2 > 0 && Number.isFinite(t / g2))) {
3856
+ return { style: colorAlong(at, colors, t), matrix: null };
3857
+ }
3858
+ // the line it runs along, from where it would start to where it is 1
3859
+ const lx = gx / g2;
3860
+ const ly = gy / g2;
3861
+ return {
3862
+ style: clippedGradient(
3863
+ ctx,
3864
+ at,
3865
+ colors,
3866
+ px - lx * t,
3867
+ py - ly * t,
3868
+ lx,
3869
+ ly,
3870
+ seen,
3871
+ ),
3872
+ matrix: null,
3873
+ };
3874
+ }
3875
+
3876
+ /**
3877
+ * Whether a context draws radial gradients. react-x11's macOS and Windows
3878
+ * contexts have the method and paint what it returns flat, in one colour
3879
+ * — over whatever a vignette was meant to let through — and theirs takes
3880
+ * no circles, which is how it is told from one that draws them: ntk's and
3881
+ * the Wayland context's take the six numbers canvas gives it. There a
3882
+ * radial gradient is drawn as nothing, as every one was.
3883
+ */
3884
+ function hasRadialGradients(ctx: PaintContext): boolean {
3885
+ return (ctx.createRadialGradient?.length ?? 0) >= 6;
3886
+ }
3887
+
3888
+ /**
3889
+ * Fills a rectangle, rounded where `corners` says, with a gradient. An
3890
+ * ellipse's shape is laid down first and filled under its matrix, which a
3891
+ * context applies to the fill's paint and not to a path it already has.
3892
+ */
3893
+ function fillGradient(
3894
+ ctx: PaintContext,
3895
+ fill: GradientFill,
3896
+ r: { x: number; y: number; w: number; h: number },
3897
+ corners: Corners | null = null,
3898
+ ): void {
3899
+ ctx.fillStyle = fill.style;
3900
+ if (!fill.matrix) {
3901
+ if (corners) {
3902
+ ctx.beginPath!();
3903
+ roundedRect(ctx, r.x, r.y, r.w, r.h, corners);
3904
+ ctx.fill!();
3905
+ } else ctx.fillRect(r.x, r.y, r.w, r.h);
3906
+ return;
3907
+ }
3908
+ if (!ctx.transform || !ctx.beginPath || !ctx.rect || !ctx.fill) return;
3909
+ ctx.save();
3910
+ ctx.beginPath();
3911
+ if (corners) roundedRect(ctx, r.x, r.y, r.w, r.h, corners);
3912
+ else ctx.rect(r.x, r.y, r.w, r.h);
3913
+ ctx.transform(...fill.matrix);
3914
+ ctx.fill();
3915
+ ctx.restore();
3916
+ }
3917
+
2920
3918
  /**
2921
3919
  * How far from the origin a gradient's line may end. X RENDER takes the
2922
3920
  * ends in 16.16 fixed point, and a pair past ±32,767 pixels threw from the
@@ -2972,30 +3970,32 @@ function clippedGradient(
2972
3970
  cx + (t1 - tc) * lx,
2973
3971
  cy + (t1 - tc) * ly,
2974
3972
  );
2975
- // the colour at a place on the line: a stop's, or between two
2976
- const colorAt = (t: number): string => {
2977
- if (!(t > at[0])) return colors[0];
2978
- for (let i = 1; i < at.length; i += 1) {
2979
- if (t > at[i]) continue;
2980
- const span = at[i] - at[i - 1];
2981
- const f = span > 0 ? (t - at[i - 1]) / span : 1;
2982
- return (
2983
- blend(colors[i - 1], colors[i], f) ??
2984
- (f < 0.5 ? colors[i - 1] : colors[i])
2985
- );
2986
- }
2987
- return colors[colors.length - 1];
2988
- };
2989
- g.addColorStop(0, colorAt(t0));
3973
+ g.addColorStop(0, colorAlong(at, colors, t0));
2990
3974
  for (let i = 0; i < at.length; i += 1) {
2991
3975
  if (at[i] > t0 && at[i] < t1) {
2992
3976
  g.addColorStop((at[i] - t0) / (t1 - t0), colors[i]);
2993
3977
  }
2994
3978
  }
2995
- g.addColorStop(1, colorAt(t1));
3979
+ g.addColorStop(1, colorAlong(at, colors, t1));
2996
3980
  return g;
2997
3981
  }
2998
3982
 
3983
+ /** The colour at a place along a gradient whose stops are at `at`: a
3984
+ * stop's, or between two. */
3985
+ function colorAlong(at: number[], colors: string[], t: number): string {
3986
+ if (!(t > at[0])) return colors[0];
3987
+ for (let i = 1; i < at.length; i += 1) {
3988
+ if (t > at[i]) continue;
3989
+ const span = at[i] - at[i - 1];
3990
+ const f = span > 0 ? (t - at[i - 1]) / span : 1;
3991
+ return (
3992
+ blend(colors[i - 1], colors[i], f) ??
3993
+ (f < 0.5 ? colors[i - 1] : colors[i])
3994
+ );
3995
+ }
3996
+ return colors[colors.length - 1];
3997
+ }
3998
+
2999
3999
  /**
3000
4000
  * The backgrounds of a table's column groups, columns, row groups and rows
3001
4001
  * (CSS 2.1 17.5.1), in that order: over the table's own and under its
@@ -3403,16 +4403,6 @@ function tileSize(
3403
4403
  return [area.width, area.height];
3404
4404
  }
3405
4405
 
3406
- /**
3407
- * Borders, as four rectangles.
3408
- *
3409
- * Not as a stroked path, for the reason richtext gives about its underlines:
3410
- * the mock backend has no path API, and a 1px border on a pixel grid is a
3411
- * rectangle rather than something an antialiased stroke improves. Corners are
3412
- * mitred by drawing the top and bottom full-width and the sides between them,
3413
- * which is right whenever the two sides share a colour and close enough when
3414
- * they do not.
3415
- */
3416
4406
  /**
3417
4407
  * A box's outline (CSS 2.1 18.4, CSS UI 4 5): a border of its own width,
3418
4408
  * style and colour round the border box grown by `outline-offset` — which
@@ -3471,6 +4461,25 @@ function paintOutline(
3471
4461
  );
3472
4462
  }
3473
4463
 
4464
+ /**
4465
+ * Borders, a side at a time.
4466
+ *
4467
+ * As rectangles where they can be, the top and the bottom full width and
4468
+ * the sides between them, and not as a stroked path, for the reason
4469
+ * richtext gives about its underlines: the mock backend has no path API, and
4470
+ * a 1px border on a pixel grid is a rectangle rather than something an
4471
+ * antialiased stroke improves. That is every border whose sides share a
4472
+ * colour, where a corner looks the same whichever side it is given to.
4473
+ *
4474
+ * Where two sides that meet differ in colour — one of them transparent
4475
+ * included — the corner is cut on the diagonal from its outer point to its
4476
+ * inner one and each side takes its half (CSS Backgrounds 3, 4.3), so a side
4477
+ * is a trapezoid, and a triangle where the box inside the borders has no
4478
+ * width: the CSS triangle a dropdown's caret and a tooltip's arrow are
4479
+ * drawn with, which came out as the rectangle around it. Only a solid side
4480
+ * is cut, and only against a side that is solid or paints nothing; dots,
4481
+ * dashes and a double border's two lines keep their rectangles.
4482
+ */
3474
4483
  function paintBorders(
3475
4484
  ctx: PaintContext,
3476
4485
  box: Frame,
@@ -3490,6 +4499,30 @@ function paintBorders(
3490
4499
  if (roundedRing(ctx, box, x, y, w, h, options)) return;
3491
4500
  if (sculpted(s) && paintSculpted(ctx, box, x, y, w, h, options)) return;
3492
4501
 
4502
+ const t = box.borderTop;
4503
+ const r = box.borderRight;
4504
+ const b = box.borderBottom;
4505
+ const l = box.borderLeft;
4506
+ /** What a side is painted in, null where it paints nothing. */
4507
+ const ink = (width: number, color: string): string | null =>
4508
+ width > 0 && !isTransparent(color) ? inkColor(color, s.color) : null;
4509
+ const topInk = ink(t, s.borderTopColor);
4510
+ const rightInk = ink(r, s.borderRightColor);
4511
+ const bottomInk = ink(b, s.borderBottomColor);
4512
+ const leftInk = ink(l, s.borderLeftColor);
4513
+ // the corners cut on the diagonal; none where the context draws no path,
4514
+ // and the sides are rectangles
4515
+ const paths = !!(ctx.beginPath && ctx.moveTo && ctx.lineTo && ctx.fill);
4516
+ const topStyle = s.borderTopStyle;
4517
+ const rightStyle = s.borderRightStyle;
4518
+ const bottomStyle = s.borderBottomStyle;
4519
+ const leftStyle = s.borderLeftStyle;
4520
+ const tl = paths && mitred(t, l, topInk, leftInk, topStyle, leftStyle);
4521
+ const tr = paths && mitred(t, r, topInk, rightInk, topStyle, rightStyle);
4522
+ const br =
4523
+ paths && mitred(b, r, bottomInk, rightInk, bottomStyle, rightStyle);
4524
+ const bl = paths && mitred(b, l, bottomInk, leftInk, bottomStyle, leftStyle);
4525
+
3493
4526
  const edge = (
3494
4527
  ex: number,
3495
4528
  ey: number,
@@ -3501,47 +4534,130 @@ function paintBorders(
3501
4534
  ): void => {
3502
4535
  const rect = clampRect(options, ex, ey, ew, eh);
3503
4536
  if (!rect) return;
3504
- ctx.fillStyle = inkColor(color, s.color);
4537
+ ctx.fillStyle = color;
3505
4538
  // The un-clamped start is the dash phase's origin, so the pattern does
3506
4539
  // not crawl as the viewport moves along a long edge.
3507
4540
  fillEdge(ctx, rect, horizontal ? ex : ey, style, horizontal);
3508
4541
  };
3509
- if (box.borderTop > 0 && !isTransparent(s.borderTopColor)) {
3510
- edge(x, y, w, box.borderTop, s.borderTopStyle, s.borderTopColor, true);
4542
+ /** A side with a cut corner: its outer edge and then its inner one. */
4543
+ const side = (points: number[], color: string): void => {
4544
+ const area = clampRect(options, x, y, w, h);
4545
+ if (!area) return;
4546
+ ctx.fillStyle = color;
4547
+ fillPolygon(ctx, points, area);
4548
+ };
4549
+ // the border box's far edges, and the padding box inside it
4550
+ const x1 = x + w;
4551
+ const y1 = y + h;
4552
+ const px0 = x + l;
4553
+ const py0 = y + t;
4554
+ const px1 = Math.max(px0, x1 - r);
4555
+ const py1 = Math.max(py0, y1 - b);
4556
+ // The left and the right are drawn after the top and the bottom. Where
4557
+ // one of them is opaque it draws its half of a cut corner over the side
4558
+ // drawn before, which keeps the whole corner: two halves each
4559
+ // antialiased along the diagonal would let what is under the border
4560
+ // show through between them.
4561
+ const over = (color: string | null): boolean =>
4562
+ color !== null && alphaOf(color) === 1;
4563
+ if (topInk !== null) {
4564
+ const cutLeft = tl && !over(leftInk);
4565
+ const cutRight = tr && !over(rightInk);
4566
+ if (cutLeft || cutRight) {
4567
+ side(
4568
+ [x, y, x1, y, cutRight ? px1 : x1, py0, cutLeft ? px0 : x, py0],
4569
+ topInk,
4570
+ );
4571
+ } else edge(x, y, w, t, topStyle, topInk, true);
4572
+ }
4573
+ if (bottomInk !== null) {
4574
+ const cutLeft = bl && !over(leftInk);
4575
+ const cutRight = br && !over(rightInk);
4576
+ if (cutLeft || cutRight) {
4577
+ side(
4578
+ [x1, y1, x, y1, cutLeft ? px0 : x, py1, cutRight ? px1 : x1, py1],
4579
+ bottomInk,
4580
+ );
4581
+ } else edge(x, y1 - b, w, b, bottomStyle, bottomInk, true);
3511
4582
  }
3512
- if (box.borderBottom > 0 && !isTransparent(s.borderBottomColor)) {
3513
- edge(
3514
- x,
3515
- y + h - box.borderBottom,
3516
- w,
3517
- box.borderBottom,
3518
- s.borderBottomStyle,
3519
- s.borderBottomColor,
3520
- true,
3521
- );
4583
+ if (leftInk !== null) {
4584
+ if (tl || bl) {
4585
+ side([x, bl ? y1 : py1, x, tl ? y : py0, px0, py0, px0, py1], leftInk);
4586
+ } else edge(x, y + t, l, h - t - b, leftStyle, leftInk, false);
3522
4587
  }
3523
- if (box.borderLeft > 0 && !isTransparent(s.borderLeftColor)) {
3524
- edge(
3525
- x,
3526
- y + box.borderTop,
3527
- box.borderLeft,
3528
- h - box.borderTop - box.borderBottom,
3529
- s.borderLeftStyle,
3530
- s.borderLeftColor,
3531
- false,
3532
- );
4588
+ if (rightInk !== null) {
4589
+ if (tr || br) {
4590
+ side([x1, tr ? y : py0, x1, br ? y1 : py1, px1, py1, px1, py0], rightInk);
4591
+ } else edge(x1 - r, y + t, r, h - t - b, rightStyle, rightInk, false);
3533
4592
  }
3534
- if (box.borderRight > 0 && !isTransparent(s.borderRightColor)) {
3535
- edge(
3536
- x + w - box.borderRight,
3537
- y + box.borderTop,
3538
- box.borderRight,
3539
- h - box.borderTop - box.borderBottom,
3540
- s.borderRightStyle,
3541
- s.borderRightColor,
3542
- false,
3543
- );
4593
+ }
4594
+
4595
+ /**
4596
+ * Whether the corner two sides meet at is cut on its diagonal, each side
4597
+ * taking its half (CSS Backgrounds 3, 4.3): where both have a width, they
4598
+ * are painted in different colours or only one is painted at all, and each
4599
+ * is solid or unpainted. Sides of one colour make the same corner either
4600
+ * way, and so does a corner of a single pixel, which is left to the side
4601
+ * that has it.
4602
+ */
4603
+ function mitred(
4604
+ a: number,
4605
+ b: number,
4606
+ inkA: string | null,
4607
+ inkB: string | null,
4608
+ styleA: ComputedStyle['borderTopStyle'],
4609
+ styleB: ComputedStyle['borderTopStyle'],
4610
+ ): boolean {
4611
+ if (!(a > 0 && b > 0) || (a <= 1 && b <= 1) || inkA === inkB) return false;
4612
+ return (
4613
+ (inkA === null || styleA === 'solid') &&
4614
+ (inkB === null || styleB === 'solid')
4615
+ );
4616
+ }
4617
+
4618
+ /**
4619
+ * Fill a convex polygon, its corners as `x, y` pairs, cut to `area`: the
4620
+ * part of it near what is painted, which is what keeps a side thousands of
4621
+ * pixels long inside X's coordinates. Cut edge by edge rather than by
4622
+ * moving its corners in, which would turn a diagonal that crosses the
4623
+ * painted area: a slanted divider, `border-left: 100vw solid transparent`,
4624
+ * has a corner far outside it.
4625
+ */
4626
+ function fillPolygon(
4627
+ ctx: PaintContext,
4628
+ points: number[],
4629
+ area: { x: number; y: number; w: number; h: number },
4630
+ ): void {
4631
+ const bounds = [area.x, area.y, area.x + area.w, area.y + area.h];
4632
+ let cut = points;
4633
+ // each edge of the area in turn: what is inside it is kept, and an edge
4634
+ // of the polygon that crosses it ends on it (Sutherland–Hodgman)
4635
+ for (let edge = 0; edge < 4 && cut.length; edge += 1) {
4636
+ const axis = edge & 1;
4637
+ const bound = bounds[edge];
4638
+ const sign = edge < 2 ? 1 : -1;
4639
+ const from = cut;
4640
+ cut = [];
4641
+ for (let i = 0; i < from.length; i += 2) {
4642
+ const j = (i + 2) % from.length;
4643
+ const da = sign * (from[i + axis] - bound);
4644
+ const db = sign * (from[j + axis] - bound);
4645
+ if (da >= 0) cut.push(from[i], from[i + 1]);
4646
+ if (da < 0 !== db < 0) {
4647
+ const k = da / (da - db);
4648
+ const other =
4649
+ from[i + 1 - axis] + (from[j + 1 - axis] - from[i + 1 - axis]) * k;
4650
+ if (axis) cut.push(other, bound);
4651
+ else cut.push(bound, other);
4652
+ }
4653
+ }
3544
4654
  }
4655
+ if (cut.length < 6) return;
4656
+ ctx.beginPath!();
4657
+ ctx.moveTo!(cut[0], cut[1]);
4658
+ for (let i = 2; i < cut.length; i += 2) ctx.lineTo!(cut[i], cut[i + 1]);
4659
+ ctx.closePath?.();
4660
+ ctx.fill!();
3545
4661
  }
3546
4662
 
3547
4663
  /**
@@ -3605,17 +4721,9 @@ function paintBorderImage(
3605
4721
  h,
3606
4722
  (sctx) => {
3607
4723
  if (svg) svg.draw(sctx, 0, 0, w, h, scale);
3608
- else if (typeof source !== 'string' && sctx.createLinearGradient) {
3609
- sctx.fillStyle = linearGradient(
3610
- sctx,
3611
- source,
3612
- 0,
3613
- 0,
3614
- w,
3615
- h,
3616
- box.style.color,
3617
- );
3618
- sctx.fillRect(0, 0, w, h);
4724
+ else if (typeof source !== 'string') {
4725
+ const paint = gradientFill(sctx, source, 0, 0, w, h, box.style.color);
4726
+ if (paint) fillGradient(sctx, paint, { x: 0, y: 0, w, h });
3619
4727
  }
3620
4728
  },
3621
4729
  );
@@ -3739,11 +4847,11 @@ function paintBorderImage(
3739
4847
  return true;
3740
4848
  }
3741
4849
 
3742
- const GRADIENT_KEYS = new WeakMap<LinearGradient, string>();
4850
+ const GRADIENT_KEYS = new WeakMap<Gradient, string>();
3743
4851
 
3744
4852
  /** A gradient as a key for what is drawn of it: its angle and stops, and
3745
4853
  * the colour `currentColor` among them is. */
3746
- function gradientKey(gradient: LinearGradient, color: string): string {
4854
+ function gradientKey(gradient: Gradient, color: string): string {
3747
4855
  let key = GRADIENT_KEYS.get(gradient);
3748
4856
  if (key === undefined) {
3749
4857
  key = JSON.stringify(gradient);
@@ -4317,13 +5425,20 @@ function fitted(
4317
5425
  }
4318
5426
 
4319
5427
  /** An inline `<svg>`, drawn in its content box. Its `currentColor` is the
4320
- * box's `color`, as an icon's is the text's around it. */
5428
+ * box's `color`, as an icon's is the text's around it, and what it is
5429
+ * painted with the box's `fill` and `stroke`, where the document's style
5430
+ * sheets set them: an icon set's `.icon { fill: currentColor }`. */
4321
5431
  function paintSvg(ctx: PaintContext, box: Box, options: PaintOptions): void {
4322
5432
  if (!box.el) return;
4323
5433
  const x = Math.round(box.contentX + options.originX);
4324
5434
  const y = Math.round(box.contentY + options.originY);
4325
5435
  const w = Math.round(box.contentX + options.originX + box.contentWidth) - x;
4326
5436
  const h = Math.round(box.contentY + options.originY + box.contentHeight) - y;
5437
+ const style = box.style;
5438
+ const paint = (value: string | null): string | null =>
5439
+ value === null || value === 'currentColor' || value === 'none'
5440
+ ? value
5441
+ : inkColor(value, style.color);
4327
5442
  inlineDrawing(box.el).draw(
4328
5443
  ctx,
4329
5444
  x,
@@ -4331,7 +5446,9 @@ function paintSvg(ctx: PaintContext, box: Box, options: PaintOptions): void {
4331
5446
  w,
4332
5447
  h,
4333
5448
  options.scale ?? 1,
4334
- box.style.color,
5449
+ style.color,
5450
+ paint(style.fill),
5451
+ paint(style.stroke),
4335
5452
  );
4336
5453
  }
4337
5454
 
@@ -4374,14 +5491,19 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4374
5491
  const above = ink?.above ?? 0;
4375
5492
  const below = ink?.below ?? 0;
4376
5493
  const slack = top - box.maxLineHeight - below;
4377
- while (lo < hi) {
5494
+ // the lines columns took apart are in no order down the page
5495
+ const apart = columned.any && COLUMN_LINES.has(lines);
5496
+ while (!apart && lo < hi) {
4378
5497
  const mid = (lo + hi) >> 1;
4379
5498
  if (lines[mid].y > slack) hi = mid;
4380
5499
  else lo = mid + 1;
4381
5500
  }
4382
5501
  for (let i = lo; i < lines.length; i += 1) {
4383
5502
  const line = lines[i];
4384
- if (line.y - above >= bottom) break;
5503
+ if (line.y - above >= bottom) {
5504
+ if (apart) continue;
5505
+ break;
5506
+ }
4385
5507
  if (line.y < bottom && line.y + line.height > top) visible.push(line);
4386
5508
  else if (ink && inkInto(line, top, bottom)) {
4387
5509
  visible.push(line);
@@ -4415,14 +5537,17 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4415
5537
  );
4416
5538
  for (const text of line.texts) {
4417
5539
  const natural = text.layout.lines[text.layoutLine];
4418
- if (natural)
5540
+ if (!natural) continue;
5541
+ for (const laid of [natural, trailOf(text, natural)]) {
5542
+ if (!laid) continue;
4419
5543
  paintRunBackgrounds(
4420
5544
  ctx,
4421
- natural,
5545
+ laid,
4422
5546
  text.drawX + dx,
4423
5547
  text.drawY + dy,
4424
5548
  options.scale ?? 1,
4425
5549
  );
5550
+ }
4426
5551
  }
4427
5552
  paintSelection(ctx, line, options);
4428
5553
  }
@@ -4445,8 +5570,10 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4445
5570
  : null;
4446
5571
  for (const line of visible) {
4447
5572
  for (const text of line.texts) {
4448
- if (drawn.has(text.layout)) continue;
4449
- drawn.add(text.layout);
5573
+ // once for each column its lines are in, where they are in several
5574
+ const rows = columned.any ? COLUMN_ROWS.get(text) : undefined;
5575
+ if (drawn.has(rows ?? text.layout)) continue;
5576
+ drawn.add(rows ?? text.layout);
4450
5577
  const top = text.drawY + dy;
4451
5578
  if (top < -COORD_LIMIT || top + text.layout.height > COORD_LIMIT) {
4452
5579
  // A single layout so tall its own lines overflow the Int16 envelope
@@ -4457,8 +5584,11 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4457
5584
  continue;
4458
5585
  }
4459
5586
  const bands = recolored?.get(text.layout);
5587
+ const clipped =
5588
+ rows !== undefined && clipToRows(ctx, text, rows, text.drawX + dx, top);
4460
5589
  if (bands) drawRecolored(ctx, text.layout, text.drawX + dx, top, bands);
4461
5590
  else text.layout.draw(ctx, text.drawX + dx, top);
5591
+ if (clipped) ctx.restore();
4462
5592
  }
4463
5593
  }
4464
5594
 
@@ -4471,7 +5601,8 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4471
5601
  for (const line of visible) {
4472
5602
  for (const placed of line.atomics) {
4473
5603
  if (hoisted && HOISTED.has(placed.box)) continue;
4474
- paintBox(ctx, placed.box, options);
5604
+ if (turns(placed.box)) paintTransformed(ctx, placed.box, options);
5605
+ else paintBox(ctx, placed.box, options);
4475
5606
  }
4476
5607
  }
4477
5608
  }
@@ -4648,12 +5779,14 @@ function paintClippedText(
4648
5779
  const gradient =
4649
5780
  style.backgroundGradient ??
4650
5781
  (style.backgroundImages?.find(
4651
- (image): image is LinearGradient =>
5782
+ (image): image is Gradient =>
4652
5783
  image !== null && typeof image !== 'string',
4653
5784
  ) ||
4654
5785
  null);
4655
5786
  let fill: unknown = null;
4656
- if (gradient && ctx.createLinearGradient) {
5787
+ // where the fill is a picture of the gradient, the corner it starts at
5788
+ let origin: { x: number; y: number } | null = null;
5789
+ if (gradient) {
4657
5790
  if (!(area.width > 0 && area.height > 0)) continue;
4658
5791
  // the text it shows through: what the fill is drawn over
4659
5792
  let x0 = Infinity;
@@ -4668,7 +5801,7 @@ function paintClippedText(
4668
5801
  y1 = Math.max(y1, r.y + r.height);
4669
5802
  }
4670
5803
  }
4671
- fill = linearGradient(
5804
+ const paint = gradientFill(
4672
5805
  ctx,
4673
5806
  gradient,
4674
5807
  area.x,
@@ -4678,7 +5811,40 @@ function paintClippedText(
4678
5811
  style.color,
4679
5812
  x1 > x0 ? { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } : null,
4680
5813
  );
4681
- } else if (!isTransparent(style.backgroundColor)) {
5814
+ if (paint?.matrix) {
5815
+ // An ellipse is a circle filled through a matrix, which would draw
5816
+ // the glyphs through it too: they are filled with a picture of the
5817
+ // gradient instead, drawn once at the area's size
5818
+ const left = Math.round(area.x);
5819
+ const top = Math.round(area.y);
5820
+ const w = Math.max(1, Math.round(area.x + area.width) - left);
5821
+ const h = Math.max(1, Math.round(area.y + area.height) - top);
5822
+ const picture =
5823
+ ctx.createPattern && ctx.translate
5824
+ ? options.cached?.(
5825
+ `clip-text|${gradientKey(gradient, style.color)}|${w}x${h}`,
5826
+ w,
5827
+ h,
5828
+ (sctx) => {
5829
+ const p = gradientFill(
5830
+ sctx,
5831
+ gradient,
5832
+ 0,
5833
+ 0,
5834
+ w,
5835
+ h,
5836
+ style.color,
5837
+ );
5838
+ if (p) fillGradient(sctx, p, { x: 0, y: 0, w, h });
5839
+ },
5840
+ )
5841
+ : null;
5842
+ if (!picture) continue;
5843
+ fill = ctx.createPattern!(picture, 'no-repeat');
5844
+ origin = { x: left, y: top };
5845
+ } else if (paint) fill = paint.style;
5846
+ }
5847
+ if (fill === null && !isTransparent(style.backgroundColor)) {
4682
5848
  fill = inkColor(style.backgroundColor as string, style.color);
4683
5849
  }
4684
5850
  if (fill === null) continue;
@@ -4690,7 +5856,11 @@ function paintClippedText(
4690
5856
  for (const r of part.rects) ctx.rect(r.x, r.y, r.width, r.height);
4691
5857
  ctx.clip();
4692
5858
  ctx.fillStyle = fill;
4693
- inkless.draw(ctx, part.x, part.y);
5859
+ if (origin) {
5860
+ // a pattern is placed from the origin of the space it fills
5861
+ ctx.translate!(origin.x, origin.y);
5862
+ inkless.draw(ctx, part.x - origin.x, part.y - origin.y);
5863
+ } else inkless.draw(ctx, part.x, part.y);
4694
5864
  ctx.restore();
4695
5865
  }
4696
5866
  }
@@ -4730,10 +5900,15 @@ function paintTextShadows(
4730
5900
  if (cast === null) continue;
4731
5901
  const left = text.drawX + dx;
4732
5902
  if (cast !== MIXED) {
4733
- // every run casts the same: the layout's once for each shadow
4734
- if (whole.has(layout)) continue;
4735
- whole.add(layout);
5903
+ // every run casts the same: the layout's once for each shadow, in
5904
+ // each column its lines are in
5905
+ const rows = columned.any ? COLUMN_ROWS.get(text) : undefined;
5906
+ if (whole.has(rows ?? layout)) continue;
5907
+ whole.add(rows ?? layout);
5908
+ const clipped =
5909
+ rows !== undefined && clipToRows(ctx, text, rows, left, top);
4736
5910
  castShadows(ctx, text, left, top, cast, null);
5911
+ if (clipped) ctx.restore();
4737
5912
  continue;
4738
5913
  }
4739
5914
  const natural = layout.lines[text.layoutLine];
@@ -4865,10 +6040,12 @@ function paintRules(
4865
6040
  const shifted = SHIFTED_LINES.has(line);
4866
6041
  for (const text of line.texts) {
4867
6042
  const natural = text.layout.lines[text.layoutLine];
4868
- if (natural) {
6043
+ if (!natural) continue;
6044
+ for (const laid of [natural, trailOf(text, natural)]) {
6045
+ if (!laid) continue;
4869
6046
  paintRunRules(
4870
6047
  ctx,
4871
- natural,
6048
+ laid,
4872
6049
  text.drawX + dx,
4873
6050
  text.drawY + dy + (shifted ? ruleDrop(text) : 0),
4874
6051
  scale,
@@ -4879,6 +6056,29 @@ function paintRules(
4879
6056
  }
4880
6057
  }
4881
6058
 
6059
+ /**
6060
+ * The spaces a text ends on where its line goes on after them
6061
+ * (`LineText.trail`), as a line of one run to draw the decorations of: the
6062
+ * run they are the end of, as wide as they are, after the line's content.
6063
+ * The engine stripped them from the piece it laid out as a line, and a
6064
+ * link's underline stopped at its last letter, a space short of where a
6065
+ * browser draws it. Null where the text has none, or reads right to left.
6066
+ */
6067
+ function trailOf<L extends LineText['layout']['lines'][number]>(
6068
+ text: LineText,
6069
+ natural: L,
6070
+ ): L | null {
6071
+ if (!text.trail || !text.trailRun) return null;
6072
+ const last = natural.runs[natural.runs.length - 1];
6073
+ if (!last || last.run?.direction === 'rtl') return null;
6074
+ return {
6075
+ ...natural,
6076
+ runs: [
6077
+ { ...last, x: natural.width, width: text.trail, span: text.trailRun },
6078
+ ],
6079
+ };
6080
+ }
6081
+
4882
6082
  /**
4883
6083
  * How far below a raised text its rules go: to the baseline of the element
4884
6084
  * that decorates it. `vertical-align` moves the text and not the lines an