UNPKG

modern-text

Version:

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

1,542 lines (1,522 loc) 87 kB
'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