Skip to content

Commit 6203e87

Browse files
authored
fix: support text-decoration-skip-ink (#717)
Fixes #704 Before: <img width="260" height="120" alt="image" src="https://github.com/user-attachments/assets/d40a2743-828b-46a6-a1d1-b1a96ab15d5e" /> After: <img width="260" height="120" alt="image" src="https://github.com/user-attachments/assets/c6ab5215-b487-4f72-b4b6-53439a027e88" />
1 parent 2630740 commit 6203e87

14 files changed

+507
-60
lines changed

src/builder/text-decoration.ts

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,60 @@
11
import { buildXMLString } from '../utils.js'
2+
import type { GlyphBox } from '../font.js'
3+
4+
function buildSkipInkSegments(
5+
start: number,
6+
end: number,
7+
glyphBoxes: GlyphBox[],
8+
y: number,
9+
strokeWidth: number,
10+
baseline: number
11+
) {
12+
const halfStroke = strokeWidth / 2
13+
const bleed = Math.max(halfStroke, strokeWidth * 1.25)
14+
const skipRanges: [number, number][] = []
15+
16+
for (const box of glyphBoxes) {
17+
// Only skip glyphs that actually cross the underline position and extend below the baseline.
18+
if (box.y2 < baseline + halfStroke || box.y1 > y + halfStroke) continue
19+
20+
const from = Math.max(start, box.x1 - bleed)
21+
const to = Math.min(end, box.x2 + bleed)
22+
23+
if (from >= to) continue
24+
if (skipRanges.length === 0) {
25+
skipRanges.push([from, to])
26+
continue
27+
}
28+
29+
const last = skipRanges[skipRanges.length - 1]
30+
if (from <= last[1]) {
31+
last[1] = Math.max(last[1], to)
32+
} else {
33+
skipRanges.push([from, to])
34+
}
35+
}
36+
37+
if (!skipRanges.length) {
38+
return [[start, end]] as [number, number][]
39+
}
40+
41+
const segments: [number, number][] = []
42+
let cursor = start
43+
44+
for (const [from, to] of skipRanges) {
45+
if (from > cursor) {
46+
segments.push([cursor, from])
47+
}
48+
cursor = Math.max(cursor, to)
49+
if (cursor >= end) break
50+
}
51+
52+
if (cursor < end) {
53+
segments.push([cursor, end])
54+
}
55+
56+
return segments
57+
}
258

359
export default function buildDecoration(
460
{
@@ -8,20 +64,23 @@ export default function buildDecoration(
864
ascender,
965
clipPathId,
1066
matrix,
67+
glyphBoxes,
1168
}: {
1269
width: number
1370
left: number
1471
top: number
1572
ascender: number
1673
clipPathId?: string
1774
matrix?: string
75+
glyphBoxes?: GlyphBox[]
1876
},
1977
style: Record<string, any>
2078
) {
2179
const {
2280
textDecorationColor,
2381
textDecorationStyle,
2482
textDecorationLine,
83+
textDecorationSkipInk,
2584
fontSize,
2685
color,
2786
} = style
@@ -45,36 +104,56 @@ export default function buildDecoration(
45104
? `0 ${height * 2}`
46105
: undefined
47106

107+
const applySkipInk =
108+
textDecorationLine === 'underline' &&
109+
(textDecorationSkipInk || 'auto') !== 'none' &&
110+
glyphBoxes?.length
111+
112+
const baseline = top + ascender
113+
114+
const segments = applySkipInk
115+
? buildSkipInkSegments(left, left + width, glyphBoxes, y, height, baseline)
116+
: ([[left, left + width]] as [number, number][])
117+
48118
// https://www.w3.org/TR/css-backgrounds-3/#valdef-line-style-double
49119
const extraLine =
50120
textDecorationStyle === 'double'
51-
? buildXMLString('line', {
52-
x1: left,
53-
y1: y + height + 1,
54-
x2: left + width,
55-
y2: y + height + 1,
121+
? segments
122+
.map(([x1, x2]) =>
123+
buildXMLString('line', {
124+
x1,
125+
y1: y + height + 1,
126+
x2,
127+
y2: y + height + 1,
128+
stroke: textDecorationColor || color,
129+
'stroke-width': height,
130+
'stroke-dasharray': dasharray,
131+
'stroke-linecap':
132+
textDecorationStyle === 'dotted' ? 'round' : 'square',
133+
transform: matrix,
134+
})
135+
)
136+
.join('')
137+
: ''
138+
139+
return (
140+
(clipPathId ? `<g clip-path="url(#${clipPathId})">` : '') +
141+
segments
142+
.map(([x1, x2]) =>
143+
buildXMLString('line', {
144+
x1,
145+
y1: y,
146+
x2,
147+
y2: y,
56148
stroke: textDecorationColor || color,
57149
'stroke-width': height,
58150
'stroke-dasharray': dasharray,
59151
'stroke-linecap':
60152
textDecorationStyle === 'dotted' ? 'round' : 'square',
61153
transform: matrix,
62154
})
63-
: ''
64-
65-
return (
66-
(clipPathId ? `<g clip-path="url(#${clipPathId})">` : '') +
67-
buildXMLString('line', {
68-
x1: left,
69-
y1: y,
70-
x2: left + width,
71-
y2: y,
72-
stroke: textDecorationColor || color,
73-
'stroke-width': height,
74-
'stroke-dasharray': dasharray,
75-
'stroke-linecap': textDecorationStyle === 'dotted' ? 'round' : 'square',
76-
transform: matrix,
77-
}) +
155+
)
156+
.join('') +
78157
extraLine +
79158
(clipPathId ? '</g>' : '')
80159
)

0 commit comments

Comments
 (0)