modern-text
Version:
Measure and render text in a way that describes the DOM.
604 lines (594 loc) • 20.2 kB
JavaScript
'use strict';
const modernIdoc = require('modern-idoc');
const modernPath2d = require('modern-path2d');
function definePlugin(options) {
return options;
}
function splitCurve(curve, point) {
let { x, y } = point;
if (curve instanceof modernPath2d.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 = modernPath2d.Vector2.lerp(v0, v1, u);
const d = modernPath2d.Vector2.lerp(v1, v2, u);
const B = modernPath2d.Vector2.lerp(r, d, u);
return [new modernPath2d.QuadraticBezierCurve(v0, r, B), new modernPath2d.QuadraticBezierCurve(B.clone(), d, v2)];
}
}
}
} else if (curve instanceof modernPath2d.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 modernPath2d.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 modernPath2d.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 modernPath2d.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 modernPath2d.Vector2(x, y);
x = 0.5 * v2.x + 0.5 * v3.x;
y = 0.5 * v2.y + 0.5 * v3.y;
const u = new modernPath2d.Vector2(x, y);
return [new modernPath2d.CubicBezierCurve(v0, c, h, a), new modernPath2d.CubicBezierCurve(a.clone(), b, u, v3)];
}
} else if (curve instanceof modernPath2d.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 modernPath2d.LineCurve(v1, new modernPath2d.Vector2(x, y)), new modernPath2d.LineCurve(new modernPath2d.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 ? modernPath2d.BoundingBox.from(...boxes) : new modernPath2d.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 modernPath2d.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 modernPath2d.LineCurve && curve.getLength() > this.lineHeight * 0.05) {
const { p1, p2 } = curve;
const res = new modernPath2d.QuadraticBezierCurve(
p1.clone(),
new modernPath2d.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 modernPath2d.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 modernPath2d.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 modernPath2d.Vector2(0, center.y - top) : new modernPath2d.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 modernPath2d.Vector2(0, center.y - top) : new modernPath2d.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 (modernIdoc.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;
}
});
}
exports.defineDeformation = defineDeformation;
exports.definePlugin = definePlugin;
exports.deformationPlugin = deformationPlugin;
exports.getDeformationNames = getDeformationNames;
exports.removeDeformation = removeDeformation;