@react-x11/components 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) 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 +42 -1
  6. package/dist/html/css/cascade.d.ts.map +1 -1
  7. package/dist/html/css/cascade.js +336 -16
  8. package/dist/html/css/cascade.js.map +1 -1
  9. package/dist/html/css/color.d.ts +18 -0
  10. package/dist/html/css/color.d.ts.map +1 -1
  11. package/dist/html/css/color.js +27 -0
  12. package/dist/html/css/color.js.map +1 -1
  13. package/dist/html/css/parse.d.ts +18 -4
  14. package/dist/html/css/parse.d.ts.map +1 -1
  15. package/dist/html/css/parse.js +66 -28
  16. package/dist/html/css/parse.js.map +1 -1
  17. package/dist/html/css/shapes.d.ts +44 -0
  18. package/dist/html/css/shapes.d.ts.map +1 -0
  19. package/dist/html/css/shapes.js +194 -0
  20. package/dist/html/css/shapes.js.map +1 -0
  21. package/dist/html/css/style.d.ts +128 -15
  22. package/dist/html/css/style.d.ts.map +1 -1
  23. package/dist/html/css/style.js +547 -144
  24. package/dist/html/css/style.js.map +1 -1
  25. package/dist/html/css/transform.d.ts +85 -0
  26. package/dist/html/css/transform.d.ts.map +1 -0
  27. package/dist/html/css/transform.js +425 -0
  28. package/dist/html/css/transform.js.map +1 -0
  29. package/dist/html/css/ua.d.ts.map +1 -1
  30. package/dist/html/css/ua.js +14 -2
  31. package/dist/html/css/ua.js.map +1 -1
  32. package/dist/html/css/values.d.ts +4 -0
  33. package/dist/html/css/values.d.ts.map +1 -1
  34. package/dist/html/css/values.js.map +1 -1
  35. package/dist/html/fonts.d.ts +55 -5
  36. package/dist/html/fonts.d.ts.map +1 -1
  37. package/dist/html/fonts.js +284 -23
  38. package/dist/html/fonts.js.map +1 -1
  39. package/dist/html/layout/axes.d.ts +14 -0
  40. package/dist/html/layout/axes.d.ts.map +1 -0
  41. package/dist/html/layout/axes.js +28 -0
  42. package/dist/html/layout/axes.js.map +1 -0
  43. package/dist/html/layout/block.d.ts +104 -12
  44. package/dist/html/layout/block.d.ts.map +1 -1
  45. package/dist/html/layout/block.js +410 -39
  46. package/dist/html/layout/block.js.map +1 -1
  47. package/dist/html/layout/boxes.d.ts +72 -1
  48. package/dist/html/layout/boxes.d.ts.map +1 -1
  49. package/dist/html/layout/boxes.js +40 -2
  50. package/dist/html/layout/boxes.js.map +1 -1
  51. package/dist/html/layout/cache.d.ts +1 -1
  52. package/dist/html/layout/cache.d.ts.map +1 -1
  53. package/dist/html/layout/cache.js +6 -0
  54. package/dist/html/layout/cache.js.map +1 -1
  55. package/dist/html/layout/css-grid.d.ts.map +1 -1
  56. package/dist/html/layout/css-grid.js +144 -24
  57. package/dist/html/layout/css-grid.js.map +1 -1
  58. package/dist/html/layout/flex.d.ts.map +1 -1
  59. package/dist/html/layout/flex.js +585 -85
  60. package/dist/html/layout/flex.js.map +1 -1
  61. package/dist/html/layout/inline.d.ts +3 -0
  62. package/dist/html/layout/inline.d.ts.map +1 -1
  63. package/dist/html/layout/inline.js +144 -54
  64. package/dist/html/layout/inline.js.map +1 -1
  65. package/dist/html/layout/multicol.d.ts +42 -0
  66. package/dist/html/layout/multicol.d.ts.map +1 -0
  67. package/dist/html/layout/multicol.js +897 -0
  68. package/dist/html/layout/multicol.js.map +1 -0
  69. package/dist/html/layout/shaping.d.ts +7 -0
  70. package/dist/html/layout/shaping.d.ts.map +1 -1
  71. package/dist/html/layout/shaping.js +25 -20
  72. package/dist/html/layout/shaping.js.map +1 -1
  73. package/dist/html/layout/table.d.ts.map +1 -1
  74. package/dist/html/layout/table.js +97 -31
  75. package/dist/html/layout/table.js.map +1 -1
  76. package/dist/html/node.d.ts +4 -0
  77. package/dist/html/node.d.ts.map +1 -1
  78. package/dist/html/node.js +319 -47
  79. package/dist/html/node.js.map +1 -1
  80. package/dist/html/paint.d.ts +83 -5
  81. package/dist/html/paint.d.ts.map +1 -1
  82. package/dist/html/paint.js +1474 -217
  83. package/dist/html/paint.js.map +1 -1
  84. package/dist/html/svg.d.ts +39 -9
  85. package/dist/html/svg.d.ts.map +1 -1
  86. package/dist/html/svg.js +482 -37
  87. package/dist/html/svg.js.map +1 -1
  88. package/dist/html/widgets.js +87 -7
  89. package/dist/html/widgets.js.map +1 -1
  90. package/dist/html/woff2.d.ts +14 -0
  91. package/dist/html/woff2.d.ts.map +1 -0
  92. package/dist/html/woff2.js +608 -0
  93. package/dist/html/woff2.js.map +1 -0
  94. package/dist/maps/controller.d.ts +17 -1
  95. package/dist/maps/controller.d.ts.map +1 -1
  96. package/dist/maps/controller.js +27 -7
  97. package/dist/maps/controller.js.map +1 -1
  98. package/dist/richtext/node.d.ts +8 -1
  99. package/dist/richtext/node.d.ts.map +1 -1
  100. package/dist/richtext/node.js +5 -1
  101. package/dist/richtext/node.js.map +1 -1
  102. package/dist/richtext/runs.d.ts +25 -6
  103. package/dist/richtext/runs.d.ts.map +1 -1
  104. package/dist/richtext/runs.js +240 -24
  105. package/dist/richtext/runs.js.map +1 -1
  106. package/package.json +1 -1
  107. package/src/html/controls.ts +85 -2
  108. package/src/html/css/cascade.ts +366 -8
  109. package/src/html/css/color.ts +29 -0
  110. package/src/html/css/parse.ts +71 -31
  111. package/src/html/css/shapes.ts +239 -0
  112. package/src/html/css/style.ts +652 -147
  113. package/src/html/css/transform.ts +459 -0
  114. package/src/html/css/ua.ts +15 -2
  115. package/src/html/css/values.ts +4 -0
  116. package/src/html/fonts.ts +312 -29
  117. package/src/html/layout/axes.ts +62 -0
  118. package/src/html/layout/block.ts +466 -37
  119. package/src/html/layout/boxes.ts +95 -3
  120. package/src/html/layout/cache.ts +6 -0
  121. package/src/html/layout/css-grid.ts +169 -32
  122. package/src/html/layout/flex.ts +686 -79
  123. package/src/html/layout/inline.ts +149 -55
  124. package/src/html/layout/multicol.ts +977 -0
  125. package/src/html/layout/shaping.ts +27 -18
  126. package/src/html/layout/table.ts +105 -35
  127. package/src/html/node.ts +354 -48
  128. package/src/html/paint.ts +1804 -315
  129. package/src/html/svg.ts +573 -36
  130. package/src/html/widgets.ts +95 -9
  131. package/src/html/woff2.ts +612 -0
  132. package/src/maps/controller.ts +28 -7
  133. package/src/richtext/node.ts +13 -2
  134. 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,11 +82,13 @@ 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,
72
89
  Marker,
73
90
  SelectionStyler,
91
+ ShapeStyler,
74
92
  } from './layout/boxes.js';
75
93
  import type { SelectionStyle } from './css/cascade.js';
