modern-text
Version:
Measure and render text in a way that describes the DOM.
1,542 lines (1,522 loc) • 87 kB
JavaScript
'use strict';
const modernPath2d = require('modern-path2d');
const modernIdoc = require('modern-idoc');
const modernFont = require('modern-font');
const diff = require('diff');
function parseColor(ctx, source, box) {
if (typeof source === "string" && source.startsWith("linear-gradient")) {
const { x0, y0, x1, y1, stops } = parseCssLinearGradient(source, box.left, box.top, box.width, box.height);
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
stops.forEach((stop) => gradient.addColorStop(stop.offset, stop.color));
return gradient;
}
return source;
}
function uploadColor(style, box, ctx) {
if (style?.color) {
style.color = parseColor(ctx, style.color, box);
}
if (style?.backgroundColor) {
style.backgroundColor = parseColor(ctx, style.backgroundColor, box);
}
if (style?.textStrokeColor) {
style.textStrokeColor = parseColor(ctx, style.textStrokeColor, box);
}
}
function parseCssLinearGradient(css, x, y, width, height) {
const str = css.match(/linear-gradient\((.+)\)$/)?.[1] ?? "";
const first = str.split(",")[0];
const cssDeg = first.includes("deg") ? first : "0deg";
const matched = str.replace(cssDeg, "").matchAll(/(#|rgba|rgb)(.+?) ([\d.]+%)/gi);
const deg = Number(cssDeg.replace("deg", "")) || 0;
const rad = deg * Math.PI / 180;
const offsetX = width * Math.sin(rad);
const offsetY = height * Math.cos(rad);
return {
x0: x + width / 2 - offsetX,
y0: y + height / 2 + offsetY,
x1: x + width / 2 + offsetX,
y1: y + height / 2 - offsetY,
stops: Array.from(matched).map((res) => {
let color = res[2];
if (color.startsWith("(")) {
color = color.split(",").length > 3 ? `rgba${color}` : `rgb${color}`;
} else {
color = `#${color}`;
}
return {
offset: Number(res[3].replace("%", "")) / 100,
color
};
})
};
}
function drawPath(options) {
const { ctx, path, fontSize, clipRect } = options;
ctx.save();
ctx.beginPath();
const pathStyle = path.style;
const style = {
...pathStyle,
fill: options.color ?? pathStyle.fill,
stroke: options.textStrokeColor ?? pathStyle.stroke,
strokeWidth: options.textStrokeWidth ? options.textStrokeWidth * fontSize : pathStyle.strokeWidth,
strokeLinecap: "round",
strokeLinejoin: "round",
shadowOffsetX: (options.shadowOffsetX ?? 0) * fontSize,
shadowOffsetY: (options.shadowOffsetY ?? 0) * fontSize,
shadowBlur: (options.shadowBlur ?? 0) * fontSize,
shadowColor: options.shadowColor
};
if (clipRect) {
ctx.rect(clipRect.left, clipRect.top, clipRect.width, clipRect.height);
ctx.clip();
ctx.beginPath();
}
path.drawTo(ctx, style);
ctx.restore();
}
function setupView(ctx, pixelRatio, boundingBox) {
const { left, top, width, height } = boundingBox;
const view = ctx.canvas;
view.dataset.viewBox = String(`${left} ${top} ${width} ${height}`);
view.dataset.pixelRatio = String(pixelRatio);
const canvasWidth = width;
const canvasHeight = height;
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);
}
function uploadColors(ctx, text) {
const { paragraphs, computedStyle: style, glyphBox } = text;
uploadColor(style, glyphBox, ctx);
paragraphs.forEach((paragraph) => {
uploadColor(paragraph.computedStyle, paragraph.lineBox, ctx);
paragraph.fragments.forEach((fragment) => {
uploadColor(fragment.computedStyle, fragment.inlineBox, ctx);
});
});
}
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
};
class Character {
constructor(content, index, parent) {
this.content = content;
this.index = index;
this.parent = parent;
}
path = new modernPath2d.Path2D();
lineBox = new modernPath2d.BoundingBox();
inlineBox = new modernPath2d.BoundingBox();
glyphBox;
advanceWidth = 0;
advanceHeight = 0;
underlinePosition = 0;
underlineThickness = 0;
strikeoutPosition = 0;
strikeoutSize = 0;
ascender = 0;
descender = 0;
typoAscender = 0;
typoDescender = 0;
typoLineGap = 0;
winAscent = 0;
winDescent = 0;
xHeight = 0;
capHeight = 0;
baseline = 0;
centerDiviation = 0;
fontStyle;
get compatibleGlyphBox() {
const size = this.computedStyle.fontSize * 0.8;
return this.glyphBox ?? (this.isVertical ? new modernPath2d.BoundingBox(
this.lineBox.left + this.lineBox.width / 2 - size / 2,
this.lineBox.top,
size,
this.lineBox.height
) : new modernPath2d.BoundingBox(
this.lineBox.left,
this.lineBox.top + this.lineBox.height / 2 - size / 2,
this.lineBox.width,
size
));
}
get center() {
return this.compatibleGlyphBox.center;
}
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) {
const fontFamily = this.computedStyle.fontFamily;
const _fonts = fonts ?? modernFont.fonts;
const font = fontFamily ? _fonts.get(fontFamily) : _fonts.fallbackFont;
return font?.getSFNT();
}
updateGlyph(sfnt = this._getFontSFNT()) {
if (!sfnt) {
return this;
}
const { hhea, os2, post, head } = sfnt;
const unitsPerEm = head.unitsPerEm;
const ascender = hhea.ascent;
const descender = hhea.descent;
const { content, computedStyle, isVertical } = this;
const { fontSize } = computedStyle;
const rate = unitsPerEm / fontSize;
const advanceWidth = sfnt.getAdvanceWidth(content, fontSize);
const advanceHeight = (ascender + Math.abs(descender)) / rate;
const baseline = ascender / rate;
this.advanceWidth = advanceWidth;
this.advanceHeight = advanceHeight;
this.inlineBox.width = isVertical ? advanceHeight : advanceWidth;
this.inlineBox.height = isVertical ? advanceWidth : advanceHeight;
this.underlinePosition = (ascender - post.underlinePosition) / rate;
this.underlineThickness = post.underlineThickness / rate;
this.strikeoutPosition = (ascender - os2.yStrikeoutPosition) / rate;
this.strikeoutSize = os2.yStrikeoutSize / rate;
this.ascender = ascender / rate;
this.descender = descender / rate;
this.typoAscender = os2.sTypoAscender / rate;
this.typoDescender = os2.sTypoDescender / rate;
this.typoLineGap = os2.sTypoLineGap / rate;
this.winAscent = os2.usWinAscent / rate;
this.winDescent = os2.usWinDescent / rate;
this.xHeight = os2.sxHeight / rate;
this.capHeight = os2.sCapHeight / rate;
this.baseline = baseline;
this.centerDiviation = advanceHeight / 2 - baseline;
this.fontStyle = fsSelectionMap[os2.fsSelection] ?? macStyleMap[head.macStyle];
return this;
}
update(fonts) {
const sfnt = this._getFontSFNT(fonts);
if (!sfnt) {
return this;
}
this.updateGlyph(sfnt);
const {
isVertical,
content,
computedStyle: style,
baseline,
inlineBox,
ascender,
descender,
typoAscender,
fontStyle,
advanceWidth,
advanceHeight
} = this;
const { left, top } = inlineBox;
const needsItalic = style.fontStyle === "italic" && fontStyle !== "italic";
let x = left;
let y = top + baseline;
let glyphIndex;
const path = new modernPath2d.Path2D();
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,
isVertical ? {
x: point.x,
y: top - (advanceHeight - advanceWidth) / 2 + baseline
} : void 0
);
}
path.rotate(90, 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 = {
fill: style.color,
stroke: style.textStrokeWidth ? style.textStrokeColor : "none",
strokeWidth: style.textStrokeWidth ? style.textStrokeWidth * style.fontSize * 0.03 : 0
};
this.path = path;
this.glyphBox = this.getGlyphBoundingBox();
return this;
}
_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.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 modernPath2d.BoundingBox(min.x, min.y, max.x - min.x, max.y - min.y);
}
drawTo(ctx, config = {}) {
const style = this.computedStyle;
const options = {
ctx,
path: this.path,
fontSize: style.fontSize,
color: style.color,
...config
};
if (this.glyphBox) {
drawPath(options);
} else {
ctx.save();
ctx.beginPath();
const pathStyle = this.path.style;
const _style = {
...pathStyle,
fill: options.color ?? pathStyle.fill,
stroke: options.textStrokeColor ?? pathStyle.stroke,
strokeWidth: options.textStrokeWidth ? options.textStrokeWidth * options.fontSize : pathStyle.strokeWidth,
shadowOffsetX: (options.shadowOffsetX ?? 0) * options.fontSize,
shadowOffsetY: (options.shadowOffsetY ?? 0) * options.fontSize,
shadowBlur: (options.shadowBlur ?? 0) * options.fontSize,
shadowColor: options.shadowColor
};
modernPath2d.setCanvasContext(ctx, _style);
ctx.font = `${options.fontSize}px ${options.fontFamily}`;
if (this.isVertical) {
ctx.textBaseline = "middle";
ctx.fillText(this.content, this.inlineBox.left, this.inlineBox.top + this.inlineBox.height / 2);
} else {
ctx.textBaseline = "alphabetic";
ctx.fillText(this.content, this.inlineBox.left, this.inlineBox.top + this.baseline);
}
ctx.restore();
}
}
}
function createSVGLoader() {
const loaded = /* @__PURE__ */ new Map();
async function load(svg) {
if (!loaded.has(svg)) {
loaded.set(svg, 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("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 dom = modernPath2d.svgToDOM(
loader.needsLoad(svg) ? loader.loaded.get(svg) ?? svg : svg
);
const pathSet = modernPath2d.svgToPath2DSet(dom);
result = { dom, pathSet };
parsed.set(svg, result);
}
return result;
}
return {
parsed,
parse
};
}
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 modernIdoc.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 === "object") {
return isEqualObject(val1, val2);
}
return val1 === val2;
}
return false;
}
function hexToRgb(hex) {
const cleanHex = hex.startsWith("#") ? hex.slice(1) : hex;
const isValidHex = /^(?:[0-9A-F]{3}|[0-9A-F]{6})$/i.test(cleanHex);
if (!isValidHex)
return null;
const fullHex = cleanHex.length === 3 ? cleanHex.split("").map((char) => char + char).join("") : cleanHex;
const r = Number.parseInt(fullHex.slice(0, 2), 16);
const g = Number.parseInt(fullHex.slice(2, 4), 16);
const b = Number.parseInt(fullHex.slice(4, 6), 16);
return `rgb(${r}, ${g}, ${b})`;
}
function filterEmpty(val) {
if (!val)
return val;
const res = {};
for (const key in val) {
if (val[key] !== "" && val[key] !== void 0) {
res[key] = val[key];
}
}
return res;
}
class Fragment {
constructor(content, style = {}, parent) {
this.content = content;
this.style = style;
this.parent = parent;
this.updateComputedStyle().initCharacters();
}
inlineBox = new modernPath2d.BoundingBox();
get computedContent() {
const style = this.computedStyle;
return style.textTransform === "uppercase" ? this.content.toUpperCase() : style.textTransform === "lowercase" ? this.content.toLowerCase() : this.content;
}
updateComputedStyle() {
this.computedStyle = {
...this.parent.computedStyle,
...filterEmpty(this.style)
};
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, parentStyle) {
this.style = style;
this.parentStyle = parentStyle;
this.updateComputedStyle();
}
lineBox = new modernPath2d.BoundingBox();
fragments = [];
updateComputedStyle() {
this.computedStyle = {
...filterEmpty(this.parentStyle),
...filterEmpty(this.style)
};
return this;
}
addFragment(content, style) {
const fragment = new Fragment(content, style, this);
this.fragments.push(fragment);
return fragment;
}
}
function definePlugin(options) {
return options;
}
class Measurer {
static notZeroStyles = /* @__PURE__ */ new Set([
"width",
"height"
]);
static pxStyles = /* @__PURE__ */ new Set([
"width",
"height",
"fontSize",
"letterSpacing",
"textStrokeWidth",
"textIndent",
"shadowOffsetX",
"shadowOffsetY",
"shadowBlur",
"margin",
"marginLeft",
"marginTop",
"marginRight",
"marginBottom",
"padding",
"paddingLeft",
"paddingTop",
"paddingRight",
"paddingBottom"
]);
_toDOMStyle(style) {
const _style = {};
for (const key in style) {
const value = style[key];
if (Measurer.notZeroStyles.has(key) && value === 0) ; else if (typeof value === "number" && Measurer.pxStyles.has(key)) {
_style[key] = `${value}px`;
} else {
_style[key] = value;
}
}
return _style;
}
/**
* <section style="...">
* <ul>
* <li style="...">
* <span style="...">...</span>
* <span>...</span>
* </li>
* </ul>
* </section>
*/
createDOM(paragraphs, rootStyle) {
const dom = document.createElement("section");
const style = { ...rootStyle };
const isHorizontal = rootStyle.writingMode.includes("horizontal");
switch (rootStyle.textAlign) {
case "start":
case "left":
style.justifyContent = "flex-start";
break;
case "center":
style.justifyContent = "center";
break;
case "end":
case "right":
style.justifyContent = "flex-end";
break;
}
switch (rootStyle.verticalAlign) {
case "top":
style.alignItems = "flex-start";
break;
case "middle":
style.alignItems = "center";
break;
case "bottom":
style.alignItems = "flex-end";
break;
}
const isFlex = Boolean(style.justifyContent || style.alignItems);
Object.assign(dom.style, {
...this._toDOMStyle({
...style,
boxSizing: style.boxSizing ?? "border-box",
display: style.display ?? (isFlex ? "inline-flex" : void 0),
width: style.width ?? "max-content",
height: style.height ?? "max-content"
}),
whiteSpace: "pre-wrap",
wordBreak: "break-all"
});
const ul = document.createElement("ul");
Object.assign(ul.style, {
verticalAlign: "inherit",
listStyleType: "inherit",
padding: "0",
margin: "0",
width: isFlex && isHorizontal ? "100%" : void 0,
height: isFlex && !isHorizontal ? "100%" : void 0
});
paragraphs.forEach((paragraph) => {
const li = document.createElement("li");
Object.assign(li.style, {
verticalAlign: "inherit",
...this._toDOMStyle(paragraph.style)
});
paragraph.fragments.forEach((fragment) => {
const span = document.createElement("span");
Object.assign(span.style, {
verticalAlign: "inherit",
...this._toDOMStyle(fragment.style)
});
span.appendChild(document.createTextNode(fragment.content));
li.appendChild(span);
});
ul.appendChild(li);
});
dom.appendChild(ul);
return dom;
}
measureDOMText(text) {
const range = document.createRange();
range.selectNodeContents(text);
const data = text.data ?? "";
let offset = 0;
return Array.from(data).map((char) => {
const start = offset += data.substring(offset).indexOf(char);
const end = start + char.length;
offset += char.length;
range.setStart(text, Math.max(start, 0));
range.setEnd(text, end);
const rects = range.getClientRects?.() ?? [range.getBoundingClientRect()];
let rect = rects[rects.length - 1];
if (rects.length > 1 && rect.width < 2) {
rect = rects[rects.length - 2];
}
const content = range.toString();
if (content !== "" && rect && rect.width + rect.height !== 0) {
return {
content,
top: rect.top,
left: rect.left,
height: rect.height,
width: rect.width
};
}
return void 0;
}).filter(Boolean);
}
measureDOM(dom) {
const paragraphs = [];
const fragments = [];
const characters = [];
dom.querySelectorAll("li").forEach((pDOM, paragraphIndex) => {
const pBox = pDOM.getBoundingClientRect();
paragraphs.push({
paragraphIndex,
left: pBox.left,
top: pBox.top,
width: pBox.width,
height: pBox.height
});
pDOM.querySelectorAll(":scope > *").forEach((fDOM, fragmentIndex) => {
const fBox = fDOM.getBoundingClientRect();
fragments.push({
paragraphIndex,
fragmentIndex,
left: fBox.left,
top: fBox.top,
width: fBox.width,
height: fBox.height
});
let characterIndex = 0;
if (!fDOM.children.length && fDOM.firstChild instanceof window.Text) {
this.measureDOMText(fDOM.firstChild).forEach((char) => {
characters.push({
...char,
newParagraphIndex: -1,
paragraphIndex,
fragmentIndex,
characterIndex: characterIndex++,
textWidth: -1,
textHeight: -1
});
});
} else {
fDOM.querySelectorAll(":scope > *").forEach((cDOM) => {
if (cDOM.firstChild instanceof window.Text) {
this.measureDOMText(cDOM.firstChild).forEach((char) => {
characters.push({
...char,
newParagraphIndex: -1,
paragraphIndex,
fragmentIndex,
characterIndex: characterIndex++,
textWidth: -1,
textHeight: -1
});
});
}
});
}
});
});
return {
paragraphs,
fragments,
characters
};
}
measureParagraphDOM(paragraphs, dom) {
const rect = dom.getBoundingClientRect();
const measured = this.measureDOM(dom);
measured.paragraphs.forEach((p) => {
const _p = paragraphs[p.paragraphIndex];
_p.lineBox.left = p.left - rect.left;
_p.lineBox.top = p.top - rect.top;
_p.lineBox.width = p.width;
_p.lineBox.height = p.height;
});
measured.fragments.forEach((f) => {
const _f = paragraphs[f.paragraphIndex].fragments[f.fragmentIndex];
_f.inlineBox.left = f.left - rect.left;
_f.inlineBox.top = f.top - rect.top;
_f.inlineBox.width = f.width;
_f.inlineBox.height = f.height;
});
const results = [];
let i = 0;
measured.characters.forEach((character) => {
const { paragraphIndex, fragmentIndex, characterIndex } = character;
results.push({
...character,
newParagraphIndex: paragraphIndex,
left: character.left - rect.left,
top: character.top - rect.top
});
const item = paragraphs[paragraphIndex].fragments[fragmentIndex].characters[characterIndex];
const { fontHeight, isVertical } = item;
const result = results[i];
item.inlineBox.left = result.left;
item.inlineBox.top = result.top;
item.inlineBox.width = result.width;
item.inlineBox.height = result.height;
if (isVertical) {
item.lineBox.left = result.left + (result.width - fontHeight) / 2;
item.lineBox.top = result.top;
item.lineBox.width = fontHeight;
item.lineBox.height = result.height;
} else {
item.lineBox.left = result.left;
item.lineBox.top = result.top + (result.height - fontHeight) / 2;
item.lineBox.width = result.width;
item.lineBox.height = fontHeight;
}
i++;
});
return {
paragraphs,
boundingBox: new modernPath2d.BoundingBox(0, 0, rect.width, rect.height)
};
}
measure(paragraphs, rootStyle, dom) {
let destory;
if (!dom) {
dom = this.createDOM(paragraphs, rootStyle);
Object.assign(dom.style, {
position: "fixed",
visibility: "hidden"
});
document.body.appendChild(dom);
destory = () => dom?.parentNode?.removeChild(dom);
}
const result = this.measureParagraphDOM(paragraphs, dom);
destory?.();
return result;
}
}
class EventEmitter {
eventListeners = /* @__PURE__ */ new Map();
addEventListener(event, listener, options) {
const object = { value: listener, options };
const listeners = this.eventListeners.get(event);
if (!listeners) {
this.eventListeners.set(event, object);
} else if (Array.isArray(listeners)) {
listeners.push(object);
} else {
this.eventListeners.set(event, [listeners, object]);
}
return this;
}
removeEventListener(event, listener, options) {
if (!listener) {
this.eventListeners.delete(event);
return this;
}
const listeners = this.eventListeners.get(event);
if (!listeners) {
return this;
}
if (Array.isArray(listeners)) {
const events = [];
for (let i = 0, length = listeners.length; i < length; i++) {
const object = listeners[i];
if (object.value !== listener || typeof options === "object" && options?.once && (typeof object.options === "boolean" || !object.options?.once)) {
events.push(object);
}
}
if (events.length) {
this.eventListeners.set(event, events.length === 1 ? events[0] : events);
} else {
this.eventListeners.delete(event);
}
} else {
if (listeners.value === listener && (typeof options === "boolean" || !options?.once || (typeof listeners.options === "boolean" || listeners.options?.once))) {
this.eventListeners.delete(event);
}
}
return this;
}
removeAllListeners() {
this.eventListeners.clear();
return this;
}
hasEventListener(event) {
return this.eventListeners.has(event);
}
dispatchEvent(event, arg) {
const listeners = this.eventListeners.get(event);
if (listeners) {
if (Array.isArray(listeners)) {
for (let len = listeners.length, i = 0; i < len; i++) {
const object = listeners[i];
if (typeof object.options === "object" && object.options?.once) {
this.off(event, object.value, object.options);
}
object.value.apply(this, [arg]);
}
} else {
if (typeof listeners.options === "object" && listeners.options?.once) {
this.off(event, listeners.value, listeners.options);
}
listeners.value.apply(this, [arg]);
}
return true;
} else {
return false;
}
}
on(event, listener, options) {
return this.addEventListener(event, listener, options);
}
once(event, listener) {
return this.addEventListener(event, listener, { once: true });
}
off(event, listener, options) {
return this.removeEventListener(event, listener, options);
}
emit(event, arg) {
this.dispatchEvent(event, arg);
}
}
function background() {
const pathSet = new modernPath2d.Path2DSet();
const loader = createSVGLoader();
const parser = createSVGParser(loader);
return {
name: "background",
pathSet,
load: async (text) => {
const { backgroundImage } = text.style;
if (backgroundImage && loader.needsLoad(backgroundImage)) {
await loader.load(backgroundImage);
}
},
update: (text) => {
pathSet.paths.length = 0;
const { style, lineBox, isVertical } = text;
const {
backgroundImage,
backgroundSize,
backgroundColormap
} = style;
if (modernIdoc.isNone(backgroundImage))
return;
const { pathSet: imagePathSet } = parser.parse(backgroundImage);
const imageBox = imagePathSet.getBoundingBox(true);
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 = Math.max(text.fontSize * 5 / imageBox.width);
scaleY = scaleX;
const dist = new modernPath2d.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 modernPath2d.Matrix3();
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((p) => {
p.applyMatrix3(transform);
});
});
pathSet.paths.push(...paths);
},
renderOrder: -2,
render: (ctx, text) => {
const { boundingBox, computedStyle: style } = text;
if (!modernIdoc.isNone(style.backgroundColor)) {
ctx.fillStyle = style.backgroundColor;
ctx.fillRect(...boundingBox.array);
}
pathSet.paths.forEach((path) => {
drawPath({ ctx, path, fontSize: style.fontSize });
if (text.debug) {
const box = new modernPath2d.Path2DSet([path]).getBoundingBox();
if (box) {
ctx.strokeRect(box.x, box.y, box.width, box.height);
}
}
});
text.paragraphs.forEach((p) => {
const { lineBox, style: style2 } = p;
if (!modernIdoc.isNone(style2.backgroundColor)) {
ctx.fillStyle = style2.backgroundColor;
ctx.fillRect(...lineBox.array);
}
p.fragments.forEach((f) => {
const { inlineBox, style: style3 } = f;
if (!modernIdoc.isNone(style3.backgroundColor)) {
ctx.fillStyle = style3.backgroundColor;
ctx.fillRect(...inlineBox.array);
}
});
});
}
};
}
function getHighlightStyle(style) {
const {
highlight: highlight2,
highlightImage,
highlightReferImage,
highlightColormap,
highlightLine,
highlightSize,
highlightThickness
} = style;
return {
image: highlight2?.image ?? highlightImage ?? "none",
referImage: highlight2?.referImage ?? highlightReferImage ?? "none",
colormap: highlight2?.colormap ?? highlightColormap ?? "none",
line: highlight2?.line ?? highlightLine ?? "none",
size: highlight2?.size ?? highlightSize ?? "cover",
thickness: highlight2?.thickness ?? highlightThickness ?? "100%"
};
}
function highlight() {
const pathSet = new modernPath2d.Path2DSet();
const clipRects = [];
const loader = createSVGLoader();
const parser = createSVGParser(loader);
return definePlugin({
name: "highlight",
pathSet,
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 highlight2 = getHighlightStyle(style);
const {
image,
colormap,
line,
size,
thickness
} = highlight2;
if (!modernIdoc.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 = highlight2;
});
groups = groups.filter((characters) => characters.length);
for (let i = 0; i < groups.length; i++) {
const characters = groups[i];
const char = characters[0];
const groupBox = modernPath2d.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);
const styleScale = fontSize / imageBox.width * 2;
const targetBox = new modernPath2d.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 = !modernIdoc.isNone(referImage) && modernIdoc.isNone(line);
if (hasReferImage) {
imageBox.copy(
parser.parse(referImage).pathSet.getBoundingBox(true)
);
} else {
let _line;
if (modernIdoc.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 * 0.2;
const paddingY = targetBox.height * 0.2;
if (isVertical) {
targetBox.x -= paddingY / 2;
targetBox.y -= paddingX / 2;
targetBox.x -= targetBox.height;
} else {
targetBox.x -= paddingX / 2;
targetBox.y -= paddingY / 2;
}
targetBox.width += paddingX;
targetBox.height += paddingY;
break;
}
case "overline":
targetBox.height = imageBox.height * styleScale;
if (isVertical) {
targetBox.x = inlineBox.left + inlineBox.width;
} else {
targetBox.y = inlineBox.top;
}
break;
case "line-through":
targetBox.height = imageBox.height * styleScale;
if (isVertical) {
targetBox.x = inlineBox.left + inlineBox.width - strikeoutPosition + targetBox.height / 2;
} else {
targetBox.y = inlineBox.top + strikeoutPosition - targetBox.height / 2;
}
break;
case "underline":
targetBox.height = imageBox.height * styleScale;
if (isVertical) {
targetBox.x = glyphBox.left + glyphBox.width - underlinePosition;
} else {
targetBox.y = inlineBox.top + underlinePosition;
}
break;
}
}
const transform = new modernPath2d.Matrix3();
transform.translate(-imageBox.x, -imageBox.y);
transform.scale(targetBox.width / imageBox.width, targetBox.height / imageBox.height);
if (isVertical) {
const tx = targetBox.width / 2;
const ty = targetBox.height / 2;
if (!hasReferImage) {
transform.translate(-tx, -ty);
}
transform.rotate(-Math.PI / 2);
if (!hasReferImage) {
transform.translate(ty, tx);
}
}
transform.translate(targetBox.x, targetBox.y);
for (let i2 = 0; i2 < Math.ceil(rawWidth / userWidth); i2++) {
const _transform = transform.clone();
if (isVertical) {
_transform.translate(0, i2 * targetBox.width);
} else {
_transform.translate(i2 * targetBox.width, 0);
}
imagePathSet.paths.forEach((originalPath) => {
const path = originalPath.clone().applyTransform(_transform);
if (path.style.strokeWidth)
path.style.strokeWidth *= styleScale * _thickness;
if (path.style.strokeMiterlimit)
path.style.strokeMiterlimit *= styleScale;
if (path.style.strokeDashoffset)
path.style.strokeDashoffset *= styleScale;
if (path.style.strokeDasharray)
path.style.strokeDasharray = path.style.strokeDasharray.map((v) => v * styleScale);
if (path.style.fill && path.style.fill in _colormap) {
path.style.fill = _colormap[path.style.fill];
}
if (path.style.stroke && path.style.stroke in _colormap) {
path.style.stroke = _colormap[path.style.stroke];
}
pathSet.paths.push(path);
if (rawWidth !== userWidth) {
if (isVertical) {
clipRects[pathSet.paths.length - 1] = new modernPath2d.BoundingBox(
groupBox.left - groupBox.width * 2,
groupBox.top,
groupBox.width * 4,
groupBox.height
);
} else {
clipRects[pathSet.paths.length - 1] = new modernPath2d.BoundingBox(
groupBox.left,
groupBox.top - groupBox.height * 2,
groupBox.width,
groupBox.height * 4
);
}
}
});
}
}
},
renderOrder: -1,
getBoundingBox: () => {
const boundingBoxs = [];
pathSet.paths.forEach((path, index) => {
const clipRect = clipRects[index];
let box = path.getBoundingBox();
if (clipRect) {
const x = Math.max(box.x, clipRect.x);
const y = Math.max(box.y, clipRect.y);
const right = Math.min(box.right, clipRect.right);
const bottom = Math.min(box.bottom, clipRect.bottom);
box = new modernPath2d.BoundingBox(x, y, right - x, bottom - y);
}
boundingBoxs.push(box);
});
return modernPath2d.BoundingBox.from(...boundingBoxs);
},
render: (ctx, text) => {
pathSet.paths.forEach((path, index) => {
drawPath({
ctx,
path,
fontSize: text.computedStyle.fontSize,
clipRect: clipRects[index]
});
if (text.debug) {
const box = new modernPath2d.Path2DSet([path]).getBoundingBox();
if (box) {
ctx.strokeRect(box.x, box.y, box.width, box.height);
}
}
});
}
});
}
function genDisc(r, color) {
return `<svg width="${r * 2}" height="${r * 2}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${r}" cy="${r}" r="${r}" fill="${color}" />
</svg>`;
}
function listStyle() {
const pathSet = new modernPath2d.Path2DSet();
return definePlugin({
name: "listStyle",
pathSet,
update: (text) => {
pathSet.paths.length = 0;
const { paragraphs, isVertical, fontSize } = text;
const padding = fontSize * 0.45;
paragraphs.forEach((paragraph) => {
const {
computedStyle: style
} = paragraph;
const {
color,
listStyleImage,
listStyleColormap,
listStyleSize,
listStyleType
} = style;
const colormap = parseColormap(listStyleColormap);
let size = listStyleSize;
let image;
if (!modernIdoc.isNone(listStyleImage)) {
image = listStyleImage;
} else if (!modernIdoc.isNone(listStyleType)) {
const r = fontSize * 0.38 / 2;
size = size === "cover" ? r * 2 : size;
switch (listStyleType) {
case "disc":
image = genDisc(r, String(color));
break;
}
}
if (!image) {
return;
}
const imagePathSet = modernPath2d.svgToPath2DSet(image);
const imageBox = imagePathSet.getBoundingBox();
const char = paragraph.fragments[0]?.characters[0];
if (!char) {
return;
}
const { inlineBox } = char;
const scale = size === "cover" ? 1 : parseValueNumber(size, { total: fontSize, fontSize }) / fontSize;
const m = new modernPath2d.Matrix3();
if (isVertical) {
const _scale = fontSize / imageBox.height * scale;
m.translate(-imageBox.left, -imageBox.top).rotate(Math.PI / 2).scale(_scale, _scale).translate(
inlineBox.left + (inlineBox.width - imageBox.height * _scale) / 2,
inlineBox.top - padding
);
} else {
const _scale = fontSize / imageBox.height * scale;
m.translate(-imageBox.left, -imageBox.top).scale(_scale, _scale).translate(
inlineBox.left - imageBox.width * _scale - padding,
inlineBox.top + (inlineBox.height - imageBox.height * _scale) / 2
);
}
pathSet.paths.push(...imagePathSet.paths.map((p) => {
const path = p.clone();
path.applyTransform(m);
if (path.style.fill && path.style.fill in colormap) {
path.style.fill = colormap[path.style.fill];
}
if (path.style.stroke && path.style.stroke in colormap) {
path.style.stroke = colormap[path.style.stroke];
}
return path;
}));
});
}
});
}
function outline() {
return {
name: "outline"
// TODO
};
}
const tempV1 = new modernPath2d.Vector2();
const tempM1 = new modernPath2d.Matrix3();
const tempM2 = new modernPath2d.Matrix3();
function render() {
return definePlugin({
name: "render",
getBoundingBox: (text) => {
const { characters, fontSize, effects } = text;
const boxes = [];
characters.forEach((character) => {
effects?.forEach((style) => {
if (!character.glyphBox) {
return;
}
const aabb = character.glyphBox.clone();
const m = getTransform2D(text, style);
tempV1.set(aabb.left, aabb.top);
tempV1.applyMatrix3(m);
aabb.left = tempV1.x;
aabb.top = tempV1.y;
tempV1.set(aabb.right, aabb.bottom);
tempV1.applyMatrix3(m);
aabb.width = tempV1.x - aabb.left;
aabb.height = tempV1.y - aabb.top;
const shadowOffsetX = (style.shadowOffsetX ?? 0) * fontSize;
const shadowOffsetY = (style.shadowOffsetY ?? 0) * fontSize;
const textStrokeWidth = Math.max(0.1, style.textStrokeWidth ?? 0) * fontSize;
aabb.left += shadowOffsetX - textStrokeWidth;
aabb.top += shadowOffsetY - textStrokeWidth;
aabb.width += textStrokeWidth * 2;
aabb.height += textStrokeWidth * 2;
boxes.push(aabb);
});
});
return boxes.length ? modernPath2d.BoundingBox.from(...boxes) : void 0;
},
render: (ctx, text) => {
const { paragraphs, glyphBox, effects } = text;
if (effects) {
effects.forEach((style) => {
uploadColor(style, glyphBox, ctx);
ctx.save();
const [a, c, e, b, d, f] = getTransform2D(text, style).transpose().elements;
ctx.transform(a, b, c, d, e, f);
text.forEachCharacter((character) => {
character.drawTo(ctx, style);
});
ctx.restore();
});
} else {
paragraphs.forEach((paragraph) => {
paragraph.fragments.forEach((fragment) => {
fragment.characters.forEach((character) => {
character.drawTo(ctx);
});
});
});
}
if (text.debug) {
paragraphs.forEach((paragraph) => {
ctx.strokeRect(paragraph.lineBox.x, paragraph.lineBox.y, paragraph.lineBox.width, paragraph.lineBox.height);
});
}
}
});
}
function getTransform2D(text, style) {
const { fontSize, glyphBox } = text;
const translateX = (style.translateX ?? 0) * fontSize;
const translateY = (style.translateY ?? 0) * fontSize;
const PI_2 = Math.PI * 2;
const skewX = (style.skewX ?? 0) / 360 * PI_2;
const skewY = (style.skewY ?? 0) / 360 * PI_2;
const { left, top, width, height } = glyphBox;
const centerX = left + width / 2;
const centerY = top + height / 2;
tempM1.identity();
tempM2.makeTranslation(translateX, translateY);
tempM1.multiply(tempM2);
tempM2.makeTranslation(centerX, centerY);
tempM1.multiply(tempM2);
tempM2.set(1, Math.tan(skewX), 0, Math.tan(skewY), 1, 0, 0, 0, 1);
tempM1.multiply(tempM2);
tempM2.makeTranslation(-centerX, -centerY);
tempM1.multiply(tempM2);
return tempM1.clone();
}
function textDecoration() {
const pathSet = new modernPath2d.Path2DSet();
return definePlugin({
name: "textDecoration",
pathSet,
update: (text) => {
pathSet.paths.length = 0;
const groups = [];
let group;
let prevStyle;
text.forEachCharacter((character) => {
const {
computedStyle: style,
isVertical,
inlineBox,
underlinePosition,
underlineThickness,
strikeoutPosition,
strikeoutSize
} = character;
const { color, textDecoration: textDecoration2, writingMode } = style;
if (!modernIdoc.isNone(textDecoration2)) {
let flag = false;
if (prevStyle?.textDecoration === textDecoration2 && prevStyle?.writingMode === writingMode && prevStyle?.color === color && (isVertical ? group[0].inlineBox.left === inlineBox.left : group[0].inlineBox.top === inlineBox.top)) {
switch (textDecoration2) {
case "underline":
if (group[0].underlinePosition === underlinePosition && group[0].underlineThickness === underlineThickness) {
flag = true;
}
break;
case "line-through":
if (group[0].strikeoutPosition === strikeoutPosition && group[0].strikeoutSize === strikeoutSize) {
flag = true;
}
break;
}
}
if (flag) {
group.push(character);
} else {
group = [];
group.push(character);
groups.push(group);
}
prevStyle = style;
} else {
prevStyle = void 0;
}
});
groups.forEach((group