modern-text
Version:
Measure and render text in a way that describes the DOM.
1,529 lines (1,518 loc) • 87.3 kB
JavaScript
import { fonts } from 'modern-font';
import { isNone, isGradient, normalizeGradient, clearUndef, Reactivable, normalizeText, getDefaultStyle, property } from 'modern-idoc';
import { svgToDom, svgToPath2DSet, Path2DSet, Transform2D, setCanvasContext, Path2D, BoundingBox, Vector2 } from 'modern-path2d';
import { a as definePlugin, b as deformationPlugin } from './modern-text.C0w4dLp7.mjs';
function createSvgLoader() {
const loaded = /* @__PURE__ */ new Map();
async function load(svg) {
if (!loaded.has(svg)) {
loaded.set(svg, "");
try {
loaded.set(svg, await fetch(svg).then((rep) => rep.text()));
} catch (err) {
console.warn(err);
loaded.delete(svg);
}
}
}
function needsLoad(source) {
return source.startsWith("/") || source.startsWith("./") || source.startsWith("http://") || source.startsWith("https://") || source.startsWith("blob://");
}
return {
loaded,
needsLoad,
load
};
}
function createSvgParser(loader) {
const parsed = /* @__PURE__ */ new Map();
function parse(svg) {
let result = parsed.get(svg);
if (!result) {
const svgString = loader.needsLoad(svg) ? loader.loaded.get(svg) : svg;
if (svgString) {
const dom = svgToDom(svgString);
const pathSet = svgToPath2DSet(dom);
result = { dom, pathSet };
parsed.set(svg, result);
} else {
const dom = document.createElementNS("http://www.w3.org/2000/svg", "svg");
dom.setAttribute("width", "0");
dom.setAttribute("height", "0");
dom.setAttribute("viewBox", "0 0 0 0");
result = { dom, pathSet: new Path2DSet() };
}
}
return result;
}
return {
parsed,
parse
};
}
function getEffectTransform2D(text, effect) {
const { transform, transformOrigin } = effect;
const { fontSize, lineBox } = text;
const { left, top, width, height } = lineBox;
const { x: cx, y: cy } = parseTransformOrigin(
transformOrigin ?? "center",
left,
top,
width,
height
);
const t = new Transform2D();
if (transform) {
t.translate(cx, cy);
t.prependCssTransform(transform, {
width: fontSize,
height: fontSize
});
t.translate(-cx, -cy);
}
return t;
}
function parseTransformOrigin(origin, left, top, width, height) {
const keywordX = { left: 0, center: 0.5, right: 1 };
const keywordY = { top: 0, center: 0.5, bottom: 1 };
const parts = origin.trim().split(/\s+/);
const rawX = parts[0] ?? "center";
const rawY = parts[1] ?? "center";
let ox;
if (rawX in keywordX) {
ox = left + keywordX[rawX] * width;
} else if (rawX.endsWith("%")) {
ox = left + Number.parseFloat(rawX) / 100 * width;
} else {
ox = left + Number.parseFloat(rawX);
}
let oy;
if (rawY in keywordY) {
oy = top + keywordY[rawY] * height;
} else if (rawY.endsWith("%")) {
oy = top + Number.parseFloat(rawY) / 100 * height;
} else {
oy = top + Number.parseFloat(rawY);
}
return { x: ox, y: oy };
}
function parseValueNumber(value, ctx) {
if (typeof value === "number") {
return value;
} else {
if (value.endsWith("%")) {
value = value.substring(0, value.length - 1);
return Math.ceil(Number(value) / 100 * ctx.total);
} else if (value.endsWith("rem")) {
value = value.substring(0, value.length - 3);
return Number(value) * ctx.fontSize;
} else if (value.endsWith("em")) {
value = value.substring(0, value.length - 2);
return Number(value) * ctx.fontSize;
} else {
return Number(value);
}
}
}
function parseColormap(colormap) {
return isNone(colormap) ? {} : colormap;
}
function isEqualObject(obj1, obj2) {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
const keys = Array.from(/* @__PURE__ */ new Set([...keys1, ...keys2]));
return keys.every((key) => isEqualValue(obj1[key], obj2[key]));
}
function isEqualValue(val1, val2) {
const typeof1 = typeof val1;
const typeof2 = typeof val2;
if (typeof1 === typeof2) {
if (typeof1 && typeof1 === "object") {
return isEqualObject(val1, val2);
}
return val1 === val2;
}
return false;
}
class Canvas2DRenderer {
constructor(text, context) {
this.text = text;
this.context = context;
}
pixelRatio = window?.devicePixelRatio || 1;
// 子区域(平铺)渲染:只把 boundingBox 内相对偏移 (x,y)、尺寸 (width,height) 的一块
// 画到画布上(其余字形被画布边界裁掉)。用于超大文字按 GPU 上限分块栅格。不设则画整段。
region;
_setupView = () => {
const pixelRatio = this.pixelRatio;
const ctx = this.context;
const bb = this.text.boundingBox;
const region = this.region;
const left = bb.left + (region?.x ?? 0);
const top = bb.top + (region?.y ?? 0);
const canvasWidth = region?.width ?? bb.width;
const canvasHeight = region?.height ?? bb.height;
const view = ctx.canvas;
view.dataset.viewBox = String(`${left} ${top} ${canvasWidth} ${canvasHeight}`);
view.dataset.pixelRatio = String(pixelRatio);
view.width = Math.max(1, Math.ceil(canvasWidth * pixelRatio));
view.height = Math.max(1, Math.ceil(canvasHeight * pixelRatio));
view.style.width = `${canvasWidth}px`;
view.style.height = `${canvasHeight}px`;
ctx.clearRect(0, 0, view.width, view.height);
ctx.scale(pixelRatio, pixelRatio);
ctx.translate(-left, -top);
};
_setupColors = () => {
this.uploadColor(this.text.glyphBox, {
style: this.text.computedStyle,
fill: this.text.computedFill,
outline: this.text.computedOutline,
effects: this.text.computedEffects
});
this.text.paragraphs.forEach((paragraph) => {
this.uploadColor(paragraph.lineBox, {
style: paragraph.computedStyle,
fill: paragraph.computedFill,
outline: paragraph.computedOutline
});
paragraph.fragments.forEach((fragment) => {
this.uploadColor(fragment.inlineBox, {
style: fragment.computedStyle,
fill: fragment.computedFill,
outline: fragment.computedOutline
});
});
});
};
setup = () => {
this._setupView();
this._setupColors();
return this;
};
_parseColor = (source, box) => {
if (typeof source === "string" && isGradient(source)) {
source = normalizeGradient(source)[0];
}
if (typeof source === "object" && "type" in source) {
switch (source.type) {
case "linear-gradient": {
const { left, top, width: w, height: h } = box;
const { angle = 0, stops } = source;
if (![left, top, w, h, angle].every(Number.isFinite)) {
return stops?.find((s) => s?.color)?.color ?? "transparent";
}
const cx = left + w / 2;
const cy = top + h / 2;
const rad = (angle + 90) * Math.PI / 180;
const dx = Math.sin(rad);
const dy = -Math.cos(rad);
const l = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad));
const x0 = cx - dx * (l / 2);
const y0 = cy - dy * (l / 2);
const x1 = cx + dx * (l / 2);
const y1 = cy + dy * (l / 2);
const g = this.context.createLinearGradient(x0, y0, x1, y1);
for (const s of stops) {
const offset = Number.isFinite(s.offset) ? Math.min(1, Math.max(0, s.offset)) : 0;
try {
g.addColorStop(offset, s.color);
} catch {
}
}
return g;
}
}
}
return source;
};
_uploadedStyles = [
"color",
"backgroundColor",
"textStrokeColor"
];
uploadColor = (box, ctx) => {
const { style, fill, outline, effects } = ctx;
if (style) {
this._uploadedStyles.forEach((key) => {
style[key] = this._parseColor(style[key], box);
});
}
if (fill?.enabled) {
if (fill.linearGradient) {
fill._linearGradient = this._parseColor({
type: "linear-gradient",
...fill.linearGradient
}, box);
}
}
if (outline?.enabled) {
if (outline.linearGradient) {
outline._linearGradient = this._parseColor({
type: "linear-gradient",
...outline.linearGradient
}, box);
}
}
effects?.forEach((effect) => {
if (effect.fill?.enabled && effect.fill.linearGradient) {
effect.fill._linearGradient = this._parseColor({
type: "linear-gradient",
...effect.fill.linearGradient
}, box);
}
if (effect.outline?.enabled && effect.outline.linearGradient) {
effect.outline._linearGradient = this._parseColor({
type: "linear-gradient",
...effect.outline.linearGradient
}, box);
}
});
};
_mergePathStyle(path, style) {
const pathStyle = path.style;
const stroke = style.stroke ?? pathStyle.stroke;
const strokeWidth = style.strokeWidth ? style.strokeWidth : pathStyle.strokeWidth;
return {
...clearUndef(pathStyle),
...clearUndef(style),
stroke: strokeWidth === void 0 || strokeWidth > 0 ? stroke : void 0,
strokeLinecap: style.strokeLinecap ?? pathStyle.strokeLinecap ?? "round",
strokeLinejoin: style.strokeLinejoin ?? pathStyle.strokeLinejoin ?? "round",
strokeWidth
};
}
drawPath = (path, options = {}) => {
const { clipRect, ...pathStyle } = options;
const ctx = this.context;
ctx.save();
ctx.beginPath();
if (clipRect) {
ctx.rect(clipRect.left, clipRect.top, clipRect.width, clipRect.height);
ctx.clip();
ctx.beginPath();
}
path.drawTo(ctx, this._mergePathStyle(path, pathStyle));
ctx.restore();
};
effectToPathStyle(effect) {
const fontSize = this.text.computedStyle.fontSize;
let style = {};
if (effect.fill?.enabled) {
style = {
...style,
// 优先用已解析的渐变(_linearGradient,见 uploadColor),无渐变才回退纯色
fill: effect.fill._linearGradient ?? effect.fill.color
};
}
if (effect.outline?.enabled) {
style = {
...style,
stroke: effect.outline._linearGradient ?? effect.outline.color,
strokeWidth: (effect.outline.width ?? 0) * fontSize
};
}
if (effect.shadow?.enabled) {
style = {
...style,
shadowOffsetX: (effect.shadow.offsetX ?? 0) * fontSize,
shadowOffsetY: (effect.shadow.offsetY ?? 0) * fontSize,
shadowBlur: (effect.shadow.blur ?? 0) * fontSize,
shadowColor: effect.shadow.color
};
}
return style;
}
transformEffect(effect) {
const { a, b, c, d, tx, ty } = getEffectTransform2D(this.text, effect);
this.context.transform(a, b, c, d, tx, ty);
}
_shadowCanvas;
_shadowCtx;
drawWithShadow = (shadow, drawFn) => {
const mainCtx = this.context;
const view = mainCtx.canvas;
if (!this._shadowCanvas) {
this._shadowCanvas = document.createElement("canvas");
this._shadowCtx = this._shadowCanvas.getContext("2d");
}
const off = this._shadowCanvas;
const offCtx = this._shadowCtx;
if (!offCtx) {
drawFn();
return;
}
if (off.width < view.width) {
off.width = view.width;
}
if (off.height < view.height) {
off.height = view.height;
}
offCtx.setTransform(1, 0, 0, 1, 0, 0);
offCtx.clearRect(0, 0, view.width, view.height);
const m = mainCtx.getTransform();
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f);
const prev = this.context;
this.context = offCtx;
try {
drawFn();
} finally {
this.context = prev;
}
const fontSize = this.text.computedStyle.fontSize;
const pr = this.pixelRatio;
mainCtx.save();
mainCtx.setTransform(1, 0, 0, 1, 0, 0);
mainCtx.shadowOffsetX = (shadow.offsetX ?? 0) * fontSize * pr;
mainCtx.shadowOffsetY = (shadow.offsetY ?? 0) * fontSize * pr;
mainCtx.shadowBlur = (shadow.blur ?? 0) * fontSize * pr;
mainCtx.shadowColor = shadow.color;
mainCtx.drawImage(off, 0, 0, view.width, view.height, 0, 0, view.width, view.height);
mainCtx.restore();
};
drawCharacter = (character, effect = {}) => {
const ctx = this.context;
const {
computedStyle: style,
path,
glyphBox,
isVertical,
content,
inlineBox,
baseline,
computedFill,
computedOutline
} = character;
const fill = computedFill?.enabled ? computedFill : void 0;
const outline = computedOutline?.enabled ? computedOutline : void 0;
const effectPathStyle = this.effectToPathStyle(effect);
const pathStyle = {
strokeLinecap: outline?.lineCap,
strokeLinejoin: outline?.lineJoin,
...style,
...effectPathStyle,
fill: effectPathStyle.fill ?? fill?._linearGradient ?? fill?.color ?? style.color,
strokeWidth: effectPathStyle.strokeWidth ?? outline?.width ?? style.textStrokeWidth,
stroke: effectPathStyle.stroke ?? outline?._linearGradient ?? outline?.color ?? style.textStrokeColor
};
if (glyphBox) {
this.drawPath(path, pathStyle);
} else {
ctx.save();
ctx.beginPath();
setCanvasContext(ctx, this._mergePathStyle(path, pathStyle));
ctx.font = `${style.fontSize}px ${style.fontFamily || this.text.defaultFamily}`;
if (isVertical) {
ctx.textBaseline = "middle";
ctx.fillText(content, inlineBox.left, inlineBox.top + inlineBox.height / 2);
} else {
ctx.textBaseline = "alphabetic";
ctx.fillText(content, inlineBox.left, inlineBox.top + baseline);
}
ctx.restore();
}
};
}
const set1 = /* @__PURE__ */ new Set(["\xA9", "\xAE", "\xF7"]);
const set2 = /* @__PURE__ */ new Set([
"\u2014",
"\u2026",
"\u201C",
"\u201D",
"\uFE4F",
"\uFE4B",
"\uFE4C",
"\u2018",
"\u2019",
"\u02DC"
]);
const fsSelectionMap = {
1: "italic",
32: "bold"
};
const macStyleMap = {
1: "italic",
2: "bold"
};
const fontWeightMap = {
100: -0.2,
200: -0.1,
300: 0,
400: 0,
normal: 0,
500: 0.1,
600: 0.2,
700: 0.3,
bold: 0.3,
800: 0.4,
900: 0.5
};
const GLYPH_CACHE_CAP = 4096;
const glyphCache = /* @__PURE__ */ new Map();
function glyphCacheSet(key, tmpl) {
glyphCache.set(key, tmpl);
if (glyphCache.size > GLYPH_CACHE_CAP) {
glyphCache.delete(glyphCache.keys().next().value);
}
}
let _sfntIdCounter = 0;
const _sfntIds = /* @__PURE__ */ new WeakMap();
function sfntId(sfnt) {
let id = _sfntIds.get(sfnt);
if (id === void 0) {
id = ++_sfntIdCounter;
_sfntIds.set(sfnt, id);
}
return id;
}
const FONT_METRICS_CAP = 256;
const fontMetricsCache = /* @__PURE__ */ new Map();
function getFontMetrics(sfnt, fontSize) {
const key = `${sfntId(sfnt)}|${fontSize}`;
const cached = fontMetricsCache.get(key);
if (cached) {
return cached;
}
const { hhea, os2, post, head } = sfnt;
const unitsPerEm = head.unitsPerEm;
const ascender = hhea.ascent;
const descender = hhea.descent;
const rate = unitsPerEm / fontSize;
const advanceHeight = (ascender + Math.abs(descender)) / rate;
const baseline = ascender / rate;
const m = {
sfnt,
unitsPerEm,
advanceHeight,
baseline,
ascender: ascender / rate,
descender: descender / rate,
underlinePosition: (ascender - post.underlinePosition) / rate,
underlineThickness: post.underlineThickness / rate,
strikeoutPosition: (ascender - os2.yStrikeoutPosition) / rate,
strikeoutSize: os2.yStrikeoutSize / rate,
typoAscender: os2.sTypoAscender / rate,
typoDescender: os2.sTypoDescender / rate,
typoLineGap: os2.sTypoLineGap / rate,
winAscent: os2.usWinAscent / rate,
winDescent: os2.usWinDescent / rate,
xHeight: os2.version > 1 ? os2.sxHeight / rate : 0,
capHeight: os2.version > 1 ? os2.sCapHeight / rate : 0,
centerDiviation: advanceHeight / 2 - baseline,
fontStyle: fsSelectionMap[os2.fsSelection] ?? macStyleMap[head.macStyle]
};
if (fontMetricsCache.size >= FONT_METRICS_CAP) {
fontMetricsCache.delete(fontMetricsCache.keys().next().value);
}
fontMetricsCache.set(key, m);
return m;
}
class Character {
constructor(content, index, parent) {
this.content = content;
this.index = index;
this.parent = parent;
}
// 惰性 path:布局阶段(measure)只算度量 + glyphBox,不构造逐字定位 path
// (3681 字逐字 clone+平移占 Text.update 主成本)。path 仅在真正渲染/命中时才按需构建。
_path;
_lazyPath;
get path() {
if (this._path) {
return this._path;
}
const lazy = this._lazyPath;
if (lazy) {
const { x, y } = lazy;
const p = lazy.tmpl.path.clone().setMeta(this);
p.applyTransform((pt) => {
pt.x += x;
pt.y += y;
});
p.style = lazy.style;
this._path = p;
this._lazyPath = void 0;
return p;
}
const empty = new Path2D().setMeta(this);
this._path = empty;
return empty;
}
set path(value) {
this._path = value;
this._lazyPath = void 0;
}
inlineBox = new BoundingBox();
// lineBox(行高/列厚那条 strip)完全由 inlineBox + fontHeight 推出,不再逐字存一个 BoundingBox。
// 横排:与 inlineBox 同列、在内容盒上下居中、高=fontHeight;竖排:inline/block 轴交换。
get lineBox() {
const ib = this.inlineBox;
const fh = this.fontHeight;
return this.isVertical ? new BoundingBox(ib.left + (ib.width - fh) / 2, ib.top, fh, ib.height) : new BoundingBox(ib.left, ib.top + (ib.height - fh) / 2, ib.width, fh);
}
glyphBox;
advanceWidth = 0;
// 字偶距(kerning):与「行内逻辑前一字符」之间的水平调整(px,通常为负=拉近)。
// 由 Measurer._applyKerning 在横排测量时按字形对填充;行首/换字体/竖排时为 0。
// 断行与定位都计入它,使纯/混排拉丁文本的行宽、换行点与浏览器渲染一致。
kerningBefore = 0;
// 本字符首字形在其 sfnt 内的索引(逐字),供相邻字符查字偶距。
_glyphIndex;
// 字体度量 flyweight(按 (sfnt,fontSize) 共享);下面所有度量 getter 都从这里推导,不逐字存。
_metrics;
// 度量 getter —— 零实例存储,从共享 _metrics 推导(advanceWidth 是唯一逐字存的度量)。
get advanceHeight() {
return this._metrics?.advanceHeight ?? 0;
}
get baseline() {
return this._metrics?.baseline ?? 0;
}
get ascender() {
return this._metrics?.ascender ?? 0;
}
get descender() {
return this._metrics?.descender ?? 0;
}
get underlinePosition() {
return this._metrics?.underlinePosition ?? 0;
}
get underlineThickness() {
return this._metrics?.underlineThickness ?? 0;
}
get strikeoutPosition() {
return this._metrics?.strikeoutPosition ?? 0;
}
get strikeoutSize() {
return this._metrics?.strikeoutSize ?? 0;
}
get typoAscender() {
return this._metrics?.typoAscender ?? 0;
}
get typoDescender() {
return this._metrics?.typoDescender ?? 0;
}
get typoLineGap() {
return this._metrics?.typoLineGap ?? 0;
}
get winAscent() {
return this._metrics?.winAscent ?? 0;
}
get winDescent() {
return this._metrics?.winDescent ?? 0;
}
get xHeight() {
return this._metrics?.xHeight ?? 0;
}
get capHeight() {
return this._metrics?.capHeight ?? 0;
}
get centerDiviation() {
return this._metrics?.centerDiviation ?? 0;
}
get fontStyle() {
return this._metrics?.fontStyle;
}
// 增量布局:把已测量的字形整体沿 y 平移 dy(用于未变段落因前序段落高度变化而下移)。
// 同步移动所有盒(inline/line/glyph)与 path(惰性偏移或已构建几何),保持与全量重排一致。
translateY(dy) {
if (!dy) {
return;
}
this.inlineBox.top += dy;
if (this.glyphBox) {
this.glyphBox.top += dy;
}
if (this._lazyPath) {
this._lazyPath.y += dy;
} else if (this._path) {
this._path.applyTransform((p) => {
p.y += dy;
});
}
}
get compatibleGlyphBox() {
if (this.glyphBox) {
return this.glyphBox;
}
const size = this.computedStyle.fontSize * 0.8;
const lb = this.lineBox;
return this.isVertical ? new BoundingBox(lb.left + lb.width / 2 - size / 2, lb.top, size, lb.height) : new BoundingBox(lb.left, lb.top + lb.height / 2 - size / 2, lb.width, size);
}
get center() {
return this.compatibleGlyphBox.center;
}
get computedFill() {
return this.parent.computedFill;
}
get computedOutline() {
return this.parent.computedOutline;
}
get computedStyle() {
return this.parent.computedStyle;
}
get isVertical() {
return this.computedStyle.writingMode.includes("vertical");
}
get fontSize() {
return this.computedStyle.fontSize;
}
get fontHeight() {
return this.fontSize * this.computedStyle.lineHeight;
}
_getFontSFNT(fonts$1) {
const fontFamily = this.computedStyle.fontFamily;
const _fonts = fonts$1 ?? fonts;
const font = fontFamily ? _fonts.get(fontFamily) : _fonts.fallbackFont;
let sfnt = font?.getSFNT();
if (sfnt?.textToGlyphIndexes(this.content).includes(0)) {
sfnt = _fonts.fallbackFont?.getSFNT();
}
return sfnt;
}
updateGlyph(sfnt = this._getFontSFNT()) {
if (!sfnt) {
return this;
}
const { content, computedStyle } = this;
const fontSize = computedStyle.fontSize;
this._metrics = getFontMetrics(sfnt, fontSize);
this.advanceWidth = sfnt.getAdvanceWidth(content, fontSize);
this._glyphIndex = sfnt.textToGlyphIndexes(content)[0];
return this;
}
// 与逻辑前一字符 prev 之间的字偶距(px)。仅当同一 sfnt(同字体、非缺字回退分叉)
// 且两侧字形索引均有效时返回非零;否则 0。kerning value 为 font units,按本字符字号转 px。
computeKerningBefore(prev) {
const m = this._metrics;
if (!prev || !m || prev._metrics?.sfnt !== m.sfnt || prev._glyphIndex === void 0 || this._glyphIndex === void 0) {
return 0;
}
const kv = m.sfnt.getKerningValue(prev._glyphIndex, this._glyphIndex);
return kv ? kv * this.computedStyle.fontSize / m.unitsPerEm : 0;
}
/**
* Populate glyph metrics only (advance width/height, ascender/descender,
* baseline, …) without building the glyph `path` or touching boxes.
*
* The pure-JS `Measurer` must know advances *before* it can place characters,
* so it calls this ahead of layout. `update()` later recomputes the same metrics
* while building the path, so this is idempotent.
*/
measureGlyph(fonts) {
return this.updateGlyph(this._getFontSFNT(fonts));
}
update(fonts) {
const sfnt = this._getFontSFNT(fonts);
if (!sfnt) {
return this;
}
this.updateGlyph(sfnt);
const style = this.computedStyle;
const needsItalic = style.fontStyle === "italic" && this.fontStyle !== "italic";
if (!this.isVertical && !needsItalic && !style.textStrokeWidth) {
this._updateFromCache(sfnt, style);
return this;
}
const {
isVertical,
content,
baseline,
inlineBox,
ascender,
descender,
typoAscender,
fontStyle,
advanceWidth,
advanceHeight
} = this;
const { left, top } = inlineBox;
let x = left;
let y = top + baseline;
let glyphIndex;
const path = new Path2D().setMeta(this);
if (isVertical) {
x += (advanceHeight - advanceWidth) / 2;
if (Math.abs(advanceWidth - advanceHeight) > 0.1) {
y -= (ascender - typoAscender) / (ascender + Math.abs(descender)) * advanceHeight;
}
glyphIndex = void 0;
}
if (isVertical && !set1.has(content) && (content.codePointAt(0) <= 256 || set2.has(content))) {
path.addCommands(
sfnt.getPathCommands(
content,
x,
top + baseline - (advanceHeight - advanceWidth) / 2,
style.fontSize
)
);
const point = {
y: top - (advanceHeight - advanceWidth) / 2 + advanceHeight / 2,
x: x + advanceWidth / 2
};
if (needsItalic) {
this._italic(path, {
x: point.x,
y: top - (advanceHeight - advanceWidth) / 2 + baseline
});
}
path.rotate(Math.PI / 2, point);
} else {
if (glyphIndex !== void 0) {
path.addCommands(
sfnt.glyphs.get(glyphIndex).getPathCommands(x, y, style.fontSize)
);
if (needsItalic) {
this._italic(
path,
isVertical ? {
x: x + advanceWidth / 2,
y: top + typoAscender / (ascender + Math.abs(descender)) * advanceHeight
} : void 0
);
}
} else {
path.addCommands(sfnt.getPathCommands(content, x, y, style.fontSize));
if (needsItalic) {
this._italic(
path,
isVertical ? { x: x + advanceHeight / 2, y } : void 0
);
}
}
}
const fontWeight = style.fontWeight ?? 400;
if (fontWeight in fontWeightMap && ((fontWeight === 700 || fontWeight === "bold") && fontStyle !== "bold")) {
path.bold(fontWeightMap[fontWeight] * style.fontSize * 0.05);
}
path.style = clearUndef({
fill: style.color,
fillRule: "nonzero",
stroke: style.textStrokeWidth ? style.textStrokeColor : void 0,
strokeWidth: style.textStrokeWidth ? style.textStrokeWidth * style.fontSize * 0.03 : void 0
});
this.path = path;
this.glyphBox = this.getGlyphBoundingBox();
return this;
}
// 横排非合成斜体的快路径:用字形模板缓存构建 path 与 glyphBox。
_updateFromCache(sfnt, style) {
const content = this.content;
const fontSize = style.fontSize;
const x = this.inlineBox.left;
const y = this.inlineBox.top + this.baseline;
const fontWeight = style.fontWeight ?? 400;
const boldAmount = fontWeight in fontWeightMap && (fontWeight === 700 || fontWeight === "bold") && this.fontStyle !== "bold" ? fontWeightMap[fontWeight] * fontSize * 0.05 : 0;
const key = `${content}|${sfntId(sfnt)}|${fontSize}|${boldAmount}`;
let tmpl = glyphCache.get(key);
if (!tmpl) {
const tpath = new Path2D();
tpath.addCommands(sfnt.getPathCommands(content, 0, 0, fontSize));
if (boldAmount) {
tpath.bold(boldAmount);
}
let glyphBox;
if (tpath.curves[0]?.curves.length) {
const { min, max } = tpath.getMinMax(void 0, void 0, true);
glyphBox = new BoundingBox(min.x, min.y, max.x - min.x, max.y - min.y);
}
tmpl = { path: tpath, glyphBox };
glyphCacheSet(key, tmpl);
}
this._path = void 0;
this._lazyPath = {
tmpl,
x,
y,
style: clearUndef({ fill: style.color, fillRule: "nonzero" })
};
this.glyphBox = tmpl.glyphBox ? new BoundingBox(tmpl.glyphBox.left + x, tmpl.glyphBox.top + y, tmpl.glyphBox.width, tmpl.glyphBox.height) : void 0;
}
_italic(path, startPoint) {
path.skew(-0.24, 0, startPoint || {
y: this.inlineBox.top + this.baseline,
x: this.inlineBox.left + this.inlineBox.width / 2
});
}
getGlyphMinMax(min, max, withStyle) {
if (this._path === void 0 && this._lazyPath !== void 0) {
const gb = this.glyphBox;
if (!gb) {
return void 0;
}
const tl = new Vector2(gb.left, gb.top);
const br = new Vector2(gb.left + gb.width, gb.top + gb.height);
const rMin = min ?? new Vector2(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
const rMax = max ?? new Vector2(Number.MIN_SAFE_INTEGER, Number.MIN_SAFE_INTEGER);
rMin.clampMin(tl, br);
rMax.clampMax(tl, br);
return { min: rMin, max: rMax };
}
if (this.path.curves[0]?.curves.length) {
return this.path.getMinMax(min, max, withStyle);
} else {
return void 0;
}
}
getGlyphBoundingBox(withStyle) {
const minMax = this.getGlyphMinMax(void 0, void 0, withStyle);
if (!minMax) {
return void 0;
}
const { min, max } = minMax;
return new BoundingBox(min.x, min.y, max.x - min.x, max.y - min.y);
}
}
class Fragment {
constructor(content, style = {}, fill, outline, index, parent) {
this.content = content;
this.style = style;
this.fill = fill;
this.outline = outline;
this.index = index;
this.parent = parent;
this.update().initCharacters();
}
inlineBox = new BoundingBox();
get computedContent() {
const style = this.computedStyle;
return style.textTransform === "uppercase" ? this.content.toUpperCase() : style.textTransform === "lowercase" ? this.content.toLowerCase() : this.content;
}
update() {
this.computedStyle = {
...this.parent.computedStyle,
...clearUndef(this.style)
};
const fill = this.fill ?? this.parent.computedFill;
this.computedFill = fill ? clearUndef(fill) : void 0;
const outline = this.outline ?? this.parent.computedOutline;
this.computedOutline = outline ? clearUndef(outline) : void 0;
return this;
}
initCharacters() {
const characters = [];
let index = 0;
for (const c of this.computedContent) {
characters.push(new Character(c, index++, this));
}
this.characters = characters;
return this;
}
}
class Paragraph {
constructor(style, index, parent) {
this.style = style;
this.index = index;
this.parent = parent;
this.update();
}
lineBox = new BoundingBox();
fragments = [];
fill;
outline;
// 增量布局缓存(见 Text._update / Measurer):
// _layoutDirty=true 时须全量重排该段;为 false(复用未变段)时,measurer 仅按 dy 平移。
// _layoutTop/_layoutHeight/_layoutRight 记录上次测量的绝对 y 顶/高/右,供平移与并集复用。
// _glyphBox 缓存该段字形包围盒(绝对坐标),平移时随段一起移,免去逐字 union。
_layoutDirty = true;
_layoutTop = 0;
_layoutHeight = 0;
_layoutRight = 0;
_glyphBox;
// 本段是否「有效测量」:含字符却所有字形 advance 为 0(字体未就绪时的退化测量)则为 false,
// 不可作为增量复用基准,须下次重测,避免把字体就绪前的零宽布局固化。
_layoutValid = false;
update() {
this.computedStyle = {
...clearUndef(this.parent.computedStyle),
...clearUndef(this.style)
};
const fill = this.fill ?? this.parent.computedFill;
this.computedFill = fill ? clearUndef(fill) : void 0;
const outline = this.outline ?? this.parent.computedOutline;
this.computedOutline = outline ? clearUndef(outline) : void 0;
return this;
}
}
function side(style, name, edge) {
return style[`${name}${edge}`] ?? style[name] ?? 0;
}
class Measurer {
measure(paragraphs, rootStyle, _dom, fonts$1) {
const _fonts = fonts$1 ?? fonts;
for (const paragraph of paragraphs) {
if (!paragraph._layoutDirty) {
continue;
}
for (const fragment of paragraph.fragments) {
for (const character of fragment.characters) {
character.measureGlyph(_fonts);
}
}
}
if (!rootStyle.writingMode.includes("vertical")) {
this._applyKerning(paragraphs);
}
return rootStyle.writingMode.includes("vertical") ? this._measureVertical(paragraphs, rootStyle) : this._measureHorizontal(paragraphs, rootStyle);
}
_rootPadding(rootStyle) {
return {
top: side(rootStyle, "padding", "Top"),
right: side(rootStyle, "padding", "Right"),
bottom: side(rootStyle, "padding", "Bottom"),
left: side(rootStyle, "padding", "Left")
};
}
_measureHorizontal(paragraphs, rootStyle) {
const rootPad = this._rootPadding(rootStyle);
const hasWidth = typeof rootStyle.width === "number";
const noWrap = rootStyle.textWrap === "nowrap";
const availWidth = hasWidth && !noWrap ? rootStyle.width - rootPad.left - rootPad.right : Infinity;
let y = rootPad.top;
let maxRight = rootPad.left;
for (const paragraph of paragraphs) {
const pStyle = paragraph.computedStyle;
const pBox = paragraph.style;
const mTop = side(pBox, "margin", "Top");
const mBottom = side(pBox, "margin", "Bottom");
const mLeft = side(pBox, "margin", "Left");
const mRight = side(pBox, "margin", "Right");
const pTop = side(pBox, "padding", "Top");
const pBottom = side(pBox, "padding", "Bottom");
const pLeft = side(pBox, "padding", "Left");
const pRight = side(pBox, "padding", "Right");
const liLeft = rootPad.left + mLeft + pLeft;
const liAvail = availWidth === Infinity ? Infinity : availWidth - mLeft - mRight - pLeft - pRight;
y += mTop + pTop;
const paraTop = y;
let paraRight = liLeft;
if (!paragraph._layoutDirty) {
const dy = paraTop - paragraph._layoutTop;
if (dy) {
for (const fragment of paragraph.fragments) {
for (const c of fragment.characters) {
c.translateY(dy);
}
fragment.inlineBox.top += dy;
}
paragraph.lineBox.top += dy;
if (paragraph._glyphBox) {
paragraph._glyphBox.top += dy;
}
}
paragraph._layoutTop = paraTop;
paraRight = paragraph._layoutRight;
y = paraTop + paragraph.lineBox.height;
if (paraRight > maxRight) {
maxRight = paraRight;
}
y += pBottom + mBottom;
continue;
}
const lines = this._breakLines(paragraph, liAvail);
const align = pStyle.textAlign;
const indent = pStyle.textIndent ?? 0;
let hasChars = false;
let anyAdvance = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineIndent = i === 0 ? indent : 0;
let lineHeight = pStyle.fontSize * pStyle.lineHeight;
let contentWidth = 0;
let firstW = true;
for (const c of line) {
if (c.fontHeight > lineHeight) {
lineHeight = c.fontHeight;
}
if (!firstW) {
contentWidth += c.kerningBefore;
}
firstW = false;
contentWidth += this._advance(c);
}
let x = liLeft + lineIndent;
if (liAvail !== Infinity) {
const slack = liAvail - lineIndent - contentWidth;
if (align === "center") {
x += slack / 2;
} else if (align === "end" || align === "right") {
x += slack;
}
}
let firstInLine = true;
for (const c of line) {
hasChars = true;
if (c.advanceWidth > 0) {
anyAdvance = true;
}
if (!firstInLine) {
x += c.kerningBefore;
}
firstInLine = false;
const adv = c.advanceWidth;
const contentHeight = c.advanceHeight;
c.inlineBox.left = x;
c.inlineBox.top = y + (lineHeight - contentHeight) / 2;
c.inlineBox.width = adv;
c.inlineBox.height = contentHeight;
x += this._advance(c);
}
if (x > paraRight) {
paraRight = x;
}
y += lineHeight;
}
if (paraRight > maxRight) {
maxRight = paraRight;
}
for (const fragment of paragraph.fragments) {
this._unionInto(fragment.inlineBox, fragment.characters.map((c) => c.inlineBox));
}
paragraph.lineBox.left = liLeft;
paragraph.lineBox.top = paraTop;
paragraph.lineBox.width = liAvail === Infinity ? paraRight - liLeft : liAvail;
paragraph.lineBox.height = y - paraTop;
paragraph._layoutTop = paraTop;
paragraph._layoutHeight = paragraph.lineBox.height;
paragraph._layoutRight = paraRight;
paragraph._glyphBox = void 0;
paragraph._layoutValid = !hasChars || anyAdvance;
y += pBottom + mBottom;
}
const contentBottom = y + rootPad.bottom;
const totalWidth = hasWidth ? rootStyle.width : maxRight + rootPad.right;
const totalHeight = typeof rootStyle.height === "number" ? rootStyle.height : contentBottom;
if (typeof rootStyle.height === "number") {
const slack = totalHeight - contentBottom;
const va = rootStyle.verticalAlign;
const dy = va === "middle" ? slack / 2 : va === "bottom" ? slack : 0;
if (dy) {
this._shiftAll(paragraphs, dy);
}
}
return {
paragraphs,
boundingBox: new BoundingBox(0, 0, totalWidth, totalHeight)
};
}
/**
* Vertical writing-mode (`vertical-rl`): columns stack right-to-left, glyphs
* flow top→bottom. It is the horizontal layout with the inline and block axes
* swapped — the inline (down-column) advance is `advanceWidth` (CJK upright = em;
* Latin is rotated, so its advance ≈ its width), the cross-axis content box is
* `advanceHeight`, and the column thickness is `fontHeight`. The lineBox stays
* accurate even though inlineBox carries the content-box rounding residual.
*
* v1: `vertical-rl` only; no per-paragraph margin/padding or block alignment.
*/
_measureVertical(paragraphs, rootStyle) {
const rootPad = this._rootPadding(rootStyle);
const hasHeight = typeof rootStyle.height === "number";
const noWrap = rootStyle.textWrap === "nowrap";
const availHeight = hasHeight && !noWrap ? rootStyle.height - rootPad.top - rootPad.bottom : Infinity;
const columns = [];
for (const paragraph of paragraphs) {
const strut = paragraph.computedStyle.fontSize * paragraph.computedStyle.lineHeight;
for (const line of this._breakLines(paragraph, availHeight)) {
let thickness = strut;
for (const c of line) {
if (c.fontHeight > thickness) {
thickness = c.fontHeight;
}
}
columns.push({ chars: line, thickness });
}
}
const totalThickness = columns.reduce((sum, c) => sum + c.thickness, 0);
const hasWidth = typeof rootStyle.width === "number";
const blockWidth = hasWidth ? Math.max(rootStyle.width - rootPad.left - rootPad.right, totalThickness) : totalThickness;
let xRight = rootPad.left + blockWidth;
let maxBottom = rootPad.top;
for (const column of columns) {
xRight -= column.thickness;
const colLeft = xRight;
let y = rootPad.top;
for (const c of column.chars) {
const advance = c.advanceWidth;
const contentWidth = c.advanceHeight;
c.inlineBox.top = y;
c.inlineBox.height = advance;
c.inlineBox.width = contentWidth;
c.inlineBox.left = colLeft + (column.thickness - contentWidth) / 2;
y += this._advance(c);
}
if (y > maxBottom) {
maxBottom = y;
}
}
for (const paragraph of paragraphs) {
for (const fragment of paragraph.fragments) {
this._unionInto(fragment.inlineBox, fragment.characters.map((c) => c.inlineBox));
}
this._unionInto(
paragraph.lineBox,
paragraph.fragments.flatMap((f) => f.characters.map((c) => c.inlineBox))
);
}
const totalWidth = hasWidth ? rootStyle.width : blockWidth + rootPad.left + rootPad.right;
const totalHeight = hasHeight ? rootStyle.height : maxBottom + rootPad.bottom;
return {
paragraphs,
boundingBox: new BoundingBox(0, 0, totalWidth, totalHeight)
};
}
/** Advance step including CSS letter-spacing (px). */
_advance(character) {
return character.advanceWidth + (character.computedStyle.letterSpacing ?? 0);
}
/**
* Break a paragraph's characters into visual lines.
* v1: `word-break: break-all` (break before any character that would overflow)
* plus explicit `\n`/`\r` hard breaks. The newline itself occupies no line box.
*/
_breakLines(paragraph, avail) {
const lines = [];
let current = [];
let width = 0;
const flush = () => {
lines.push(current);
current = [];
width = 0;
};
for (const fragment of paragraph.fragments) {
for (const c of fragment.characters) {
if (c.content === "\n" || c.content === "\r") {
current.push(c);
flush();
continue;
}
const kern = current.length > 0 ? c.kerningBefore : 0;
if (avail !== Infinity && current.length > 0 && width + kern + c.advanceWidth > avail) {
flush();
}
if (current.length > 0) {
width += c.kerningBefore;
}
current.push(c);
width += this._advance(c);
}
}
if (current.length > 0 || lines.length === 0) {
flush();
}
return lines;
}
// 横排:为每段(仅 dirty 段)填充相邻字形的字偶距。clean 段沿用上次结果(kern 是行内水平量,
// 不随段落 y 平移变化),契合增量布局。
_applyKerning(paragraphs) {
for (const paragraph of paragraphs) {
if (!paragraph._layoutDirty) {
continue;
}
let prev;
for (const fragment of paragraph.fragments) {
for (const c of fragment.characters) {
c.kerningBefore = c.computeKerningBefore(prev);
prev = c;
}
}
}
}
_unionInto(target, boxes) {
const used = boxes.filter((b) => b.width !== 0 || b.height !== 0);
if (!used.length) {
return;
}
const u = BoundingBox.from(...used);
target.left = u.left;
target.top = u.top;
target.width = u.width;
target.height = u.height;
}
_shiftAll(paragraphs, dy) {
for (const paragraph of paragraphs) {
paragraph.lineBox.top += dy;
for (const fragment of paragraph.fragments) {
fragment.inlineBox.top += dy;
for (const character of fragment.characters) {
character.inlineBox.top += dy;
}
}
}
}
dispose() {
}
}
function backgroundPlugin() {
const pathSet = new Path2DSet();
const loader = createSvgLoader();
const parser = createSvgParser(loader);
return {
name: "background",
pathSet,
context: {
loader,
parser
},
load: async (text) => {
const { backgroundImage } = text.computedStyle;
if (backgroundImage && loader.needsLoad(backgroundImage)) {
await loader.load(backgroundImage);
}
},
update: (text) => {
pathSet.paths.length = 0;
const { computedStyle, lineBox, isVertical } = text;
const {
backgroundImage,
backgroundSize,
backgroundColormap
} = computedStyle;
if (isNone(backgroundImage))
return;
const { pathSet: imagePathSet } = parser.parse(backgroundImage);
const imageBox = imagePathSet.getBoundingBox(true) ?? new BoundingBox();
let x, y, width, height;
if (isVertical) {
({ x: y, y: x, width: height, height: width } = lineBox);
} else {
({ x, y, width, height } = lineBox);
}
const colormap = parseColormap(backgroundColormap ?? "none");
const paths = imagePathSet.paths.map((p) => {
const cloned = p.clone();
if (cloned.style.fill && cloned.style.fill in colormap) {
cloned.style.fill = colormap[cloned.style.fill];
}
if (cloned.style.stroke && cloned.style.stroke in colormap) {
cloned.style.stroke = colormap[cloned.style.stroke];
}
return cloned;
});
let scaleX;
let scaleY;
if (backgroundSize === "rigid") {
scaleX = text.fontSize * 5 / imageBox.width;
scaleY = scaleX;
const dist = new Vector2();
dist.x = imageBox.width - width / scaleX;
dist.y = imageBox.height - height / scaleY;
paths.forEach((path) => {
path.applyTransform((p) => {
const hasX = p.x > imageBox.left + imageBox.width / 2;
const hasY = p.y > imageBox.top + imageBox.height / 2;
if (hasX) {
p.x -= dist.x;
}
if (hasY) {
p.y -= dist.y;
}
});
});
} else {
scaleX = width / imageBox.width;
scaleY = height / imageBox.height;
}
const transform = new Transform2D();
transform.translate(-imageBox.x, -imageBox.y);
transform.scale(scaleX, scaleY);
if (isVertical) {
transform.translate(-width / 2, -height / 2);
transform.rotate(Math.PI / 2);
transform.translate(height / 2, width / 2);
}
transform.translate(x, y);
paths.forEach((path) => {
path.applyTransform(transform);
});
pathSet.paths.push(...paths);
},
renderOrder: -2,
render: (renderer) => {
const { text, context } = renderer;
const { lineBox, computedStyle: style } = text;
if (!isNone(style.backgroundColor)) {
context.fillStyle = style.backgroundColor;
context.fillRect(...lineBox.array);
}
pathSet.paths.forEach((path) => {
renderer.drawPath(path);
if (text.debug) {
const box = new Path2DSet([path]).getBoundingBox();
if (box) {
context.strokeRect(box.x, box.y, box.width, box.height);
}
}
});
text.paragraphs.forEach((p) => {
const { lineBox: lineBox2, style: style2 } = p;
if (!isNone(style2.backgroundColor)) {
context.fillStyle = style2.backgroundColor;
context.fillRect(...lineBox2.array);
}
p.fragments.forEach((f) => {
const { inlineBox, style: style3 } = f;
if (!isNone(style3.backgroundColor)) {
context.fillStyle = style3.backgroundColor;
context.fillRect(...inlineBox.array);
}
});
});
}
};
}
function getHighlightStyle(style) {
const {
highlight,
highlightImage,
highlightReferImage,
highlightColormap,
highlightLine,
highlightSize,
highlightThickness
} = style;
return {
image: highlight?.image ?? highlightImage ?? "none",
referImage: highlight?.referImage ?? highlightReferImage ?? "none",
colormap: highlight?.colormap ?? highlightColormap ?? "none",
line: highlight?.line ?? highlightLine ?? "none",
size: highlight?.size ?? highlightSize ?? "cover",
thickness: highlight?.thickness ?? highlightThickness ?? "100%"
};
}
function highlightPlugin() {
const pathSet = new Path2DSet();
const clipRects = [];
const loader = createSvgLoader();
const parser = createSvgParser(loader);
return definePlugin({
name: "highlight",
pathSet,
context: {
clipRects,
loader,
parser
},
load: async (text) => {
const set = /* @__PURE__ */ new Set();
text.forEachCharacter((character) => {
const { computedStyle: style } = character;
const { image, referImage } = getHighlightStyle(style);
if (image && loader.needsLoad(image)) {
set.add(image);
}
if (referImage && loader.needsLoad(referImage)) {
set.add(referImage);
}
});
await Promise.all(Array.from(set).map((src) => loader.load(src)));
},
update: (text) => {
clipRects.length = 0;
pathSet.paths.length = 0;
let groups = [];
let group;
let prevHighlight;
text.forEachCharacter((character) => {
const {
computedStyle: style
} = character;
const highlight = getHighlightStyle(style);
const {
image,
colormap,
line,
size,
thickness
} = highlight;
if (!isNone(image)) {
const { inlineBox, isVertical } = character;
const { fontSize } = style;
if ((!prevHighlight || isEqualValue(prevHighlight.image, image) && isEqualValue(prevHighlight.colormap, colormap) && isEqualValue(prevHighlight.line, line) && isEqualValue(prevHighlight.size, size) && isEqualValue(prevHighlight.thickness, thickness)) && group?.length && (isVertical ? group[0].inlineBox.left === inlineBox.left : group[0].inlineBox.top === inlineBox.top) && group[0].fontSize === fontSize) {
group.push(character);
} else {
group = [];
group.push(character);
groups.push(group);
}
} else {
if (group?.length) {
group = [];
groups.push(group);
}
}
prevHighlight = highlight;
});
groups = groups.filter((characters) => characters.length);
for (let i = 0; i < groups.length; i++) {
const characters = groups[i];
const char = characters[0];
const groupBox = BoundingBox.from(...characters.map((c) => c.compatibleGlyphBox));
if (!groupBox.height || !groupBox.width) {
continue;
}
const {
computedStyle: style,
isVertical,
inlineBox,
compatibleGlyphBox: glyphBox,
strikeoutPosition,
underlinePosition
} = char;
const { fontSize } = style;
const {
image,
referImage,
colormap,
line,
size,
thickness
} = getHighlightStyle(style);
const _thickness = parseValueNumber(thickness, { fontSize, total: groupBox.width }) / groupBox.width;
const _colormap = parseColormap(colormap);
const { pathSet: imagePathSet, dom: imageDom } = parser.parse(image);
const imageBox = imagePathSet.getBoundingBox(true) ?? new BoundingBox();
const styleScale = fontSize / imageBox.width * 2;
const targetBox = new BoundingBox().copy(groupBox);
if (isVertical) {
targetBox.width = groupBox.height;
targetBox.height = groupBox.width;
targetBox.left = groupBox.left + groupBox.width;
}
const rawWidth = Math.floor(targetBox.width);
let userWidth = rawWidth;
if (size !== "cover") {
userWidth = parseValueNumber(size, { fontSize, total: groupBox.width }) || rawWidth;
targetBox.width = userWidth;
}
const hasReferImage = !isNone(referImage) && isNone(line);
if (hasReferImage) {
imageBox.copy(
parser.parse(referImage).pathSet.getBoundingBox(true) ?? new BoundingBox()
);
} else {
let _line;
if (isNone(line)) {
if (imageBox.width / imageBox.height > 4) {
_line = "underline";
const viewBox = imageDom.getAttribute("viewBox");
if (viewBox) {
const [_x, y, _w, h] = viewBox.split(" ").map((v) => Number(v));
const viewCenter = y + h / 2;
if (imageBox.y < viewCenter && imageBox.y + imageBox.height > viewCenter) {
_line = "line-through";
} else if (imageBox.y + imageBox.height < viewCenter) {
_line = "overline";
} else {
_line = "underline";
}
}
} else {
_line = "outline";
}
} else {
_line = line;
}
switch (_line) {
case "outline": {
const paddingX = targetBox.width