diff --git a/examples/alipay/pages/streaming.js b/examples/alipay/pages/streaming.js index a6589cd..0f8de40 100644 --- a/examples/alipay/pages/streaming.js +++ b/examples/alipay/pages/streaming.js @@ -2,8 +2,8 @@ // 与真实聊天接口的前端消费方式一致。页面卸载或关闭开关时必须取消定时器。 function createStreamPlayer(options) { const content = options.content; - const chunkSize = options.chunkSize == null ? 1 : options.chunkSize; - const interval = options.interval == null ? 120 : options.interval; + const chunkSize = options.chunkSize == null ? 4 : options.chunkSize; + const interval = options.interval == null ? 30 : options.interval; const chars = Array.from(content); const chunks = []; for (let i = 0; i < chars.length; i += chunkSize) { @@ -14,8 +14,8 @@ function createStreamPlayer(options) { let runId = 0; function delayAfter(chunk) { - if (/\n\s*$/.test(chunk)) return interval + 180; - if (/[。!?;:.!?]\s*$/.test(chunk)) return interval + 100; + if (/\n\s*$/.test(chunk)) return interval + 40; + if (/[。!?;:.!?]\s*$/.test(chunk)) return interval + 20; return interval; } diff --git a/examples/wechat/pages/streaming.js b/examples/wechat/pages/streaming.js index a6589cd..0f8de40 100644 --- a/examples/wechat/pages/streaming.js +++ b/examples/wechat/pages/streaming.js @@ -2,8 +2,8 @@ // 与真实聊天接口的前端消费方式一致。页面卸载或关闭开关时必须取消定时器。 function createStreamPlayer(options) { const content = options.content; - const chunkSize = options.chunkSize == null ? 1 : options.chunkSize; - const interval = options.interval == null ? 120 : options.interval; + const chunkSize = options.chunkSize == null ? 4 : options.chunkSize; + const interval = options.interval == null ? 30 : options.interval; const chars = Array.from(content); const chunks = []; for (let i = 0; i < chars.length; i += chunkSize) { @@ -14,8 +14,8 @@ function createStreamPlayer(options) { let runId = 0; function delayAfter(chunk) { - if (/\n\s*$/.test(chunk)) return interval + 180; - if (/[。!?;:.!?]\s*$/.test(chunk)) return interval + 100; + if (/\n\s*$/.test(chunk)) return interval + 40; + if (/[。!?;:.!?]\s*$/.test(chunk)) return interval + 20; return interval; } diff --git a/package.json b/package.json index 76dbe3f..8b99c17 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "import": "./dist/index.mjs", "require": "./dist/index.js" }, + "./es/Markdown/index": "./dist/es/Markdown/index.js", + "./es/MiniNodeRenderer/index": "./dist/es/MiniNodeRenderer/index.js", "./es/*": "./dist/es/*", "./shared/*": "./dist/shared/*", "./components/*": "./dist/components/*", diff --git a/scripts/copy-component-assets.mjs b/scripts/copy-component-assets.mjs index 8b48fd3..45c0517 100644 --- a/scripts/copy-component-assets.mjs +++ b/scripts/copy-component-assets.mjs @@ -55,29 +55,6 @@ function copyKatexTtfFonts(baseDest) { } } -function writeKatexTtfDataModule(baseDest) { - const srcDir = join(root, 'node_modules', 'katex', 'dist', 'fonts'); - if (!existsSync(srcDir)) return; - const entries = []; - for (const f of readdirSync(srcDir).sort()) { - if (!f.endsWith('.ttf')) continue; - const base64 = readFileSync(join(srcDir, f)).toString('base64'); - entries.push(` ${JSON.stringify(f)}: ${JSON.stringify('data:font/ttf;base64,' + base64)}`); - } - writeFileSync( - join(baseDest, 'katex-font-data.js'), - [ - "'use strict';", - '', - '// Generated by scripts/copy-component-assets.mjs. Used by Alipay real-device loadFontFace.', - 'module.exports = {', - entries.join(',\n'), - '};', - '', - ].join('\n'), - ); -} - // 1. 静态资源 — keep legacy es/* and the unified components/* entry in sync. const alipaySrc = join(srcRoot, 'alipay'); const wechatSrc = join(srcRoot, 'wechat'); @@ -157,10 +134,10 @@ if (existsSync(pluginsSrc)) { console.log('[x-markdown-mini] plugin styles copied to dist/plugins and dist/miniprogram_dist/plugins'); } -// 1c. KaTeX runtime fonts — publish real ttf files in the npm package. -// They are loaded lazily via loadFontFace only when latex is enabled / formula nodes exist. -copyKatexTtfFonts(distRoot); +// 1c. KaTeX runtime fonts — WeChat only. +// Alipay 真机 loadFontFace 只认白名单 https CDN ttf(见 shared/loadKatexFonts.ts 的 KATEX_FONT_CDN), +// 包内本地 ttf 与 base64 data URI 均不生效,故不再向 Alipay 包根(distRoot)发 ttf、也不再生成 +// base64 数据模块(katex-font-data.js)——净减包体。WeChat 的 /miniprogram_npm/.../katex-fonts 是 +// 构建npm 真实产出的可解析路径,作为「本地优先 + CDN 兜底」的本地那一档,仍然发布。 copyKatexTtfFonts(distMpRoot); -writeKatexTtfDataModule(distRoot); -writeKatexTtfDataModule(distMpRoot); -console.log('[x-markdown-mini] KaTeX ttf fonts copied and data modules generated'); +console.log('[x-markdown-mini] KaTeX ttf fonts copied to miniprogram_dist (WeChat local fallback)'); diff --git a/scripts/copy-miniprogram-dist.mjs b/scripts/copy-miniprogram-dist.mjs index dfb898b..a1d68da 100644 --- a/scripts/copy-miniprogram-dist.mjs +++ b/scripts/copy-miniprogram-dist.mjs @@ -22,10 +22,14 @@ function copy(src, dest) { // Main library CJS bundle copy(join(distRoot, 'index.js'), join(mpRoot, 'index.js')); -// Shared helper +// Shared helpers copy( join(distRoot, 'shared', 'flattenInline.js'), join(mpRoot, 'shared', 'flattenInline.js'), ); +copy( + join(distRoot, 'shared', 'loadKatexFonts.js'), + join(mpRoot, 'shared', 'loadKatexFonts.js'), +); -console.log('[x-markdown-mini] copied index.js + shared/flattenInline.js to miniprogram_dist'); \ No newline at end of file +console.log('[x-markdown-mini] copied index.js + shared/{flattenInline,loadKatexFonts}.js to miniprogram_dist'); \ No newline at end of file diff --git a/scripts/prepare-publish.mjs b/scripts/prepare-publish.mjs index 931e6dc..7c81572 100644 --- a/scripts/prepare-publish.mjs +++ b/scripts/prepare-publish.mjs @@ -37,6 +37,11 @@ const pub = { import: './index.mjs', require: './index.js', }, + // 无扩展名子路径显式映射:小程序 usingComponents 不能带扩展名,而 Node exports 的 `*` + // 通配不会自动补扩展名,导致 Minifish 尊重 exports 时解析 `.../es/Markdown/index` 失败、 + // 组件加载不出来。这里补上直达 .js 的精确映射(比 `./es/*` 通配更具体,优先命中)。 + './es/Markdown/index': './es/Markdown/index.js', + './es/MiniNodeRenderer/index': './es/MiniNodeRenderer/index.js', './es/*': './es/*', './shared/*': './shared/*', './components/*': './components/*', diff --git a/src/__tests__/alipayInlineLayout.test.ts b/src/__tests__/alipayInlineLayout.test.ts index 49f9bdf..6a99a33 100644 --- a/src/__tests__/alipayInlineLayout.test.ts +++ b/src/__tests__/alipayInlineLayout.test.ts @@ -34,4 +34,20 @@ describe('Alipay inline links', () => { expect(sharedElements).toMatch(/\.md-del[\s\S]*?text-decoration:\s*line-through/); expect(rendererAcss).toContain('.md-link--active'); }); + + it('never per-char animates a list marker (would split "1." across lines)', () => { + // 流式动画把文本拆成逐字 ,但列表 marker(.md-list-marker,仅 28rpx 宽的 flex 项) + // 拆开后 "1" 和 "." 会分行。marker 必须整体渲染,故动画分支门控排除 marker。 + const axml = readFileSync( + resolve(root, 'src/components/alipay/MiniNodeRenderer/index.axml'), + 'utf8', + ); + const sjs = readFileSync( + resolve(root, 'src/components/alipay/MiniNodeRenderer/index.sjs'), + 'utf8', + ); + expect(axml).toContain('u.isText(node.name) && animation && !u.isMarker(node)'); + expect(sjs).toMatch(/function isMarker\(node\)\s*\{[\s\S]*?md-list-marker/); + expect(sjs).toContain('isMarker: isMarker'); + }); }); diff --git a/src/__tests__/examplesStreaming.test.ts b/src/__tests__/examplesStreaming.test.ts index c1b7c40..4aa9eeb 100644 --- a/src/__tests__/examplesStreaming.test.ts +++ b/src/__tests__/examplesStreaming.test.ts @@ -44,7 +44,7 @@ describe('mini-program streaming examples', () => { vi.useRealTimers(); }); - it(`${platform} defaults to a human-visible LLM-like pace`, () => { + it(`${platform} defaults to a brisk LLM-like pace`, () => { const helper = resolve(root, `examples/${platform}/pages/streaming.js`); const { createStreamPlayer } = require(helper) as { createStreamPlayer: (options: { @@ -62,12 +62,13 @@ describe('mini-program streaming examples', () => { }); player.play(); - vi.advanceTimersByTime(100); + // 初始空帧同步发出;首个 chunk 在默认 30ms 间隔后到达(远快于旧的 120ms/字)。 expect(frames.at(-1)).toEqual(['', true, 'streaming']); vi.advanceTimersByTime(30); const firstBurst = frames.at(-1)!; - expect(firstBurst[0].length).toBeGreaterThan(0); + // 默认每帧显现多个字符(chunkSize>1),而不是逐字——这就是「快一点」。 + expect(firstBurst[0].length).toBeGreaterThan(1); expect(firstBurst[0].length).toBeLessThan(content.length); expect(firstBurst[1]).toBe(true); @@ -76,7 +77,7 @@ describe('mini-program streaming examples', () => { vi.useRealTimers(); }); - it(`${platform} reveals one character per upstream frame by default`, () => { + it(`${platform} reveals multiple characters per frame by default`, () => { const helper = resolve(root, `examples/${platform}/pages/streaming.js`); const { createStreamPlayer } = require(helper) as { createStreamPlayer: (options: { @@ -88,13 +89,14 @@ describe('mini-program streaming examples', () => { vi.useFakeTimers(); const frames: string[] = []; + // 默认 chunkSize=4:5 字 → ['逐字逐字','逐'],两帧显现(比逐字更快)。 createStreamPlayer({ - content: '逐字', + content: '逐字逐字逐', interval: 20, onFrame: (content) => frames.push(content), }).play(); vi.runAllTimers(); - expect(frames).toEqual(['', '逐', '逐字']); + expect(frames).toEqual(['', '逐字逐字', '逐字逐字逐']); vi.useRealTimers(); }); } diff --git a/src/__tests__/platformStyles.test.ts b/src/__tests__/platformStyles.test.ts index 0b37079..bcd5f39 100644 --- a/src/__tests__/platformStyles.test.ts +++ b/src/__tests__/platformStyles.test.ts @@ -21,10 +21,10 @@ describe('platform presentation consistency', () => { }); it('assigns Alipay KaTeX fonts via single-class kxf-* rules + unique families', () => { - // 真机根因:(1) loadFontFace 同名 family 只认首字面 → 每字面唯一 family;(2) woff 虽回调 - // loaded 但不参与 排版,支付宝 runtime 必须加载 ttf;(3) KaTeX 经嵌套 - // 渲染,跨组件边界的后代选择器 / view→text 继承都不可靠 → 字体意图由 - // plugins/Latex/index.ts 下沉成 kxf-* 类盖到字形 ,再用单类选择器赋字体(命中 text 自身)。 + // 真机根因:(1) loadFontFace 同名 family 只认首字面 → 每字面唯一 family;(2) 本地路径 / base64 + // data URI 只在模拟器生效,真机只认白名单 https CDN ttf;(3) KaTeX 经嵌套 + // 渲染,跨组件边界的后代选择器 / view→text 继承都不可靠 → 字体意图由 plugins/Latex/index.ts + // 下沉成 kxf-* 类盖到字形 ,再用单类选择器赋字体(命中 text 自身)。 const acss = source('../plugins/Latex/style.acss'); const loader = source('../components/shared/loadKatexFonts.ts'); // 必须是「单类」选择器(.kxf-x {),不能是会跨组件边界失效的后代选择器(.katex … text)。 @@ -48,18 +48,18 @@ describe('platform presentation consistency', () => { } expect(loader).toContain("alipayFile: 'KaTeX_Main-Regular.ttf'"); expect(loader).toContain("alipayFile: 'KaTeX_Math-Italic.ttf'"); - expect(loader).toMatch(/const ALIPAY_LOCAL_BASES = \[\s*'\/katex-fonts'/); - expect(loader).toMatch(/if \(isAlipay\) \{\s*for \(let i = 0; i < localBases\.length; i\+\+\)/); - expect(loader).toContain('function loadInlineFontData()'); - expect(loader).toContain("req('../../katex-font-data.js')"); - expect(loader).toContain("data && data[f.alipayFile]"); - expect(loader).toContain('if (isAlipay) {'); - expect(loader).toContain("CDN + '/' + f.alipayFile"); - expect(loader).toContain("'/node_modules/' + PACKAGE_NAME + '/dist/katex-fonts'"); - expect(loader).toContain("'/node_modules/' + PACKAGE_NAME + '/katex-fonts'"); - expect(loader).toContain("'/miniprogram_npm/' + PACKAGE_NAME + '/dist/miniprogram_dist/katex-fonts'"); + // 支付宝走白名单 CDN ttf:映射表覆盖每个 alipayFile,且真机分支只 push CDN url(无本地/base64)。 + expect(loader).toContain('export const KATEX_FONT_CDN'); + expect(loader).toContain("'KaTeX_Main-Regular.ttf': 'https://mdn.alipayobjects.com"); + expect(loader).toContain("'KaTeX_Size4-Regular.ttf': 'https://mdn.alipayobjects.com"); + expect(loader).toMatch(/if \(isAlipay\) \{\s*[\s\S]*?if \(cdn\) sources\.push\('url\("' \+ cdn \+ '"\)'\);\s*return sources;/); + // 真机不再依赖包内本地路径 / base64 data URI(模拟器专属,已移除)。 + expect(loader).not.toContain('ALIPAY_LOCAL_BASES'); + expect(loader).not.toContain('loadInlineFontData'); + expect(loader).not.toContain('katex-font-data.js'); + expect(loader).not.toContain('/node_modules/'); + // 微信仍保留可解析的本地 miniprogram_npm ttf(构建npm 真实产出)作 CDN 之外的兜底。 expect(loader).toContain("'/miniprogram_npm/' + PACKAGE_NAME + '/katex-fonts'"); - expect(loader).toMatch(/sources\.push\('url\("' \+ localBases\[i\] \+ '\/' \+ f\.alipayFile \+ '"\)'\);/); }); it('keeps Alipay typewriter text classes on the real text leaf', () => { @@ -74,21 +74,29 @@ describe('platform presentation consistency', () => { expect(axml).toContain('style="{{u.styleOf(c)}}"'); }); - it('keeps all KaTeX font-ready callbacks until fonts finish loading', () => { + it('notifies font-ready at most once and only when not-yet-ready', () => { // may start font loading before nodes exist; registers - // after it receives KaTeX nodes. Real devices need both callbacks preserved so a - // late renderer can still force a redraw after loadFontFace completes. + // after it receives KaTeX nodes. onKatexFontsReady queues waiters until loadFontFace + // completes, flushes them ONCE, and — critically — does NOT fire when already ready. + // 后者是消除「流式每个 chunk 全页闪」的关键:已就绪的调用方不再触发强制重排。 const loader = source('../components/shared/loadKatexFonts.ts'); + expect(loader).toContain('export function areKatexFontsReady()'); + expect(loader).toContain('export function ensureKatexFonts()'); + expect(loader).toContain('export function onKatexFontsReady('); expect(loader).toContain('readyCallbacks'); - expect(loader).toMatch(/if \(onReady\) readyCallbacks\.push\(onReady\);/); - expect(loader).toMatch(/if \(ready\) \{\s*callSafe\(onReady\);/s); + // 只在尚未就绪时入队;已就绪直接 return(不回调、不重绘)。 + expect(loader).toMatch(/if \(ready\) return;\s*readyCallbacks\.push\(cb\);/s); + // 全部字面完成后一次性 flush。 expect(loader).toMatch(/for \(let i = 0; i < callbacks\.length; i\+\+\) callSafe\(callbacks\[i\]\);/); }); - it('build script emits inline ttf font data modules for package roots', () => { + it('build script publishes WeChat-only ttf and no base64 data module', () => { + // 支付宝真机走 CDN ttf,包内本地 ttf / base64 data URI 均不生效 → 不再向 Alipay 包根发 ttf、 + // 不再生成 katex-font-data.js。微信的 miniprogram_dist 仍发布本地 ttf 作 CDN 之外的兜底。 const buildScript = source('../../scripts/copy-component-assets.mjs'); - expect(buildScript).toContain('writeKatexTtfDataModule(distRoot)'); - expect(buildScript).toContain('writeKatexTtfDataModule(distMpRoot)'); - expect(buildScript).toContain('data:font/ttf;base64,'); + expect(buildScript).toContain('copyKatexTtfFonts(distMpRoot)'); + expect(buildScript).not.toContain('copyKatexTtfFonts(distRoot)'); + expect(buildScript).not.toContain('writeKatexTtfDataModule'); + expect(buildScript).not.toContain('data:font/ttf;base64,'); }); }); diff --git a/src/__tests__/plugins.test.ts b/src/__tests__/plugins.test.ts index 1ee3705..8ff708b 100644 --- a/src/__tests__/plugins.test.ts +++ b/src/__tests__/plugins.test.ts @@ -187,26 +187,28 @@ describe('Latex — tokenizer', () => { }); it('registers KaTeX fonts globally only when latex is enabled', () => { - // 真机修复:组件作用域 @font-face 在支付宝真机不生效,改用 loadFontFace 全局注册; - // 且必须仅在开启 latex 时调用——没用公式的页面不应加载这些字体。 + // 真机修复:组件作用域 @font-face 在支付宝真机不生效,改用 loadFontFace 全局注册(支付宝走 + // 白名单 CDN ttf,微信本地 miniprogram_npm ttf 优先 + CDN 兜底);且仅在开启 latex 时调用。 const loader = readFileSync( new URL('../components/shared/loadKatexFonts.ts', import.meta.url), 'utf8', ); expect(loader).toContain('loadFontFace'); expect(loader).toContain('global: true'); - expect(loader).toContain('/node_modules/'); + expect(loader).toContain('mdn.alipayobjects.com'); expect(loader).toContain('/miniprogram_npm/'); expect(loader).toContain('/katex-fonts'); + // 支付宝分支不再引用包内本地路径(模拟器专属,真机不生效)。 + expect(loader).not.toContain('/node_modules/'); for (const platform of ['alipay', 'wechat']) { const src = readFileSync( new URL(`../components/${platform}/Markdown/index.ts`, import.meta.url), 'utf8', ); - expect(src).toContain('loadKatexFonts'); // 调用必须门控在 latex 判断之后 - expect(src).toMatch(/latex\)\s*loadKatexFonts\(/); + expect(src).toContain('ensureKatexFonts'); + expect(src).toMatch(/latex[\s\S]{0,200}ensureKatexFonts\(\)/); } }); @@ -215,16 +217,16 @@ describe('Latex — tokenizer', () => { new URL('../components/alipay/MiniNodeRenderer/index.ts', import.meta.url), 'utf8', ); - expect(renderer).toContain('loadKatexFonts'); + expect(renderer).toContain('ensureKatexFonts'); expect(renderer).toContain("indexOf('katex')"); - expect(renderer).toMatch(/if\s*\([^)]*\(this\.props\.nodes\)[^)]*\)\s*loadKatexFonts\(/); + expect(renderer).toMatch(/if\s*\([^)]*\(this\.props\.nodes\)[^)]*\)\s*ensureKatexFonts\(/); // Must reuse the shared all-faces loader, not a crippled local stub that only // registers one face (the JS-接入 page relies on this for every KaTeX font on // device — registering only KaTeX_Math left every other glyph in the system font). expect(renderer).toMatch( - /import\s*\{[^}]*loadKatexFonts[^}]*\}\s*from\s*'\.\.\/\.\.\/shared\/loadKatexFonts\.js'/, + /from\s*'\.\.\/\.\.\/shared\/loadKatexFonts\.js'/, ); - expect(renderer).not.toMatch(/function\s+loadKatexFonts\s*\(/); + expect(renderer).not.toMatch(/function\s+ensureKatexFonts\s*\(/); // The shared loader must register the full KaTeX face set, not just one family. const loader = readFileSync( @@ -236,7 +238,7 @@ describe('Latex — tokenizer', () => { } }); - it('guards Alipay KaTeX reflow callbacks after component unmount', () => { + it('guards Alipay KaTeX reflow: once-only, idle-gated, unmount-safe', () => { const files = [ '../components/alipay/Markdown/index.ts', '../components/alipay/MiniNodeRenderer/index.ts', @@ -247,7 +249,14 @@ describe('Latex — tokenizer', () => { expect(src, file).toContain('mounted: false'); expect(src, file).toContain('this.mounted = true;'); expect(src, file).toContain('this.mounted = false;'); - expect(src, file).toContain('if (this.mounted) this._katexFontReflow();'); + // 每实例至多重排一次 + 空闲态门控(消除流式全页闪)。 + expect(src, file).toContain('_maybeKatexReflow'); + expect(src, file).toContain('areKatexFontsReady()'); + expect(src, file).toMatch(/if \(this\._isStreaming\) return;/); + // 就绪回调里 unmount / 已重排 / 仍在流式 均跳过,再执行卸载-重挂重绘。 + expect(src, file).toMatch( + /if \(!this\.mounted \|\| this\._katexReflowed \|\| this\._isStreaming\) return;\s*this\._katexReflowed = true;\s*this\._katexFontReflow\(\);/s, + ); expect(src, file).toMatch( /this\.setData\(\{ katexReflow: false \}, \(\) => \{\s*if \(this\.mounted\) this\.setData\(\{ katexReflow: true \}\);/s, ); diff --git a/src/components/alipay/Markdown/index.ts b/src/components/alipay/Markdown/index.ts index 23e1cf7..a86d852 100644 --- a/src/components/alipay/Markdown/index.ts +++ b/src/components/alipay/Markdown/index.ts @@ -6,7 +6,11 @@ import type { MarkedExtension, } from '../../../index.js'; import { flattenInlineNodes } from '../../shared/flattenInline.js'; -import { loadKatexFonts } from '../../shared/loadKatexFonts.js'; +import { + areKatexFontsReady, + ensureKatexFonts, + onKatexFontsReady, +} from '../../shared/loadKatexFonts.js'; declare const Component: (opts: Record) => void; // 运行时按需加载插件:插件扩展带 tokenizer/miniRenderer 等函数,无法通过属性绑定/ @@ -70,6 +74,13 @@ function sameList(a: string[] | null, b: string[] | null): boolean { return true; } +// 节点树里是否含 KaTeX(class 带 "katex")——无公式则不折腾字体重排。 +function hasKatexNodes(nodes: any[] | undefined): boolean { + return !!nodes && nodes.some((node: any) => + !!node && (String((node.attrs || {}).class || '').indexOf('katex') > -1 || + hasKatexNodes(node.children))); +} + Component({ props: defaultProps, data: { @@ -82,6 +93,11 @@ Component({ }, md: null as XMarkdownMini | null, mounted: false, + // 每实例最多重排一次;_katexReflowArmed 防止流式空闲期重复注册回调;_isStreaming 用于 C1: + // 流式进行中不做显式重排(下一个 chunk 会自然用已就绪字体重绘),只在空闲/完成态补一次。 + _katexReflowed: false, + _katexReflowArmed: false, + _isStreaming: false, didMount(this: any) { this.mounted = true; @@ -123,10 +139,13 @@ Component({ methods: { _build(this: any, props: MarkdownProps) { const components = props.components ?? []; - // 仅在开启 latex 时注册 KaTeX 字体(支付宝走包内本地 woff);字体就绪后强制重排一次。 - if (props.latex) loadKatexFonts(() => { - if (this.mounted) this._katexFontReflow(); - }); + // 仅在开启 latex 时注册 KaTeX 字体(支付宝走白名单 CDN ttf)。重排交给 _maybeKatexReflow: + // 每实例至多一次、且仅当字体尚未就绪 + 非流式进行中,避免每个 chunk 全页闪。 + if (props.latex) { + this._katexReflowed = false; + this._katexReflowArmed = false; + ensureKatexFonts(); + } const extensions = bakeExtensions(!!props.latex, !!props.highlight); this.md?.reset(); this.md = new XMarkdownMini({ escapeText: false, components, extensions }); @@ -139,6 +158,12 @@ Component({ !!streamConfig && typeof streamConfig === 'object' && streamConfig.enableAnimation !== false ); + // 流式进行中(还有后续 chunk)时不做显式重排;空闲/完成态(hasNextChunk===false 或 + // streaming===false)再补一次,配合 _maybeKatexReflow 的 C1 守卫消除那一次闪。 + this._isStreaming = streamConfig === true || ( + !!streamConfig && typeof streamConfig === 'object' && + streamConfig.hasNextChunk !== false + ); this.setData({ animation }); this.md.renderNodes({ content: props.content, @@ -153,11 +178,30 @@ Component({ props.onRenderProgress?.(payload), onRenderComplete: () => props.onRenderComplete?.(), onPatch: (nodes: MiniNode[]) => { - if (this.mounted) this.setData({ nodes: flattenInlineNodes(nodes) }); + if (this.mounted) { + this.setData({ nodes: flattenInlineNodes(nodes) }); + this._maybeKatexReflow(); + } }, }); }, + // 每实例至多重排一次:无公式 / 字体已就绪(首屏即正确)/ 流式进行中,均直接跳过——这是消除 + // 「每个 chunk 全页闪」的核心。仅当「有公式 + 字体尚未就绪 + 已进入空闲态」时注册一次就绪回调。 + _maybeKatexReflow(this: any) { + if (this._katexReflowed || this._katexReflowArmed) return; + if (!hasKatexNodes(this.data.nodes)) return; + if (areKatexFontsReady()) { this._katexReflowed = true; return; } + if (this._isStreaming) return; + this._katexReflowArmed = true; + onKatexFontsReady(() => { + this._katexReflowArmed = false; + if (!this.mounted || this._katexReflowed || this._isStreaming) return; + this._katexReflowed = true; + this._katexFontReflow(); + }); + }, + // KaTeX 字体就绪后强制重排:卸载再重挂渲染子树,让公式 用已注册的字体重绘。 _katexFontReflow(this: any) { if (!this.mounted) return; diff --git a/src/components/alipay/MiniNodeRenderer/index.axml b/src/components/alipay/MiniNodeRenderer/index.axml index f945dd8..953ab79 100644 --- a/src/components/alipay/MiniNodeRenderer/index.axml +++ b/src/components/alipay/MiniNodeRenderer/index.axml @@ -3,9 +3,10 @@ + 按下标 key 复用,只有新字触发动画);非流式时整段渲染为单个 。 + 列表 marker 例外:结构性原子文本,逐字拆分会在窄容器里分行,始终整体渲染(走下面的 elif)。 --> -1; } +// 列表 marker("1." / "•")是结构性原子文本,绝不能逐字拆分:拆成多个 会在字符间产生 +// 换行机会,而 .md-list-marker 是仅 28rpx 宽的 flex 项,容纳不下两个字符盒 → "1" 和 "." 会分行。 +// 因此即便处于流式动画态,marker 也整体渲染为单个 (与微信 isListItem 分支的原子渲染一致)。 +function isMarker(node) { + var cls = (node.attrs || {})['class'] || ''; + return cls.indexOf('md-list-marker') > -1; +} function classOf(node) { var attrs = node.attrs || {}; var cls = attrs['class'] || ''; @@ -132,6 +139,7 @@ export default { copyOf: copyOf, isRich: isRich, isSlot: isSlot, + isMarker: isMarker, classOf: classOf, segClassOf: segClassOf, charsOf: charsOf, diff --git a/src/components/alipay/MiniNodeRenderer/index.ts b/src/components/alipay/MiniNodeRenderer/index.ts index 515db87..b833502 100644 --- a/src/components/alipay/MiniNodeRenderer/index.ts +++ b/src/components/alipay/MiniNodeRenderer/index.ts @@ -2,7 +2,11 @@ export {}; import { getTableShadowState } from '../../shared/tableScroll.js'; import { resolveLinkHref } from '../../shared/flattenInline.js'; -import { loadKatexFonts } from '../../shared/loadKatexFonts.js'; +import { + areKatexFontsReady, + ensureKatexFonts, + onKatexFontsReady, +} from '../../shared/loadKatexFonts.js'; declare const Component: (opts: Record) => void; declare const my: any; @@ -56,18 +60,22 @@ Component({ cw: {} as Record, timer: null as ReturnType | null, mounted: false, + // 每实例最多重排一次;animation 近似「流式进行中」,用于 C1:流式期间不显式重排,空闲态补一次。 + _katexReflowed: false, + _katexReflowArmed: false, + _isStreaming: false, didMount(this: any) { this.mounted = true; - if (hk(this.props.nodes)) loadKatexFonts(() => { - if (this.mounted) this._katexFontReflow(); - }); + this._isStreaming = !!this.props.animation; + if (hk(this.props.nodes)) ensureKatexFonts(); + this._maybeKatexReflow(); this._scheduleMeasure(); }, didUpdate(this: any, prevProps: MiniNodeRendererProps) { if (prevProps.nodes !== this.props.nodes) { - if (hk(this.props.nodes)) loadKatexFonts(() => { - if (this.mounted) this._katexFontReflow(); - }); + this._isStreaming = !!this.props.animation; + if (hk(this.props.nodes)) ensureKatexFonts(); + this._maybeKatexReflow(); this._scheduleMeasure(); } }, @@ -77,6 +85,21 @@ Component({ this.timer = null; }, methods: { + // 每实例至多重排一次:无公式 / 字体已就绪 / 流式进行中,均跳过——消除「每个 chunk 全页闪」。 + _maybeKatexReflow(this: any) { + if (this._katexReflowed || this._katexReflowArmed) return; + if (!hk(this.props.nodes)) return; + if (areKatexFontsReady()) { this._katexReflowed = true; return; } + if (this._isStreaming) return; + this._katexReflowArmed = true; + onKatexFontsReady(() => { + this._katexReflowArmed = false; + if (!this.mounted || this._katexReflowed || this._isStreaming) return; + this._katexReflowed = true; + this._katexFontReflow(); + }); + }, + // 字体就绪后强制重排:卸载再重挂节点子树,让公式 用已注册的字体重绘。 _katexFontReflow(this: any) { if (!this.mounted) return; diff --git a/src/components/shared/loadKatexFonts.ts b/src/components/shared/loadKatexFonts.ts index e40d5a2..41b7d58 100644 --- a/src/components/shared/loadKatexFonts.ts +++ b/src/components/shared/loadKatexFonts.ts @@ -1,50 +1,69 @@ -// KaTeX 字体的「真机」注册(2026-06-29,经支付宝真机字体自检探针多轮实证)。 +// KaTeX 字体的「真机」注册(2026-07 修订,经支付宝真机字体自检探针 + Minifish 消费侧实证)。 // -// 真机硬结论: -// 1. CSS `@font-face`(base64 / CDN / 任意作用域)在「未配下载合法域名白名单」的 appId 上一律 -// 不生效——CSS 字体的网络下载受 downloadFile/request 合法域名白名单管控;模拟器不校验,故只在 -// 模拟器生效。(markdown-x-mini 用 CSS @font-face + CDN 能真机生效,是因为它是已上线应用、配了 -// 字体 CDN 的合法域名。) -// 2. JS API `loadFontFace` 在真机预览下会对 woff 返回 loaded,但在当前支付宝真机渲染引擎里 -// 不参与 排版;ttf 才是可实际应用到文本的格式。用包内本地 ttf 可避免网络白名单 -// 和 CDN 到达后不自动重排的问题。 -// 3. 支付宝真机 `loadFontFace` 同名 family 只认首个字面,故每字面注册成「唯一 family」(alipayFamily), +// 真机硬结论(最新): +// 1. 支付宝真机 `my.loadFontFace` 只认「已加白名单的 https 网络字体(需 CORS)」——包内本地 +// 路径(`/katex-fonts/*.ttf`)与 `data:` base64 URI 都只在模拟器 webview 生效,真机一律失败。 +// 早期 beta 观察到「本地 ttf 真机可用」,实为 x-markdown-mini 自带 examples 把 dist/katex-fonts +// 同步到工程根、`/katex-fonts` 恰好可解析;真实消费方(如 Minifish 2.0)的打包产物不暴露该 +// 路径,本地 source 全失败 → 字形回退系统字(PingFang SC)。因此支付宝改走「可靠 CDN ttf 优先」。 +// 2. 字体 CDN 必须开启 CORS(`Access-Control-Allow-Origin: *` 或 `${appId}.hybrid.alipay-eco.com`), +// 且消费方要把 CDN 域名加进小程序「下载合法域名白名单」。这里用 mdn.alipayobjects.com 托管的 +// 20 个 KaTeX ttf(逐字节校验、200 + CORS `*`),afts 每文件独立 ID、无公共 base,故用映射表。 +// 3. 支付宝 `loadFontFace` 同名 family 只认首个字面,故每字面注册成「唯一 family」(alipayFamily), // CSS 端(plugins/Latex/style.acss 的 .kxf-* 单类规则 + plugins/Latex/index.ts 把 kxf-* 类下沉到 // 字形 )据此赋字体——绕开「同名多字面」「跨组件后代选择器」「view→text 继承」三个真机坑。 // -// 所以支付宝走「应用根目录本地 ttf + 唯一 family」优先;inline / HTTPS / 包内 node_modules ttf -// 只作兼容兜底。beta.11 证明包内 node_modules 字体 URL 不可靠;beta.12 证明公开 CDN 在真机环境 -// 仍会受域名/下载策略影响;beta.13 进一步暴露了一个更麻烦的点:某些 source 会回调 success, -// 但字体仍不实际参与 排版,导致后续真正可用的本地 source 被短路。因此支付宝必须把已由 -// 真机探针验证过的 `/katex-fonts/*.ttf` 放在第一位。微信仍优先走包内 ttf,失败时保留 CDN woff -// 兜底。`onReady` 在全部字面加载完成后回调一次,供组件强制重排(兜底,即便本地几乎即时, -// 也确保已渲染的公式在字体就绪后重新绘制)。 +// 微信与支付宝不同:KaTeX 经单个 渲染(webview),字体由 loadFontFace 全局注册后内部 +// 解析。微信的本地 ttf 位于 `/miniprogram_npm/@ant-design/x-markdown-mini/katex-fonts`——这是 构建npm +// 真实产出的可解析运行时路径(不受支付宝那类打包限制),故微信保留「本地 ttf 优先 + 共享 CDN 兜底」。 +// +// onKatexFontsReady 至多回调一次、且仅当注册时字体尚未就绪:这样流式每个 chunk 不再触发「卸载-重挂」 +// 强制重排,消除整页反复闪;字体已缓存(二次渲染)时直接跳过,零重绘。 // // 检测沿用 `typeof my` / `typeof wx`(而非 globalThis),与 src/platforms/index.ts 一致:旧版支付宝 // 基础库没有 globalThis,而 typeof 对未声明标识符不抛错。 declare const my: any; declare const wx: any; -declare const require: any; -// 支付宝真机用 CDN ttf 作为首选:woff 会回调 loaded 但不参与 排版;ttf 才实际生效。 -// 微信保留原 CDN woff 兜底,避免改变既有可用路径。 -const CDN = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts'; const PACKAGE_NAME = '@ant-design/x-markdown-mini'; -const ALIPAY_LOCAL_BASES = [ - '/katex-fonts', - '/node_modules/' + PACKAGE_NAME + '/dist/katex-fonts', - '/node_modules/' + PACKAGE_NAME + '/katex-fonts', -]; + +// 微信本地 ttf 兜底路径(构建npm 会把包内 katex-fonts 落到 miniprogram_npm 下,真机可解析)。 const WECHAT_LOCAL_BASES = [ '/miniprogram_npm/' + PACKAGE_NAME + '/dist/miniprogram_dist/katex-fonts', '/miniprogram_npm/' + PACKAGE_NAME + '/katex-fonts', '/katex-fonts', ]; +// 公式字体 CDN(20 个 KaTeX ttf,mdn.alipayobjects.com 托管:已验证 200 + CORS `*` + 与源逐字节一致)。 +// key = FACES 里的 alipayFile(ttf 文件名)。afts 每文件独立 ID,无公共 base,不能拼 base + '/' + file。 +// ⚠️ 升级 KaTeX 字体版本时需重新上传并刷新整张表;建议 CI 对每个 URL 做 HEAD 断言 200 + CORS。 +export const KATEX_FONT_CDN: Record = { + 'KaTeX_Main-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/L-a2RqXz_KQAAAAAQ0AAAAgADlJoAQFr/KaTeX_Main-Regular.ttf', + 'KaTeX_Math-Italic.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/vG_gSZcjJTcAAAAAQfAAAAgADlJoAQFr/KaTeX_Math-Italic.ttf', + 'KaTeX_Main-Bold.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/yYAFSbELpiIAAAAAQyAAAAgADlJoAQFr/KaTeX_Main-Bold.ttf', + 'KaTeX_Main-Italic.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/6vKqS7mpABUAAAAAQhAAAAgADlJoAQFr/KaTeX_Main-Italic.ttf', + 'KaTeX_Math-BoldItalic.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/fugLS4TQ_TIAAAAAQeAAAAgADlJoAQFr/KaTeX_Math-BoldItalic.ttf', + 'KaTeX_Main-BoldItalic.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/XgjrS7wWTbAAAAAAQgAAAAgADlJoAQFr/KaTeX_Main-BoldItalic.ttf', + 'KaTeX_Size1-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/8TDlSpILzAkAAAAAQMAAAAgADlJoAQFr/KaTeX_Size1-Regular.ttf', + 'KaTeX_Size2-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/Z4PkT68y0dUAAAAAQLAAAAgADlJoAQFr/KaTeX_Size2-Regular.ttf', + 'KaTeX_Size3-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/7gtaRYkVdFQAAAAAQHAAAAgADlJoAQFr/KaTeX_Size3-Regular.ttf', + 'KaTeX_Size4-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/aW8uRJzwokwAAAAAQKAAAAgADlJoAQFr/KaTeX_Size4-Regular.ttf', + 'KaTeX_AMS-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/Wt0vQKdiQoIAAAAAQ-AAAAgADlJoAQFr/KaTeX_AMS-Regular.ttf', + 'KaTeX_SansSerif-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/ZLV_TIlyACIAAAAAQTAAAAgADlJoAQFr/KaTeX_SansSerif-Regular.ttf', + 'KaTeX_SansSerif-Bold.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/ra9YQp9FKE8AAAAAQYAAAAgADlJoAQFr/KaTeX_SansSerif-Bold.ttf', + 'KaTeX_SansSerif-Italic.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/CpvzTqKXKZ8AAAAAQWAAAAgADlJoAQFr/KaTeX_SansSerif-Italic.ttf', + 'KaTeX_Caligraphic-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/tZ8fTYt9JKsAAAAAQMAAAAgADlJoAQFr/KaTeX_Caligraphic-Regular.ttf', + 'KaTeX_Caligraphic-Bold.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/k9MMRZaHkWUAAAAAQMAAAAgADlJoAQFr/KaTeX_Caligraphic-Bold.ttf', + 'KaTeX_Fraktur-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/4htAQpf8VrQAAAAAQTAAAAgADlJoAQFr/KaTeX_Fraktur-Regular.ttf', + 'KaTeX_Fraktur-Bold.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/fCFXTrrddScAAAAAQTAAAAgADlJoAQFr/KaTeX_Fraktur-Bold.ttf', + 'KaTeX_Script-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/NH6oSrgkGvMAAAAAQQAAAAgADlJoAQFr/KaTeX_Script-Regular.ttf', + 'KaTeX_Typewriter-Regular.ttf': 'https://mdn.alipayobjects.com/huamei_yz9z7c/afts/file/qoBkQ6SH5IIAAAAAQbAAAAgADlJoAQFr/KaTeX_Typewriter-Regular.ttf', +}; + interface KatexFace { file: string; alipayFile: string; - /** 原始 KaTeX family 名(WeChat 用:rich-text + style.wxss 按 family+style/weight 选字面)。 */ + /** 原始 KaTeX family 名(WeChat 用:rich-text + loadFontFace 按 family+style/weight 选字面)。 */ family: string; weight: 'normal' | 'bold'; style: 'normal' | 'italic'; @@ -80,10 +99,9 @@ const FACES: KatexFace[] = [ // 进程内只注册一次:同一页面多个组件实例不重复发起 loadFontFace。 let started = false; let ready = false; -// 字体加载可能由 在节点渲染前触发,也可能由底层 在拿到 -// KaTeX 节点后触发。必须保留全部等待者;只记第一个回调会让真机错过重排,停在系统字体。 +// 字体加载可能由 在节点渲染前触发,也可能由底层 在拿到 KaTeX 节点 +// 后触发。保留全部等待者;全部字面加载完成后一次性 flush,之后新调用直接跳过(见 onKatexFontsReady)。 let readyCallbacks: (() => void)[] = []; -let inlineFontData: Record | null | undefined; function callSafe(cb: (() => void) | null | undefined): void { if (!cb) return; @@ -99,49 +117,25 @@ function debugKatexFonts(): boolean { } function sourceKind(source: string): string { - if (source.indexOf('data:font/ttf') > -1) return 'inline-ttf'; - if (source.indexOf('cdn.jsdelivr.net') > -1) return 'cdn'; - if (source.indexOf('/node_modules/') > -1) return 'node_modules'; + if (source.indexOf('mdn.alipayobjects.com') > -1) return 'cdn'; + if (source.indexOf('/miniprogram_npm/') > -1) return 'miniprogram_npm'; if (source.indexOf('/katex-fonts/') > -1) return 'root'; return 'unknown'; } -function loadInlineFontData(): Record | null { - if (inlineFontData !== undefined) return inlineFontData; - try { - // This file is generated into dist/katex-font-data.js and dist/miniprogram_dist/katex-font-data.js. - // Keep require indirect so the bundler does not try to resolve it from src/. - const req = require; - const mod = req('../../katex-font-data.js'); - inlineFontData = (mod && (mod.default || mod)) || null; - } catch (e) { - inlineFontData = null; - } - if (debugKatexFonts()) { - console.log('[xmd:katex-font-data]', { - ok: !!inlineFontData, - count: inlineFontData ? Object.keys(inlineFontData).length : 0, - }); - } - return inlineFontData; -} - function sourcesForFace(f: KatexFace, isAlipay: boolean): string[] { - const localBases = isAlipay ? ALIPAY_LOCAL_BASES : WECHAT_LOCAL_BASES; const sources: string[] = []; + const cdn = KATEX_FONT_CDN[f.alipayFile]; if (isAlipay) { - for (let i = 0; i < localBases.length; i++) { - sources.push('url("' + localBases[i] + '/' + f.alipayFile + '")'); - } - const data = loadInlineFontData(); - if (data && data[f.alipayFile]) sources.push('url("' + data[f.alipayFile] + '")'); - sources.push('url("' + CDN + '/' + f.alipayFile + '")'); + // 支付宝真机唯一可用:白名单 https CDN ttf。本地/base64 只在模拟器生效,已移除。 + if (cdn) sources.push('url("' + cdn + '")'); return sources; } - for (let i = 0; i < localBases.length; i++) { - sources.push('url("' + localBases[i] + '/' + f.alipayFile + '")'); + // 微信:本地 miniprogram_npm ttf 优先(真机可解析、无需域名白名单),共享 CDN 作兜底。 + for (let i = 0; i < WECHAT_LOCAL_BASES.length; i++) { + sources.push('url("' + WECHAT_LOCAL_BASES[i] + '/' + f.alipayFile + '")'); } - if (!isAlipay) sources.push('url("' + CDN + '/' + f.file + '")'); + if (cdn) sources.push('url("' + cdn + '")'); return sources; } @@ -181,55 +175,74 @@ function loadFace(api: any, opts: { family: string; desc: { style: string; weigh tryNext(); } -/** - * 把 KaTeX 字体以 global 作用域注册到当前小程序运行时。仅在开启 latex / 出现 katex 时调用。 - * 失败静默降级——.kxf-* 的 font-family 链里有 "PingFang SC" 等系统兜底字体。 - * - * @param onReady 全部字面加载完成后回调一次(已就绪时同步回调)。组件用它强制重排,确保已渲染 - * 的公式在字体到位后重新绘制。无 loadFontFace 的环境(浏览器/docs)立即回调。 - */ -export function loadKatexFonts(onReady?: () => void): void { +function resolveApi(): { api: any; isAlipay: boolean } | null { // 支付宝真机缺 globalThis,故用 typeof 直接探测 my / wx。 - const isAlipay = typeof my !== 'undefined' && typeof my.loadFontFace === 'function'; - const api: any = isAlipay - ? my - : typeof wx !== 'undefined' && typeof wx.loadFontFace === 'function' - ? wx - : null; - // 浏览器/无 loadFontFace:字体由 CSS 处理,直接回调让组件正常渲染。 - if (!api) { - callSafe(onReady); - return; + if (typeof my !== 'undefined' && typeof my.loadFontFace === 'function') { + return { api: my, isAlipay: true }; } - if (ready) { - callSafe(onReady); - return; + if (typeof wx !== 'undefined' && typeof wx.loadFontFace === 'function') { + return { api: wx, isAlipay: false }; } - if (onReady) readyCallbacks.push(onReady); - if (started) return; - started = true; + return null; +} + +function flushReadyCallbacks(): void { + const callbacks = readyCallbacks; + readyCallbacks = []; + for (let i = 0; i < callbacks.length; i++) callSafe(callbacks[i]); +} +function startLoading(api: any, isAlipay: boolean): void { let done = 0; const total = FACES.length; const tick = (): void => { done++; if (done >= total && !ready) { ready = true; - const callbacks = readyCallbacks; - readyCallbacks = []; - for (let i = 0; i < callbacks.length; i++) callSafe(callbacks[i]); + flushReadyCallbacks(); } }; - for (let i = 0; i < FACES.length; i++) { const f = FACES[i]; loadFace(api, { // 支付宝:唯一 family + 统一 normal/normal(CSS 的 .kxf-* 也请求 normal/normal,确定性命中, // 斜/粗由字体文件本身提供);微信:原始 family + 真实 style/weight(rich-text 内部按此选字面)。 family: isAlipay ? f.alipayFamily : f.family, - // 包内本地 ttf 零网络、零白名单;微信保留 CDN woff 兜底。 sources: sourcesForFace(f, isAlipay), desc: isAlipay ? { style: 'normal', weight: 'normal' } : { style: f.style, weight: f.weight }, }, tick); } } + +/** 字体是否已全部就绪。组件用它判断首屏是否已正确、是否需要重排。 */ +export function areKatexFontsReady(): boolean { + return ready; +} + +/** + * 确保 KaTeX 字体以 global 作用域注册(幂等,进程内只发起一次)。仅在开启 latex / 出现 katex 时调用。 + * 失败静默降级——.kxf-* 的 font-family 链里有 "PingFang SC" 等系统兜底字体。 + * 无 loadFontFace 的环境(浏览器/docs)直接置就绪,字体由 CSS 处理。 + */ +export function ensureKatexFonts(): void { + if (ready || started) return; + const resolved = resolveApi(); + if (!resolved) { + // 浏览器/无 loadFontFace:字体由 CSS 处理,直接置就绪并回调既有等待者。 + ready = true; + flushReadyCallbacks(); + return; + } + started = true; + startLoading(resolved.api, resolved.isAlipay); +} + +/** + * 注册「字体就绪」回调:至多回调一次,且仅当注册时字体尚未就绪(确有重绘必要)。已就绪则直接返回、 + * 不回调、不重绘——这样流式每个 chunk 不再触发强制重排,字体已缓存的二次渲染也零重绘。 + */ +export function onKatexFontsReady(cb: () => void): void { + ensureKatexFonts(); + if (ready) return; + readyCallbacks.push(cb); +} diff --git a/src/components/wechat/Markdown/index.ts b/src/components/wechat/Markdown/index.ts index 7a45a41..336d226 100644 --- a/src/components/wechat/Markdown/index.ts +++ b/src/components/wechat/Markdown/index.ts @@ -6,7 +6,7 @@ import type { MarkedExtension, } from '../../../index.js'; import { flattenInlineNodes } from '../../shared/flattenInline.js'; -import { loadKatexFonts } from '../../shared/loadKatexFonts.js'; +import { ensureKatexFonts } from '../../shared/loadKatexFonts.js'; import { createTextAnimationState, reconcileTextAnimation, @@ -87,8 +87,8 @@ Component({ methods: { _build(this: any) { const components = (this.data.components as string[] | null) ?? []; - // 仅在开启 latex 时把 KaTeX 字体注册到全局(真机 @font-face 修复);未开启则零开销。 - if (this.data.latex) loadKatexFonts(); + // 仅在开启 latex 时把 KaTeX 字体注册到全局(rich-text 靠 loadFontFace 解析字形);未开启则零开销。 + if (this.data.latex) ensureKatexFonts(); const extensions = bakeExtensions(!!this.data.latex, !!this.data.highlight); this.md?.reset(); resetTextAnimation(this.textAnimation); diff --git a/src/components/wechat/MiniNodeRenderer/index.ts b/src/components/wechat/MiniNodeRenderer/index.ts index 49e94d1..77f500f 100644 --- a/src/components/wechat/MiniNodeRenderer/index.ts +++ b/src/components/wechat/MiniNodeRenderer/index.ts @@ -2,10 +2,19 @@ export {}; import { getTableShadowState } from '../../shared/tableScroll.js'; import { resolveLinkHref } from '../../shared/flattenInline.js'; +import { ensureKatexFonts } from '../../shared/loadKatexFonts.js'; declare const Component: (opts: Record) => void; declare const wx: any; +// JS 接入页直接用 (不经 ),KaTeX rich-text 靠全局 loadFontFace +// 解析字形,故字体注册必须也在这里触发——否则微信 JS 接入的公式会整片回退系统字体。 +function hasKatexNodes(nodes: any[] | undefined): boolean { + return !!nodes && nodes.some((node: any) => + !!node && (String((node.attrs || {}).class || '').indexOf('katex') > -1 || + hasKatexNodes(node.children))); +} + function copyToClipboard(text: string): void { if (!text) return; wx.setClipboardData({ @@ -41,13 +50,15 @@ Component({ _tableMeasureTimer: null as ReturnType | null, _mounted: false, observers: { - nodes(this: any) { + nodes(this: any, nodes: any[]) { + if (hasKatexNodes(nodes)) ensureKatexFonts(); this._scheduleTableMeasure(); }, }, lifetimes: { attached(this: any) { this._mounted = true; + if (hasKatexNodes(this.data.nodes)) ensureKatexFonts(); this._scheduleTableMeasure(); }, detached(this: any) { diff --git a/tsup.config.ts b/tsup.config.ts index b918d8d..5fdd7ab 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -26,6 +26,12 @@ const externalRuntimePlugin = { path: '../../shared/flattenInline.js', external: true, })); + // loadKatexFonts 现含 20 条 CDN 映射 + 全部字面 + 就绪 API,体量已不小;像 flattenInline + // 一样 external 成每个包根一份的 shared 模块,避免内联进 4×2 个组件 wrapper 造成体积膨胀。 + build.onResolve({ filter: /^\.\.\/\.\.\/shared\/loadKatexFonts\.js$/ }, () => ({ + path: '../../shared/loadKatexFonts.js', + external: true, + })); // 组件内部按需 require 插件(CodeHighlight / Latex),把 KaTeX/highlight.js 留在 // 各自的 plugin bundle 里,绝不打进组件 wrapper。源码写 `../../../plugins//index.js` // (src 里 3 上能解析到 src/plugins/);输出端组件在 dist/{es,components}//, @@ -75,7 +81,12 @@ export default defineConfig([ }, // 2) shared helpers(仅主 dist;miniprogram_dist 副本由 copy-miniprogram-dist.mjs 生成) { - entry: { 'shared/flattenInline': 'src/components/shared/flattenInline.ts' }, + entry: { + 'shared/flattenInline': 'src/components/shared/flattenInline.ts', + // loadKatexFonts external 成独立 shared 模块(见 externalRuntimePlugin),组件 wrapper + // 只 require 它、不内联——净减每个组件包体,且加到 WeChat MiniNodeRenderer 也几乎零成本。 + 'shared/loadKatexFonts': 'src/components/shared/loadKatexFonts.ts', + }, outDir: 'dist', format: ['cjs'], // Bundle the text-animation re-exports into this single published helper;