UNPKG

modern-text

Version:

Measure and render text in a way that describes the DOM.

598 lines (589 loc) 19.7 kB
import { isNone } from 'modern-idoc'; import { QuadraticBezierCurve, Vector2, CubicBezierCurve, LineCurve, BoundingBox } from 'modern-path2d'; function definePlugin(options) { return options; } function splitCurve(curve, point) { let { x, y } = point; if (curve instanceof QuadraticBezierCurve) { const { p1: v0, cp: v1, p2: v2 } = curve; const s = x !== void 0 && Math.abs(Math.sign(v0.x - x) + Math.sign(v1.x - x) + Math.sign(v2.x - x)) === 1; const n = y !== void 0 && Math.abs(Math.sign(v0.y - y) + Math.sign(v1.y - y) + Math.sign(v2.y - y)) === 1; let o, e, dist, c; if (s) { o = v0.x - 2 * v1.x + v2.x; e = 2 * (-v0.x + v1.x); dist = v0.x - x; } if (n) { o = v0.y - 2 * v1.y + v2.y; e = 2 * (-v0.y + v1.y); dist = v0.y - y; } if (s || n) { c = e * e - 4 * o * dist; if (c > 0) { const h = Math.sqrt(c); const a = (-e + h) / 2 / o; const b = (-e - h) / 2 / o; const u = [a, b].find((v) => v > 0 && v < 1); if (u) { const r = Vector2.lerp(v0, v1, u); const d = Vector2.lerp(v1, v2, u); const B = Vector2.lerp(r, d, u); return [new QuadraticBezierCurve(v0, r, B), new QuadraticBezierCurve(B.clone(), d, v2)]; } } } } else if (curve instanceof CubicBezierCurve) { const { p1: v0, cp1: v1, cp2: v2, p2: v3 } = curve; const { min, max } = curve.getMinMax(); const e = x !== void 0 && (min.x - x) * (max.x - x) < 0; const l = y !== void 0 && (min.y - y) * (max.y - y) < 0; if (e || l) { x = 0.5 * v0.x + 0.5 * v1.x; y = 0.5 * v0.y + 0.5 * v1.y; const c = new Vector2(x, y); x = 0.25 * v0.x + 0.5 * v1.x + 0.25 * v2.x; y = 0.25 * v0.y + 0.5 * v1.y + 0.25 * v2.y; const h = new Vector2(x, y); x = 0.125 * v0.x + 0.375 * v1.x + 0.375 * v2.x + 0.125 * v3.x; y = 0.125 * v0.y + 0.375 * v1.y + 0.375 * v2.y + 0.125 * v3.y; const a = new Vector2(x, y); x = 0.25 * v1.x + 0.5 * v2.x + 0.25 * v3.x; y = 0.25 * v1.y + 0.5 * v2.y + 0.25 * v3.y; const b = new Vector2(x, y); x = 0.5 * v2.x + 0.5 * v3.x; y = 0.5 * v2.y + 0.5 * v3.y; const u = new Vector2(x, y); return [new CubicBezierCurve(v0, c, h, a), new CubicBezierCurve(a.clone(), b, u, v3)]; } } else if (curve instanceof LineCurve) { const { p1: v1, p2: v2 } = curve; const changedX = x !== void 0 && (v1.x - x) * (v2.x - x) < 0; const changedY = y !== void 0 && (v1.y - y) * (v2.y - y) < 0; if (changedX) { y = v1.y + (v2.y - v1.y) / (v2.x - v1.x) * (x - v1.x); } if (changedY) { x = v1.x + (v2.x - v1.x) / (v2.y - v1.y) * (y - v1.y); } if (changedX || changedY) { return [new LineCurve(v1, new Vector2(x, y)), new LineCurve(new Vector2(x, y), v2)]; } } return [curve]; } class Deformer { /** 见 DeformerOptions.autoWidth */ autoWidth = false; /** autoWidth 下缓存本次变形的「干净字形内容框」(首次访问=变形前算,之后 deform 会改写字形,须缓存) */ _contentBox; get boundingBox() { if (!this.autoWidth) { return this.text.lineBox; } if (!this._contentBox) { const boxes = this.characters.map((c) => c.glyphBox).filter(Boolean); const lb = this.text.lineBox; this._contentBox = boxes.length ? BoundingBox.from(...boxes) : new BoundingBox(lb.left, lb.top, lb.width, lb.height); } return this._contentBox; } get paragraphs() { return this.text.paragraphs; } get isHorizontal() { return this.text.computedStyle.writingMode.startsWith("horizontal"); } get baseWidth() { return this.isHorizontal ? this.boundingBox.width : this.boundingBox.height; } get baseHeight() { return this.isHorizontal ? this.boundingBox.height : this.boundingBox.width; } get characters() { return this.paragraphs.flatMap((p) => p.fragments.flatMap((f) => f.characters)); } constructor({ text, intensities = [], maxFontSize, autoWidth = false }) { this.text = text; this.autoWidth = autoWidth; this.intensities = intensities.map((val) => val / 100); this.lineHeight = this._maxFontSize() || maxFontSize || 100; } _maxFontSize() { let max = 0; this.characters.forEach((character) => { if (character.glyphBox) { max = Math.max(max, character.fontSize); } }); return max; } _breakLine() { const isVertical = !this.isHorizontal; const { left, top, bottom, right } = this.boundingBox; const x = 0.5 * (left + right); const y = 0.5 * (top + bottom); this.characters.forEach((character) => { if (!character.glyphBox) { return; } character.path.curves.forEach((subPath) => { subPath.curves = subPath.curves.flatMap((curve) => { return splitCurve(curve, isVertical ? { y } : { x }); }); }); }); } _makeTheJointSmooth() { this.characters.forEach((character) => { if (!character.glyphBox) { return; } character.path.getFlatCurves().forEach((curve) => { if (curve instanceof QuadraticBezierCurve && curve.isFromLine) { const { p1, cp, p2 } = curve; cp.x = 2 * cp.x - 0.5 * (p1.x + p2.x); cp.y = 2 * cp.y - 0.5 * (p1.y + p2.y); } }); }); } _lineToQuadraticBezier() { this.characters.forEach((character) => { if (!character.glyphBox) { return; } character.path.curves.forEach((subPath) => { subPath.curves = subPath.curves.map((curve) => { if (curve instanceof LineCurve && curve.getLength() > this.lineHeight * 0.05) { const { p1, p2 } = curve; const res = new QuadraticBezierCurve( p1.clone(), new Vector2(0.5 * (p1.x + p2.x), 0.5 * (p1.y + p2.y)), p2.clone() ); res.isFromLine = true; return res; } return curve; }); }); }); } _transform(transform, getArg1, getArg) { const highlight = this.text.plugins.get("highlight"); const arg = getArg?.(); let i = 0; const charactersLength = this.characters.filter((c) => c.glyphBox).length; this.paragraphs.forEach((paragraph, paragraphIndex) => { paragraph.fragments.forEach((fragment, fragmentIndex) => { fragment.characters.forEach((character, characterIndex) => { if (!character.glyphBox) { return; } const arg1 = getArg1?.( { paragraphIndex, fragmentIndex, characterIndex, character }, arg ); character.path.getControlPointRefs().forEach((point) => { const [x, y] = transform(point, arg1); point.set(x, y); }); if (getArg1 && highlight?.pathSet?.paths) { const step = highlight.pathSet.paths.length / charactersLength; const start = i * step; for (let _i = 0; _i < step; _i++) { highlight?.pathSet?.paths[start + _i]?.getControlPointRefs().forEach((point) => { const [x, y] = transform(point, arg1); point.set(x, y); }); } i++; } }); }); }); this.paragraphs.forEach((paragraph) => { paragraph.fragments.forEach((fragment) => { fragment.characters.forEach((character) => { character.glyphBox = character.getGlyphBoundingBox(); }); }); }); if (!getArg1) { highlight?.pathSet?.paths?.forEach((v) => { v.getControlPointRefs().forEach((point) => { const [x, y] = transform(point); point.set(x, y); }); }); } } } class BendDeformer extends Deformer { constructor(options, preset) { super(options); this.preset = preset; } deform() { if (!Math.hypot(...this.intensities)) { return; } this._lineToQuadraticBezier(); this._bend(); this._makeTheJointSmooth(); } _bend() { const { boundingBox, baseWidth, baseHeight, preset } = this; const lineHeight = this.lineHeight * 2 / Math.sin(this.intensities[0] * Math.PI * 0.5); const isVertical = preset.vertical === void 0 || preset.vertical === "auto" ? !this.isHorizontal : preset.vertical; const { left, top, width, height } = boundingBox; const size = (isVertical ? height : width) / 2 / lineHeight; const center = { x: left + width / 2, y: top + height / 2 }; let centerDistAngle; const centerDist = { x: center.x, y: center.y }; if (isVertical) { centerDistAngle = 0; centerDist.x -= baseHeight / 2 / Math.tan(size) + Math.sign(size) * width / 2; } else { centerDistAngle = 1.5 * Math.PI; centerDist.y += baseWidth / 2 / Math.tan(size) + Math.sign(size) * height / 2; } const method = preset.transform({ lineHeight, size, center, centerDist, centerDistAngle, width, height, isHorizontal: this.isHorizontal }); this._transform(method); } } class FfdDeformer extends Deformer { constructor(options, preset) { super(options); this.preset = preset; } deform() { if (!Math.hypot(...this.intensities)) { return; } const { preset } = this; if (preset.breakLine) { this._breakLine(); } if (preset.lineToQuad) { this._lineToQuadraticBezier(); } const [a = 0, b = 0] = this.intensities; const points = this._createFFDControlPoints(preset.hBlocks, preset.vBlocks); preset.build(points, { a, b, baseWidth: this.baseWidth, baseHeight: this.baseHeight, lineHeight: this.lineHeight, adjust: (point, dx, dy) => this._adjustControlPoints(point, { x: dx, y: dy }) }); if (preset.bezier) { this._calculateForBezierFFD(points, preset.hBlocks, preset.vBlocks); this._makeTheJointSmooth(); } else { this._calculateForLinearFFD(points, preset.hBlocks, preset.vBlocks); } } _calculateForFFD(cb, points, hBlocks, vBlocks) { const { left, top, right, width, height } = this.boundingBox; this._transform( this.isHorizontal ? (point) => { const xProgress = (point.x - left) / width; const yProgress = (point.y - top) / height; let [x, y] = [0, 0]; for (let h = 0; h < hBlocks + 2; h++) { const _h = cb(hBlocks, h, xProgress); for (let v = 0; v < vBlocks + 2; v++) { const _v = cb(vBlocks, v, yProgress); const p = points[h * (vBlocks + 2) + v]; x += _h * _v * p.x; y += _h * _v * p.y; } } return [x, y]; } : (point) => { const xProgress = (right - point.x) / width; const yProgress = (point.y - top) / height; let [x, y] = [0, 0]; for (let h = 0; h < hBlocks + 2; h++) { const _h = cb(hBlocks, h, yProgress); for (let v = 0; v < vBlocks + 2; v++) { const _v = cb(vBlocks, v, xProgress); const p = points[h * (vBlocks + 2) + v]; x += _h * _v * p.x; y += _h * _v * p.y; } } return [x, y]; } ); } _createFFDControlPoints(hBlocks, vBlocks) { const { left, top, right, width, height } = this.boundingBox; const points = []; if (this.isHorizontal) { const avgWidth = width / (hBlocks + 1); const avgHeight = height / (vBlocks + 1); for (let h = 0; h < hBlocks + 2; h++) { for (let v = 0; v < vBlocks + 2; v++) { points.push(new Vector2(left + h * avgWidth, top + v * avgHeight)); } } } else { const avgWidth = width / (vBlocks + 1); const avgHeight = height / (hBlocks + 1); for (let h = 0; h < hBlocks + 2; h++) { for (let v = 0; v < vBlocks + 2; v++) { points.push(new Vector2(right - v * avgWidth, top + h * avgHeight)); } } } return points; } _adjustControlPoints(point1, point2) { if (this.isHorizontal) { point1.x += point2.x; point1.y += point2.y; } else { point1.x -= point2.y; point1.y += point2.x; } } _factorialForFFD(val) { let result = 1; for (let i = 2; i <= val; i++) { result *= i; } return result; } _combineForFFD(total, current) { return this._factorialForFFD(total) / this._factorialForFFD(total - current) / this._factorialForFFD(current); } _calculateForBezierFFD(points, hBlocks, vBlocks) { this._calculateForFFD( (count, current, progress) => { return this._combineForFFD(count + 1, current) * (1 - progress) ** (count + 1 - current) * progress ** current; }, points, hBlocks, vBlocks ); } _linearBasis(count, current, progress) { const t = current < progress * count ? current + 1 - progress * count : progress * count - current + 1; return Math.max(0, t); } _calculateForLinearFFD(points, hBlocks, vBlocks) { this._calculateForFFD( (count, current, progress) => { return this._linearBasis(count + 1, current, progress); }, points, hBlocks, vBlocks ); } } class VerbatimDeformer extends Deformer { constructor(options, preset) { super(options); this.preset = preset; } _context() { return { intensities: this.intensities, baseWidth: this.baseWidth, lineHeight: this.lineHeight, isHorizontal: this.isHorizontal, boundingBox: this.boundingBox }; } deform() { if (!Math.hypot(...this.intensities)) { return; } const { preset } = this; const ctx = this._context(); if (preset.engine === "offset") { this._transform( preset.point, preset.perChar ? (info) => preset.perChar(ctx, info) : void 0 ); } else { const followTangent = preset.followTangent ?? true; const { funcPerChar, funcPerPoint } = this._getPerCharAndPointFunc(followTangent); this._transform(funcPerPoint, funcPerChar, () => { const { width, height, left, top } = this.boundingBox; return { width, height, left, top, curve: preset.makeCurve(ctx), isHorizontal: this.isHorizontal, needExpandAlongNormal: preset.expandAlongNormal ?? false }; }); } } _getPerCharAndPointFunc(rotate = false) { let funcPerChar; let funcPerPoint; if (rotate) { funcPerPoint = this._resetPointPos; funcPerChar = this._calculateNewCenter; } else { funcPerPoint = this._resetPointPosWithoutRotate; funcPerChar = this._calculateNewCenterWithoutRotate; } return { funcPerPoint, funcPerChar }; } _calculateNewCenter({ character }, arg) { const { width, height, left, top, curve, isHorizontal, needExpandAlongNormal = false } = arg; const { center, centerDiviation } = character; const pos = isHorizontal ? (center.x - left) / width : (center.y - top) / height; const a = isHorizontal ? new Vector2(0, center.y - top) : new Vector2(center.x - left, 0); if (needExpandAlongNormal) { const p = curve.getNormal(pos); a.x += p.x * -centerDiviation; a.y += p.y * -centerDiviation; } const newCenter = curve.getPointAt(pos).add(a); const tangent = curve.getTangent(pos); const cos = tangent.x; const sin = tangent.y; return { cos, sin, newCenter, center }; } _calculateNewCenterWithoutRotate({ character }, arg) { const { width, height, left, top, curve, isHorizontal } = arg; const { center } = character; const pos = isHorizontal ? (center.x - left) / width : (center.y - top) / height; const offsetPoint = isHorizontal ? new Vector2(0, center.y - top) : new Vector2(center.x - left, 0); return { newCenter: curve.getPointAt(pos).add(offsetPoint), center }; } _resetPointPos(point, arg) { const { cos = 1, sin = 0, center, newCenter } = arg; const dx = point.x - center.x; const dy = point.y - center.y; const x = newCenter.x + dx * cos - dy * sin; const y = newCenter.y + dx * sin + dy * cos; return [x, y]; } _resetPointPosWithoutRotate(point, arg) { const { center, newCenter } = arg; const x = point.x - center.x + newCenter.x; const y = point.y - center.y + newCenter.y; return [x, y]; } } const deformationPresets = /* @__PURE__ */ new Map(); function defineDeformation(name, preset) { deformationPresets.set(name, preset); } function removeDeformation(name) { deformationPresets.delete(name); } function getDeformationNames() { return [...deformationPresets.keys()]; } function deformationPlugin() { return definePlugin({ name: "deformation", updateOrder: 2, update: (text) => { const config = text.deformation; const type = config?.type; if (isNone(type) || !type) { return; } const preset = deformationPresets.get(type); if (!preset) { return; } const options = { text, intensities: config.intensities ?? preset.defaultIntensities, maxFontSize: config.maxFontSize, // 变形域用「文字自然内容框」而非元素框——变形恒按内容规范渲染、与元素选框解耦(框窄不再压重叠、 // 拖框不再拉伸变形,宿主可把选框自由贴合变形后视觉)。这是**所有变形**的默认行为: // 逐字形状(engine='curve':ellipse/triangle/heart…)排字位置 pos=(字心x-left)/width、形状半径 // extent=0.5*强度*width 都只吃 width,同样需要「自然内容宽」——之前误以为要近方框而对其关掉 // autoWidth,反而让它们走了被 fitBox 收小的元素框 → 字符 pos>1 排到形状外挤成一坨。故一律开启。 // 注意:不走 config.autoWidth——该字段不在 NormalizedText schema 会被 modern-idoc 剥离,传不到这里。 autoWidth: true }; let deformer; switch (preset.engine) { case "ffd": deformer = new FfdDeformer(options, preset); break; case "bend": deformer = new BendDeformer(options, preset); break; case "curve": case "offset": deformer = new VerbatimDeformer(options, preset); break; } text.forEachCharacter((character) => character.update(text.fonts)); deformer.deform(); let box = text.getGlyphBox(); const dx = box.left; const dy = box.top; if (dx || dy) { const highlight = text.plugins.get("highlight"); for (const character of text.characters) { if (!character.glyphBox) { continue; } character.path.getControlPointRefs().forEach((point) => { point.set(point.x - dx, point.y - dy); }); character.glyphBox = character.getGlyphBoundingBox(); } highlight?.pathSet?.paths?.forEach((path) => { path.getControlPointRefs().forEach((point) => { point.set(point.x - dx, point.y - dy); }); }); box = text.getGlyphBox(); } text.rawGlyphBox = box; text.glyphBox = box; text.lineBox = box; } }); } export { definePlugin as a, deformationPlugin as b, defineDeformation as d, getDeformationNames as g, removeDeformation as r };