76
94
  import {
@@ -124,6 +142,24 @@ export interface PaintContext extends FillContext {
124
142
  x1: number,
125
143
  y1: number,
126
144
  ): { addColorStop(offset: number, color: string): void };
145
+ createRadialGradient?(
146
+ x0: number,
147
+ y0: number,
148
+ r0: number,
149
+ x1: number,
150
+ y1: number,
151
+ r1: number,
152
+ ): { addColorStop(offset: number, color: string): void };
153
+ /** Multiplies the current matrix, which a fill's paint is sampled
154
+ * through and a path already laid down is not. */
155
+ transform?(
156
+ a: number,
157
+ b: number,
158
+ c: number,
159
+ d: number,
160
+ e: number,
161
+ f: number,
162
+ ): void;
127
163
  clip?(): void;
128
164
  /** Canvas shadows: ntk bakes and caches the blur, CoreGraphics draws it. */
129
165
  shadowColor?: string;
@@ -134,6 +170,20 @@ export interface PaintContext extends FillContext {
134
170
  /** ntk's X11 context has patterns; the Cocoa one does not, and tiles. */
135
171
  createPattern?(image: unknown, repetition: string): unknown;
136
172
  translate?(x: number, y: number): void;
173
+ /** Multiplies the context's matrix: what a transformed box is drawn
174
+ * through (`paintTransformed`). */
175
+ transform?(
176
+ a: number,
177
+ b: number,
178
+ c: number,
179
+ d: number,
180
+ e: number,
181
+ f: number,
182
+ ): void;
183
+ /** Whether text drawn under a matrix that turns or scales is drawn
184
+ * turned and scaled, glyphs and all: the native contexts', and not
185
+ * ntk's, whose glyphs are drawn as they were shaped. */
186
+ scalesText?: boolean;
137
187
  /** What every drawing is multiplied by: an element's `opacity`. */
138
188
  globalAlpha?: number;
139
189
  /** How a drawing meets what is under it; a value a backend does not
@@ -151,8 +201,9 @@ export interface PaintOptions {
151
201
  /** Where the document's origin sits in the window. Scrolling is this. */
152
202
  originX: number;
153
203
  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. */
204
+ /** Device pixels per logical pixel, for the run rules — the two lines
205
+ * of a double one are a logical pixel apart, not a device one; a rule's
206
+ * thickness is its style's, in device pixels already. Default 1. */
156
207
  scale?: number;
157
208
  /** The rectangle being repainted, in window coordinates, or null for all. */
158
209
  damage: Rect | null;
@@ -197,6 +248,9 @@ export interface PaintOptions {
197
248
  /** @internal Each box's `::selection`, where a rule styles one
198
249
  * (`BoxTree.selectionStyler`). */
199
250
  selectionStyler?: SelectionStyler | null;
251
+ /** @internal What the rules give the shapes in each drawing, where one
252
+ * could reach any (`BoxTree.shapeStyler`). */
253
+ shapeStyler?: ShapeStyler | null;
200
254
  }
201
255
 
202
256
  /**
@@ -228,6 +282,28 @@ const LINE_INK = new WeakMap<
228
282
  { above: number; below: number }
229
283
  >();
230
284
 
285
+ /**
286
+ * For a box painted through a matrix (`placedMatrix`), the bounds of what
287
+ * it and its descendants draw before the matrix is applied: what
288
+ * `boundsX`… would have been, which are where it puts them. The paint
289
+ * inside the box, and a point taken back into it, are in these.
290
+ */
291
+ const UNTRANSFORMED = new WeakMap<Box, Rect>();
292
+
293
+ /** The bounds of what a box painted through a matrix draws, in its own
294
+ * coordinates: asked of a box `placedMatrix` answers for, whose entry the
295
+ * last `computePaintBounds` wrote. */
296
+ export function ownBounds(box: Box): Rect {
297
+ return (
298
+ UNTRANSFORMED.get(box) ?? {
299
+ x: box.boundsX,
300
+ y: box.boundsY,
301
+ width: box.boundsWidth,
302
+ height: box.boundsHeight,
303
+ }
304
+ );
305
+ }
306
+
231
307
  export function computePaintBounds(box: Box, moved = false): number {
232
308
  // A box with no rectangle of its own gives only what it holds: nothing,
233
309
  // until a child with bounds is met.
@@ -398,6 +474,37 @@ export function computePaintBounds(box: Box, moved = false): number {
398
474
  }
399
475
  if (lines && (above > 0 || below > 0)) LINE_INK.set(lines, { above, below });
400
476
  else if (lines) LINE_INK.delete(lines);
477
+ // a `clip-path` cuts the box and all it holds: no ink past the path, and
478
+ // none where it leaves nothing — though the scrollable overflow is what
479
+ // it was, as a browser has it, which is why a page that hides a box with
480
+ // one makes it a pixel square as well
481
+ if (own && box.style.clipPath) {
482
+ const { rect } = clipPathOf(box, DOCUMENT);
483
+ x1 = Math.max(x1, rect.x);
484
+ y1 = Math.max(y1, rect.y);
485
+ x2 = Math.min(x2, rect.x + rect.w);
486
+ y2 = Math.min(y2, rect.y + rect.h);
487
+ if (!(x2 > x1 && y2 > y1)) x1 = Infinity;
488
+ }
489
+ // A box that turns, scales or skews draws what it holds through its
490
+ // matrix (`paintTransformed`): its ink is where that puts it, the
491
+ // rectangle around the one it would have filled, and the one it would
492
+ // have filled is kept for the paint inside it, which is in its own
493
+ // coordinates. One flattened to nothing draws nothing.
494
+ const matrix = own ? placedMatrix(box) : null;
495
+ if (matrix && x1 !== Infinity) {
496
+ UNTRANSFORMED.set(box, { x: x1, y: y1, width: x2 - x1, height: y2 - y1 });
497
+ if (invert(matrix)) {
498
+ // no further than a length reaches (`LARGEST`): a page's
499
+ // `scale(1e30)` is a box as large as a browser holds one, and no
500
+ // larger
501
+ const to = mapRect(matrix, x1, y1, x2 - x1, y2 - y1);
502
+ x1 = Math.max(to.x, -LARGEST);
503
+ y1 = Math.max(to.y, -LARGEST);
504
+ x2 = Math.min(to.x + to.width, LARGEST);
505
+ y2 = Math.min(to.y + to.height, LARGEST);
506
+ } else x1 = Infinity;
507
+ }
401
508
  if (x1 === Infinity) {
402
509
  // nothing to draw: no damage meets it, and no parent takes it in
403
510
  box.boundsX = Infinity;
@@ -420,12 +527,20 @@ export function computePaintBounds(box: Box, moved = false): number {
420
527
  // whether the box clips only matters where its content reaches past it,
421
528
  // and its style is one more object a walk of every box would read
422
529
  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;
530
+ let reach = end;
531
+ if (bottom > end) {
532
+ const style = box.style;
533
+ reach =
534
+ box.parent &&
535
+ (style.overflowX !== 'visible' || style.overflowY !== 'visible')
536
+ ? end
537
+ : bottom;
538
+ }
539
+ // the scrollable overflow of a transformed box is where its transform
540
+ // puts it (CSS Overflow 3, 2.2)
541
+ if (!matrix || reach === -Infinity) return reach;
542
+ const to = mapRect(matrix, box.x, box.y, box.width, reach - box.y);
543
+ return Math.min(to.y + to.height, LARGEST);
429
544
  }
430
545
 
431
546
  /**
@@ -574,12 +689,27 @@ export function paintDocument(
574
689
  if (!canFill(ctx)) return;
575
690
  ctx.save();
576
691
  const canvas = canvasBackground(tree);
692
+ const scheme = rootScheme(tree);
693
+ if (scheme !== tree.paletteScheme) {
694
+ // A document is drawn on the window's own ground, the palette's, as an
695
+ // embedded one is on a transparent canvas. Where its root's colour
696
+ // scheme is not the scheme of what it is embedded in, its canvas is
697
+ // opaque, in the `Canvas` colour of its own (CSS Color Adjust 1, 2.2):
698
+ // a page that says it is light, and sets its text dark on no
699
+ // background, is not read against a dark window.
700
+ const area = canvasArea(tree.root, options);
701
+ if (area) {
702
+ ctx.fillStyle = SCHEME_COLORS[scheme].canvas;
703
+ ctx.fillRect(area.x, area.y, area.w, area.h);
704
+ }
705
+ }
577
706
  if (canvas) paintCanvas(ctx, canvas, tree.root, options);
578
707
  paintBox(ctx, tree.root, {
579
708
  ...options,
580
709
  canvasSource: canvas?.source,
581
710
  negative: tree.negative,
582
711
  selectionStyler: options.selection ? tree.selectionStyler : null,
712
+ shapeStyler: tree.shapeStyler,
583
713
  });
584
714
  ctx.restore();
585
715
  }
@@ -631,6 +761,34 @@ function canvasBackground(tree: BoxTree): CanvasBackground | null {
631
761
  : null;
632
762
  }
633
763
 
764
+ /** The used colour scheme of the document's root element: the `<html>`'s,
765
+ * or that of whichever box stands in for it (`canvasBackground`). */
766
+ function rootScheme(tree: BoxTree): 'light' | 'dark' {
767
+ if (tree.impliedHtml) return tree.impliedHtml.colorScheme;
768
+ return (childNamed(tree.root, 'html') ?? tree.root).style.colorScheme;
769
+ }
770
+
771
+ /** The canvas, cut to what is being painted: the whole element where the
772
+ * host says what that is, and else the root box. */
773
+ function canvasArea(
774
+ root: Box,
775
+ options: PaintOptions,
776
+ ): { x: number; y: number; w: number; h: number } | null {
777
+ const whole = options.canvas ?? {
778
+ x: root.x + options.originX,
779
+ y: root.y + options.originY,
780
+ width: root.width,
781
+ height: root.height,
782
+ };
783
+ return clampRect(
784
+ options,
785
+ Math.round(whole.x),
786
+ Math.round(whole.y),
787
+ Math.ceil(whole.width),
788
+ Math.ceil(whole.height),
789
+ );
790
+ }
791
+
634
792
  /** A box's child element of that name, or one inside the anonymous boxes
635
793
  * it is wrapped in: a `<body>` in an `<html>` set `display: table` is in
636
794
  * an anonymous row and cell. */
@@ -651,19 +809,7 @@ function paintCanvas(
651
809
  root: Box,
652
810
  options: PaintOptions,
653
811
  ): 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
- );
812
+ const area = canvasArea(root, options);
667
813
  if (!area) return;
668
814
  const layers = layersOf(source) ?? [source];
669
815
  const visible = source.visibility === 'visible';
@@ -673,7 +819,7 @@ function paintCanvas(
673
819
  ctx.fillStyle = inkColor(style.backgroundColor as string, style.color);
674
820
  ctx.fillRect(area.x, area.y, area.w, area.h);
675
821
  }
676
- if (style.backgroundGradient && ctx.createLinearGradient && visible) {
822
+ if (style.backgroundGradient && visible) {
677
823
  // sized by the root element's box and repeated down the canvas, as a
678
824
  // browser does — the stripes a short page with a gradient on its
679
825
  // body shows — or by the viewport, where it is fixed
@@ -936,28 +1082,312 @@ function paintBox(ctx: PaintContext, box: Box, options: PaintOptions): void {
936
1082
  return;
937
1083
  }
938
1084
  // `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();
1085
+ // background and borders among it (CSS 2.1 11.1.2), and `clip-path` the
1086
+ // part of any box (`pushOwnClips`) — the content painted here, not in a
1087
+ // call of its own: a frame more a level and a deep document ran out of
1088
+ // stack
1089
+ const pushed = pushOwnClips(ctx, box, options);
1090
+ if (pushed >= 0) {
1091
+ paintContent(ctx, box, pathClips(box) ? underPath(options) : options);
1092
+ for (let i = 0; i < pushed; i += 1) ctx.restore();
1093
+ }
1094
+ if (fade) ctx.restore();
1095
+ }
1096
+
1097
+ /** How large a surface a transformed box is drawn on: a large window's
1098
+ * worth of pixels twice over, and no side longer than a pixmap's may be. */
1099
+ const RASTER_LIMIT = 16 * 1024 * 1024;
1100
+ const RASTER_SIDE = 16384;
1101
+
1102
+ /** What XRender carries a picture's transform in is 16.16 fixed point: a
1103
+ * number past this does not fit, and is thrown out of the request's
1104
+ * encoder. */
1105
+ const FIXED_LIMIT = 32000;
1106
+
1107
+ /**
1108
+ * A box that turns, scales or skews (CSS Transforms 1): painted as it was
1109
+ * laid out, through its matrix — about its `transform-origin`, where its
1110
+ * translation has already put it (`placedMatrix`). What it holds is
1111
+ * painted with it, as the stacking context it is (`stacksLayers`), culled
1112
+ * against the damage taken back through the matrix.
1113
+ *
1114
+ * The native contexts draw everything through a matrix, a text layout's
1115
+ * outlines among it, glyphs and all (`scalesText`). ntk's draws paths
1116
+ * through one; a glyph it draws as it was shaped, upright and at its size,
1117
+ * where the matrix puts its origin, and an image through a transform the
1118
+ * server holds in fixed point, which a picture drawn small far across a
1119
+ * window does not fit. So there only a box that is paths and flat colour —
1120
+ * an icon, a chevron, a spinner — is drawn through the matrix, and any
1121
+ * other on a surface of its own, as it was laid out, with the surface
1122
+ * drawn through the matrix (`paintRaster`): a picture of its text rather
1123
+ * than the text, resampled, and right about where it is and which way up.
1124
+ *
1125
+ * A context with no `transform` draws the box where it was laid out: the
1126
+ * headless mock, which draws nothing, and a recording one.
1127
+ */
1128
+ function paintTransformed(
1129
+ ctx: PaintContext,
1130
+ box: Box,
1131
+ options: PaintOptions,
1132
+ ): void {
1133
+ const matrix = placedMatrix(box);
1134
+ if (!matrix) {
1135
+ paintBox(ctx, box, options);
1136
+ return;
1137
+ }
1138
+ if (
1139
+ !intersects(box, options) ||
1140
+ COLLAPSED_CELLS.has(box) ||
1141
+ CLAMPED.has(box)
1142
+ ) {
1143
+ return;
1144
+ }
1145
+ if (typeof ctx.transform !== 'function') {
1146
+ paintPlaced(ctx, box, options);
1147
+ return;
1148
+ }
1149
+ // In the window's coordinates, where the document's origin is the
1150
+ // options': T(origin) · matrix · T(-origin). And about an origin moved
1151
+ // with the box's corner to the pixel it is drawn from: what is painted is
1152
+ // snapped to the pixel grid a box at a time, so a box a fraction of a
1153
+ // pixel down the page is drawn from a whole one, and turned about where
1154
+ // it was laid out it came back off the grid — a square mirrored onto
1155
+ // itself showed a line of what was under it along each side. A browser
1156
+ // snaps a transformed box's corner before its transform the same way.
1157
+ const left = box.x + options.originX;
1158
+ const top = box.y + options.originY;
1159
+ const ox = options.originX + Math.round(left) - left;
1160
+ const oy = options.originY + Math.round(top) - top;
1161
+ const [a, b, c, d] = matrix;
1162
+ const through: Matrix = [
1163
+ a,
1164
+ b,
1165
+ c,
1166
+ d,
1167
+ matrix[4] + ox - (a * ox + c * oy),
1168
+ matrix[5] + oy - (b * ox + d * oy),
1169
+ ];
1170
+ const back = invert(through);
1171
+ if (!back) return;
1172
+ const damage = options.damage;
1173
+ const inside: PaintOptions = {
1174
+ ...options,
1175
+ damage:
1176
+ damage && mapRect(back, damage.x, damage.y, damage.width, damage.height),
1177
+ };
1178
+ if (
1179
+ ctx.scalesText !== true &&
1180
+ !drawnAsPaths(box) &&
1181
+ paintRaster(ctx, box, inside, through)
1182
+ ) {
1183
+ return;
1184
+ }
1185
+ ctx.save();
1186
+ ctx.transform(a, b, c, d, through[4], through[5]);
1187
+ paintPlaced(ctx, box, inside);
1188
+ ctx.restore();
1189
+ }
1190
+
1191
+ /**
1192
+ * Whether a box's style turns, scales or skews it, which is asked before
1193
+ * every box that may be one is painted (`paintPositioned`, `paintLines`) —
1194
+ * there, and not in `paintBox`, whose frame is one of three a level of
1195
+ * nesting costs the stack: a name more in it, and a document a thousand
1196
+ * boxes deep ran out.
1197
+ */
1198
+ function turns(box: Box): boolean {
1199
+ const style = box.style;
1200
+ return (
1201
+ style.transform !== null || style.rotate !== null || style.scale !== null
1202
+ );
1203
+ }
1204
+
1205
+ /**
1206
+ * `paintBox` for a box whose ink bounds are not in the coordinates it is
1207
+ * being painted in, and so is not culled by them: one painted through a
1208
+ * matrix, in its own (`paintTransformed`).
1209
+ */
1210
+ function paintPlaced(ctx: PaintContext, box: Box, options: PaintOptions): void {
1211
+ const opacity = opacityOf(box);
1212
+ if (opacity <= 0) return;
1213
+ const fade = opacity < 1 && typeof ctx.globalAlpha === 'number';
1214
+ if (fade) {
1215
+ ctx.save();
1216
+ ctx.globalAlpha = ctx.globalAlpha! * opacity;
1217
+ }
1218
+ if (!masked(box.style) || !paintMasked(ctx, box, options)) {
1219
+ paintClipped(ctx, box, options);
946
1220
  }
947
1221
  if (fade) ctx.restore();
948
1222
  }
949
1223
 
950
- /** A box and what it holds, cut to its `clip` as `paintBox` cuts it. */
1224
+ /**
1225
+ * Whether all a box and what it holds draw is paths and flat colour:
1226
+ * backgrounds, borders, outlines and inline drawings — no text and no list
1227
+ * marker, whose glyphs a context may not turn, and no image, gradient,
1228
+ * shadow or mask, which one draws through a picture's transform
1229
+ * (`paintTransformed`).
1230
+ */
1231
+ function drawnAsPaths(box: Box): boolean {
1232
+ if (box.subtreeTextEnd > box.subtreeTextStart) return false;
1233
+ const stack: Box[] = [box];
1234
+ while (stack.length) {
1235
+ const at = stack.pop()!;
1236
+ if (at.marker || at.replaced === 'image') return false;
1237
+ const style = at.style;
1238
+ if (
1239
+ style.backgroundImage !== null ||
1240
+ style.backgroundGradient !== null ||
1241
+ style.backgroundImages !== null ||
1242
+ style.boxShadow !== null ||
1243
+ style.borderImage.source !== null ||
1244
+ masked(style)
1245
+ ) {
1246
+ return false;
1247
+ }
1248
+ for (const child of at.children) stack.push(child);
1249
+ }
1250
+ return true;
1251
+ }
1252
+
1253
+ /**
1254
+ * A transformed box painted on a surface of its own, as it was laid out,
1255
+ * and the surface drawn through its matrix: the part of what it draws that
1256
+ * the damage reaches. True where that is done — or is nothing to do, or
1257
+ * cannot be: a part too large for a surface, or a matrix that does not fit
1258
+ * the fixed point a picture's transform is sent in, which is one that draws
1259
+ * the box a thirtieth its size far across a window, or flatter than can be
1260
+ * seen. None of it is drawn then. False where the context has no surface to
1261
+ * draw on, and the caller draws the box through the matrix itself.
1262
+ */
1263
+ function paintRaster(
1264
+ ctx: PaintContext,
1265
+ box: Box,
1266
+ options: PaintOptions,
1267
+ through: Matrix,
1268
+ ): boolean {
1269
+ if (!options.surface || !ctx.drawImage) return false;
1270
+ const own = ownBounds(box);
1271
+ let x0 = Math.floor(own.x + options.originX);
1272
+ let y0 = Math.floor(own.y + options.originY);
1273
+ let x1 = Math.ceil(own.x + own.width + options.originX);
1274
+ let y1 = Math.ceil(own.y + own.height + options.originY);
1275
+ const damage = options.damage;
1276
+ if (damage) {
1277
+ // a pixel more each way: the edge of what is drawn is sampled from
1278
+ // beside it
1279
+ x0 = Math.max(x0, Math.floor(damage.x) - 1);
1280
+ y0 = Math.max(y0, Math.floor(damage.y) - 1);
1281
+ x1 = Math.min(x1, Math.ceil(damage.x + damage.width) + 1);
1282
+ y1 = Math.min(y1, Math.ceil(damage.y + damage.height) + 1);
1283
+ }
1284
+ const w = x1 - x0;
1285
+ const h = y1 - y0;
1286
+ if (!(w > 0 && h > 0)) return true;
1287
+ if (w > RASTER_SIDE || h > RASTER_SIDE || w * h > RASTER_LIMIT) return true;
1288
+ // what the context makes of the draw below: the picture's transform is
1289
+ // the inverse of its matrix after the surface's place in it
1290
+ const placed = invert([
1291
+ through[0],
1292
+ through[1],
1293
+ through[2],
1294
+ through[3],
1295
+ through[0] * x0 + through[2] * y0 + through[4],
1296
+ through[1] * x0 + through[3] * y0 + through[5],
1297
+ ]);
1298
+ if (!placed || placed.some((n) => Math.abs(n) > FIXED_LIMIT)) return true;
1299
+ const surface = options.surface(w, h);
1300
+ if (!surface) return false;
1301
+ try {
1302
+ // the surface's own coordinates, the window's moved to its corner
1303
+ const on: PaintOptions = {
1304
+ ...options,
1305
+ originX: options.originX - x0,
1306
+ originY: options.originY - y0,
1307
+ damage: { x: 0, y: 0, width: w, height: h },
1308
+ canvas: options.canvas && {
1309
+ ...options.canvas,
1310
+ x: options.canvas.x - x0,
1311
+ y: options.canvas.y - y0,
1312
+ },
1313
+ viewport: options.viewport && {
1314
+ ...options.viewport,
1315
+ x: options.viewport.x - x0,
1316
+ y: options.viewport.y - y0,
1317
+ },
1318
+ clips: [],
1319
+ };
1320
+ paintPlaced(surface.getContext('2d') as PaintContext, box, on);
1321
+ ctx.save();
1322
+ ctx.transform!(
1323
+ through[0],
1324
+ through[1],
1325
+ through[2],
1326
+ through[3],
1327
+ through[4],
1328
+ through[5],
1329
+ );
1330
+ ctx.drawImage(surface, x0, y0);
1331
+ ctx.restore();
1332
+ return true;
1333
+ } finally {
1334
+ surface.destroy?.();
1335
+ }
1336
+ }
1337
+
1338
+ /** A box and what it holds, cut to its `clip` and its `clip-path` as
1339
+ * `paintBox` cuts it. */
951
1340
  function paintClipped(
952
1341
  ctx: PaintContext,
953
1342
  box: Box,
954
1343
  options: PaintOptions,
955
1344
  ): 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();
1345
+ const pushed = pushOwnClips(ctx, box, options);
1346
+ if (pushed < 0) return;
1347
+ paintContent(ctx, box, pathClips(box) ? underPath(options) : options);
1348
+ for (let i = 0; i < pushed; i += 1) ctx.restore();
1349
+ }
1350
+
1351
+ /**
1352
+ * Cut what follows to what a box shows of itself: the part its `clip`
1353
+ * names, where it is absolutely positioned, and the part its `clip-path`
1354
+ * does (CSS Masking 1, 5.1). How many clips were pushed, for the caller to
1355
+ * restore — none where the context cannot clip — or -1 where they leave
1356
+ * none of the box to show, and nothing was pushed.
1357
+ */
1358
+ function pushOwnClips(
1359
+ ctx: PaintContext,
1360
+ box: Box,
1361
+ options: PaintOptions,
1362
+ ): number {
1363
+ const style = box.style;
1364
+ const clip = box.outOfFlow && style.clip ? clipOf(box, options) : null;
1365
+ if (clip && !(clip.w > 0 && clip.h > 0)) return -1;
1366
+ const path = pathClips(box) ? clipPathOf(box, options) : null;
1367
+ if (path && !(path.rect.w > 0 && path.rect.h > 0)) return -1;
1368
+ let pushed = 0;
1369
+ if (clip && pushClip(ctx, clip, null)) pushed += 1;
1370
+ if (path && pushClip(ctx, path.rect, path.radii)) pushed += 1;
1371
+ return pushed;
1372
+ }
1373
+
1374
+ /** Whether a box is cut to a `clip-path`: one with a rectangle of its own
1375
+ * for the path to be measured in. An inline box is drawn on its block's
1376
+ * lines, and is not cut. */
1377
+ export function pathClips(box: Box): boolean {
1378
+ return box.style.clipPath !== null && hasRect(box);
1379
+ }
1380
+
1381
+ /**
1382
+ * The options what a box cut to a `clip-path` holds is painted with: no
1383
+ * clip around it for a positioned box to escape. The path cuts all the
1384
+ * element holds, whatever that is positioned from, where a box that clips
1385
+ * its overflow lets out a box whose containing block is outside it — put
1386
+ * off until that clip ended (`paintPositioned`), one in here was painted
1387
+ * with the path gone too.
1388
+ */
1389
+ function underPath(options: PaintOptions): PaintOptions {
1390
+ return { ...options, clips: [] };
961
1391
  }
962
1392
 
963
1393
  const MASK_LAYERS = new WeakMap<ComputedStyle, ComputedStyle[]>();
@@ -1110,7 +1540,9 @@ function paintContent(
1110
1540
  for (const child of below) paintStacked(ctx, child, options);
1111
1541
  }
1112
1542
  if (visible) {
1113
- if (box.marker) paintMarker(ctx, box.marker, options);
1543
+ if (box.marker) {
1544
+ paintMarker(ctx, box.marker, options, box.style.colorScheme);
1545
+ }
1114
1546
  if (box.replaced === 'image') paintImage(ctx, box, options);
1115
1547
  else if (box.replaced === 'svg') paintSvg(ctx, box, options);
1116
1548
  }
@@ -1200,6 +1632,13 @@ function paintOwnBackground(
1200
1632
  if (box.kind === 'table-row' || box.kind === 'table-row-group') return;
1201
1633
  if (hidden(box)) return;
1202
1634
  const style = box.style;
1635
+ if (columned.any) {
1636
+ const pieces = COLUMN_PIECES.get(box);
1637
+ if (pieces) {
1638
+ paintColumnPieces(ctx, box, pieces, options);
1639
+ return;
1640
+ }
1641
+ }
1203
1642
  if (style.boxShadow) paintShadows(ctx, box, options, false);
1204
1643
  if (box !== options.canvasSource) {
1205
1644
  paintLayers(ctx, box, options, frameImages(ctx, box, options));
@@ -1211,6 +1650,88 @@ function paintOwnBackground(
1211
1650
  if (box.kind === 'table') paintPartBackgrounds(ctx, box, options);
1212
1651
  }
1213
1652
 
1653
+ /**
1654
+ * The background and the borders of a block a column break falls inside
1655
+ * (CSS Multi-column 1), a piece at a time: drawn in each column as the
1656
+ * whole box standing there, and cut to the piece of it that is — which is
1657
+ * `box-decoration-break: slice`, CSS's default (CSS Fragmentation 3, 6.1):
1658
+ * no border and no padding at a break, and the background going on from
1659
+ * one piece to the next. Its shadow is not drawn: a sliced box casts one as
1660
+ * a whole and this has no whole box to cast it from.
1661
+ */
1662
+ function paintColumnPieces(
1663
+ ctx: PaintContext,
1664
+ box: Box,
1665
+ pieces: readonly ColumnPiece[],
1666
+ options: PaintOptions,
1667
+ ): void {
1668
+ if (!ctx.save || !ctx.restore || !ctx.beginPath || !ctx.rect || !ctx.clip)
1669
+ return;
1670
+ for (const piece of pieces) {
1671
+ if (!(piece.height > 0 && piece.width > 0)) continue;
1672
+ const frame: Frame = {
1673
+ x: piece.x,
1674
+ y: piece.wholeY,
1675
+ width: piece.width,
1676
+ height: piece.wholeHeight,
1677
+ captionTop: 0,
1678
+ captionBottom: 0,
1679
+ borderTop: box.borderTop,
1680
+ borderRight: box.borderRight,
1681
+ borderBottom: box.borderBottom,
1682
+ borderLeft: box.borderLeft,
1683
+ padTop: box.padTop,
1684
+ padRight: box.padRight,
1685
+ padBottom: box.padBottom,
1686
+ padLeft: box.padLeft,
1687
+ style: box.style,
1688
+ };
1689
+ const left = Math.round(piece.x + options.originX);
1690
+ const top = Math.round(piece.y + options.originY);
1691
+ const right = Math.round(piece.x + piece.width + options.originX);
1692
+ const bottom = Math.round(piece.y + piece.height + options.originY);
1693
+ ctx.save();
1694
+ ctx.beginPath();
1695
+ ctx.rect(left, top, right - left, bottom - top);
1696
+ ctx.clip();
1697
+ paintLayers(ctx, frame, options, frameImages(ctx, frame, options));
1698
+ paintBorders(ctx, frame, options);
1699
+ ctx.restore();
1700
+ }
1701
+ }
1702
+
1703
+ /**
1704
+ * Clip to the rows of a text's layout that are in the text's column
1705
+ * (`COLUMN_ROWS`), where the layout is drawn at `left` and `top`: the rest
1706
+ * of it is another column's, drawn there. Answers whether it clipped, and
1707
+ * the caller restores.
1708
+ */
1709
+ function clipToRows(
1710
+ ctx: PaintContext,
1711
+ text: LineText,
1712
+ rows: { top: number; bottom: number },
1713
+ left: number,
1714
+ top: number,
1715
+ ): boolean {
1716
+ if (!ctx.save || !ctx.restore || !ctx.beginPath || !ctx.rect || !ctx.clip)
1717
+ return false;
1718
+ const y1 = Math.round(top + rows.top);
1719
+ const y2 = Math.round(top + rows.bottom);
1720
+ ctx.save();
1721
+ ctx.beginPath();
1722
+ ctx.rect(
1723
+ Math.floor(left) - ROW_REACH,
1724
+ y1,
1725
+ Math.ceil(text.layout.width) + 2 * ROW_REACH,
1726
+ y2 - y1,
1727
+ );
1728
+ ctx.clip();
1729
+ return true;
1730
+ }
1731
+
1732
+ /** How far to either side of its layout a column's text may draw. */
1733
+ const ROW_REACH = 2048;
1734
+
1214
1735
  /**
1215
1736
  * How `paintLayers` draws a frame's image layers: each in the area its
1216
1737
  * `background-clip` names, from the box its `background-origin` names
@@ -1632,6 +2153,8 @@ function inFlow(parent: Box, child: Box, options: PaintOptions): boolean {
1632
2153
  style.overflowY === 'visible' &&
1633
2154
  opacityOf(child) >= 1 &&
1634
2155
  !masked(style) &&
2156
+ // cut to a path with all it holds, as one group
2157
+ !style.clipPath &&
1635
2158
  // containment makes it a stacking context, painted whole
1636
2159
  !contained(child, CONTAIN_LAYOUT | CONTAIN_PAINT)
1637
2160
  );
@@ -1660,6 +2183,7 @@ function flowFlex(parent: Box, child: Box, options: PaintOptions): boolean {
1660
2183
  style.overflowY === 'visible' &&
1661
2184
  !(child.outOfFlow && style.clip) &&
1662
2185
  !masked(style) &&
2186
+ !style.clipPath &&
1663
2187
  !contained(child, CONTAIN_LAYOUT | CONTAIN_PAINT)
1664
2188
  );
1665
2189
  }
@@ -1733,7 +2257,7 @@ function paintFlowLines(
1733
2257
  }
1734
2258
  if (!intersects(child, options)) continue;
1735
2259
  if (child.marker && child.style.visibility === 'visible') {
1736
- paintMarker(ctx, child.marker, options);
2260
+ paintMarker(ctx, child.marker, options, child.style.colorScheme);
1737
2261
  }
1738
2262
  if (child.lines) paintLines(ctx, child, options);
1739
2263
  paintFlowLines(ctx, child, options, outlines);
@@ -1750,7 +2274,7 @@ function paintFlowLines(
1750
2274
  * beyond its background's edge. */
1751
2275
  function overflowClip(
1752
2276
  box: Box,
1753
- options: PaintOptions,
2277
+ options: ClipSpace,
1754
2278
  ): { x: number; y: number; w: number; h: number } {
1755
2279
  const left = box.x + options.originX;
1756
2280
  const top = frameY(box) + options.originY;
@@ -1776,7 +2300,7 @@ function overflowClip(
1776
2300
  */
1777
2301
  function clipEdge(
1778
2302
  box: Box,
1779
- options: PaintOptions,
2303
+ options: ClipSpace,
1780
2304
  ): {
1781
2305
  rect: { x: number; y: number; w: number; h: number };
1782
2306
  radii: Corners | null;
@@ -1932,7 +2456,9 @@ function paintPositioned(
1932
2456
  return;
1933
2457
  }
1934
2458
  }
1935
- paintBox(ctx, box, atViewport(box, options));
2459
+ // through its matrix, where it turns, scales or skews
2460
+ if (turns(box)) paintTransformed(ctx, box, atViewport(box, options));
2461
+ else paintBox(ctx, box, atViewport(box, options));
1936
2462
  }
1937
2463
 
1938
2464
  /**
@@ -1971,12 +2497,19 @@ export const FIXED_BOXES = new WeakMap<object, Box[]>();
1971
2497
  export function fixedToViewport(box: Box): boolean {
1972
2498
  if (box.style.position !== 'fixed') return false;
1973
2499
  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;
2500
+ if (holdsFixed(at)) return false;
1976
2501
  }
1977
2502
  return true;
1978
2503
  }
1979
2504
 
2505
+ /** Whether a box is the containing block of the fixed boxes in it: a
2506
+ * transformed box, or one with layout or paint containment. */
2507
+ function holdsFixed(box: Box): boolean {
2508
+ return (
2509
+ transformed(box.style) || contained(box, CONTAIN_LAYOUT | CONTAIN_PAINT)
2510
+ );
2511
+ }
2512
+
1980
2513
  function rootOf(box: Box): Box {
1981
2514
  let root = box;
1982
2515
  while (root.parent) root = root.parent;
@@ -1986,17 +2519,22 @@ function rootOf(box: Box): Box {
1986
2519
  /**
1987
2520
  * The box an out-of-flow box is positioned in, whose clips are the ones it
1988
2521
  * 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.
2522
+ * positioned or transformed ancestor, or the root. Null for a box fixed to
2523
+ * the viewport, which is under none — one in a transformed box is fixed to
2524
+ * that, and under what it is under.
1991
2525
  */
1992
2526
  export function containingBlockOf(box: Box): Box | null {
1993
- if (box.style.position === 'fixed') return null;
2527
+ if (box.style.position === 'fixed') {
2528
+ for (let at = box.parent; at?.parent; at = at.parent) {
2529
+ if (holdsFixed(at)) return at;
2530
+ }
2531
+ return null;
2532
+ }
1994
2533
  let containing = box.parent;
1995
2534
  while (
1996
2535
  containing?.parent &&
1997
2536
  containing.style.position === 'static' &&
1998
- !containing.style.translate &&
1999
- !containing.style.transformTranslate
2537
+ !transformed(containing.style)
2000
2538
  ) {
2001
2539
  containing = containing.parent;
2002
2540
  }
@@ -2080,7 +2618,7 @@ export function clipsOverflow(box: Box): boolean {
2080
2618
  /** A box's `clip` region, in window coordinates. */
2081
2619
  function clipOf(
2082
2620
  box: Box,
2083
- options: PaintOptions,
2621
+ options: ClipSpace,
2084
2622
  ): { x: number; y: number; w: number; h: number } {
2085
2623
  const clip = box.style.clip!;
2086
2624
  const left = clip.left ?? 0;
@@ -2097,33 +2635,239 @@ function clipOf(
2097
2635
  };
2098
2636
  }
2099
2637
 
2638
+ /** What placing a clip reads of a paint's options: where the document's
2639
+ * origin is, its scale, and how far an axis left open has to reach. */
2640
+ type ClipSpace = Pick<PaintOptions, 'originX' | 'originY' | 'scale' | 'damage'>;
2641
+
2642
+ /** The document's own coordinates, as a space to place a clip in. */
2643
+ const DOCUMENT: ClipSpace = { originX: 0, originY: 0, scale: 1, damage: null };
2644
+
2100
2645
  /**
2101
- * The radii of a box's padding edge — its border radii less the borders —
2102
- * for a clip, or null where a rectangle clips the same: padding at least a
2103
- * corner's radius on both of its sides keeps the content box clear of the
2104
- * corner, so only content overflowing it both ways at once could tell them
2105
- * apart. A rounded clip is a mask the size of the window on X11, and a code
2106
- * block — rounded, padded, `overflow: hidden` — had one made for every
2107
- * paint.
2646
+ * A box's `clip-path` in window coordinates (CSS Masking 1, 5.1): the
2647
+ * rectangle its shape is (CSS Shapes 1, 3.1), each edge on the pixel it
2648
+ * falls nearest as a box's are, and its corners where it has any — of no
2649
+ * area where the shape leaves none of the box to show. Measured in the box
2650
+ * the path names, the border box unless it says: `inset()` in from its
2651
+ * edges, `rect()` and `xywh()` from its top left, and the box itself, with
2652
+ * the element's own corners, where no shape is written.
2108
2653
  */
2109
- function innerRadii(box: Box): Corners | null {
2110
- const corners = cornersOf(box.style, box.width, frameHeight(box));
2111
- if (!corners) return null;
2112
- const inner = insetCorners(
2113
- corners,
2114
- box.borderTop,
2115
- box.borderRight,
2116
- box.borderBottom,
2117
- box.borderLeft,
2118
- );
2119
- const { x, y } = inner;
2120
- if (
2121
- box.padLeft >= x[0] &&
2122
- box.padTop >= y[0] &&
2123
- box.padRight >= x[1] &&
2124
- box.padTop >= y[1] &&
2125
- box.padRight >= x[2] &&
2126
- box.padBottom >= y[2] &&
2654
+ function clipPathOf(
2655
+ box: Box,
2656
+ options: ClipSpace,
2657
+ ): {
2658
+ rect: { x: number; y: number; w: number; h: number };
2659
+ radii: Corners | null;
2660
+ } {
2661
+ const path = box.style.clipPath!;
2662
+ // the box it is measured in, in from the border box — or out from it
2663
+ const within = path.box;
2664
+ const [it, ir, ib, il] =
2665
+ within === 'margin-box'
2666
+ ? [-box.marginTop, -box.marginRight, -box.marginBottom, -box.marginLeft]
2667
+ : edgeInsets(box, within);
2668
+ const width = Math.max(0, box.width - il - ir);
2669
+ const height = Math.max(0, frameHeight(box) - it - ib);
2670
+ // how far in from that box's edges the rectangle's are
2671
+ const [a, b, c, d] = path.lengths;
2672
+ let top = 0;
2673
+ let right = 0;
2674
+ let bottom = 0;
2675
+ let left = 0;
2676
+ if (path.shape === 'inset') {
2677
+ top = resolve(a, height);
2678
+ right = resolve(b, width);
2679
+ bottom = resolve(c, height);
2680
+ left = resolve(d, width);
2681
+ // a pair that adds up to more than the box is across is reduced to
2682
+ // it, each in its proportion, as overlapping radii are (3.1):
2683
+ // `inset(50%)` and `inset(75%)` are both the box's centre, and nothing
2684
+ if (left + right > width && left > 0 && right > 0) {
2685
+ const f = Math.max(0, width) / (left + right);
2686
+ left *= f;
2687
+ right *= f;
2688
+ }
2689
+ if (top + bottom > height && top > 0 && bottom > 0) {
2690
+ const f = Math.max(0, height) / (top + bottom);
2691
+ top *= f;
2692
+ bottom *= f;
2693
+ }
2694
+ } else if (path.shape === 'rect') {
2695
+ // four edges from the top and the left, `auto` the box's own; the
2696
+ // right and the bottom are no less than the left and the top
2697
+ top = a === AUTO ? 0 : resolve(a, height);
2698
+ left = d === AUTO ? 0 : resolve(d, width);
2699
+ right = b === AUTO ? 0 : width - Math.max(left, resolve(b, width));
2700
+ bottom = c === AUTO ? 0 : height - Math.max(top, resolve(c, height));
2701
+ } else if (path.shape === 'xywh') {
2702
+ left = resolve(a, width);
2703
+ top = resolve(b, height);
2704
+ right = width - left - Math.max(0, resolve(c, width));
2705
+ bottom = height - top - Math.max(0, resolve(d, height));
2706
+ }
2707
+ const x0 = box.x + options.originX + il;
2708
+ const y0 = frameY(box) + options.originY + it;
2709
+ const x = Math.round(x0 + left);
2710
+ const y = Math.round(y0 + top);
2711
+ const rect = {
2712
+ x,
2713
+ y,
2714
+ w: Math.max(0, Math.round(x0 + width - right) - x),
2715
+ h: Math.max(0, Math.round(y0 + height - bottom) - y),
2716
+ };
2717
+ let radii: Corners | null = null;
2718
+ if (path.shape === null) {
2719
+ // the box alone: its edge, shaped as the element's corners shape it
2720
+ const corners = cornersOf(box.style, box.width, frameHeight(box));
2721
+ if (corners) {
2722
+ radii =
2723
+ within === 'margin-box'
2724
+ ? spreadCorners(
2725
+ corners,
2726
+ box.width,
2727
+ frameHeight(box),
2728
+ -it,
2729
+ -ir,
2730
+ -ib,
2731
+ -il,
2732
+ )
2733
+ : roundedOf(insetCorners(corners, it, ir, ib, il));
2734
+ }
2735
+ } else if (path.radii) {
2736
+ // a percentage is of the box the shape is in, and the radii are fitted
2737
+ // to the rectangle they round
2738
+ radii = cornersFor(path.radii, path.radiiY, width, height, rect.w, rect.h);
2739
+ }
2740
+ return { rect, radii };
2741
+ }
2742
+
2743
+ /** Corners, or null where every one of them is square. */
2744
+ function roundedOf(c: Corners): Corners | null {
2745
+ for (let i = 0; i < 4; i += 1) if (c.x[i] > 0 && c.y[i] > 0) return c;
2746
+ return null;
2747
+ }
2748
+
2749
+ /**
2750
+ * Whether a point of the document is in what a box's `clip-path` leaves of
2751
+ * it: what a hit test asks, since nothing the path cuts away — of the box
2752
+ * or of anything in it — is under a pointer (`deepestAt`).
2753
+ */
2754
+ export function inClipPath(box: Box, x: number, y: number): boolean {
2755
+ const { rect, radii } = clipPathOf(box, DOCUMENT);
2756
+ const right = rect.x + rect.w;
2757
+ const bottom = rect.y + rect.h;
2758
+ if (x < rect.x || x >= right || y < rect.y || y >= bottom) return false;
2759
+ if (!radii) return true;
2760
+ // inside the rectangle, and past a corner's curve: each corner is a
2761
+ // quarter of an ellipse about a centre its radii in from the two edges
2762
+ for (let i = 0; i < 4; i += 1) {
2763
+ const rx = radii.x[i];
2764
+ const ry = radii.y[i];
2765
+ if (!(rx > 0 && ry > 0)) continue;
2766
+ const onLeft = i === 0 || i === 3;
2767
+ const cx = onLeft ? rect.x + rx : right - rx;
2768
+ const cy = i < 2 ? rect.y + ry : bottom - ry;
2769
+ if (onLeft ? x >= cx : x <= cx) continue;
2770
+ if (i < 2 ? y >= cy : y <= cy) continue;
2771
+ const dx = (x - cx) / rx;
2772
+ const dy = (y - cy) / ry;
2773
+ if (dx * dx + dy * dy > 1) return false;
2774
+ }
2775
+ return true;
2776
+ }
2777
+
2778
+ /**
2779
+ * Whether a point of the document is in the part of a box its `clip` shows
2780
+ * (CSS 2.1 11.1.2) — anywhere, for a box that is cut to none: what a hit
2781
+ * test asks, since what the clip cuts away is not drawn (`deepestAt`). What
2782
+ * it cuts away is the box and what is painted with it: all the box holds
2783
+ * where it is a stacking context (`stacksLayers`), and elsewhere all but a
2784
+ * fixed box, which the context around it paints under no clip
2785
+ * (`clipsFor`). Chrome cuts that one too.
2786
+ */
2787
+ export function inClip(box: Box, x: number, y: number): boolean {
2788
+ if (!box.outOfFlow || !box.style.clip) return true;
2789
+ const rect = clipOf(box, DOCUMENT);
2790
+ return (
2791
+ x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h
2792
+ );
2793
+ }
2794
+
2795
+ /**
2796
+ * The part of the document a box shows through, in the document's own
2797
+ * pixels, or null where nothing cuts it: the `clip` it is cut to (CSS 2.1
2798
+ * 11.1.2), and the clips of the boxes it is under — each that clips its
2799
+ * overflow or is cut to a `clip` of its own, from its containing block up
2800
+ * (11.1.1), so an absolute box is outside a box that clips where its
2801
+ * containing block is, and a fixed one outside them all. And every
2802
+ * `clip-path` from the box up, its own among them: a path cuts all its
2803
+ * element holds, whatever that is positioned from (CSS Masking 1, 5.1) —
2804
+ * to the rectangle a path's shape is, its corners left square. A rectangle
2805
+ * of no area where none of it shows. Painting cuts what it draws as it
2806
+ * walks down; this is for what is not painted here and has to be cut all
2807
+ * the same, a control's widget (`controlRectsOf`).
2808
+ */
2809
+ export function clipAround(box: Box, scale = 1): Rect | null {
2810
+ const space: ClipSpace = { originX: 0, originY: 0, scale, damage: null };
2811
+ let x0 = -Infinity;
2812
+ let y0 = -Infinity;
2813
+ let x1 = Infinity;
2814
+ let y1 = Infinity;
2815
+ let cut = false;
2816
+ const under = (rect: { x: number; y: number; w: number; h: number }) => {
2817
+ cut = true;
2818
+ x0 = Math.max(x0, rect.x);
2819
+ y0 = Math.max(y0, rect.y);
2820
+ x1 = Math.min(x1, rect.x + rect.w);
2821
+ y1 = Math.min(y1, rect.y + rect.h);
2822
+ };
2823
+ if (box.outOfFlow && box.style.clip) under(clipOf(box, space));
2824
+ // what holds a box, as clips go: its parent, or out of the flow its
2825
+ // containing block, and the boxes between them do not clip it
2826
+ const holder = (inner: Box): Box | null =>
2827
+ inner.outOfFlow ? containingBlockOf(inner) : inner.parent;
2828
+ for (let outer = holder(box); outer; outer = holder(outer)) {
2829
+ if (outer.outOfFlow && outer.style.clip) under(clipOf(outer, space));
2830
+ if (clipsOverflow(outer)) under(clipEdge(outer, space).rect);
2831
+ }
2832
+ for (let at: Box | null = box; at; at = at.parent) {
2833
+ if (pathClips(at)) under(clipPathOf(at, space).rect);
2834
+ }
2835
+ if (!cut) return null;
2836
+ return {
2837
+ x: x0,
2838
+ y: y0,
2839
+ width: Math.max(0, x1 - x0),
2840
+ height: Math.max(0, y1 - y0),
2841
+ };
2842
+ }
2843
+
2844
+ /**
2845
+ * The radii of a box's padding edge — its border radii less the borders —
2846
+ * for a clip, or null where a rectangle clips the same: padding at least a
2847
+ * corner's radius on both of its sides keeps the content box clear of the
2848
+ * corner, so only content overflowing it both ways at once could tell them
2849
+ * apart. A rounded clip is a mask the size of the window on X11, and a code
2850
+ * block — rounded, padded, `overflow: hidden` — had one made for every
2851
+ * paint.
2852
+ */
2853
+ function innerRadii(box: Box): Corners | null {
2854
+ const corners = cornersOf(box.style, box.width, frameHeight(box));
2855
+ if (!corners) return null;
2856
+ const inner = insetCorners(
2857
+ corners,
2858
+ box.borderTop,
2859
+ box.borderRight,
2860
+ box.borderBottom,
2861
+ box.borderLeft,
2862
+ );
2863
+ const { x, y } = inner;
2864
+ if (
2865
+ box.padLeft >= x[0] &&
2866
+ box.padTop >= y[0] &&
2867
+ box.padRight >= x[1] &&
2868
+ box.padTop >= y[1] &&
2869
+ box.padRight >= x[2] &&
2870
+ box.padBottom >= y[2] &&
2127
2871
  box.padLeft >= x[3] &&
2128
2872
  box.padBottom >= y[3]
2129
2873
  ) {
@@ -2177,6 +2921,24 @@ function cornersOf(style: ComputedStyle, w: number, h: number): Corners | null {
2177
2921
  ) {
2178
2922
  return null;
2179
2923
  }
2924
+ return cornersFor(across, down, w, h, w, h);
2925
+ }
2926
+
2927
+ /**
2928
+ * Radii as corners in pixels: each a length or a percentage of a box `w`
2929
+ * by `h`, across and down, and all of them reduced together where two on a
2930
+ * side of the rectangle they round — `fitW` by `fitH`, which for a
2931
+ * `border-radius` is that box, and for a `clip-path`'s `round` is the
2932
+ * rectangle inside it — would overlap. Null where every corner is square.
2933
+ */
2934
+ function cornersFor(
2935
+ across: readonly Len[],
2936
+ down: readonly Len[] | null,
2937
+ w: number,
2938
+ h: number,
2939
+ fitW: number,
2940
+ fitH: number,
2941
+ ): Corners | null {
2180
2942
  const vertical = down ?? across;
2181
2943
  const x: Corners['x'] = [0, 0, 0, 0];
2182
2944
  const y: Corners['y'] = [0, 0, 0, 0];
@@ -2194,10 +2956,10 @@ function cornersOf(style: ComputedStyle, w: number, h: number): Corners | null {
2194
2956
  const fit = (side: number, sum: number): void => {
2195
2957
  if (sum > side) f = Math.min(f, Math.max(0, side) / sum);
2196
2958
  };
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]);
2959
+ fit(fitW, x[0] + x[1]);
2960
+ fit(fitH, y[1] + y[2]);
2961
+ fit(fitW, x[2] + x[3]);
2962
+ fit(fitH, y[3] + y[0]);
2201
2963
  if (f < 1) {
2202
2964
  for (let i = 0; i < 4; i += 1) {
2203
2965
  x[i] *= f;
@@ -2349,8 +3111,7 @@ function layered(parent: Box, child: Box): boolean {
2349
3111
  if (
2350
3112
  style.position !== 'relative' &&
2351
3113
  style.position !== 'sticky' &&
2352
- !style.translate &&
2353
- !style.transformTranslate
3114
+ !transformed(style)
2354
3115
  ) {
2355
3116
  return false;
2356
3117
  }
@@ -2364,7 +3125,7 @@ function layered(parent: Box, child: Box): boolean {
2364
3125
  * drawn twice — darker at every antialiased edge — and a translucent
2365
3126
  * background had its alpha doubled.
2366
3127
  */
2367
- function onLine(parent: Box, child: Box): boolean {
3128
+ export function onLine(parent: Box, child: Box): boolean {
2368
3129
  if (parent.lines === null && parent.kind !== 'inline') return false;
2369
3130
  if (child.isFloat || child.outOfFlow) return false;
2370
3131
  return (
@@ -2438,20 +3199,26 @@ function settleLayers(box: Box, list: Box[]): void {
2438
3199
  * it itself, and the ones below its flow: positioned with a `z-index`, or a
2439
3200
  * flex item with one; fixed or sticky with none (CSS Positioned Layout 3, as
2440
3201
  * 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
3202
+ * `z-index: -1` went behind the page); transformed (CSS Transforms 1, 3);
3203
+ * or under full opacity (CSS Color 4
2442
3204
  * 3.2), its own or the opacity it takes from an inline box it broke
2443
3205
  * (`FADED_BLOCKS`), which is painted as one group — faded with it, or at
2444
3206
  * `opacity: 0` not at all, where its root context drew a hover menu's
2445
3207
  * absolute children.
2446
3208
  */
2447
- function stacksLayers(box: Box): boolean {
3209
+ export function stacksLayers(box: Box): boolean {
2448
3210
  const style = box.style;
2449
3211
  if (style.position === 'fixed' || style.position === 'sticky') return true;
2450
3212
  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;
3213
+ // and so does a mask, which is applied to the group (CSS Masking 1, 7),
3214
+ // and a clip path, which cuts it (5.1)
3215
+ if (masked(style) || pathClips(box)) return true;
2453
3216
  // layout and paint containment make one (CSS Containment 2, 3.3, 3.5)
2454
3217
  if (contained(box, CONTAIN_LAYOUT | CONTAIN_PAINT)) return true;
3218
+ // and a transform (CSS Transforms 1, 3), on a box one applies to: what it
3219
+ // holds is painted with it, through its matrix, and not by a context
3220
+ // outside it
3221
+ if (transformed(style) && box.kind !== 'inline') return true;
2455
3222
  if (typeof style.zIndex !== 'number') return false;
2456
3223
  return style.position !== 'static' || flexItem(box);
2457
3224
  }
@@ -2475,7 +3242,7 @@ export function clipsFor(box: Box, context: Box): Box[] {
2475
3242
  const fixed = box.style.position === 'fixed';
2476
3243
  while (from && from !== context) {
2477
3244
  const style = from.style;
2478
- if (style.translate || style.transformTranslate) break;
3245
+ if (transformed(style)) break;
2479
3246
  if (!fixed && style.position !== 'static') break;
2480
3247
  from = from.parent;
2481
3248
  }
@@ -2571,9 +3338,19 @@ function hoistFrom(box: Box, root: boolean): Box[] | null {
2571
3338
  }
2572
3339
 
2573
3340
  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;
3341
+ return layerOf(a) - layerOf(b);
3342
+ }
3343
+
3344
+ /** The `z-index` a box is ordered by among its stacking context's layers:
3345
+ * its own where one applies — to a positioned box, and to a flex or a grid
3346
+ * item (CSS 2.1 9.9.1, CSS Flexbox 5.4) — and none for a box that is
3347
+ * among them for its transform alone, which is painted in the document's
3348
+ * order whatever `z-index` it was given. */
3349
+ function layerOf(box: Box): number {
3350
+ const style = box.style;
3351
+ const z = style.zIndex;
3352
+ if (z === 'auto') return 0;
3353
+ return style.position !== 'static' || flexItem(box) ? z : 0;
2577
3354
  }
2578
3355
 
2579
3356
  /** Whether anything this box or its descendants draw is in the damage. */
@@ -2673,7 +3450,7 @@ function paintBackground(
2673
3450
  ctx.fillStyle = inkColor(color as string, style.color);
2674
3451
  fill();
2675
3452
  }
2676
- if (gradient && ctx.createLinearGradient) {
3453
+ if (gradient) {
2677
3454
  // over the colour, as the layer an image is: the size of the padding
2678
3455
  // box, which is where it starts, and repeated under the borders — or
2679
3456
  // the viewport's, where it is fixed; its line runs across the whole of
@@ -2695,7 +3472,7 @@ function paintBackground(
2695
3472
  // one fill in the rounded shape; under the borders it carries on
2696
3473
  // with its end colours where a browser would show the next tile
2697
3474
  if (!(at.width > 0 && at.height > 0)) return;
2698
- ctx.fillStyle = linearGradient(
3475
+ const paint = gradientFill(
2699
3476
  ctx,
2700
3477
  gradient,
2701
3478
  at.x,
@@ -2705,7 +3482,7 @@ function paintBackground(
2705
3482
  style.color,
2706
3483
  rect,
2707
3484
  );
2708
- fill();
3485
+ if (paint) fillGradient(ctx, paint, rect, rounded);
2709
3486
  } else paintGradient(ctx, style, gradient, rect, at);
2710
3487
  }
2711
3488
  }
@@ -2735,7 +3512,7 @@ const NO_SIZE: IntrinsicSize = { width: null, height: null, ratio: 0 };
2735
3512
  function paintGradient(
2736
3513
  ctx: PaintContext,
2737
3514
  style: ComputedStyle,
2738
- gradient: LinearGradient,
3515
+ gradient: Gradient,
2739
3516
  area: { x: number; y: number; w: number; h: number },
2740
3517
  at: Rect,
2741
3518
  ): void {
@@ -2784,17 +3561,8 @@ function paintGradient(
2784
3561
  ) {
2785
3562
  // a sliver of a root repeated down a long canvas: the one tile, and
2786
3563
  // 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);
3564
+ const paint = gradientFill(ctx, gradient, x0, y0, w, h, style.color, area);
3565
+ if (paint) fillGradient(ctx, paint, area);
2798
3566
  return;
2799
3567
  }
2800
3568
  for (let y = fromY; y < toY; y += stepY) {
@@ -2806,17 +3574,8 @@ function paintGradient(
2806
3574
  const right = Math.min(x + w, area.x + area.w);
2807
3575
  if (right <= left) continue;
2808
3576
  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);
3577
+ const paint = gradientFill(ctx, gradient, x, y, w, h, style.color, tile);
3578
+ if (paint) fillGradient(ctx, paint, tile);
2820
3579
  }
2821
3580
  }
2822
3581
  }
@@ -2856,32 +3615,11 @@ function linearGradient(
2856
3615
  const dy = -Math.cos(angle);
2857
3616
  const length = Math.abs(w * dx) + Math.abs(h * dy);
2858
3617
  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
- }
3618
+ const at = stopOffsets(stops, length);
2881
3619
  // the context's offsets are 0 to 1, so a line that stops reach past runs
2882
3620
  // 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]!);
3621
+ const from = Math.min(0, at[0]);
3622
+ const to = Math.max(1, at[at.length - 1]);
2885
3623
  const startX = x + w / 2 - (dx * length) / 2;
2886
3624
  const startY = y + h / 2 - (dy * length) / 2;
2887
3625
  const x0 = startX + dx * length * from;
@@ -2912,11 +3650,278 @@ function linearGradient(
2912
3650
  }
2913
3651
  const g = ctx.createLinearGradient!(x0, y0, x1, y1);
2914
3652
  for (let i = 0; i < stops.length; i += 1) {
2915
- g.addColorStop((at[i]! - from) / (to - from), colors[i]);
3653
+ g.addColorStop((at[i] - from) / (to - from), colors[i]);
2916
3654
  }
2917
3655
  return g;
2918
3656
  }
2919
3657
 
3658
+ /**
3659
+ * Where each of a gradient's stops is along a line `length` long, as a
3660
+ * fraction of it (CSS Images 3, 3.5.1): a stop without a position is
3661
+ * spread evenly between its neighbours', the first at 0 and the last at 1
3662
+ * where they have none, and none is before the one before it.
3663
+ */
3664
+ function stopOffsets(stops: GradientStop[], length: number): number[] {
3665
+ const at: (number | null)[] = stops.map((stop) =>
3666
+ stop.at === null
3667
+ ? null
3668
+ : length > 0
3669
+ ? resolve(stop.at, length) / length
3670
+ : 0,
3671
+ );
3672
+ if (at[0] === null) at[0] = 0;
3673
+ if (at[at.length - 1] === null) at[at.length - 1] = 1;
3674
+ for (let i = 1; i < at.length; i += 1) {
3675
+ if (at[i] === null) {
3676
+ // spread the ones without a position between the ones with
3677
+ let j = i;
3678
+ while (at[j] === null) j += 1;
3679
+ const from = at[i - 1]!;
3680
+ const to = at[j]!;
3681
+ for (let k = i; k < j; k += 1) {
3682
+ at[k] = from + ((to - from) * (k - i + 1)) / (j - i + 1);
3683
+ }
3684
+ }
3685
+ at[i] = Math.max(at[i]!, at[i - 1]!);
3686
+ }
3687
+ return at as number[];
3688
+ }
3689
+
3690
+ /** What a gradient fills with: the context's gradient, or the one colour
3691
+ * it comes to, and the matrix it is sampled through where it is an
3692
+ * ellipse — a context's radial gradients are circles, and an ellipse is
3693
+ * one stretched along its longer axis. */
3694
+ interface GradientFill {
3695
+ style: unknown;
3696
+ matrix: [number, number, number, number, number, number] | null;
3697
+ }
3698
+
3699
+ /** A gradient's fill for a box at (`x`, `y`), `w` by `h`: null where the
3700
+ * context draws no gradient of its kind. */
3701
+ function gradientFill(
3702
+ ctx: PaintContext,
3703
+ gradient: Gradient,
3704
+ x: number,
3705
+ y: number,
3706
+ w: number,
3707
+ h: number,
3708
+ currentColor: string,
3709
+ visible: { x: number; y: number; w: number; h: number } | null = null,
3710
+ ): GradientFill | null {
3711
+ if (gradient.kind === 'radial') {
3712
+ return radialGradient(ctx, gradient, x, y, w, h, currentColor, visible);
3713
+ }
3714
+ if (!ctx.createLinearGradient) return null;
3715
+ return {
3716
+ style: linearGradient(ctx, gradient, x, y, w, h, currentColor, visible),
3717
+ matrix: null,
3718
+ };
3719
+ }
3720
+
3721
+ /**
3722
+ * A `radial-gradient()` across a box (CSS Images 3, 3.2.2): centred where
3723
+ * its position puts it, and as large as its radii, or as its keyword says
3724
+ * — the side or the corner of the box nearest the centre or furthest from
3725
+ * it, and for an ellipse through a corner, the ellipse with the shape the
3726
+ * sides would give it. The stops are spaced along the ray from the centre
3727
+ * to the ending shape's edge, which for an ellipse is its horizontal
3728
+ * radius; one before the centre, where the ray starts, gives the colour
3729
+ * there and no more, and one past the edge carries the gradient on.
3730
+ *
3731
+ * A shape with no width or no height is drawn as its last colour, as
3732
+ * Chrome draws each (crbug.com/635727): the spec draws a zero width as a
3733
+ * gradient mirrored about the centre line, which Chrome does not.
3734
+ */
3735
+ function radialGradient(
3736
+ ctx: PaintContext,
3737
+ gradient: RadialGradient,
3738
+ x: number,
3739
+ y: number,
3740
+ w: number,
3741
+ h: number,
3742
+ currentColor: string,
3743
+ visible: { x: number; y: number; w: number; h: number } | null = null,
3744
+ ): GradientFill | null {
3745
+ const colors = gradient.stops.map((stop) =>
3746
+ inkColor(stop.color, currentColor),
3747
+ );
3748
+ const cx = resolve(gradient.at[0], w);
3749
+ const cy = resolve(gradient.at[1], h);
3750
+ let rx: number;
3751
+ let ry: number;
3752
+ if (gradient.radii) {
3753
+ rx = resolve(gradient.radii[0], w);
3754
+ ry = resolve(gradient.radii[1], h);
3755
+ } else {
3756
+ const extent = gradient.extent ?? 'farthest-corner';
3757
+ const side = extent.startsWith('closest') ? Math.min : Math.max;
3758
+ const sx = side(Math.abs(cx), Math.abs(w - cx));
3759
+ const sy = side(Math.abs(cy), Math.abs(h - cy));
3760
+ const corner = extent.endsWith('corner');
3761
+ if (gradient.circle) {
3762
+ rx = ry = corner ? Math.hypot(sx, sy) : side(sx, sy);
3763
+ } else {
3764
+ // through the corner, in the shape the sides give it
3765
+ rx = corner ? sx * Math.SQRT2 : sx;
3766
+ ry = corner ? sy * Math.SQRT2 : sy;
3767
+ }
3768
+ }
3769
+ if (!(rx > 0 && ry > 0)) {
3770
+ return { style: colors[colors.length - 1], matrix: null };
3771
+ }
3772
+ if (!hasRadialGradients(ctx)) return null;
3773
+ let at = stopOffsets(gradient.stops, rx);
3774
+ let inks = colors;
3775
+ if (at[0] < 0) {
3776
+ // the colour at the centre, between the stops either side of it
3777
+ const first = colorAlong(at, colors, 0);
3778
+ const i = at.findIndex((t) => t > 0);
3779
+ if (i < 0) return { style: first, matrix: null };
3780
+ at = [0, ...at.slice(i)];
3781
+ inks = [first, ...colors.slice(i)];
3782
+ }
3783
+ const ax = x + cx;
3784
+ const ay = y + cy;
3785
+ const seen = visible ?? { x, y, w, h };
3786
+ if (!(Math.abs(ax) < RADIAL_REACH && Math.abs(ay) < RADIAL_REACH)) {
3787
+ return farRadial(ctx, at, inks, ax, ay, rx, ry, seen);
3788
+ }
3789
+ let to = Math.max(1, at[at.length - 1]);
3790
+ if (Math.max(rx, ry) * to > GRADIENT_REACH) {
3791
+ // as far along the ray as what is seen of it reaches, and no further:
3792
+ // a circle a context cannot carry, cut to the part it draws
3793
+ let reach = 0;
3794
+ for (const px of [seen.x, seen.x + seen.w]) {
3795
+ for (const py of [seen.y, seen.y + seen.h]) {
3796
+ reach = Math.max(reach, Math.hypot((px - ax) / rx, (py - ay) / ry));
3797
+ }
3798
+ }
3799
+ if (reach > 0 && reach < to) {
3800
+ const last = colorAlong(at, inks, reach);
3801
+ const kept = at.filter((t) => t < reach).length;
3802
+ at = [...at.slice(0, kept), reach];
3803
+ inks = [...inks.slice(0, kept), last];
3804
+ to = reach;
3805
+ }
3806
+ }
3807
+ const circle = rx === ry;
3808
+ // a circle as wide as the ellipse is narrow, stretched along the other
3809
+ // axis: the matrix only ever widens, so its inverse, which a context
3810
+ // samples the gradient through, reaches no further than the centre does
3811
+ const r = Math.min(rx, ry);
3812
+ const g = circle
3813
+ ? ctx.createRadialGradient!(ax, ay, 0, ax, ay, r * to)
3814
+ : ctx.createRadialGradient!(0, 0, 0, 0, 0, r * to);
3815
+ for (let i = 0; i < at.length; i += 1) {
3816
+ g.addColorStop(at[i] / to, inks[i]);
3817
+ }
3818
+ return {
3819
+ style: g,
3820
+ matrix: circle ? null : [rx / r, 0, 0, ry / r, ax, ay],
3821
+ };
3822
+ }
3823
+
3824
+ /**
3825
+ * How far from the origin a radial gradient's centre may be and still be
3826
+ * the context's to draw: ntk makes a gradient past what X RENDER carries at
3827
+ * a scale that fits, and its 16.16 matrix holds that scale to a fraction of
3828
+ * a pixel to here.
3829
+ */
3830
+ const RADIAL_REACH = 1 << 18;
3831
+
3832
+ /**
3833
+ * A radial gradient whose centre is further off than a context carries —
3834
+ * `at 0 calc(infinity * 1px)`, which comes to the largest length there is —
3835
+ * across the part of it that is seen: from that far its rings are as good
3836
+ * as straight, so it is drawn as the linear gradient that runs the way the
3837
+ * rings spread there, as fast as they do, cut to what is seen as a linear
3838
+ * gradient's line is. The rings bend away from it by the square of what is
3839
+ * seen over eight times the distance, under a pixel from here.
3840
+ */
3841
+ function farRadial(
3842
+ ctx: PaintContext,
3843
+ at: number[],
3844
+ colors: string[],
3845
+ ax: number,
3846
+ ay: number,
3847
+ rx: number,
3848
+ ry: number,
3849
+ seen: { x: number; y: number; w: number; h: number },
3850
+ ): GradientFill | null {
3851
+ if (!ctx.createLinearGradient) return null;
3852
+ // the middle of what is seen, in the circle's own space, and how fast
3853
+ // the gradient runs there, a pixel
3854
+ const px = seen.x + seen.w / 2;
3855
+ const py = seen.y + seen.h / 2;
3856
+ const ux = (px - ax) / rx;
3857
+ const uy = (py - ay) / ry;
3858
+ const t = Math.hypot(ux, uy);
3859
+ const gx = ux / rx / t;
3860
+ const gy = uy / ry / t;
3861
+ const g2 = gx * gx + gy * gy;
3862
+ if (!(t > 0 && g2 > 0 && Number.isFinite(t / g2))) {
3863
+ return { style: colorAlong(at, colors, t), matrix: null };
3864
+ }
3865
+ // the line it runs along, from where it would start to where it is 1
3866
+ const lx = gx / g2;
3867
+ const ly = gy / g2;
3868
+ return {
3869
+ style: clippedGradient(
3870
+ ctx,
3871
+ at,
3872
+ colors,
3873
+ px - lx * t,
3874
+ py - ly * t,
3875
+ lx,
3876
+ ly,
3877
+ seen,
3878
+ ),
3879
+ matrix: null,
3880
+ };
3881
+ }
3882
+
3883
+ /**
3884
+ * Whether a context draws radial gradients. react-x11's macOS and Windows
3885
+ * contexts have the method and paint what it returns flat, in one colour
3886
+ * — over whatever a vignette was meant to let through — and theirs takes
3887
+ * no circles, which is how it is told from one that draws them: ntk's and
3888
+ * the Wayland context's take the six numbers canvas gives it. There a
3889
+ * radial gradient is drawn as nothing, as every one was.
3890
+ */
3891
+ function hasRadialGradients(ctx: PaintContext): boolean {
3892
+ return (ctx.createRadialGradient?.length ?? 0) >= 6;
3893
+ }
3894
+
3895
+ /**
3896
+ * Fills a rectangle, rounded where `corners` says, with a gradient. An
3897
+ * ellipse's shape is laid down first and filled under its matrix, which a
3898
+ * context applies to the fill's paint and not to a path it already has.
3899
+ */
3900
+ function fillGradient(
3901
+ ctx: PaintContext,
3902
+ fill: GradientFill,
3903
+ r: { x: number; y: number; w: number; h: number },
3904
+ corners: Corners | null = null,
3905
+ ): void {
3906
+ ctx.fillStyle = fill.style;
3907
+ if (!fill.matrix) {
3908
+ if (corners) {
3909
+ ctx.beginPath!();
3910
+ roundedRect(ctx, r.x, r.y, r.w, r.h, corners);
3911
+ ctx.fill!();
3912
+ } else ctx.fillRect(r.x, r.y, r.w, r.h);
3913
+ return;
3914
+ }
3915
+ if (!ctx.transform || !ctx.beginPath || !ctx.rect || !ctx.fill) return;
3916
+ ctx.save();
3917
+ ctx.beginPath();
3918
+ if (corners) roundedRect(ctx, r.x, r.y, r.w, r.h, corners);
3919
+ else ctx.rect(r.x, r.y, r.w, r.h);
3920
+ ctx.transform(...fill.matrix);
3921
+ ctx.fill();
3922
+ ctx.restore();
3923
+ }
3924
+
2920
3925
  /**
2921
3926
  * How far from the origin a gradient's line may end. X RENDER takes the
2922
3927
  * ends in 16.16 fixed point, and a pair past ±32,767 pixels threw from the
@@ -2972,30 +3977,32 @@ function clippedGradient(
2972
3977
  cx + (t1 - tc) * lx,
2973
3978
  cy + (t1 - tc) * ly,
2974
3979
  );
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));
3980
+ g.addColorStop(0, colorAlong(at, colors, t0));
2990
3981
  for (let i = 0; i < at.length; i += 1) {
2991
3982
  if (at[i] > t0 && at[i] < t1) {
2992
3983
  g.addColorStop((at[i] - t0) / (t1 - t0), colors[i]);
2993
3984
  }
2994
3985
  }
2995
- g.addColorStop(1, colorAt(t1));
3986
+ g.addColorStop(1, colorAlong(at, colors, t1));
2996
3987
  return g;
2997
3988
  }
2998
3989
 
3990
+ /** The colour at a place along a gradient whose stops are at `at`: a
3991
+ * stop's, or between two. */
3992
+ function colorAlong(at: number[], colors: string[], t: number): string {
3993
+ if (!(t > at[0])) return colors[0];
3994
+ for (let i = 1; i < at.length; i += 1) {
3995
+ if (t > at[i]) continue;
3996
+ const span = at[i] - at[i - 1];
3997
+ const f = span > 0 ? (t - at[i - 1]) / span : 1;
3998
+ return (
3999
+ blend(colors[i - 1], colors[i], f) ??
4000
+ (f < 0.5 ? colors[i - 1] : colors[i])
4001
+ );
4002
+ }
4003
+ return colors[colors.length - 1];
4004
+ }
4005
+
2999
4006
  /**
3000
4007
  * The backgrounds of a table's column groups, columns, row groups and rows
3001
4008
  * (CSS 2.1 17.5.1), in that order: over the table's own and under its
@@ -3230,11 +4237,20 @@ function paintBackgroundImage(
3230
4237
  // a drawing is drawn a tile at a time, at the size it was given
3231
4238
  if (tiles <= MAX_TILES) {
3232
4239
  for (let y = fromY; y < toY; y += stepY) {
3233
- for (let x = fromX; x < toX; x += stepX)
3234
- svg.draw(ctx, Math.round(x), Math.round(y), iw, ih, scale);
4240
+ for (let x = fromX; x < toX; x += stepX) {
4241
+ svg.drawImage(
4242
+ ctx,
4243
+ Math.round(x),
4244
+ Math.round(y),
4245
+ iw,
4246
+ ih,
4247
+ scale,
4248
+ style.colorScheme,
4249
+ );
4250
+ }
3235
4251
  }
3236
4252
  } else {
3237
- svg.draw(ctx, x0, y0, iw, ih, scale);
4253
+ svg.drawImage(ctx, x0, y0, iw, ih, scale, style.colorScheme);
3238
4254
  }
3239
4255
  } else if (
3240
4256
  tiles > 1 &&
@@ -3403,16 +4419,6 @@ function tileSize(
3403
4419
  return [area.width, area.height];
3404
4420
  }
3405
4421
 
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
4422
  /**
3417
4423
  * A box's outline (CSS 2.1 18.4, CSS UI 4 5): a border of its own width,
3418
4424
  * style and colour round the border box grown by `outline-offset` — which
@@ -3471,6 +4477,26 @@ function paintOutline(
3471
4477
  );
3472
4478
  }
3473
4479
 
4480
+ /**
4481
+ * Borders, a side at a time.
4482
+ *
4483
+ * As rectangles where they can be, the top and the bottom full width and
4484
+ * the sides between them, and not as a stroked path, for the reason
4485
+ * richtext gives about its underlines: the mock backend has no path API, and
4486
+ * a 1px border on a pixel grid is a rectangle rather than something an
4487
+ * antialiased stroke improves. That is every border whose sides share a
4488
+ * colour, where a corner looks the same whichever side it is given to.
4489
+ *
4490
+ * Where two sides that meet differ in colour — one of them transparent
4491
+ * included — the corner is cut on the diagonal from its outer point to its
4492
+ * inner one and each side takes its half (CSS Backgrounds 3, 4.3), so a side
4493
+ * is a trapezoid, and a triangle where the box inside the borders has no
4494
+ * width: the CSS triangle a dropdown's caret and a tooltip's arrow are
4495
+ * drawn with, which came out as the rectangle around it. Only a solid side
4496
+ * is cut, and only against a side that is solid or paints nothing; dots,
4497
+ * dashes and a double border's two lines keep their rectangles, the lines
4498
+ * joined where two double sides of one colour meet (`fillSide`).
4499
+ */
3474
4500
  function paintBorders(
3475
4501
  ctx: PaintContext,
3476
4502
  box: Frame,
@@ -3490,58 +4516,160 @@ function paintBorders(
3490
4516
  if (roundedRing(ctx, box, x, y, w, h, options)) return;
3491
4517
  if (sculpted(s) && paintSculpted(ctx, box, x, y, w, h, options)) return;
3492
4518
 
4519
+ const t = box.borderTop;
4520
+ const r = box.borderRight;
4521
+ const b = box.borderBottom;
4522
+ const l = box.borderLeft;
4523
+ /** What a side is painted in, null where it paints nothing. */
4524
+ const ink = (width: number, color: string): string | null =>
4525
+ width > 0 && !isTransparent(color) ? inkColor(color, s.color) : null;
4526
+ const topInk = ink(t, s.borderTopColor);
4527
+ const rightInk = ink(r, s.borderRightColor);
4528
+ const bottomInk = ink(b, s.borderBottomColor);
4529
+ const leftInk = ink(l, s.borderLeftColor);
4530
+ // the corners cut on the diagonal; none where the context draws no path,
4531
+ // and the sides are rectangles
4532
+ const paths = !!(ctx.beginPath && ctx.moveTo && ctx.lineTo && ctx.fill);
4533
+ const topStyle = s.borderTopStyle;
4534
+ const rightStyle = s.borderRightStyle;
4535
+ const bottomStyle = s.borderBottomStyle;
4536
+ const leftStyle = s.borderLeftStyle;
4537
+ const tl = paths && mitred(t, l, topInk, leftInk, topStyle, leftStyle);
4538
+ const tr = paths && mitred(t, r, topInk, rightInk, topStyle, rightStyle);
4539
+ const br =
4540
+ paths && mitred(b, r, bottomInk, rightInk, bottomStyle, rightStyle);
4541
+ const bl = paths && mitred(b, l, bottomInk, leftInk, bottomStyle, leftStyle);
4542
+
4543
+ const widths = [t, r, b, l];
4544
+ const joins = doubleJoins(box);
4545
+ /** A side with square corners, the top first and clockwise. */
3493
4546
  const edge = (
3494
- ex: number,
3495
- ey: number,
3496
- ew: number,
3497
- eh: number,
4547
+ at: number,
3498
4548
  style: ComputedStyle['borderTopStyle'],
3499
4549
  color: string,
3500
- horizontal: boolean,
3501
4550
  ): void => {
3502
- const rect = clampRect(options, ex, ey, ew, eh);
3503
- if (!rect) return;
3504
- ctx.fillStyle = inkColor(color, s.color);
3505
- // The un-clamped start is the dash phase's origin, so the pattern does
3506
- // not crawl as the viewport moves along a long edge.
3507
- fillEdge(ctx, rect, horizontal ? ex : ey, style, horizontal);
4551
+ ctx.fillStyle = color;
4552
+ fillSide(ctx, options, at, x, y, w, h, widths, style, joins);
3508
4553
  };
3509
- if (box.borderTop > 0 && !isTransparent(s.borderTopColor)) {
3510
- edge(x, y, w, box.borderTop, s.borderTopStyle, s.borderTopColor, true);
4554
+ /** A side with a cut corner: its outer edge and then its inner one. */
4555
+ const side = (points: number[], color: string): void => {
4556
+ const area = clampRect(options, x, y, w, h);
4557
+ if (!area) return;
4558
+ ctx.fillStyle = color;
4559
+ fillPolygon(ctx, points, area);
4560
+ };
4561
+ // the border box's far edges, and the padding box inside it
4562
+ const x1 = x + w;
4563
+ const y1 = y + h;
4564
+ const px0 = x + l;
4565
+ const py0 = y + t;
4566
+ const px1 = Math.max(px0, x1 - r);
4567
+ const py1 = Math.max(py0, y1 - b);
4568
+ // The left and the right are drawn after the top and the bottom. Where
4569
+ // one of them is opaque it draws its half of a cut corner over the side
4570
+ // drawn before, which keeps the whole corner: two halves each
4571
+ // antialiased along the diagonal would let what is under the border
4572
+ // show through between them.
4573
+ const over = (color: string | null): boolean =>
4574
+ color !== null && alphaOf(color) === 1;
4575
+ if (topInk !== null) {
4576
+ const cutLeft = tl && !over(leftInk);
4577
+ const cutRight = tr && !over(rightInk);
4578
+ if (cutLeft || cutRight) {
4579
+ side(
4580
+ [x, y, x1, y, cutRight ? px1 : x1, py0, cutLeft ? px0 : x, py0],
4581
+ topInk,
4582
+ );
4583
+ } else edge(0, topStyle, topInk);
4584
+ }
4585
+ if (bottomInk !== null) {
4586
+ const cutLeft = bl && !over(leftInk);
4587
+ const cutRight = br && !over(rightInk);
4588
+ if (cutLeft || cutRight) {
4589
+ side(
4590
+ [x1, y1, x, y1, cutLeft ? px0 : x, py1, cutRight ? px1 : x1, py1],
4591
+ bottomInk,
4592
+ );
4593
+ } else edge(2, bottomStyle, bottomInk);
3511
4594
  }
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
- );
4595
+ if (leftInk !== null) {
4596
+ if (tl || bl) {
4597
+ side([x, bl ? y1 : py1, x, tl ? y : py0, px0, py0, px0, py1], leftInk);
4598
+ } else edge(3, leftStyle, leftInk);
3522
4599
  }
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
- );
4600
+ if (rightInk !== null) {
4601
+ if (tr || br) {
4602
+ side([x1, tr ? y : py0, x1, br ? y1 : py1, px1, py1, px1, py0], rightInk);
4603
+ } else edge(1, rightStyle, rightInk);
3533
4604
  }
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
- );
4605
+ }
4606
+
4607
+ /**
4608
+ * Whether the corner two sides meet at is cut on its diagonal, each side
4609
+ * taking its half (CSS Backgrounds 3, 4.3): where both have a width, they
4610
+ * are painted in different colours or only one is painted at all, and each
4611
+ * is solid or unpainted. Sides of one colour make the same corner either
4612
+ * way, and so does a corner of a single pixel, which is left to the side
4613
+ * that has it.
4614
+ */
4615
+ function mitred(
4616
+ a: number,
4617
+ b: number,
4618
+ inkA: string | null,
4619
+ inkB: string | null,
4620
+ styleA: ComputedStyle['borderTopStyle'],
4621
+ styleB: ComputedStyle['borderTopStyle'],
4622
+ ): boolean {
4623
+ if (!(a > 0 && b > 0) || (a <= 1 && b <= 1) || inkA === inkB) return false;
4624
+ return (
4625
+ (inkA === null || styleA === 'solid') &&
4626
+ (inkB === null || styleB === 'solid')
4627
+ );
4628
+ }
4629
+
4630
+ /**
4631
+ * Fill a convex polygon, its corners as `x, y` pairs, cut to `area`: the
4632
+ * part of it near what is painted, which is what keeps a side thousands of
4633
+ * pixels long inside X's coordinates. Cut edge by edge rather than by
4634
+ * moving its corners in, which would turn a diagonal that crosses the
4635
+ * painted area: a slanted divider, `border-left: 100vw solid transparent`,
4636
+ * has a corner far outside it.
4637
+ */
4638
+ function fillPolygon(
4639
+ ctx: PaintContext,
4640
+ points: number[],
4641
+ area: { x: number; y: number; w: number; h: number },
4642
+ ): void {
4643
+ const bounds = [area.x, area.y, area.x + area.w, area.y + area.h];
4644
+ let cut = points;
4645
+ // each edge of the area in turn: what is inside it is kept, and an edge
4646
+ // of the polygon that crosses it ends on it (Sutherland–Hodgman)
4647
+ for (let edge = 0; edge < 4 && cut.length; edge += 1) {
4648
+ const axis = edge & 1;
4649
+ const bound = bounds[edge];
4650
+ const sign = edge < 2 ? 1 : -1;
4651
+ const from = cut;
4652
+ cut = [];
4653
+ for (let i = 0; i < from.length; i += 2) {
4654
+ const j = (i + 2) % from.length;
4655
+ const da = sign * (from[i + axis] - bound);
4656
+ const db = sign * (from[j + axis] - bound);
4657
+ if (da >= 0) cut.push(from[i], from[i + 1]);
4658
+ if (da < 0 !== db < 0) {
4659
+ const k = da / (da - db);
4660
+ const other =
4661
+ from[i + 1 - axis] + (from[j + 1 - axis] - from[i + 1 - axis]) * k;
4662
+ if (axis) cut.push(other, bound);
4663
+ else cut.push(bound, other);
4664
+ }
4665
+ }
3544
4666
  }
4667
+ if (cut.length < 6) return;
4668
+ ctx.beginPath!();
4669
+ ctx.moveTo!(cut[0], cut[1]);
4670
+ for (let i = 2; i < cut.length; i += 2) ctx.lineTo!(cut[i], cut[i + 1]);
4671
+ ctx.closePath?.();
4672
+ ctx.fill!();
3545
4673
  }
3546
4674
 
3547
4675
  /**
@@ -3599,23 +4727,16 @@ function paintBorderImage(
3599
4727
  const svg = image instanceof SvgDrawing ? image : null;
3600
4728
  const w = Math.max(1, Math.round(cw));
3601
4729
  const h = Math.max(1, Math.round(ch));
4730
+ const scheme = box.style.colorScheme;
3602
4731
  const drawn = options.cached?.(
3603
- `border-image|${key}|${w}x${h}`,
4732
+ `border-image|${key}|${w}x${h}|${scheme}`,
3604
4733
  w,
3605
4734
  h,
3606
4735
  (sctx) => {
3607
- 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);
4736
+ if (svg) svg.drawImage(sctx, 0, 0, w, h, scale, scheme);
4737
+ else if (typeof source !== 'string') {
4738
+ const paint = gradientFill(sctx, source, 0, 0, w, h, box.style.color);
4739
+ if (paint) fillGradient(sctx, paint, { x: 0, y: 0, w, h });
3619
4740
  }
3620
4741
  },
3621
4742
  );
@@ -3739,11 +4860,11 @@ function paintBorderImage(
3739
4860
  return true;
3740
4861
  }
3741
4862
 
3742
- const GRADIENT_KEYS = new WeakMap<LinearGradient, string>();
4863
+ const GRADIENT_KEYS = new WeakMap<Gradient, string>();
3743
4864
 
3744
4865
  /** A gradient as a key for what is drawn of it: its angle and stops, and
3745
4866
  * the colour `currentColor` among them is. */
3746
- function gradientKey(gradient: LinearGradient, color: string): string {
4867
+ function gradientKey(gradient: Gradient, color: string): string {
3747
4868
  let key = GRADIENT_KEYS.get(gradient);
3748
4869
  if (key === undefined) {
3749
4870
  key = JSON.stringify(gradient);
@@ -3834,28 +4955,164 @@ function roundedRing(
3834
4955
  }
3835
4956
  if (color === null) return true;
3836
4957
  if (isTransparent(color)) return true;
3837
- const rect = clampRect(options, x, y, w, h);
3838
- if (!rect) return true;
3839
- const top = box.borderTop;
3840
- const right = box.borderRight;
3841
- const bottom = box.borderBottom;
3842
- const left = box.borderLeft;
4958
+ const ring = cutRing(
4959
+ options,
4960
+ x,
4961
+ y,
4962
+ w,
4963
+ h,
4964
+ corners,
4965
+ box.borderTop,
4966
+ box.borderRight,
4967
+ box.borderBottom,
4968
+ box.borderLeft,
4969
+ );
4970
+ if (!ring) return true;
4971
+ const { outer, inner } = ring;
3843
4972
  ctx.fillStyle = inkColor(color, s.color);
3844
4973
  fillRing(
3845
4974
  ctx,
3846
- { x: rect.x, y: rect.y, width: rect.w, height: rect.h },
3847
- corners,
3848
- {
3849
- x: rect.x + left,
3850
- y: rect.y + top,
3851
- width: Math.max(0, rect.w - left - right),
3852
- height: Math.max(0, rect.h - top - bottom),
3853
- },
3854
- insetCorners(corners, top, right, bottom, left),
4975
+ outer.rect,
4976
+ outer.corners,
4977
+ inner ? inner.rect : { x: 0, y: 0, width: 0, height: 0 },
4978
+ inner ? inner.corners : outer.corners,
3855
4979
  );
3856
4980
  return true;
3857
4981
  }
3858
4982
 
4983
+ /** A rectangle and the radii of its corners. */
4984
+ interface Rounded {
4985
+ rect: Rect;
4986
+ corners: Corners;
4987
+ }
4988
+
4989
+ /**
4990
+ * A rounded box's ring — its border edge and its padding edge, the box
4991
+ * inside its borders — cut to the neighbourhood of what is being painted,
4992
+ * as `clampRect` cuts a rectangle. Both edges are the box's own, cut to
4993
+ * one window, so what is left is the part of the true ring in it. Cutting
4994
+ * the box first and insetting the cut by the borders put the hole a
4995
+ * border's width in from the cut rather than from the box's edge, and left
4996
+ * none where a border was wider than `CLAMP_PAD`: the ring filled whatever
4997
+ * of the box a paint reached. A cut is straight, so a side of the window
4998
+ * is moved out past any corner's curve it would cross, and a corner on a
4999
+ * side the window cut is square, its curve being outside. Null where the
5000
+ * ring has nothing in the window.
5001
+ */
5002
+ function cutRing(
5003
+ options: PaintOptions,
5004
+ x: number,
5005
+ y: number,
5006
+ w: number,
5007
+ h: number,
5008
+ corners: Corners,
5009
+ top: number,
5010
+ right: number,
5011
+ bottom: number,
5012
+ left: number,
5013
+ ): { outer: Rounded; inner: Rounded | null } | null {
5014
+ const { x: cx, y: cy } = corners;
5015
+ // where each corner's curve runs, across and down — the padding edge's
5016
+ // run inside the border edge's
5017
+ const across: [number, number][] = [
5018
+ [x, x + cx[0]],
5019
+ [x + w - cx[1], x + w],
5020
+ [x + w - cx[2], x + w],
5021
+ [x, x + cx[3]],
5022
+ ];
5023
+ const down: [number, number][] = [
5024
+ [y, y + cy[0]],
5025
+ [y, y + cy[1]],
5026
+ [y + h - cy[2], y + h],
5027
+ [y + h - cy[3], y + h],
5028
+ ];
5029
+ const d = options.damage;
5030
+ const side = (at: number, spans: [number, number][], towards: number) =>
5031
+ Math.max(
5032
+ -COORD_LIMIT,
5033
+ Math.min(COORD_LIMIT, outOfSpans(at, spans, towards)),
5034
+ );
5035
+ const x0 = side(d ? d.x - CLAMP_PAD : -COORD_LIMIT, across, -1);
5036
+ const y0 = side(d ? d.y - CLAMP_PAD : -COORD_LIMIT, down, -1);
5037
+ const x1 = side(d ? d.x + d.width + CLAMP_PAD : COORD_LIMIT, across, 1);
5038
+ const y1 = side(d ? d.y + d.height + CLAMP_PAD : COORD_LIMIT, down, 1);
5039
+ const cut = (
5040
+ rx: number,
5041
+ ry: number,
5042
+ rw: number,
5043
+ rh: number,
5044
+ c: Corners,
5045
+ ): Rounded | null => {
5046
+ const l = Math.max(rx, x0);
5047
+ const t = Math.max(ry, y0);
5048
+ const r = Math.min(rx + rw, x1);
5049
+ const b = Math.min(ry + rh, y1);
5050
+ if (r <= l || b <= t) return null;
5051
+ // the corners of the sides the window left where they were
5052
+ const kept = [
5053
+ t === ry && l === rx,
5054
+ t === ry && r === rx + rw,
5055
+ b === ry + rh && r === rx + rw,
5056
+ b === ry + rh && l === rx,
5057
+ ];
5058
+ return {
5059
+ rect: { x: l, y: t, width: r - l, height: b - t },
5060
+ corners: {
5061
+ x: c.x.map((v, i) => (kept[i] ? v : 0)) as Corners['x'],
5062
+ y: c.y.map((v, i) => (kept[i] ? v : 0)) as Corners['y'],
5063
+ },
5064
+ };
5065
+ };
5066
+ const outer = cut(x, y, w, h, corners);
5067
+ if (!outer) return null;
5068
+ const iw = w - left - right;
5069
+ const ih = h - top - bottom;
5070
+ const inner =
5071
+ iw > 0 && ih > 0
5072
+ ? cut(
5073
+ x + left,
5074
+ y + top,
5075
+ iw,
5076
+ ih,
5077
+ insetCorners(corners, top, right, bottom, left),
5078
+ )
5079
+ : null;
5080
+ // a window inside the padding edge, where the ring has nothing
5081
+ if (inner && sameRounded(outer, inner)) return null;
5082
+ return { outer, inner };
5083
+ }
5084
+
5085
+ /** A position moved out of any of `spans` it is inside, towards their
5086
+ * starts where `towards` is negative and their ends where it is not. */
5087
+ function outOfSpans(
5088
+ at: number,
5089
+ spans: readonly [number, number][],
5090
+ towards: number,
5091
+ ): number {
5092
+ for (let moved = true; moved;) {
5093
+ moved = false;
5094
+ for (const [from, to] of spans) {
5095
+ if (at > from && at < to) {
5096
+ at = towards < 0 ? from : to;
5097
+ moved = true;
5098
+ }
5099
+ }
5100
+ }
5101
+ return at;
5102
+ }
5103
+
5104
+ /** Whether two rounded rectangles are one shape. */
5105
+ function sameRounded(a: Rounded, b: Rounded): boolean {
5106
+ return (
5107
+ a.rect.x === b.rect.x &&
5108
+ a.rect.y === b.rect.y &&
5109
+ a.rect.width === b.rect.width &&
5110
+ a.rect.height === b.rect.height &&
5111
+ a.corners.x.every((v, i) => v === b.corners.x[i]) &&
5112
+ a.corners.y.every((v, i) => v === b.corners.y[i])
5113
+ );
5114
+ }
5115
+
3859
5116
  /**
3860
5117
  * Clip to what a box's border paints, for a layer of `background-clip:
3861
5118
  * border-area` (CSS Backgrounds 4, 2.1): each side's width and style, and
@@ -3863,12 +5120,17 @@ function roundedRing(
3863
5120
  * the value is for. The shapes are the border painter's own, so that the
3864
5121
  * background shows exactly where that border would: the ring `roundedRing`
3865
5122
  * fills where a rounded border is solid, the ring the trapezoids of a 3D
3866
- * style make, and otherwise the rectangles `fillEdge` fills a side at a
3867
- * time — the dots, the dashes, the two lines of a double border. All of
5123
+ * style make, and otherwise the rectangles `fillSide` fills a side at a
5124
+ * time — the dots, the dashes, the two lines of a double border joined as
5125
+ * they are painted, by the sides' colours though neither is drawn. All of
3868
5126
  * them run clockwise and a ring's inside the other way, so the clip is the
3869
- * union by the non-zero rule, the one every context's `clip` takes. False
3870
- * where the border paints nothing or the context cannot clip, and nothing
3871
- * is pushed.
5127
+ * union by the non-zero rule, the one every context's `clip` takes. Each
5128
+ * is placed from the box's edges and then cut to the painted area: placed
5129
+ * from the cut, a border wider than `CLAMP_PAD` put its bands and its
5130
+ * ring's hole a border's width in from wherever the paint was cut, and
5131
+ * the background was painted over the content of a box repainted in part.
5132
+ * False where the border paints nothing in the painted area or the context
5133
+ * cannot clip, and nothing is pushed.
3872
5134
  */
3873
5135
  function pushBorderArea(
3874
5136
  ctx: PaintContext,
@@ -3890,43 +5152,60 @@ function pushBorderArea(
3890
5152
  const w = Math.round(left + box.width) - x;
3891
5153
  const h = Math.round(top + frameHeight(box)) - y;
3892
5154
  if (w <= 0 || h <= 0) return false;
3893
- const area = clampRect(options, x, y, w, h);
3894
- if (!area) return false;
5155
+ if (!clampRect(options, x, y, w, h)) return false;
3895
5156
  const corners = cornersOf(s, w, h);
3896
5157
  ctx.save();
3897
5158
  ctx.beginPath();
5159
+ // how many shapes the path has: none, and the border paints nothing here
5160
+ let shapes = 0;
3898
5161
  if (corners && canCurve(ctx) && solidBorder(box)) {
3899
- roundedRect(ctx, area.x, area.y, area.w, area.h, corners, true);
3900
- const iw = area.w - l - r;
3901
- const ih = area.h - t - b;
3902
- if (iw > 0 && ih > 0) {
3903
- const inner = insetCorners(corners, t, r, b, l);
3904
- roundedRect(ctx, area.x + l, area.y + t, iw, ih, inner, true, true);
5162
+ const ring = cutRing(options, x, y, w, h, corners, t, r, b, l);
5163
+ if (ring) {
5164
+ const add = ({ rect, corners: c }: Rounded, hole: boolean): void =>
5165
+ roundedRect(
5166
+ ctx,
5167
+ rect.x,
5168
+ rect.y,
5169
+ rect.width,
5170
+ rect.height,
5171
+ c,
5172
+ true,
5173
+ hole,
5174
+ );
5175
+ add(ring.outer, false);
5176
+ // the padding edge run the other way, which cuts it out
5177
+ if (ring.inner) add(ring.inner, true);
5178
+ shapes = 1;
3905
5179
  }
3906
- } else if (sculpted(s)) {
3907
- // a band a side, which the trapezoids cover between them
3908
- ctx.rect(area.x, area.y, area.w, t);
3909
- ctx.rect(area.x, area.y + area.h - b, area.w, b);
3910
- ctx.rect(area.x, area.y + t, l, area.h - t - b);
3911
- ctx.rect(area.x + area.w - r, area.y + t, r, area.h - t - b);
3912
5180
  } else {
3913
- const path = { fillRect: ctx.rect.bind(ctx) };
3914
- const edge = (
3915
- ex: number,
3916
- ey: number,
3917
- ew: number,
3918
- eh: number,
3919
- style: ComputedStyle['borderTopStyle'],
3920
- horizontal: boolean,
3921
- ): void => {
3922
- const rect = clampRect(options, ex, ey, ew, eh);
3923
- if (rect) fillEdge(path, rect, horizontal ? ex : ey, style, horizontal);
5181
+ // A side at a time, from the box's edges and cut to the painted area
5182
+ // after: a 3D side as a band, which the trapezoids cover between them,
5183
+ // and any other as `fillSide` fills it.
5184
+ const path = {
5185
+ fillRect(ex: number, ey: number, ew: number, eh: number) {
5186
+ ctx.rect!(ex, ey, ew, eh);
5187
+ shapes += 1;
5188
+ },
3924
5189
  };
3925
- const between = h - t - b;
3926
- if (t > 0) edge(x, y, w, t, s.borderTopStyle, true);
3927
- if (b > 0) edge(x, y + h - b, w, b, s.borderBottomStyle, true);
3928
- if (l > 0) edge(x, y + t, l, between, s.borderLeftStyle, false);
3929
- if (r > 0) edge(x + w - r, y + t, r, between, s.borderRightStyle, false);
5190
+ const widths = [t, r, b, l];
5191
+ const joins = doubleJoins(box);
5192
+ const styles: ComputedStyle['borderTopStyle'][] = sculpted(s)
5193
+ ? ['solid', 'solid', 'solid', 'solid']
5194
+ : [
5195
+ s.borderTopStyle,
5196
+ s.borderRightStyle,
5197
+ s.borderBottomStyle,
5198
+ s.borderLeftStyle,
5199
+ ];
5200
+ for (let at = 0; at < 4; at += 1) {
5201
+ if (widths[at] > 0) {
5202
+ fillSide(path, options, at, x, y, w, h, widths, styles[at], joins);
5203
+ }
5204
+ }
5205
+ }
5206
+ if (!shapes) {
5207
+ ctx.restore();
5208
+ return false;
3930
5209
  }
3931
5210
  ctx.clip();
3932
5211
  return true;
@@ -3963,10 +5242,12 @@ function sculpted(s: ComputedStyle): boolean {
3963
5242
  * colour's shadow and `outset` its bottom and right; `groove` is `inset`
3964
5243
  * outside `outset`, a band each, and `ridge` the other way round
3965
5244
  * (`borderShades`). A side of any other style takes its trapezoid in its
3966
- * colour. The trapezoids are clamped to the painted area, which leaves a
3967
- * cut end square and out of sight and keeps a long box's far corners out
3968
- * of X's coordinates. False where the context cannot draw a path, and the
3969
- * sides are drawn straight.
5245
+ * colour. The trapezoids are cut to the painted area (`fillPolygon`), which
5246
+ * keeps a long box's far corners out of X's coordinates and a join on its
5247
+ * diagonal: its corners clamped in, a border wider than `CLAMP_PAD` turned
5248
+ * the join to cross the damage at 45° wherever it was, and a scrolled
5249
+ * strip smeared the corners of a thick one. False where the context cannot
5250
+ * draw a path, and the sides are drawn straight.
3970
5251
  */
3971
5252
  function paintSculpted(
3972
5253
  ctx: PaintContext,
@@ -3985,10 +5266,6 @@ function paintSculpted(
3985
5266
  const r = box.borderRight;
3986
5267
  const b = box.borderBottom;
3987
5268
  const l = box.borderLeft;
3988
- const cx = (v: number) =>
3989
- Math.max(area.x - 1, Math.min(v, area.x + area.w + 1));
3990
- const cy = (v: number) =>
3991
- Math.max(area.y - 1, Math.min(v, area.y + area.h + 1));
3992
5269
  /** The band between the edge `outer` of the way in and `inner`, for one
3993
5270
  * side: 0 is the border box, 1 the padding box. */
3994
5271
  const band = (side: number, outer: number, inner: number, color: string) => {
@@ -4009,12 +5286,7 @@ function paintSculpted(
4009
5286
  ? [o.x1, o.y1, o.x0, o.y1, i.x0, i.y1, i.x1, i.y1]
4010
5287
  : [o.x0, o.y1, o.x0, o.y0, i.x0, i.y0, i.x0, i.y1];
4011
5288
  ctx.fillStyle = color;
4012
- ctx.beginPath!();
4013
- ctx.moveTo!(cx(points[0]), cy(points[1]));
4014
- for (let k = 2; k < 8; k += 2)
4015
- ctx.lineTo!(cx(points[k]), cy(points[k + 1]));
4016
- ctx.closePath?.();
4017
- ctx.fill!();
5289
+ fillPolygon(ctx, points, area);
4018
5290
  };
4019
5291
  const sides: [number, string, string][] = [
4020
5292
  [t, s.borderTopStyle, s.borderTopColor],
@@ -4141,6 +5413,111 @@ function paintCollapsedBorders(
4141
5413
  }
4142
5414
  }
4143
5415
 
5416
+ /**
5417
+ * One side of a border box at `x, y`, `w` by `h` — `at` counts the top
5418
+ * first and clockwise — as rectangles cut to what the paint reaches: the
5419
+ * top and the bottom the width of the box and the left and the right
5420
+ * between them, so a corner is a square of one side or the other.
5421
+ *
5422
+ * A double side is two lines, a band each along its outer and its inner
5423
+ * edge. Where it meets another double side of its colour (`joins`, the
5424
+ * top left corner first) the lines of the two meet as two frames do, one
5425
+ * inside the other (CSS Backgrounds 3, 4.2 and 4.3): the outer line of the
5426
+ * left or the right runs on to the outer line of the top or the bottom,
5427
+ * and the inner line of the top or the bottom stops at the inner line of
5428
+ * the side. With square corners the outer frame was open at each of them
5429
+ * and the inner line of the top ran on past the side's to the border edge.
5430
+ * Each line is cut to the paint on its own, so a border wider than the
5431
+ * reach around the paint keeps its lines at its edges, and not at the
5432
+ * edges of the part of it cut out.
5433
+ */
5434
+ function fillSide(
5435
+ ctx: Pick<FillContext, 'fillRect'>,
5436
+ options: PaintOptions,
5437
+ at: number,
5438
+ x: number,
5439
+ y: number,
5440
+ w: number,
5441
+ h: number,
5442
+ widths: readonly number[],
5443
+ style: ComputedStyle['borderTopStyle'],
5444
+ joins: readonly boolean[],
5445
+ ): void {
5446
+ const [t, r, b, l] = widths;
5447
+ const width = widths[at];
5448
+ const horizontal = (at & 1) === 0;
5449
+ if (style === 'double' && width >= 3) {
5450
+ const band = doubleBand(width);
5451
+ const line = (lx: number, ly: number, lw: number, lh: number): void => {
5452
+ const rect = clampRect(options, lx, ly, lw, lh);
5453
+ if (rect) ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
5454
+ };
5455
+ if (horizontal) {
5456
+ // the outer line the width of the box, and the inner one from the
5457
+ // inner line of a side it joins, or from the edge of the box
5458
+ const from = joins[at === 0 ? 0 : 3] ? x + l - doubleBand(l) : x;
5459
+ const to = joins[at === 0 ? 1 : 2] ? x + w - r + doubleBand(r) : x + w;
5460
+ line(x, at === 0 ? y : y + h - band, w, band);
5461
+ line(from, at === 0 ? y + t - band : y + h - b, to - from, band);
5462
+ } else {
5463
+ // the outer line from the outer line of a side it joins, or from
5464
+ // that side's inner edge, and the inner one between the two sides
5465
+ const from = joins[at === 3 ? 0 : 1] ? y + doubleBand(t) : y + t;
5466
+ const to = joins[at === 3 ? 3 : 2] ? y + h - doubleBand(b) : y + h - b;
5467
+ line(at === 3 ? x : x + w - band, from, band, to - from);
5468
+ line(at === 3 ? x + l - band : x + w - r, y + t, band, h - t - b);
5469
+ }
5470
+ return;
5471
+ }
5472
+ const ex = at === 1 ? x + w - r : x;
5473
+ const ey = at === 0 ? y : at === 2 ? y + h - b : y + t;
5474
+ const rect = horizontal
5475
+ ? clampRect(options, ex, ey, w, width)
5476
+ : clampRect(options, ex, ey, width, h - t - b);
5477
+ // The un-clamped start is the dash phase's origin, so the pattern does
5478
+ // not crawl as the viewport moves along a long edge.
5479
+ if (rect) fillEdge(ctx, rect, horizontal ? ex : ey, style, horizontal);
5480
+ }
5481
+
5482
+ /**
5483
+ * The corners of a box where two double sides of one colour meet, the top
5484
+ * left first and clockwise, and whose lines `fillSide` joins. By the
5485
+ * colours as written, a transparent one included, which is how a clip to
5486
+ * the border's area draws the border it does not paint.
5487
+ */
5488
+ function doubleJoins(box: Frame): readonly boolean[] {
5489
+ const s = box.style;
5490
+ const color = (
5491
+ width: number,
5492
+ style: ComputedStyle['borderTopStyle'],
5493
+ value: string,
5494
+ ): string | null =>
5495
+ style === 'double' && width >= 3 ? inkColor(value, s.color) : null;
5496
+ const top = color(box.borderTop, s.borderTopStyle, s.borderTopColor);
5497
+ const bottom = color(
5498
+ box.borderBottom,
5499
+ s.borderBottomStyle,
5500
+ s.borderBottomColor,
5501
+ );
5502
+ if (top === null && bottom === null) return UNJOINED;
5503
+ const right = color(box.borderRight, s.borderRightStyle, s.borderRightColor);
5504
+ const left = color(box.borderLeft, s.borderLeftStyle, s.borderLeftColor);
5505
+ return [
5506
+ top !== null && top === left,
5507
+ top !== null && top === right,
5508
+ bottom !== null && bottom === right,
5509
+ bottom !== null && bottom === left,
5510
+ ];
5511
+ }
5512
+
5513
+ const UNJOINED: readonly boolean[] = [false, false, false, false];
5514
+
5515
+ /** How wide each of a double border's two lines is, the gap between them
5516
+ * what is left of the side. */
5517
+ function doubleBand(thickness: number): number {
5518
+ return Math.max(1, Math.floor(thickness / 3));
5519
+ }
5520
+
4144
5521
  function fillEdge(
4145
5522
  ctx: Pick<FillContext, 'fillRect'>,
4146
5523
  rect: { x: number; y: number; w: number; h: number },
@@ -4169,7 +5546,7 @@ function fillEdge(
4169
5546
  return;
4170
5547
  }
4171
5548
  if (style === 'double' && thickness >= 3) {
4172
- const band = Math.max(1, Math.floor(thickness / 3));
5549
+ const band = doubleBand(thickness);
4173
5550
  if (horizontal) {
4174
5551
  ctx.fillRect(x, y, length, band);
4175
5552
  ctx.fillRect(x, y + thickness - band, length, band);
@@ -4187,6 +5564,8 @@ function paintMarker(
4187
5564
  ctx: PaintContext,
4188
5565
  marker: Marker,
4189
5566
  options: PaintOptions,
5567
+ /** The item's colour scheme, which an SVG image in it is drawn in. */
5568
+ scheme: 'light' | 'dark',
4190
5569
  ): void {
4191
5570
  const x = marker.x + options.originX;
4192
5571
  const y = marker.y + options.originY;
@@ -4196,7 +5575,15 @@ function paintMarker(
4196
5575
  const loaded = options.backgroundImageFor?.(url);
4197
5576
  if (!loaded || !(width > 0 && height > 0)) return;
4198
5577
  if (loaded.image instanceof SvgDrawing) {
4199
- loaded.image.draw(ctx, x, y, width, height, options.scale ?? 1);
5578
+ loaded.image.drawImage(
5579
+ ctx,
5580
+ x,
5581
+ y,
5582
+ width,
5583
+ height,
5584
+ options.scale ?? 1,
5585
+ scheme,
5586
+ );
4200
5587
  } else ctx.drawImage?.(loaded.image, x, y, width, height);
4201
5588
  return;
4202
5589
  }
@@ -4228,7 +5615,15 @@ function paintImage(ctx: PaintContext, box: Box, options: PaintOptions): void {
4228
5615
  const clipped =
4229
5616
  (!!corners || past) && pushClip(ctx, { x, y, w, h }, corners);
4230
5617
  if (image instanceof SvgDrawing) {
4231
- image.draw(ctx, at.x, at.y, at.w, at.h, options.scale ?? 1);
5618
+ image.drawImage(
5619
+ ctx,
5620
+ at.x,
5621
+ at.y,
5622
+ at.w,
5623
+ at.h,
5624
+ options.scale ?? 1,
5625
+ box.style.colorScheme,
5626
+ );
4232
5627
  } else ctx.drawImage!(image, at.x, at.y, at.w, at.h);
4233
5628
  if (clipped) ctx.restore();
4234
5629
  return;
@@ -4317,13 +5712,21 @@ function fitted(
4317
5712
  }
4318
5713
 
4319
5714
  /** 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. */
5715
+ * box's `color`, as an icon's is the text's around it, and what it is
5716
+ * painted with the box's `fill` and `stroke`, where the document's style
5717
+ * sheets set them: an icon set's `.icon { fill: currentColor }`. What
5718
+ * they give a shape inside it is asked for here (`BoxTree.shapeStyler`). */
4321
5719
  function paintSvg(ctx: PaintContext, box: Box, options: PaintOptions): void {
4322
5720
  if (!box.el) return;
4323
5721
  const x = Math.round(box.contentX + options.originX);
4324
5722
  const y = Math.round(box.contentY + options.originY);
4325
5723
  const w = Math.round(box.contentX + options.originX + box.contentWidth) - x;
4326
5724
  const h = Math.round(box.contentY + options.originY + box.contentHeight) - y;
5725
+ const style = box.style;
5726
+ const paint = (value: string | null): string | null =>
5727
+ value === null || value === 'currentColor' || value === 'none'
5728
+ ? value
5729
+ : inkColor(value, style.color);
4327
5730
  inlineDrawing(box.el).draw(
4328
5731
  ctx,
4329
5732
  x,
@@ -4331,7 +5734,10 @@ function paintSvg(ctx: PaintContext, box: Box, options: PaintOptions): void {
4331
5734
  w,
4332
5735
  h,
4333
5736
  options.scale ?? 1,
4334
- box.style.color,
5737
+ style.color,
5738
+ paint(style.fill),
5739
+ paint(style.stroke),
5740
+ options.shapeStyler?.(box) ?? null,
4335
5741
  );
4336
5742
  }
4337
5743
 
@@ -4374,14 +5780,19 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4374
5780
  const above = ink?.above ?? 0;
4375
5781
  const below = ink?.below ?? 0;
4376
5782
  const slack = top - box.maxLineHeight - below;
4377
- while (lo < hi) {
5783
+ // the lines columns took apart are in no order down the page
5784
+ const apart = columned.any && COLUMN_LINES.has(lines);
5785
+ while (!apart && lo < hi) {
4378
5786
  const mid = (lo + hi) >> 1;
4379
5787
  if (lines[mid].y > slack) hi = mid;
4380
5788
  else lo = mid + 1;
4381
5789
  }
4382
5790
  for (let i = lo; i < lines.length; i += 1) {
4383
5791
  const line = lines[i];
4384
- if (line.y - above >= bottom) break;
5792
+ if (line.y - above >= bottom) {
5793
+ if (apart) continue;
5794
+ break;
5795
+ }
4385
5796
  if (line.y < bottom && line.y + line.height > top) visible.push(line);
4386
5797
  else if (ink && inkInto(line, top, bottom)) {
4387
5798
  visible.push(line);
@@ -4415,14 +5826,17 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4415
5826
  );
4416
5827
  for (const text of line.texts) {
4417
5828
  const natural = text.layout.lines[text.layoutLine];
4418
- if (natural)
5829
+ if (!natural) continue;
5830
+ for (const laid of [natural, trailOf(text, natural)]) {
5831
+ if (!laid) continue;
4419
5832
  paintRunBackgrounds(
4420
5833
  ctx,
4421
- natural,
5834
+ laid,
4422
5835
  text.drawX + dx,
4423
5836
  text.drawY + dy,
4424
5837
  options.scale ?? 1,
4425
5838
  );
5839
+ }
4426
5840
  }
4427
5841
  paintSelection(ctx, line, options);
4428
5842
  }
@@ -4445,8 +5859,10 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4445
5859
  : null;
4446
5860
  for (const line of visible) {
4447
5861
  for (const text of line.texts) {
4448
- if (drawn.has(text.layout)) continue;
4449
- drawn.add(text.layout);
5862
+ // once for each column its lines are in, where they are in several
5863
+ const rows = columned.any ? COLUMN_ROWS.get(text) : undefined;
5864
+ if (drawn.has(rows ?? text.layout)) continue;
5865
+ drawn.add(rows ?? text.layout);
4450
5866
  const top = text.drawY + dy;
4451
5867
  if (top < -COORD_LIMIT || top + text.layout.height > COORD_LIMIT) {
4452
5868
  // A single layout so tall its own lines overflow the Int16 envelope
@@ -4457,8 +5873,11 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4457
5873
  continue;
4458
5874
  }
4459
5875
  const bands = recolored?.get(text.layout);
5876
+ const clipped =
5877
+ rows !== undefined && clipToRows(ctx, text, rows, text.drawX + dx, top);
4460
5878
  if (bands) drawRecolored(ctx, text.layout, text.drawX + dx, top, bands);
4461
5879
  else text.layout.draw(ctx, text.drawX + dx, top);
5880
+ if (clipped) ctx.restore();
4462
5881
  }
4463
5882
  }
4464
5883
 
@@ -4471,7 +5890,8 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4471
5890
  for (const line of visible) {
4472
5891
  for (const placed of line.atomics) {
4473
5892
  if (hoisted && HOISTED.has(placed.box)) continue;
4474
- paintBox(ctx, placed.box, options);
5893
+ if (turns(placed.box)) paintTransformed(ctx, placed.box, options);
5894
+ else paintBox(ctx, placed.box, options);
4475
5895
  }
4476
5896
  }
4477
5897
  }
@@ -4648,12 +6068,14 @@ function paintClippedText(
4648
6068
  const gradient =
4649
6069
  style.backgroundGradient ??
4650
6070
  (style.backgroundImages?.find(
4651
- (image): image is LinearGradient =>
6071
+ (image): image is Gradient =>
4652
6072
  image !== null && typeof image !== 'string',
4653
6073
  ) ||
4654
6074
  null);
4655
6075
  let fill: unknown = null;
4656
- if (gradient && ctx.createLinearGradient) {
6076
+ // where the fill is a picture of the gradient, the corner it starts at
6077
+ let origin: { x: number; y: number } | null = null;
6078
+ if (gradient) {
4657
6079
  if (!(area.width > 0 && area.height > 0)) continue;
4658
6080
  // the text it shows through: what the fill is drawn over
4659
6081
  let x0 = Infinity;
@@ -4668,7 +6090,7 @@ function paintClippedText(
4668
6090
  y1 = Math.max(y1, r.y + r.height);
4669
6091
  }
4670
6092
  }
4671
- fill = linearGradient(
6093
+ const paint = gradientFill(
4672
6094
  ctx,
4673
6095
  gradient,
4674
6096
  area.x,
@@ -4678,7 +6100,40 @@ function paintClippedText(
4678
6100
  style.color,
4679
6101
  x1 > x0 ? { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } : null,
4680
6102
  );
4681
- } else if (!isTransparent(style.backgroundColor)) {
6103
+ if (paint?.matrix) {
6104
+ // An ellipse is a circle filled through a matrix, which would draw
6105
+ // the glyphs through it too: they are filled with a picture of the
6106
+ // gradient instead, drawn once at the area's size
6107
+ const left = Math.round(area.x);
6108
+ const top = Math.round(area.y);
6109
+ const w = Math.max(1, Math.round(area.x + area.width) - left);
6110
+ const h = Math.max(1, Math.round(area.y + area.height) - top);
6111
+ const picture =
6112
+ ctx.createPattern && ctx.translate
6113
+ ? options.cached?.(
6114
+ `clip-text|${gradientKey(gradient, style.color)}|${w}x${h}`,
6115
+ w,
6116
+ h,
6117
+ (sctx) => {
6118
+ const p = gradientFill(
6119
+ sctx,
6120
+ gradient,
6121
+ 0,
6122
+ 0,
6123
+ w,
6124
+ h,
6125
+ style.color,
6126
+ );
6127
+ if (p) fillGradient(sctx, p, { x: 0, y: 0, w, h });
6128
+ },
6129
+ )
6130
+ : null;
6131
+ if (!picture) continue;
6132
+ fill = ctx.createPattern!(picture, 'no-repeat');
6133
+ origin = { x: left, y: top };
6134
+ } else if (paint) fill = paint.style;
6135
+ }
6136
+ if (fill === null && !isTransparent(style.backgroundColor)) {
4682
6137
  fill = inkColor(style.backgroundColor as string, style.color);
4683
6138
  }
4684
6139
  if (fill === null) continue;
@@ -4690,7 +6145,11 @@ function paintClippedText(
4690
6145
  for (const r of part.rects) ctx.rect(r.x, r.y, r.width, r.height);
4691
6146
  ctx.clip();
4692
6147
  ctx.fillStyle = fill;
4693
- inkless.draw(ctx, part.x, part.y);
6148
+ if (origin) {
6149
+ // a pattern is placed from the origin of the space it fills
6150
+ ctx.translate!(origin.x, origin.y);
6151
+ inkless.draw(ctx, part.x - origin.x, part.y - origin.y);
6152
+ } else inkless.draw(ctx, part.x, part.y);
4694
6153
  ctx.restore();
4695
6154
  }
4696
6155
  }
@@ -4730,10 +6189,15 @@ function paintTextShadows(
4730
6189
  if (cast === null) continue;
4731
6190
  const left = text.drawX + dx;
4732
6191
  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);
6192
+ // every run casts the same: the layout's once for each shadow, in
6193
+ // each column its lines are in
6194
+ const rows = columned.any ? COLUMN_ROWS.get(text) : undefined;
6195
+ if (whole.has(rows ?? layout)) continue;
6196
+ whole.add(rows ?? layout);
6197
+ const clipped =
6198
+ rows !== undefined && clipToRows(ctx, text, rows, left, top);
4736
6199
  castShadows(ctx, text, left, top, cast, null);
6200
+ if (clipped) ctx.restore();
4737
6201
  continue;
4738
6202
  }
4739
6203
  const natural = layout.lines[text.layoutLine];
@@ -4865,10 +6329,12 @@ function paintRules(
4865
6329
  const shifted = SHIFTED_LINES.has(line);
4866
6330
  for (const text of line.texts) {
4867
6331
  const natural = text.layout.lines[text.layoutLine];
4868
- if (natural) {
6332
+ if (!natural) continue;
6333
+ for (const laid of [natural, trailOf(text, natural)]) {
6334
+ if (!laid) continue;
4869
6335
  paintRunRules(
4870
6336
  ctx,
4871
- natural,
6337
+ laid,
4872
6338
  text.drawX + dx,
4873
6339
  text.drawY + dy + (shifted ? ruleDrop(text) : 0),
4874
6340
  scale,
@@ -4879,6 +6345,29 @@ function paintRules(
4879
6345
  }
4880
6346
  }
4881
6347
 
6348
+ /**
6349
+ * The spaces a text ends on where its line goes on after them
6350
+ * (`LineText.trail`), as a line of one run to draw the decorations of: the
6351
+ * run they are the end of, as wide as they are, after the line's content.
6352
+ * The engine stripped them from the piece it laid out as a line, and a
6353
+ * link's underline stopped at its last letter, a space short of where a
6354
+ * browser draws it. Null where the text has none, or reads right to left.
6355
+ */
6356
+ function trailOf<L extends LineText['layout']['lines'][number]>(
6357
+ text: LineText,
6358
+ natural: L,
6359
+ ): L | null {
6360
+ if (!text.trail || !text.trailRun) return null;
6361
+ const last = natural.runs[natural.runs.length - 1];
6362
+ if (!last || last.run?.direction === 'rtl') return null;
6363
+ return {
6364
+ ...natural,
6365
+ runs: [
6366
+ { ...last, x: natural.width, width: text.trail, span: text.trailRun },
6367
+ ],
6368
+ };
6369
+ }
6370
+
4882
6371
  /**
4883
6372
  * How far below a raised text its rules go: to the baseline of the element
4884
6373
  * that decorates it. `vertical-align` moves the text and not the lines an