From 77703a855a4fac04b1a2f919226124d1deaa19c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=8B=E4=BA=8E=E5=88=9D=E8=A7=81?= <752204717@qq.com> Date: Thu, 4 Jun 2026 17:38:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/global-math.js | 10 +- src/components/common/common.vue | 10 +- src/components/common/langs/en.js | 23 +- src/components/common/langs/zh.js | 20 +- src/components/page/GenerateCharts.vue | 8 + src/components/page/OnlineProofreading.vue | 8 + src/components/page/articleAdd.vue | 53 +- .../page/articleProcessRevision.vue | 62 +- src/components/page/articleReviewerAIAdd.vue | 1 + src/components/page/articleReviewerAdd.vue | 9 +- src/components/page/comArtHtmlCreatNew.vue | 7 + .../page/components/table/DynamicTable.vue | 6 +- .../page/components/table/LatexDataPanel.vue | 337 ++++++++++- .../page/components/table/content.vue | 35 +- .../page/components/table/editTable.vue | 7 +- src/components/page/components/table/word.vue | 139 +++-- .../page/components/table/wordHtml.vue | 7 +- .../components/table/wordHtmlTypesetting.vue | 4 +- src/components/page/edit_per_text.vue | 2 +- src/components/page/mailboxSend.vue | 18 +- src/utils/mathFormulaModule.js | 160 +++++- src/utils/wordImportReport.js | 324 +++++++++++ src/utils/wordMathImport.js | 532 +++++++++++++++++- 23 files changed, 1610 insertions(+), 172 deletions(-) create mode 100644 src/utils/wordImportReport.js diff --git a/public/js/global-math.js b/public/js/global-math.js index 4577c79..8b85a49 100644 --- a/public/js/global-math.js +++ b/public/js/global-math.js @@ -32,17 +32,21 @@ function stripLatexDelimiters(latex) { .trim(); } -/** 块/行内统一用 $$ 渲染,保证公式大小一致;排版由 data-wrap + CSS 控制 */ -function buildLatexContentForRender(latex) { +/** 按 data-wrap 选择行内 $ 或块级 $$ 分隔符 */ +function buildLatexContentForRender(latex, wrapMode) { const raw = stripLatexDelimiters(latex); if (!raw) return ''; + if (wrapMode === 'inline') { + return '$' + raw + '$'; + } return '$$' + raw + '$$'; } function prepareWmathElement(element) { const latexContent = element.getAttribute('data-latex'); if (!latexContent) return; - element.innerHTML = buildLatexContentForRender(latexContent); + const wrap = element.getAttribute('data-wrap') || 'block'; + element.innerHTML = buildLatexContentForRender(latexContent, wrap); } // **定义全局渲染方法** diff --git a/src/components/common/common.vue b/src/components/common/common.vue index a1781ec..9bac70a 100644 --- a/src/components/common/common.vue +++ b/src/components/common/common.vue @@ -2,14 +2,14 @@ //记得切换 //正式 -// const mediaUrl = '/public/'; -// const baseUrl = '/'; +const mediaUrl = '/public/'; +const baseUrl = '/'; //正式环境 -const mediaUrl = 'https://submission.tmrjournals.com/public/'; -// const mediaUrl = 'http://zmzm.tougao.dev.com/public/'; -const baseUrl = '/api' +// const mediaUrl = 'https://submission.tmrjournals.com/public/'; +// // const mediaUrl = 'http://zmzm.tougao.dev.com/public/'; +// const baseUrl = '/api' //测试环境 diff --git a/src/components/common/langs/en.js b/src/components/common/langs/en.js index 2ae466b..409b57f 100644 --- a/src/components/common/langs/en.js +++ b/src/components/common/langs/en.js @@ -1087,11 +1087,11 @@ const en = { importWordMathNoNew: 'All formulas already exist. No new rows added.', importWordMathParsed: 'Word parsed: {parsed} formula(s) found, {added} added.', importWordMathParsedNone: 'Word parsed: {parsed} formula(s) found; all already exist.', - mathFormula: 'Latex', + mathFormula: 'LaTeX-compliant', mathFormulaMore: 'more formulas', latexDataCopy: 'Copy', latexDataCopied: 'Copied', - latexDataClickToCopy: 'Click a formula to copy', + latexDataClickToCopy: 'Click a row number or formula to copy', latexDataAdd: 'Add', latexDataEdit: 'Edit', latexDataDelete: 'Delete', @@ -1108,6 +1108,23 @@ const en = { latexMathEditorTooltip: 'Insert or edit a LaTeX formula', latexMathConfirm: 'OK', latexDataCancel: 'Cancel', + latexDataRowIssueTip: 'This formula may have recognition or rendering issues; click Edit to review', + latexDataIssueSummary: '{count} formula(s) failed to render (rows {indexes}). Row numbers are in red. This notice hides once all are fixed and saved.', + latexDataIssueInlinePrefix: '{count} failed to render:', + latexDataIssueLocate: 'Locate issues ({count})', + latexDataWordDownloadHtml: 'Download source HTML', + latexDataWordPreviewTitle: 'Word formula source report', + latexDataWordPreviewHint: + 'Badge number = formula list row. No badge = not in the list. Top links are failed rows only.', + latexDataWordReportIssuePrefix: 'Fix row:', + latexDataWordPreviewLegendOk: 'Green · OK', + latexDataWordPreviewLegendErr: 'Red · needs fix', + latexDataWordPreviewLegendDup: 'Orange · duplicate', + latexDataWordReportDupPrefix: 'Duplicate rows (same LaTeX):', + latexDataWordReportDupGroup: 'Same formula', + latexDataWordReportDupTag: 'Dup #{n}', + latexDataWordReportDupCount: '×{n}', + latexDataWordReportDownloaded: 'Source HTML downloaded. Open it in your browser.', latexDataOk: 'Save', editMathFormulaSuccess: 'Math formulas updated successfully.', selectOne: 'Please select only a single paragraph!', @@ -1591,6 +1608,7 @@ const en = { latestInvitationLabel: 'Latest invitation: ', affiliation: 'Affiliation: ', field: 'Field: ', + fieldAi: 'AI Field: ', detail: 'Detail', select: 'Select', reviewing: 'Reviewing', @@ -1605,6 +1623,7 @@ const en = { dialogAffiliation: 'Affiliation :', dialogResearchAreas: 'Research areas :', dialogField: 'Field :', + dialogFieldAi: 'AI Field :', dialogIntroduction: 'Introduction :', cancel: 'Cancel' }, diff --git a/src/components/common/langs/zh.js b/src/components/common/langs/zh.js index d53ff6c..5a41c4a 100644 --- a/src/components/common/langs/zh.js +++ b/src/components/common/langs/zh.js @@ -1073,7 +1073,7 @@ const zh = { importWordMathNoNew: '公式已存在,未新增重复项', importWordMathParsed: 'Word 解析完成:识别 {parsed} 个公式,新增 {added} 个', importWordMathParsedNone: 'Word 解析完成:识别 {parsed} 个公式,均已存在,未新增', - mathFormula: 'Latex', + mathFormula: 'LaTeX-compliant', mathFormulaMore: '条公式', latexDataCopy: '复制', latexDataCopied: '已复制', @@ -1095,6 +1095,22 @@ const zh = { latexMathConfirm: '确定', latexDataCancel: '取消', latexDataOk: '保存', + latexDataRowIssueTip: '该公式可能识别异常或未正确渲染,建议点「修改」检查', + latexDataIssueSummary: '共 {count} 处未解析成功(序号 {indexes}),对应序号已标红;全部修正保存后本提示将自动消失', + latexDataIssueInlinePrefix: '共 {count} 处未解析:', + latexDataIssueLocate: '定位未解析({count})', + latexDataWordDownloadHtml: '下载源稿 HTML', + latexDataWordPreviewTitle: 'Word 公式源稿报告', + latexDataWordPreviewHint: '角标数字=公式列表行号;无角标表示该处公式不在列表中。顶部仅列出需修改的行号。', + latexDataWordReportIssuePrefix: '需修改:', + latexDataWordPreviewLegendOk: '绿色 · 通过', + latexDataWordPreviewLegendErr: '红色 · 需修改', + latexDataWordPreviewLegendDup: '橙色 · 重复公式', + latexDataWordReportDupPrefix: '重复序号(相同 LaTeX,点击跳转):', + latexDataWordReportDupGroup: '相同公式序号', + latexDataWordReportDupTag: '重复#{n}', + latexDataWordReportDupCount: '×{n}', + latexDataWordReportDownloaded: '已生成并下载源稿 HTML,请用浏览器打开查看', editMathFormulaSuccess: '数字公式更新成功', selectOne:'请只勾选单个段落!', alreadyCommented:'文本中已有批注内容请重新选择', @@ -1572,6 +1588,7 @@ const zh = { latestInvitationLabel: '最新邀请:', affiliation: '单位:', field: '领域:', + fieldAi: 'AI 领域:', detail: '详情', select: '选择', reviewing: '审稿中', @@ -1586,6 +1603,7 @@ const zh = { dialogAffiliation: '单位:', dialogResearchAreas: '研究领域:', dialogField: '领域:', + dialogFieldAi: 'AI 领域:', dialogIntroduction: '简介:', cancel: '取消' }, diff --git a/src/components/page/GenerateCharts.vue b/src/components/page/GenerateCharts.vue index 3047d00..b5fbafd 100644 --- a/src/components/page/GenerateCharts.vue +++ b/src/components/page/GenerateCharts.vue @@ -415,6 +415,7 @@ type="content" @openLatexEditor="openLatexEditor" v-if="addContentVisible" + :enable-import-word-math="true" ref="addContent" style="margin-left: -115px" > @@ -684,6 +685,12 @@ export default { handleSaveContent() { this.$refs.commonContent.getTinymceContent('content'); }, + clearWordSelectionUI() { + const word = this.$refs.commonWord; + if (word && typeof word.clearParagraphSelection === 'function') { + word.clearParagraphSelection(); + } + }, handleSaveAddContent() { this.$refs.addContent.getTinymceContent('addcontent'); }, @@ -800,6 +807,7 @@ export default { if (res.code == 0) { loading.close(); this.editVisible = false; + this.clearWordSelectionUI(); this.refreshCurrentContent('content', am_id, res.data); this.getCommentList(); } else { diff --git a/src/components/page/OnlineProofreading.vue b/src/components/page/OnlineProofreading.vue index 0251f35..8c1c9c5 100644 --- a/src/components/page/OnlineProofreading.vue +++ b/src/components/page/OnlineProofreading.vue @@ -409,6 +409,7 @@ @getContent="getContent" @openLatexEditor="openLatexEditor" v-if="addContentVisible" + :enable-import-word-math="true" ref="addContent" style="margin-left: -115px" > @@ -687,6 +688,12 @@ export default { handleSaveContent() { this.$refs.commonContent.getTinymceContent('content'); }, + clearWordSelectionUI() { + const word = this.$refs.commonWord; + if (word && typeof word.clearParagraphSelection === 'function') { + word.clearParagraphSelection(); + } + }, handleSaveAddContent() { this.$refs.addContent.getTinymceContent('addcontent'); }, @@ -752,6 +759,7 @@ export default { .then(async (res) => { if (res.code == 0) { this.editVisible = false; + this.clearWordSelectionUI(); this.getDate(); this.getCommentList(); } diff --git a/src/components/page/articleAdd.vue b/src/components/page/articleAdd.vue index d017398..543a0f1 100644 --- a/src/components/page/articleAdd.vue +++ b/src/components/page/articleAdd.vue @@ -1102,6 +1102,10 @@ import ProgressBar from '@/components/page/components/article/progress.vue'; import { updateStepStatus, markStepAsSaved } from '@/components/page/components/article/checkStepCompletion.js'; import deepEqual from '@/components/page/components/article/deepEqual.js'; import { set } from 'vue'; +import { + buildArticleAddTableListFromWordFile, + buildArticleAddTableListItems +} from '@/utils/mathFormulaModule'; export default { components: { JournalSelector, @@ -2750,21 +2754,30 @@ export default { // event.percentage 就是当前进度(百分比,浮点数) this.uploadPercentage = Math.round(event.percent); // 取整数 }, + submitArticleTableList(list) { + if (!list || !list.length) return Promise.resolve(); + return this.$api + .post('api/Article/addArticleTable', { + article_id: this.stagingID, + list + }) + .then(() => { + this.isShowCommonWord = true; + setTimeout(() => { + this.isShowProgress = false; + }, 500); + }); + }, addWordTablesList(tables) { - var data = { - article_id: this.stagingID, - - list: tables.map((e) => ({ - table: JSON.stringify([...e]), - type: 0, - html_data: '' - })) - }; - this.$api.post('api/Article/addArticleTable', data).then((res) => { - this.isShowCommonWord = true; - setTimeout(() => { - this.isShowProgress = false; - }, 500); + this.submitArticleTableList(buildArticleAddTableListItems(tables, null)); + }, + processManuscriptWordTables(file) { + if (!file) return; + const extractFn = this.$commonJS.extractWordTablesToArrays.bind(this.$commonJS); + buildArticleAddTableListFromWordFile(file, extractFn).then((list) => { + if (list.length) { + this.submitArticleTableList(list); + } }); }, upLoadWordTables() {}, @@ -2839,17 +2852,9 @@ export default { window.history.replaceState({}, document.title, url.toString()); if (this.form.article_id) { - if (File) { + if (File && File.raw) { loading.close(); - var that = this; - const reader = new FileReader(); - reader.onload = function (e) { - that.$commonJS.extractWordTablesToArrays(File.raw, function (wordTables) { - that.addWordTablesList(wordTables); - loading.close(); - },that.form.article_id); - }; - reader.readAsArrayBuffer(File.raw); + this.processManuscriptWordTables(File.raw); } await this.onStaging(5); await this.TempoAuthor(); diff --git a/src/components/page/articleProcessRevision.vue b/src/components/page/articleProcessRevision.vue index 811e363..11cbb8d 100644 --- a/src/components/page/articleProcessRevision.vue +++ b/src/components/page/articleProcessRevision.vue @@ -525,6 +525,8 @@ + + + +
+
+

${escapeHtml(title)}

+

${escapeHtml(generatedAt)}

+
+ ${escapeHtml(legendOk)} + ${escapeHtml(legendErr)} +
+ ${hint ? `

${escapeHtml(hint)}

` : ''} +
+ ${issueBlock} +
${bodyInner}
+
+ +`; +} + +export function downloadHtmlFile(html, filename) { + const name = filename || `word-formula-report-${Date.now()}.html`; + const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/src/utils/wordMathImport.js b/src/utils/wordMathImport.js index 1415dcb..535b109 100644 --- a/src/utils/wordMathImport.js +++ b/src/utils/wordMathImport.js @@ -97,12 +97,389 @@ export function createWmathHtml(latex, wrap) { function firstByLocal(parent, name) { if (!parent) return null; - return Array.from(parent.children || []).find((child) => localName(child) === name) || null; + const target = String(name || '').toLowerCase(); + return Array.from(parent.children || []).find((child) => localName(child) === target) || null; } function allByLocal(parent, name) { if (!parent) return []; - return Array.from(parent.children || []).filter((child) => localName(child) === name); + const target = String(name || '').toLowerCase(); + return Array.from(parent.children || []).filter((child) => localName(child) === target); +} + +/** Word OMML m:dPr 上的分隔符(begChr / endChr) */ +function getDelimCharFromD(node, which) { + const dPr = firstByLocal(node, 'dPr'); + if (!dPr) return null; + const el = firstByLocal(dPr, which === 'beg' ? 'begChr' : 'endChr'); + if (!el) return null; + return el.getAttribute('m:val') || el.getAttribute('val') || el.textContent || null; +} + +function isNormDelimiterChar(ch) { + if (ch == null || ch === '') return false; + const c = String(ch).trim(); + return c === '|' || c === '‖' || c === '∥' || c === '│' || c === '\u2016' || c === '||'; +} + +/** matrix 内两行均为 with probability */ +function isMatrixWithProbabilityRows(latex) { + const s = String(latex || ''); + if (!/\\begin\{matrix\}/.test(s)) return false; + const n = (s.match(/with\s*probability/gi) || []).length; + return n >= 2; +} + +/** 分段 matrix:if/otherwise 或 with probability */ +function isPiecewiseMatrixContent(latex) { + const s = String(latex || ''); + if (!/\\begin\{matrix\}/.test(s)) return false; + if (/,\s*(?:&\s*)?if\b/i.test(s) && /\botherwise\b/i.test(s)) return true; + return isMatrixWithProbabilityRows(s); +} + +/** 已是标准 matrix/cases 分段(无需再修) */ +function isProperPiecewiseLatex(latex) { + const s = String(latex || ''); + if (isPiecewiseMatrixContent(s)) { + const hasDelims = /\\left\s*\\?\{/.test(s) && /\\right\s*[\.\)]?/.test(s) && /\\end\{matrix\}/.test(s); + if (!hasDelims) return false; + if (isMatrixWithProbabilityRows(s)) { + return /,\s*&\s*with\s+probability/gi.test(s); + } + return true; + } + if (/\\begin\{(matrix|cases|aligned)\}/.test(s) && /\\end\{(matrix|cases|aligned)\}/.test(s)) { + return true; + } + if (/\\left\s*\\?\{/.test(s) && /\\right\s*[\.\)]?/.test(s) && /\\\\/.test(s)) { + return true; + } + return false; +} + +/** Word 将分段压成一行:{…, if …, otherwise} / \atop / \text{if} 等 */ +function isFlattenedPiecewiseBrace(latex) { + const inner = String(latex || ''); + if (isProperPiecewiseLatex(inner)) return false; + if (/\\atop\b/i.test(inner)) return true; + if (!/(?:\\{|{)/.test(inner)) return false; + const hasIf = /,\s*(?:\\text\{)?if\b/i.test(inner); + const hasOtherwise = /\botherwise\b/i.test(inner); + return hasIf && hasOtherwise; +} + +function stripTrailingPiecewiseGarbage(latex) { + return String(latex || '') + .trim() + .replace(/\)\s*$/, '') + .trim(); +} + +function repairUnicodeMathSymbols(latex) { + return String(latex || '') + .replace(/\u223c/g, ' \\sim ') + .replace(/∼/g, ' \\sim ') + .replace(/\u2212/g, '-'); +} + +function repairTildeDistribution(latex) { + return repairUnicodeMathSymbols(String(latex || '')) + .replace(/([a-zA-Z])\s*\\sim\s*U\s*\(/g, '$1 \\sim \\mathcal{U}(') + .replace(/([a-zA-Z])\s*\\sim\s*\\mathcal\{U\}\s*\(/g, '$1 \\sim \\mathcal{U}(') + .replace(/([a-zA-Z])\s*~\s*U\s*\(/g, '$1 \\sim \\mathcal{U}(') + .replace(/([a-zA-Z])\s*~\s*N\s*\(/g, '$1 \\sim \\mathcal{N}('); +} + +/** + * Word 导出:={\\begin{matrix}…\\end{matrix}),缺 \\left\\{、仅有 \\right.、缺 &、Unicode ∼ 等 + */ +function repairMatrixPiecewiseDelimiters(latex) { + let s = normalizeProbabilityText(repairTildeDistribution(String(latex || '').trim())); + s = s.replace(/,\s*otherwise\b/gi, ', & otherwise'); + s = s.replace(/,with\s+probability/gi, ', & with probability'); + s = s.replace(/,\s+with\s+probability/gi, ', & with probability'); + s = s.replace(/\\end\{matrix\}\s*\)\s*$/i, '\\end{matrix} \\right.'); + + if (!/\\begin\{matrix\}/.test(s) || !isPiecewiseMatrixContent(s)) { + return s; + } + + const hasLeft = /\\left\s*\\?\{/.test(s); + const hasRight = /\\right\s*[\.\)]?/.test(s); + if (hasLeft && hasRight && isProperPiecewiseLatex(s)) { + return s; + } + + const m = s.match(/^([\s\S]+?)=\s*(?:\\{|{)\s*\\begin\{matrix\}\s*([\s\S]+?)\\end\{matrix\}\s*(?:\\right\.)?\s*$/i); + if (!m) return s; + + let inner = m[2].trim(); + inner = inner.replace(/,with\s+probability/gi, ', & with probability'); + inner = inner.replace(/,\s+with\s+probability/gi, ', & with probability'); + return `${m[1].trim()} = \\left\\{ \\begin{matrix} ${inner} \\end{matrix} \\right.`; +} + +/** 去掉 Word 在 if/otherwise 两侧插入的 \\quad,便于分段正则匹配 */ +function normalizePiecewiseSpacing(latex) { + return String(latex || '') + .replace(/,\s*\\quad\s*\\text\{if\s*\}\s*\\quad\s*/g, ', \\text{if } ') + .replace(/,\s*\\quad\s*\\text\{if\s*\}\s*/g, ', \\text{if } ') + .replace(/,\s*\\text\{if\s*\}\s*\\quad\s*/g, ', \\text{if } ') + .replace(/,\s*\\quad\s*\\text\{otherwise\s*\}\s*/g, ', \\text{otherwise} ') + .replace(/,\s*\\text\{otherwise\s*\}\s*\\quad\s*/g, ', \\text{otherwise} '); +} + +function normalizeProbabilityText(latex) { + return String(latex || '') + .replace(/withprobability/gi, ' with probability ') + .replace(/with\s+probability\s+/gi, ' with probability '); +} + +/** Word 有时输出 \\{^{上}_{下}} 叠字花括号分段 */ +function repairStackedBracePiecewise(latex) { + const s = String(latex || '').trim(); + const m = s.match( + /^([\s\S]+?)=(?:\\{|{)\^\{([\s\S]+?),\s*\\text\{if\s*\}\s*([\s\S]+?)\}_\{([\s\S]+?)(?:,\s*\\text\{otherwise\s*\})?\}\s*\)?\s*$/ + ); + if (!m) return s; + + const lhs = m[1].trim(); + const branch1 = m[2].trim(); + const condition = repairTildeDistribution(m[3].trim()); + let branch2 = m[4].trim().replace(/,\s*\\text\{otherwise\s*\}\s*$/i, '').trim(); + if (!branch1 || !branch2 || !condition) return s; + + return `${lhs} = \\left\\{ \\begin{matrix} ${branch1}, & if ${condition} \\\\ ${branch2}, & otherwise \\end{matrix} \\right.`; +} + +/** with probability 分段:\\{ expr, with probability p1) expr2, with probability p2 */ +function repairProbabilityPiecewise(latex) { + const s = normalizeProbabilityText(String(latex || '').trim()); + const m = s.match( + /^([\s\S]+?)=(?:\\{|{)([\s\S]+?),\s*with probability\s+([^,]+?)\)\s*([^,]+?),\s*with probability\s+(\S+)\s*(?:\\}|})?\s*$/ + ); + if (!m) return String(latex || '').trim(); + + const lhs = m[1].trim(); + const branch1 = m[2].trim(); + const prob1 = m[3].trim(); + const branch2 = m[4].trim(); + const prob2 = m[5].trim(); + if (!branch1 || !branch2) return String(latex || '').trim(); + + return `${lhs} = \\left\\{ \\begin{matrix} ${branch1}, & with probability ${prob1} \\\\ ${branch2}, & with probability ${prob2} \\end{matrix} \\right.`; +} + +/** 修复范数:// → \\|,以及 \\| ... \\| _{2} 间距 */ +function repairNormDelimiters(latex) { + let s = String(latex || ''); + s = s.replace(/\/\/\s*(?=\\?[a-zA-Z_{\\])/g, '\\| '); + s = s.replace(/\/\/\s*_\{/g, '\\|_{'); + s = s.replace(/,\s*if\s+\/\/\s*/gi, ', & if \\| '); + s = s.replace(/,\s*if\s+(?=\\?[a-zA-Z_\\|])/gi, ', & if '); + s = s.replace(/\\\|\s+([\s\S]+?)\s+\\\|\s*_\{2\}/g, '\\left\\|$1\\right\\|_2'); + s = s.replace(/\\\|\s+([\s\S]+?)\s+\\\|_2/g, '\\left\\|$1\\right\\|_2'); + s = s.replace(/\\\|\s*_\{2\}/g, '\\|_2'); + return s; +} + +/** 未闭合的 \\begin{matrix}:尝试收成单行分式 */ +function repairIncompleteMatrix(latex) { + const s = String(latex || '').trim(); + if (!/\\begin\{matrix\}/.test(s) || /\\end\{matrix\}/.test(s)) return s; + + const m = s.match( + /^([\s\S]+?)=\{\\begin\{matrix\}\s*\\frac\{([\s\S]+?)\}\{([^}]+)\}(?:,\s*(?:&\s*)?if\s+([\s\S]+))?\s*$/ + ); + if (!m) return s; + + const lhs = m[1].trim(); + const num = m[2].trim(); + const den = m[3].trim(); + const cond = m[4] ? m[4].trim() : ''; + if (cond) { + return `${lhs} = \\frac{${num}}{${den}}, \\quad \\text{if } ${cond}`; + } + return `${lhs} = \\frac{${num}}{${den}}`; +} + +/** \\min_{i}L、^{\\left(t \\ 1\\right)} 等 */ +function repairMiscLatexGlitches(latex) { + return String(latex || '') + .replace(/\bmin_\{1\}L/g, '\\min L') + .replace(/\\min_\{([^}]+)\}L/g, '\\min_$1 L') + .replace(/\\left\(\s*([a-zA-Z])\s*\\\s*(\d+)\s*\\right\)/g, '($1+$2)') + .replace(/\^\{\\left\(([^)]+)\s*\\\s*(\d+)\s*\\right\)\}/g, '^{($1+$2)}') + .replace(/(^|[^\\])left\(/g, '$1\\left(') + .replace(/(^|[^\\])right\)/g, '$1\\right)'); +} + +/** Word 导出 \\atop 堆叠分段:{ expr1, if cond \\atop expr2, otherwise } */ +function repairAtopPiecewise(latex) { + let s = stripTrailingPiecewiseGarbage(String(latex || '').trim()); + if (!/\\atop\b/i.test(s)) return String(latex || '').trim(); + + const m = s.match( + /^([\s\S]+?)=(?:\\{|{)([\s\S]+?),\s*(?:\\text\{if\s*\}|if)\s+([\s\S]+?)\s+\\atop\s+([\s\S]+?)(?:,\s*(?:\\text\{)?otherwise)?\s*$/i + ); + if (!m) return String(latex || '').trim(); + + const lhs = m[1].trim(); + const branch1 = m[2].trim(); + const condition = repairTildeDistribution(m[3].trim()); + const branch2 = m[4].trim().replace(/,\s*otherwise\s*$/i, '').trim(); + if (!branch1 || !branch2 || !condition) return String(latex || '').trim(); + + return `${lhs} = \\left\\{ \\begin{matrix} ${branch1}, & if ${condition} \\\\ ${branch2}, & otherwise \\end{matrix} \\right.`; +} + +function buildPiecewiseMatrix(lhs, branch1, condition, branch2) { + return `${lhs} = \\left\\{ \\begin{matrix} ${branch1}, & if ${condition} \\\\ ${branch2}, & otherwise \\end{matrix} \\right.`; +} + +/** + * 将压扁的两段式分段函数还原为 matrix + \left\{ \right. + * 支持 \\text{if}、裸 if、otherwise + */ +function repairFlattenedPiecewise(latex) { + const original = String(latex || '').trim(); + let s = stripTrailingPiecewiseGarbage(normalizePiecewiseSpacing(original)); + if (!isFlattenedPiecewiseBrace(s)) return original; + + const atopFixed = repairAtopPiecewise(s); + if (atopFixed !== s && !/\\atop\b/i.test(atopFixed)) return atopFixed; + + const m = s.match( + /^([\s\S]+?)=(?:\\{|{)([\s\S]+?),\s*(?:\\text\{if\s*\}|if)\s*([\s\S]+?),\s*(?:\\text\{otherwise\s*\}|otherwise)\s*$/i + ); + if (!m) return original; + + const lhs = m[1].trim(); + const branch1 = m[2].trim(); + let mid = repairTildeDistribution(m[3].trim()); + + const split = mid.match(/^([\s\S]+?)(<|>|<=|>=)(-?\d*\.?\d+)\s*([\s\S]+)$/); + if (!split) return original; + + const condition = `${split[1].trim()}${split[2]}${split[3]}`.trim(); + const branch2 = split[4].trim(); + if (!branch1 || !branch2 || !condition) return original; + + return buildPiecewiseMatrix(lhs, branch1, condition, branch2); +} + +/** + * 修复 Word 导入常见 LaTeX 瑕疵(范数变 //、裸 if、重复 $$ 等) + */ +export function repairImportedLatex(latex) { + let s = String(latex || '').trim(); + if (!s) return ''; + + s = s.replace(/^\$+/, '').replace(/\$+$/, '').trim(); + + s = repairMatrixPiecewiseDelimiters(s); + s = repairAtopPiecewise(s); + s = repairStackedBracePiecewise(s); + s = repairProbabilityPiecewise(s); + s = repairFlattenedPiecewise(s); + s = repairNormDelimiters(s); + s = repairIncompleteMatrix(s); + s = repairTildeDistribution(s); + s = repairMiscLatexGlitches(s); + + s = s.replace(/\s+/g, ' ').trim(); + return s; +} + +function normalizeLatexCompareKey(latex) { + return String(latex || '') + .trim() + .replace(/^\$+/, '') + .replace(/\$+$/, '') + .replace(/\s+/g, ' '); +} + +/** Word 导入后仍残留的常见异常片段(如范数变成 //) */ +export function hasLatexImportArtifacts(latex) { + const raw = String(latex || ''); + if (/\/\//.test(raw)) return true; + const inner = normalizeLatexCompareKey(latex); + if (!inner) return false; + if (/\/\/\s*(?=\\?[a-zA-Z_])/.test(inner)) return true; + if (/\/\/\s*_\{/.test(inner)) return true; + if (/\bmin_\{1\}L/.test(inner)) return true; + if (/(^|[^\\])left\(/.test(inner)) return true; + if (/(^|[^\\])right\)/.test(inner)) return true; + if (isFlattenedPiecewiseBrace(inner)) return true; + if (/[a-zA-Z]\s*~\s*U\s*\(/.test(inner)) return true; + if (/[a-zA-Z]\s*\\sim\s*U\s*\(/.test(inner)) return true; + if (/^\$\$[\s\S]+\$\$$/.test(String(latex || '').trim())) return true; + if (/(?:\\{|{)\^\{[\s\S]*\\text\{if/.test(inner)) return true; + if (/withprobability/i.test(inner)) return true; + if (/\\begin\{matrix\}/.test(inner) && !/\\end\{matrix\}/.test(inner)) return true; + if (/\\left\(\s*[a-zA-Z]\s*\\\s*\d+\s*\\right\)/.test(inner)) return true; + if (/\\atop\b/i.test(inner)) return true; + if (isPiecewiseMatrixContent(inner) && !/\\left\s*\\?\{/.test(inner)) return true; + if (/\\right\s*[\.\)]?/.test(inner) && !/\\left\s*\\?\{/.test(inner) && /\\begin\{matrix\}/.test(inner)) { + return true; + } + if (/with\s*probability/i.test(inner) && !/,\s*&\s*with\s+probability/i.test(inner)) return true; + if (/,\s*otherwise\b/i.test(inner) && !/,\s*&\s*otherwise\b/i.test(inner)) return true; + if (/∼/.test(raw) || /\u223c/.test(raw)) return true; + return false; +} + +/** data-latex 是否明显损坏(不含合法的 \\left/\\frac 等) */ +export function isAttrLatexStructurallyBroken(latex) { + const t = String(latex || '').trim(); + if (!t) return true; + if (/^\$\$/.test(t) || /\$\$$/.test(t)) return true; + if (/\\\\/.test(t) && t.length > 12) return true; + if (/\/\/\s*(_|[\\a-zA-Z])/.test(t)) return true; + return false; +} + +/** 文本是否像未渲染的 LaTeX 源码(DOM 内可见 $$、\\frac 等) */ +export function isRawLatexTextProblematic(text) { + const t = String(text || '').trim(); + if (!t) return true; + if (isAttrLatexStructurallyBroken(t)) return true; + if (/\\(begin|frac|left|right|mathcal|atop|end|sim)\b/i.test(t)) return true; + return false; +} + +/** + * 列表/预览 DOM:是否应标红(露出源码、MathJax 报错、或导入时已标记) + */ +export function isFormulaDisplayedAsRawSource(wmath) { + if (!wmath) return true; + if (wmath.getAttribute('data-import-error') === '1') return true; + if (wmath.querySelector('mjx-merror')) return true; + + const mjx = wmath.querySelector('mjx-container'); + if (mjx) { + const text = (wmath.textContent || '').trim(); + if (text && text !== '1' && isRawLatexTextProblematic(text)) return true; + return false; + } + + const text = (wmath.textContent || '').trim(); + if (!text || text === '1') { + const fromAttr = extractLatexFromWmathElement(wmath); + return isAttrLatexStructurallyBroken(fromAttr); + } + + return isRawLatexTextProblematic(text); +} + +/** + * @deprecated 标红请用 isFormulaDisplayedAsRawSource(wmath);此处仅判断缺少 wmath 标签 + */ +export function isProblematicLatexFormula(rawCellHtml) { + const div = document.createElement('div'); + div.innerHTML = String(rawCellHtml || ''); + return !div.querySelector('wmath'); } function walkOmml(node) { @@ -157,17 +534,48 @@ function walkOmml(node) { } if (name === 'd') { - const begChr = node.getAttribute('m:begChr') || node.getAttribute('begChr') || '('; - const endChr = node.getAttribute('m:endChr') || node.getAttribute('endChr') || ')'; - const inner = allByLocal(node, 'e') - .map((item) => walkOmmlChildren(item)) + const begChr = getDelimCharFromD(node, 'beg') || node.getAttribute('m:begChr') || node.getAttribute('begChr') || '('; + const endChr = getDelimCharFromD(node, 'end') || node.getAttribute('m:endChr') || node.getAttribute('endChr') || ')'; + const elems = allByLocal(node, 'e'); + let inner; + if (elems.length > 1) { + inner = elems.map((item) => walkOmmlChildren(item)).join(' \\\\ '); + } else if (elems.length === 1) { + inner = walkOmmlChildren(elems[0]); + } else { + inner = Array.from(node.children || []) + .filter((child) => localName(child) !== 'dpr') + .map((child) => walkOmml(child)) .join(''); + } + if (isNormDelimiterChar(begChr) && isNormDelimiterChar(endChr)) { + return `\\left\\|${inner}\\right\\|`; + } if (begChr === '(' && endChr === ')') return `\\left(${inner}\\right)`; if (begChr === '[' && endChr === ']') return `\\left[${inner}\\right]`; - if (begChr === '{' && endChr === '}') return `\\left\\{${inner}\\right\\}`; + if (begChr === '{' && endChr === '}') { + if (elems.length > 1 && !/\\begin\{(matrix|cases)\}/.test(inner)) { + inner = `\\begin{matrix} ${inner} \\end{matrix}`; + } + if (inner.includes('\\\\') || inner.includes('\\begin{matrix}')) { + return `\\left\\{ ${inner} \\right.`; + } + return `\\left\\{${inner}\\right\\}`; + } + if (begChr === '|' && endChr === '|') return `\\left|${inner}\\right|`; return `${begChr}${inner}${endChr}`; } + if (name === 'func') { + const fNameEl = firstByLocal(node, 'fName'); + const fname = (fNameEl && (fNameEl.getAttribute('m:val') || fNameEl.getAttribute('val') || fNameEl.textContent)) || ''; + const fnameTrim = String(fname).trim(); + const body = walkOmmlChildren(firstByLocal(node, 'e')); + if (!fnameTrim) return body; + const cmd = fnameTrim.replace(/\s+/g, ''); + return `\\${cmd}${body ? ` ${body}` : ''}`; + } + if (name === 'nary') { const chr = node.getAttribute('m:chr') || node.getAttribute('chr') || '\\int'; const sub = walkOmmlChildren(firstByLocal(node, 'sub')); @@ -184,7 +592,34 @@ function walkOmml(node) { return result; } - if (name === 'eqarr' || name === 'm') { + if (name === 'eqarr') { + const rows = allByLocal(node, 'e'); + if (rows.length > 1) { + const latexRows = rows.map((row) => walkOmmlChildren(row)); + return `\\begin{matrix} ${latexRows.join(' \\\\ ')} \\end{matrix}`; + } + return walkOmmlChildren(node); + } + + if (name === 'm') { + const matrixRows = allByLocal(node, 'mr'); + if (matrixRows.length > 0) { + const latexRows = matrixRows.map((mr) => { + const cols = allByLocal(mr, 'mc'); + if (cols.length > 0) { + return cols.map((mc) => walkOmmlChildren(mc)).join(' & '); + } + return walkOmmlChildren(mr); + }); + if (latexRows.length > 1) { + return `\\begin{matrix} ${latexRows.join(' \\\\ ')} \\end{matrix}`; + } + return latexRows[0] || walkOmmlChildren(node); + } + return walkOmmlChildren(node); + } + + if (name === 'mc') { return walkOmmlChildren(node); } @@ -208,7 +643,7 @@ function walkOmmlChildren(node) { function ommlNodeToLatex(node) { if (!node) return ''; - return walkOmml(node).replace(/\s+/g, ' ').trim(); + return repairImportedLatex(walkOmml(node).replace(/\s+/g, ' ').trim()); } function isRunBold(run) { @@ -345,16 +780,14 @@ export const MATH_FORMULA_TABLE_TITLE = 'Latex Data'; export const LEGACY_MATH_FORMULA_TABLE_TITLE = '数字公式'; export function isMathFormulaTableTitle(title) { - return title === MATH_FORMULA_TABLE_TITLE || title === LEGACY_MATH_FORMULA_TABLE_TITLE; + const t = String(title || '').trim(); + if (!t) return false; + if (t === LEGACY_MATH_FORMULA_TABLE_TITLE) return true; + return t.replace(/\s+/g, ' ').toLowerCase() === 'latex data'; } function normalizeWmathLatex(latex) { - let value = String(latex || '').trim(); - if (!value) return ''; - if (/^\$\$[\s\S]+\$\$$/.test(value) || /^\$[\s\S]+\$$/.test(value)) { - return value; - } - return `$$${value}$$`; + return repairImportedLatex(latex); } function buildWmathHtml(latex, wrap, id) { @@ -363,7 +796,7 @@ function buildWmathHtml(latex, wrap, id) { const uid = id || `wmath-${Math.random().toString(36).substr(2, 9)}`; const mode = wrap === 'inline' || wrap === 'block' ? wrap : 'block'; const safe = escapeLatexForAttr(normalized); - return `${normalized}`; + return `1`; } export function buildWmathCellHtml(latex, wrap, id) { @@ -377,10 +810,65 @@ export function getLatexFromCellHtml(html) { const wmath = div.querySelector('wmath'); if (wmath) { const fromAttr = extractLatexFromWmathElement(wmath); - if (fromAttr) return fromAttr; - return (wmath.textContent || '').trim(); + if (fromAttr) return repairImportedLatex(fromAttr); + return repairImportedLatex((wmath.textContent || '').trim()); } - return (div.textContent || '').trim(); + return repairImportedLatex((div.textContent || '').trim()); +} + +/** 展示/编辑前:修复 LaTeX 并重建 wmath 单元格 HTML(兼容历史脏数据) */ +export function normalizeWmathCellHtml(html) { + const div = document.createElement('div'); + div.innerHTML = String(html || ''); + const wmath = div.querySelector('wmath'); + if (!wmath) return html || ''; + const raw = extractLatexFromWmathElement(wmath) || (wmath.textContent || '').trim(); + const repaired = repairImportedLatex(raw); + if (!repaired) return html || ''; + const wrap = wmath.getAttribute('data-wrap') || 'block'; + const id = wmath.getAttribute('data-id') || ''; + return buildWmathHtml(repaired, wrap, id) || html || ''; +} + +/** + * 为 Word 导入预览标注公式序号(与 Latex Data 列表行号一致,仅计有 LaTeX 的 wmath) + */ +function markOrphanLatexBlocksInPreview(root) { + root.querySelectorAll('p, td, th, li').forEach((block) => { + if (block.querySelector('wmath')) return; + const text = (block.textContent || '').trim(); + if (!text) return; + if (/\$\$[\s\S]+?\$\$/.test(text) || /\\begin\{/.test(text) || /\\frac\s*\{/.test(text)) { + block.classList.add('word-import-orphan-latex'); + } + }); +} + +export function buildWordImportPreviewHtml(html) { + if (!html) return ''; + const div = document.createElement('div'); + div.innerHTML = String(html); + let index = 0; + div.querySelectorAll('wmath').forEach((el) => { + const latex = extractLatexFromWmathElement(el); + if (!latex) return; + index += 1; + const idx = String(index); + el.setAttribute('data-formula-index', idx); + const anchor = document.createElement('span'); + anchor.className = 'word-import-formula-anchor'; + anchor.setAttribute('data-formula-index', idx); + const badge = document.createElement('span'); + badge.className = 'word-import-formula-index-badge'; + badge.textContent = idx; + const parent = el.parentNode; + if (!parent) return; + parent.insertBefore(anchor, el); + anchor.appendChild(badge); + anchor.appendChild(el); + }); + markOrphanLatexBlocksInPreview(div); + return div.innerHTML; } /** 将解析后的 HTML 中的 wmath 提取为单个表格 table_data(表头 + 公式行) */ @@ -407,8 +895,8 @@ export function extractWmathTableDataFromHtml(html) { } return { - tableData: [ - [{ text: 'Latex Data', colspan: 1, rowspan: 1 }], + tableData: [ + [{ text: 'Latex Data', colspan: 1, rowspan: 1 }], ...formulaRows ], html: htmlParts.join('')