UNPKG

modern-text

Version:

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

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