From 710e1d117b155bd64f137362e744abcea208b125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=B9=8F?= Date: Wed, 8 Jul 2026 17:59:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Edocx=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E9=A2=84=E8=A7=88=E5=B8=A6=E5=A4=A7=E7=BA=B2=E7=9A=84?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/fa-docx-preview/index.vue | 626 ++++++++++++++++++ .../fa-docx-preview/useDocxOutline.ts | 246 +++++++ 2 files changed, 872 insertions(+) create mode 100644 src/components/business/fa-docx-preview/index.vue create mode 100644 src/components/business/fa-docx-preview/useDocxOutline.ts diff --git a/src/components/business/fa-docx-preview/index.vue b/src/components/business/fa-docx-preview/index.vue new file mode 100644 index 0000000..c1a3977 --- /dev/null +++ b/src/components/business/fa-docx-preview/index.vue @@ -0,0 +1,626 @@ + + + + + diff --git a/src/components/business/fa-docx-preview/useDocxOutline.ts b/src/components/business/fa-docx-preview/useDocxOutline.ts new file mode 100644 index 0000000..d68ec1b --- /dev/null +++ b/src/components/business/fa-docx-preview/useDocxOutline.ts @@ -0,0 +1,246 @@ +/** + * docx 大纲解析 composable + * + * @file-viewer/vue3 底层使用 docx-preview 渲染 docx,标题被渲染为带内联样式的

, + * 不存在语义化标签或样式类,因此其内置 collectDocumentAnchors 会把全部段落当作锚点, + * 无法形成"标题级"大纲。这里通过 JSZip 直接解压 docx 包,用 DOMParser 解析 + * word/document.xml,提取带 Heading 样式 / outlineLvl 的段落,构建分层大纲。 + * + * 兼容 WPS / 国产 Word:这类文档的 常使用纯数字 styleId + * (如 "2"、"3"),其含义定义在 word/styles.xml 中。因此需要同时解析 styles.xml, + * 建立 styleId → 标题层级 的映射,再在解析段落时查表。 + */ +import { ref, shallowRef, type Ref, type ShallowRef } from "vue"; +import JSZip from "jszip"; + +export interface DocxOutlineItem { + /** 唯一 id,同时会作为渲染后

上的 data-outline-id */ + id: string; + /** 标题层级 1-9 */ + level: number; + /** 标题文本 */ + text: string; +} + +export interface UseDocxOutlineResult { + /** 大纲条目(按文档顺序) */ + outline: Ref; + /** 解析得到的 docx ArrayBuffer,可直接喂给 复用,避免二次请求 */ + buffer: ShallowRef; + /** 解析中的 loading */ + loading: Ref; + /** 解析错误 */ + error: Ref; + /** 文件名(从 url 推断或外部传入) */ + filename: Ref; + /** 加载并解析 docx,src 可为 url(string)或 ArrayBuffer */ + load: (src: string | ArrayBuffer, name?: string) => Promise; +} + +const HEADING_STYLE_RE = /^heading[\s_-]?([1-9])$/i; +const HEADING_STYLE_CN_RE = /^标题\s?([1-9])$/; + +const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +function getAttr(el: Element, localName: string): string { + const ns = el.getAttributeNS(W_NS, localName); + if (ns) return ns; + return el.getAttribute(`w:${localName}`) || ""; +} + +function getHeadingLevelByName(name: string | null | undefined): number { + if (!name) return 0; + let m = HEADING_STYLE_RE.exec(name); + if (m) return Number(m[1]); + m = HEADING_STYLE_CN_RE.exec(name); + if (m) return Number(m[1]); + return 0; +} + +function paragraphText(p: Element): string { + const ts = p.getElementsByTagNameNS(W_NS, "t"); + let text = ""; + for (let i = 0; i < ts.length; i++) { + const node = ts[i]; + if (node) text += node.textContent || ""; + } + return text.replace(/\s+/g, " ").trim(); +} + +interface StyleEntry { + styleId: string; + name: string; + basedOn: string; + outlineLvl: number | null; +} + +function parseStylesXml(stylesXml: string): Map { + const doc = new DOMParser().parseFromString(stylesXml, "application/xml"); + const styleEls = doc.getElementsByTagNameNS(W_NS, "style"); + const map = new Map(); + for (let i = 0; i < styleEls.length; i++) { + const style = styleEls[i]; + if (!style) continue; + const type = getAttr(style, "type"); + if (type !== "paragraph") continue; + const styleId = getAttr(style, "styleId"); + if (!styleId) continue; + const nameEl = style.getElementsByTagNameNS(W_NS, "name")[0]; + const name = nameEl ? getAttr(nameEl, "val") : ""; + const basedOnEl = style.getElementsByTagNameNS(W_NS, "basedOn")[0]; + const basedOn = basedOnEl ? getAttr(basedOnEl, "val") : ""; + const outlineLvlEl = style.getElementsByTagNameNS(W_NS, "outlineLvl")[0]; + let outlineLvl: number | null = null; + if (outlineLvlEl) { + const val = Number(getAttr(outlineLvlEl, "val")); + if (Number.isFinite(val)) outlineLvl = val; + } + map.set(styleId, { styleId, name, basedOn, outlineLvl }); + } + return map; +} + +function buildStyleHeadingMap(stylesXml: string): Map { + const entries = parseStylesXml(stylesXml); + const resolved = new Map(); + function resolve(styleId: string, visited: Set): number { + const cached = resolved.get(styleId); + if (cached !== undefined) return cached; + if (visited.has(styleId)) return 0; + const entry = entries.get(styleId); + if (!entry) return 0; + visited.add(styleId); + if (entry.outlineLvl !== null) { + if (entry.outlineLvl >= 0 && entry.outlineLvl <= 8) { + const level = entry.outlineLvl + 1; + resolved.set(styleId, level); + return level; + } + resolved.set(styleId, 0); + return 0; + } + const byName = getHeadingLevelByName(entry.name); + if (byName > 0) { + resolved.set(styleId, byName); + return byName; + } + if (entry.basedOn) { + const inherited = resolve(entry.basedOn, visited); + if (inherited > 0) { + resolved.set(styleId, inherited); + return inherited; + } + } + resolved.set(styleId, 0); + return 0; + } + for (const id of entries.keys()) { + resolve(id, new Set()); + } + return resolved; +} + +export function parseDocxOutline( + documentXml: string, + styleHeadingMap?: Map +): DocxOutlineItem[] { + const doc = new DOMParser().parseFromString(documentXml, "application/xml"); + const paragraphs = doc.getElementsByTagNameNS(W_NS, "p"); + const items: DocxOutlineItem[] = []; + for (let i = 0; i < paragraphs.length; i++) { + const p = paragraphs[i]; + if (!p) continue; + const pPr = p.getElementsByTagNameNS(W_NS, "pPr")[0]; + if (!pPr) continue; + let level = 0; + const pStyle = pPr.getElementsByTagNameNS(W_NS, "pStyle")[0]; + if (pStyle) { + const styleVal = getAttr(pStyle, "val"); + if (styleVal) { + if (styleHeadingMap && styleHeadingMap.has(styleVal)) { + level = styleHeadingMap.get(styleVal) || 0; + } else { + level = getHeadingLevelByName(styleVal); + } + } + } + if (level === 0) { + const outlineLvl = pPr.getElementsByTagNameNS(W_NS, "outlineLvl")[0]; + if (outlineLvl) { + const val = Number(getAttr(outlineLvl, "val")); + if (Number.isFinite(val) && val >= 0 && val <= 8) { + level = val + 1; + } + } + } + if (level === 0) continue; + const text = paragraphText(p); + if (!text) continue; + items.push({ + id: `docx-outline-${items.length + 1}`, + level, + text, + }); + } + return items; +} + +export function useDocxOutline(): UseDocxOutlineResult { + const outline = ref([]); + const buffer = shallowRef(null); + const loading = ref(false); + const error = ref(null); + const filename = ref(""); + + async function parseBuffer(arrayBuffer: ArrayBuffer, name?: string) { + if (name) filename.value = name; + const zip = await JSZip.loadAsync(arrayBuffer); + const documentFile = zip.file("word/document.xml"); + if (!documentFile) { + throw new Error("docx 包内未找到 word/document.xml"); + } + const documentXml = await documentFile.async("string"); + const stylesFile = zip.file("word/styles.xml"); + const stylesXml = stylesFile ? await stylesFile.async("string") : ""; + const styleHeadingMap = stylesXml + ? buildStyleHeadingMap(stylesXml) + : new Map(); + outline.value = parseDocxOutline(documentXml, styleHeadingMap); + } + + async function load(src: string | ArrayBuffer, name?: string): Promise { + loading.value = true; + error.value = null; + outline.value = []; + buffer.value = null; + try { + if (typeof src === "string") { + const url = src; + if (!name) { + const u = url.split("/").pop() || ""; + filename.value = decodeURIComponent(u.split("?")[0] || "document.docx"); + } else { + filename.value = name; + } + const resp = await fetch(url); + if (!resp.ok) { + throw new Error(`加载 docx 失败:${resp.status} ${resp.statusText}`); + } + const arrayBuffer = await resp.arrayBuffer(); + buffer.value = arrayBuffer; + await parseBuffer(arrayBuffer, filename.value); + } else { + buffer.value = src; + filename.value = name || "document.docx"; + await parseBuffer(src, filename.value); + } + } catch (e: any) { + error.value = e instanceof Error ? e : new Error(String(e)); + outline.value = []; + } finally { + loading.value = false; + } + } + + return { outline, buffer, loading, error, filename, load }; +}