ntk 6.6.1 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/app.js +108 -0
- package/lib/index.js +2 -0
- package/lib/path.js +196 -6
- package/lib/renderingcontext_2d.js +458 -8
- package/lib/shapeglyphs.js +327 -0
- package/lib/window.js +495 -56
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "./imagedata.js";
|
|
14
14
|
import {
|
|
15
15
|
Path2D,
|
|
16
|
+
arcSegmentCount,
|
|
16
17
|
flattenPath,
|
|
17
18
|
transformCommands,
|
|
18
19
|
ellipseSegments,
|
|
@@ -25,7 +26,24 @@ import {
|
|
|
25
26
|
import Picture from "./picture.js";
|
|
26
27
|
import Pixmap from "./pixmap.js";
|
|
27
28
|
import { routeRaster } from "./rasterize.js";
|
|
28
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
BL,
|
|
31
|
+
BR,
|
|
32
|
+
TL,
|
|
33
|
+
TR,
|
|
34
|
+
cornerKey,
|
|
35
|
+
countShapeHit,
|
|
36
|
+
countShapeMiss,
|
|
37
|
+
getShapeGlyphPage,
|
|
38
|
+
roundRectBandRects,
|
|
39
|
+
shapePolicyOf,
|
|
40
|
+
trimShapeGlyphs,
|
|
41
|
+
} from "./shapeglyphs.js";
|
|
42
|
+
import {
|
|
43
|
+
compositeTraps,
|
|
44
|
+
drawGlyphRuns,
|
|
45
|
+
encodeGlyphItems,
|
|
46
|
+
} from "./text/glyphs.js";
|
|
29
47
|
import { TextLayout } from "./text/layout.js";
|
|
30
48
|
import { reorderRuns } from "./text/shape.js";
|
|
31
49
|
import { trapezoidize } from "./trapezoid.js";
|
|
@@ -245,6 +263,10 @@ class RenderingContext2d {
|
|
|
245
263
|
|
|
246
264
|
this._path = new Path2D();
|
|
247
265
|
this._m = [1, 0, 0, 1, 0, 0];
|
|
266
|
+
// Rounded-rect fast-path observability (issue #211): every bail-out is
|
|
267
|
+
// a silent perf cliff, so hits and misses-by-reason are always counted.
|
|
268
|
+
// NTK_DEBUG_SHAPES=1 prints the process-wide aggregate at exit.
|
|
269
|
+
this.shapeStats = { hits: 0, misses: {} };
|
|
248
270
|
this._stack = [];
|
|
249
271
|
this._clips = []; // [{ polys, rule }] in device space, already stacked
|
|
250
272
|
this._gco = "source-over";
|
|
@@ -653,7 +675,28 @@ class RenderingContext2d {
|
|
|
653
675
|
roundRect(x, y, w, h, radii) {
|
|
654
676
|
const tmp = new Path2D();
|
|
655
677
|
tmp.roundRect(x, y, w, h, radii);
|
|
678
|
+
const wasEmpty = this._path._cmds.length === 0;
|
|
656
679
|
this._path.addPath(tmp, this._m);
|
|
680
|
+
// Re-derive the recognition tag in device space (addPath cleared it): it
|
|
681
|
+
// survives only when this roundRect is the whole path and the CTM is a
|
|
682
|
+
// pure translation at record time — the default path bakes the transform
|
|
683
|
+
// into its commands here, so this is the last moment the box exists.
|
|
684
|
+
if (!tmp._roundRect || !wasEmpty) return;
|
|
685
|
+
const m = this._m;
|
|
686
|
+
if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
|
|
687
|
+
const t = tmp._roundRect;
|
|
688
|
+
this._path._roundRect = {
|
|
689
|
+
x: t.x + m[4],
|
|
690
|
+
y: t.y + m[5],
|
|
691
|
+
w: t.w,
|
|
692
|
+
h: t.h,
|
|
693
|
+
radii: t.radii,
|
|
694
|
+
};
|
|
695
|
+
} else {
|
|
696
|
+
// a box recorded under rotation/scale can never take the glyph route;
|
|
697
|
+
// leave the reason behind so fill()/stroke() count the bail-out
|
|
698
|
+
this._path._roundRectMiss = "transform";
|
|
699
|
+
}
|
|
657
700
|
}
|
|
658
701
|
|
|
659
702
|
// args: ([path], [fillRule]) — returns device-space polys + rule
|
|
@@ -933,12 +976,29 @@ class RenderingContext2d {
|
|
|
933
976
|
const thickness = this.lineWidth * scale;
|
|
934
977
|
const roundCap = this.lineCap === "round";
|
|
935
978
|
const roundJoin = this.lineJoin === "round";
|
|
979
|
+
const cap = LINE_CAP[this.lineCap] || "butt";
|
|
980
|
+
const join = LINE_JOIN[this.lineJoin] || "miter";
|
|
936
981
|
const stroke = extrudePolyline({
|
|
937
982
|
thickness,
|
|
938
|
-
cap
|
|
939
|
-
join
|
|
983
|
+
cap,
|
|
984
|
+
join,
|
|
940
985
|
miterLimit: this.miterLimit,
|
|
941
986
|
});
|
|
987
|
+
// A closed subpath has no ends, so the canvas spec gives it no caps.
|
|
988
|
+
// That cannot share the extruder above: with lineCap 'square'
|
|
989
|
+
// extrude-polyline pushes the first and last points outward along the
|
|
990
|
+
// line, which on a closed loop extends the seam over band it already
|
|
991
|
+
// covers — invisible against an opaque colour, a double-blended edge
|
|
992
|
+
// against a translucent one.
|
|
993
|
+
const closedStroke =
|
|
994
|
+
cap === "butt"
|
|
995
|
+
? stroke
|
|
996
|
+
: extrudePolyline({
|
|
997
|
+
thickness,
|
|
998
|
+
cap: "butt",
|
|
999
|
+
join,
|
|
1000
|
+
miterLimit: this.miterLimit,
|
|
1001
|
+
});
|
|
942
1002
|
// dash distances are user-space lengths; scale them like the line width
|
|
943
1003
|
const dash = this._lineDash.length
|
|
944
1004
|
? this._lineDash.map((d) => d * scale)
|
|
@@ -952,7 +1012,12 @@ class RenderingContext2d {
|
|
|
952
1012
|
// cap, and a disk at an interior vertex fills the bevel notch
|
|
953
1013
|
let hasRound = false;
|
|
954
1014
|
const r = thickness / 2;
|
|
955
|
-
|
|
1015
|
+
// A disk is an arc like any other: the sagitta formula sizes it to the
|
|
1016
|
+
// flatness tolerance instead of a floor of 8 (which spent 16 triangles
|
|
1017
|
+
// on the half-pixel cap of a 1px line) and a ceiling of 32 (which was
|
|
1018
|
+
// coarse enough to show on a fat one). Three is the fewest that
|
|
1019
|
+
// enclose any area at all.
|
|
1020
|
+
const diskSegs = Math.max(3, arcSegmentCount(2 * Math.PI, r));
|
|
956
1021
|
const addDisk = (x, y) => {
|
|
957
1022
|
hasRound = true;
|
|
958
1023
|
let ex = x + r;
|
|
@@ -988,18 +1053,35 @@ class RenderingContext2d {
|
|
|
988
1053
|
return;
|
|
989
1054
|
}
|
|
990
1055
|
if (pts.length < 2) return;
|
|
991
|
-
|
|
1056
|
+
if (closed && pts.length >= 3) {
|
|
1057
|
+
// Move the seam to the middle of the first edge. extrude-polyline
|
|
1058
|
+
// treats every polyline as open — it butt-extrudes both ends and
|
|
1059
|
+
// emits no join between them — so a closed loop lost the join at
|
|
1060
|
+
// whichever vertex it happened to start on: a stroked rectangle
|
|
1061
|
+
// came out with three square corners and a notched one, the notch
|
|
1062
|
+
// being a quarter of the line width square. Cutting the loop in
|
|
1063
|
+
// the middle of a straight edge instead leaves the two ends
|
|
1064
|
+
// collinear, so they meet exactly, and makes every real vertex
|
|
1065
|
+
// interior, so extrude-polyline gives each the join it asked for
|
|
1066
|
+
// — miter, bevel and round alike.
|
|
1067
|
+
const mid = [
|
|
1068
|
+
(pts[0][0] + pts[1][0]) / 2,
|
|
1069
|
+
(pts[0][1] + pts[1][1]) / 2,
|
|
1070
|
+
];
|
|
1071
|
+
pts = [mid, ...pts.slice(1), mid];
|
|
1072
|
+
}
|
|
1073
|
+
const mesh = (closed ? closedStroke : stroke).build(pts);
|
|
992
1074
|
for (const tri of mesh.cells) {
|
|
993
1075
|
for (let i = 0; i < 3; ++i) {
|
|
994
1076
|
tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
|
|
995
1077
|
}
|
|
996
1078
|
}
|
|
997
1079
|
if (roundJoin) {
|
|
1080
|
+
// every real vertex is interior now, the seam included: a closed
|
|
1081
|
+
// run was rotated to break at a collinear point, which needs no
|
|
1082
|
+
// join disk of its own
|
|
998
1083
|
for (let i = 1; i < pts.length - 1; i++)
|
|
999
1084
|
maybeJoinDisk(pts[i - 1], pts[i], pts[i + 1]);
|
|
1000
|
-
// the seam of a closed loop is a join too (pts[0] === pts[last])
|
|
1001
|
-
if (closed && pts.length > 2)
|
|
1002
|
-
maybeJoinDisk(pts[pts.length - 2], pts[0], pts[1]);
|
|
1003
1085
|
}
|
|
1004
1086
|
if (roundCap && !closed) {
|
|
1005
1087
|
addDisk(pts[0][0], pts[0][1]);
|
|
@@ -1428,17 +1510,34 @@ class RenderingContext2d {
|
|
|
1428
1510
|
}
|
|
1429
1511
|
|
|
1430
1512
|
strokeRect(x, y, w, h) {
|
|
1513
|
+
const m = this._m;
|
|
1514
|
+
if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
|
|
1515
|
+
const zero = { x: 0, y: 0 };
|
|
1516
|
+
const box = {
|
|
1517
|
+
x: x + m[4],
|
|
1518
|
+
y: y + m[5],
|
|
1519
|
+
w,
|
|
1520
|
+
h,
|
|
1521
|
+
radii: [zero, zero, zero, zero],
|
|
1522
|
+
};
|
|
1523
|
+
if (this._tryStrokeBox(box)) return;
|
|
1524
|
+
} else {
|
|
1525
|
+
this._shapeMiss("transform");
|
|
1526
|
+
}
|
|
1431
1527
|
const tmp = new Path2D();
|
|
1432
1528
|
tmp.rect(x, y, w, h);
|
|
1433
1529
|
this._strokePolys(flattenPath(tmp._cmds, this._m));
|
|
1434
1530
|
}
|
|
1435
1531
|
|
|
1436
1532
|
fill(...args) {
|
|
1533
|
+
if (this._tryRoundRectFill(args)) return;
|
|
1437
1534
|
const { polys, rule } = this._polysFor(args);
|
|
1438
1535
|
this._fillPolys(polys, rule);
|
|
1439
1536
|
}
|
|
1440
1537
|
|
|
1441
1538
|
stroke(...args) {
|
|
1539
|
+
const box = this._shapeBoxFor(args);
|
|
1540
|
+
if (box && this._tryStrokeBox(box)) return;
|
|
1442
1541
|
const polys =
|
|
1443
1542
|
args[0] instanceof Path2D
|
|
1444
1543
|
? flattenPath(args[0]._cmds, this._m)
|
|
@@ -1446,6 +1545,357 @@ class RenderingContext2d {
|
|
|
1446
1545
|
this._strokePolys(polys);
|
|
1447
1546
|
}
|
|
1448
1547
|
|
|
1548
|
+
// ------------------------------------------------------------------
|
|
1549
|
+
// rounded-rect fast path (issue #211)
|
|
1550
|
+
//
|
|
1551
|
+
// A fill/stroke of an axis-aligned rounded rect on integer geometry is
|
|
1552
|
+
// emitted as corner glyphs + FillRectangles — the box's only curved ink
|
|
1553
|
+
// rides the glyph path, cached server-side after first use, and nothing
|
|
1554
|
+
// is rasterized or uploaded afterwards. The pieces partition the pixels
|
|
1555
|
+
// (glyphs own their integer-cut corner boxes, rects the strips between),
|
|
1556
|
+
// so translucent colours are safe with no mask and no accumulation. Every
|
|
1557
|
+
// condition below that fails falls through to the polygon route untouched,
|
|
1558
|
+
// counting the reason in `shapeStats`.
|
|
1559
|
+
|
|
1560
|
+
/** count one fast-path bail-out; always returns false for tail-calling */
|
|
1561
|
+
_shapeMiss(reason) {
|
|
1562
|
+
const misses = this.shapeStats.misses;
|
|
1563
|
+
misses[reason] = (misses[reason] || 0) + 1;
|
|
1564
|
+
countShapeMiss(reason);
|
|
1565
|
+
return false;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* The device-space rounded-rect a fill/stroke argument list describes, or
|
|
1570
|
+
* null. The default path carries its tag in device space already (recorded
|
|
1571
|
+
* under a translate-only CTM); a Path2D argument is user-space, so the CTM
|
|
1572
|
+
* must be translate-only *now*.
|
|
1573
|
+
*/
|
|
1574
|
+
_shapeBoxFor(args) {
|
|
1575
|
+
const path = args[0] instanceof Path2D ? args[0] : this._path;
|
|
1576
|
+
const tag = path._roundRect;
|
|
1577
|
+
if (path === this._path) {
|
|
1578
|
+
if (tag) return tag;
|
|
1579
|
+
// a roundRect that could not be tagged at record time left the reason
|
|
1580
|
+
if (path._roundRectMiss) this._shapeMiss(path._roundRectMiss);
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
if (!tag) return null;
|
|
1584
|
+
const m = this._m;
|
|
1585
|
+
if (!(m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1)) {
|
|
1586
|
+
this._shapeMiss("transform");
|
|
1587
|
+
return null;
|
|
1588
|
+
}
|
|
1589
|
+
return {
|
|
1590
|
+
x: tag.x + m[4],
|
|
1591
|
+
y: tag.y + m[5],
|
|
1592
|
+
w: tag.w,
|
|
1593
|
+
h: tag.h,
|
|
1594
|
+
radii: tag.radii,
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/** premultiplied solid colour with globalAlpha folded in — scaling all
|
|
1599
|
+
* four components of a premultiplied colour is exactly what compositing
|
|
1600
|
+
* it at that opacity means */
|
|
1601
|
+
_foldedColor(style) {
|
|
1602
|
+
const c = parseColor(style);
|
|
1603
|
+
const a = this.globalAlpha;
|
|
1604
|
+
return a >= 1 ? c : [c[0] * a, c[1] * a, c[2] * a, c[3] * a];
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
_tryRoundRectFill(args) {
|
|
1608
|
+
const box = this._shapeBoxFor(args);
|
|
1609
|
+
if (!box) return false;
|
|
1610
|
+
// maxRadius <= 0 is the documented off switch (NTK_NO_SHAPE_GLYPHS
|
|
1611
|
+
// sets it too): everything falls through, including zero-radius boxes
|
|
1612
|
+
const policy = shapePolicyOf(this.window.app);
|
|
1613
|
+
if (policy.maxRadius <= 0) return this._shapeMiss("radius-cap");
|
|
1614
|
+
// (a rounded rect is one simple closed curve, so the fill rule cannot
|
|
1615
|
+
// change what it covers — the rule argument needs no inspection)
|
|
1616
|
+
if (!isPlainColor(this._fillStyle)) return this._shapeMiss("gradient");
|
|
1617
|
+
if (this._gco !== "source-over") return this._shapeMiss("composite-op");
|
|
1618
|
+
const clip = this._clips.length ? this._clipRect() : null;
|
|
1619
|
+
if (this._clips.length && !clip) return this._shapeMiss("clip-mask");
|
|
1620
|
+
|
|
1621
|
+
const { x, y, w, h, radii } = box;
|
|
1622
|
+
if (
|
|
1623
|
+
!Number.isInteger(x) ||
|
|
1624
|
+
!Number.isInteger(y) ||
|
|
1625
|
+
!Number.isInteger(w) ||
|
|
1626
|
+
!Number.isInteger(h)
|
|
1627
|
+
) {
|
|
1628
|
+
return this._shapeMiss("fractional");
|
|
1629
|
+
}
|
|
1630
|
+
for (const r of radii) {
|
|
1631
|
+
if (!Number.isInteger(r.x) || !Number.isInteger(r.y))
|
|
1632
|
+
return this._shapeMiss("fractional");
|
|
1633
|
+
if (r.x > policy.maxRadius || r.y > policy.maxRadius)
|
|
1634
|
+
return this._shapeMiss("radius-cap");
|
|
1635
|
+
}
|
|
1636
|
+
// wire coordinates are INT16, and glyph position deltas too
|
|
1637
|
+
if (
|
|
1638
|
+
x < -32768 ||
|
|
1639
|
+
y < -32768 ||
|
|
1640
|
+
x + w > 32767 ||
|
|
1641
|
+
y + h > 32767 ||
|
|
1642
|
+
w > 32767 ||
|
|
1643
|
+
h > 32767
|
|
1644
|
+
) {
|
|
1645
|
+
return this._shapeMiss("geometry");
|
|
1646
|
+
}
|
|
1647
|
+
const [tl, tr, br, bl] = radii;
|
|
1648
|
+
// effective corner boxes — a corner with either radius 0 is square
|
|
1649
|
+
const c = {
|
|
1650
|
+
tlw: tl.x && tl.y ? tl.x : 0,
|
|
1651
|
+
tlh: tl.x && tl.y ? tl.y : 0,
|
|
1652
|
+
trw: tr.x && tr.y ? tr.x : 0,
|
|
1653
|
+
trh: tr.x && tr.y ? tr.y : 0,
|
|
1654
|
+
blw: bl.x && bl.y ? bl.x : 0,
|
|
1655
|
+
blh: bl.x && bl.y ? bl.y : 0,
|
|
1656
|
+
brw: br.x && br.y ? br.x : 0,
|
|
1657
|
+
brh: br.x && br.y ? br.y : 0,
|
|
1658
|
+
};
|
|
1659
|
+
// diagonally opposite corner boxes must not overlap or the partition
|
|
1660
|
+
// would paint pixels twice (same-edge pairs are bounded by the radii
|
|
1661
|
+
// normalization, but the diagonal is not)
|
|
1662
|
+
if (
|
|
1663
|
+
(c.tlw + c.brw > w && c.tlh + c.brh > h) ||
|
|
1664
|
+
(c.trw + c.blw > w && c.trh + c.blh > h)
|
|
1665
|
+
) {
|
|
1666
|
+
return this._shapeMiss("geometry");
|
|
1667
|
+
}
|
|
1668
|
+
if (this.globalAlpha <= 0 || w <= 0 || h <= 0) {
|
|
1669
|
+
// nothing to draw — and the fallback would draw nothing too
|
|
1670
|
+
countShapeHit();
|
|
1671
|
+
this.shapeStats.hits++;
|
|
1672
|
+
return true;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const corners = [];
|
|
1676
|
+
if (c.tlw) {
|
|
1677
|
+
corners.push({
|
|
1678
|
+
key: cornerKey("fill", c.tlw, c.tlh, 0, TL),
|
|
1679
|
+
kind: "fill", rx: c.tlw, ry: c.tlh, bw: 0, corner: TL,
|
|
1680
|
+
px: x, py: y,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
if (c.trw) {
|
|
1684
|
+
corners.push({
|
|
1685
|
+
key: cornerKey("fill", c.trw, c.trh, 0, TR),
|
|
1686
|
+
kind: "fill", rx: c.trw, ry: c.trh, bw: 0, corner: TR,
|
|
1687
|
+
px: x + w - c.trw, py: y,
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
if (c.blw) {
|
|
1691
|
+
corners.push({
|
|
1692
|
+
key: cornerKey("fill", c.blw, c.blh, 0, BL),
|
|
1693
|
+
kind: "fill", rx: c.blw, ry: c.blh, bw: 0, corner: BL,
|
|
1694
|
+
px: x, py: y + h - c.blh,
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
if (c.brw) {
|
|
1698
|
+
corners.push({
|
|
1699
|
+
key: cornerKey("fill", c.brw, c.brh, 0, BR),
|
|
1700
|
+
kind: "fill", rx: c.brw, ry: c.brh, bw: 0, corner: BR,
|
|
1701
|
+
px: x + w - c.brw, py: y + h - c.brh,
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
const rects = roundRectBandRects(x, y, w, h, c);
|
|
1705
|
+
this._emitShapeGlyphs(corners, rects, this._foldedColor(this._fillStyle), clip);
|
|
1706
|
+
return true;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* Stroke fast path for a device-space box (from a roundRect tag or
|
|
1711
|
+
* strokeRect). Beyond the fill's conditions: uniform lineWidth, no
|
|
1712
|
+
* dashes — and the half-pixel one that matters: the stroke band must land
|
|
1713
|
+
* on pixel boundaries. A border drawn the correct way (path inset by bw/2,
|
|
1714
|
+
* so the band [X, X+bw] sits on integers) passes at any width, odd or even;
|
|
1715
|
+
* a 1px stroke on integer path coordinates is genuinely a two-row 50% band
|
|
1716
|
+
* and must keep falling back, because the fast path would not reproduce it.
|
|
1717
|
+
*/
|
|
1718
|
+
_tryStrokeBox(box) {
|
|
1719
|
+
// the same off switch as the fill's — see _tryRoundRectFill
|
|
1720
|
+
const policy = shapePolicyOf(this.window.app);
|
|
1721
|
+
if (policy.maxRadius <= 0) return this._shapeMiss("radius-cap");
|
|
1722
|
+
if (!isPlainColor(this._strokeStyle)) return this._shapeMiss("gradient");
|
|
1723
|
+
if (this._gco !== "source-over") return this._shapeMiss("composite-op");
|
|
1724
|
+
if (this._lineDash.length) return this._shapeMiss("dashes");
|
|
1725
|
+
const clip = this._clips.length ? this._clipRect() : null;
|
|
1726
|
+
if (this._clips.length && !clip) return this._shapeMiss("clip-mask");
|
|
1727
|
+
|
|
1728
|
+
const bw = this.lineWidth;
|
|
1729
|
+
if (!Number.isFinite(bw) || bw <= 0) return this._shapeMiss("geometry");
|
|
1730
|
+
const { x, y, w, h, radii } = box;
|
|
1731
|
+
if (!(w > 0 && h > 0)) return this._shapeMiss("geometry");
|
|
1732
|
+
// one circular radius for all four corners: the ring glyph is keyed on
|
|
1733
|
+
// a single (r, bw) and mixed or elliptical corners fall back
|
|
1734
|
+
const r0 = radii[0];
|
|
1735
|
+
for (const r of radii) {
|
|
1736
|
+
if (r.x !== r0.x || r.y !== r0.y) return this._shapeMiss("radii-mix");
|
|
1737
|
+
}
|
|
1738
|
+
if (r0.x !== r0.y) return this._shapeMiss("radii-mix");
|
|
1739
|
+
const r = r0.x;
|
|
1740
|
+
|
|
1741
|
+
const X0 = x - bw / 2; // outer band corner
|
|
1742
|
+
const Y0 = y - bw / 2;
|
|
1743
|
+
const W = w + bw; // outer band size
|
|
1744
|
+
const H = h + bw;
|
|
1745
|
+
// What the route needs is that the *ink* is pixel-aligned: the band's
|
|
1746
|
+
// four outer edges on integers, which is what the four tests below say,
|
|
1747
|
+
// and an integer width so the straight runs are whole rows and columns
|
|
1748
|
+
// of FillRectangles (fractional extents would be truncated on the wire —
|
|
1749
|
+
// and a fractional lineWidth passes the outer-band tests, e.g. x = 0.75
|
|
1750
|
+
// with bw = 1.5, so this clause carries real weight).
|
|
1751
|
+
//
|
|
1752
|
+
// The *path* radius is free. The corner glyph box is cut at
|
|
1753
|
+
// K = ceil(r + bw/2), and from the arc's tangent point out to that cut
|
|
1754
|
+
// the band is already its own straight continuation — bw whole rows —
|
|
1755
|
+
// so a fractional radius rides inside the glyph, which rasterizes on the
|
|
1756
|
+
// device pixel grid, exactly as it does on the polygon route. This is
|
|
1757
|
+
// what a border inset by bw/2 needs: nesting inside a background corner
|
|
1758
|
+
// of radius R means a path radius of R - bw/2, half-integer for every
|
|
1759
|
+
// odd width (issue #217).
|
|
1760
|
+
if (
|
|
1761
|
+
!Number.isInteger(X0) ||
|
|
1762
|
+
!Number.isInteger(Y0) ||
|
|
1763
|
+
!Number.isInteger(W) ||
|
|
1764
|
+
!Number.isInteger(H) ||
|
|
1765
|
+
!Number.isInteger(bw)
|
|
1766
|
+
) {
|
|
1767
|
+
return this._shapeMiss("fractional");
|
|
1768
|
+
}
|
|
1769
|
+
if (
|
|
1770
|
+
X0 < -32768 ||
|
|
1771
|
+
Y0 < -32768 ||
|
|
1772
|
+
X0 + W > 32767 ||
|
|
1773
|
+
Y0 + H > 32767 ||
|
|
1774
|
+
W > 32767 ||
|
|
1775
|
+
H > 32767
|
|
1776
|
+
) {
|
|
1777
|
+
return this._shapeMiss("geometry");
|
|
1778
|
+
}
|
|
1779
|
+
if (this.globalAlpha <= 0) {
|
|
1780
|
+
countShapeHit();
|
|
1781
|
+
this.shapeStats.hits++;
|
|
1782
|
+
return true;
|
|
1783
|
+
}
|
|
1784
|
+
const color = this._foldedColor(this._strokeStyle);
|
|
1785
|
+
|
|
1786
|
+
if (r === 0) {
|
|
1787
|
+
// square corners lower to four FillRectangles with no glyphs at all —
|
|
1788
|
+
// but only when the miter join actually squares them (the default);
|
|
1789
|
+
// bevel or round corners look different and keep the polygon route
|
|
1790
|
+
if (this.lineJoin !== "miter" || this.miterLimit < Math.SQRT2)
|
|
1791
|
+
return this._shapeMiss("join");
|
|
1792
|
+
if (W < 2 * bw || H < 2 * bw) return this._shapeMiss("geometry");
|
|
1793
|
+
const rects = [X0, Y0, W, bw, X0, Y0 + H - bw, W, bw];
|
|
1794
|
+
if (H > 2 * bw) {
|
|
1795
|
+
rects.push(X0, Y0 + bw, bw, H - 2 * bw);
|
|
1796
|
+
rects.push(X0 + W - bw, Y0 + bw, bw, H - 2 * bw);
|
|
1797
|
+
}
|
|
1798
|
+
this._emitShapeGlyphs([], rects, color, clip);
|
|
1799
|
+
return true;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// a border thicker than the corner radius swallows the arc's centre;
|
|
1803
|
+
// the ring glyph does not model that, so it stays on the polygon route
|
|
1804
|
+
if (r - bw / 2 < 0) return this._shapeMiss("geometry");
|
|
1805
|
+
const K = Math.ceil(r + bw / 2); // corner glyph box, integer cut lines
|
|
1806
|
+
if (K > policy.maxRadius) return this._shapeMiss("radius-cap");
|
|
1807
|
+
if (2 * K > W || 2 * K > H) return this._shapeMiss("geometry");
|
|
1808
|
+
|
|
1809
|
+
const corners = [
|
|
1810
|
+
{
|
|
1811
|
+
key: cornerKey("stroke", r, r, bw, TL),
|
|
1812
|
+
kind: "stroke", rx: r, ry: r, bw, corner: TL,
|
|
1813
|
+
px: X0, py: Y0,
|
|
1814
|
+
},
|
|
1815
|
+
{
|
|
1816
|
+
key: cornerKey("stroke", r, r, bw, TR),
|
|
1817
|
+
kind: "stroke", rx: r, ry: r, bw, corner: TR,
|
|
1818
|
+
px: X0 + W - K, py: Y0,
|
|
1819
|
+
},
|
|
1820
|
+
{
|
|
1821
|
+
key: cornerKey("stroke", r, r, bw, BL),
|
|
1822
|
+
kind: "stroke", rx: r, ry: r, bw, corner: BL,
|
|
1823
|
+
px: X0, py: Y0 + H - K,
|
|
1824
|
+
},
|
|
1825
|
+
{
|
|
1826
|
+
key: cornerKey("stroke", r, r, bw, BR),
|
|
1827
|
+
kind: "stroke", rx: r, ry: r, bw, corner: BR,
|
|
1828
|
+
px: X0 + W - K, py: Y0 + H - K,
|
|
1829
|
+
},
|
|
1830
|
+
];
|
|
1831
|
+
const rects = [];
|
|
1832
|
+
if (W > 2 * K) {
|
|
1833
|
+
rects.push(X0 + K, Y0, W - 2 * K, bw);
|
|
1834
|
+
rects.push(X0 + K, Y0 + H - bw, W - 2 * K, bw);
|
|
1835
|
+
}
|
|
1836
|
+
if (H > 2 * K) {
|
|
1837
|
+
rects.push(X0, Y0 + K, bw, H - 2 * K);
|
|
1838
|
+
rects.push(X0 + W - bw, Y0 + K, bw, H - 2 * K);
|
|
1839
|
+
}
|
|
1840
|
+
this._emitShapeGlyphs(corners, rects, color, clip);
|
|
1841
|
+
return true;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* Emit one recognized box: ensure the corner glyphs, one CompositeGlyphs
|
|
1846
|
+
* run for them, one FillRectangles for the strips — inside a server-side
|
|
1847
|
+
* clip rectangle when the clip stack is rectangular (the way glyph runs
|
|
1848
|
+
* for text already do it).
|
|
1849
|
+
*/
|
|
1850
|
+
_emitShapeGlyphs(corners, rects, color, clip) {
|
|
1851
|
+
countShapeHit();
|
|
1852
|
+
this.shapeStats.hits++;
|
|
1853
|
+
if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
|
|
1854
|
+
const R = this.Render;
|
|
1855
|
+
const app = this.window.app;
|
|
1856
|
+
if (clip) {
|
|
1857
|
+
R.SetPictureClipRectangles(this.picture.id, 0, 0, [
|
|
1858
|
+
clip.x,
|
|
1859
|
+
clip.y,
|
|
1860
|
+
clip.w,
|
|
1861
|
+
clip.h,
|
|
1862
|
+
]);
|
|
1863
|
+
}
|
|
1864
|
+
if (corners.length) {
|
|
1865
|
+
const page = getShapeGlyphPage(app);
|
|
1866
|
+
page.ensure(corners);
|
|
1867
|
+
const items = [];
|
|
1868
|
+
for (const spec of corners) {
|
|
1869
|
+
items.push({
|
|
1870
|
+
gs: page.glyphset.id,
|
|
1871
|
+
lid: page.entry(spec.key).lid,
|
|
1872
|
+
adv: 0,
|
|
1873
|
+
x: spec.px,
|
|
1874
|
+
y: spec.py,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
const encoded = encodeGlyphItems(items, page.bits);
|
|
1878
|
+
const src = app.solidPicture(color[0], color[1], color[2], color[3]);
|
|
1879
|
+
R.CompositeGlyphs(
|
|
1880
|
+
encoded.bits,
|
|
1881
|
+
R.PictOp.Over,
|
|
1882
|
+
src.id,
|
|
1883
|
+
this.picture.id,
|
|
1884
|
+
0,
|
|
1885
|
+
encoded.gsid,
|
|
1886
|
+
items[0].x,
|
|
1887
|
+
items[0].y,
|
|
1888
|
+
encoded.elts,
|
|
1889
|
+
);
|
|
1890
|
+
trimShapeGlyphs(app);
|
|
1891
|
+
}
|
|
1892
|
+
if (rects.length) {
|
|
1893
|
+
R.FillRectangles(R.PictOp.Over, this.picture.id, color, rects);
|
|
1894
|
+
}
|
|
1895
|
+
if (clip) this._resetPictureClip();
|
|
1896
|
+
this._markDirty();
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1449
1899
|
/**
|
|
1450
1900
|
* Intersect the clip region with the given path (or the current path).
|
|
1451
1901
|
* Cleared by restore() to the state at the matching save() — like the
|