From f9fd764c4ec0bb9bab5d76f34685b9862531bf70 Mon Sep 17 00:00:00 2001 From: euvre <93761161+euvre@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:56:06 +0800 Subject: [PATCH] Fix overlapping CJK text lines in docx file preview (#17693) --- .../document-preview/doc-preview.tsx | 122 +++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/web/src/components/document-preview/doc-preview.tsx b/web/src/components/document-preview/doc-preview.tsx index fea4a9d44d..af876dce7d 100644 --- a/web/src/components/document-preview/doc-preview.tsx +++ b/web/src/components/document-preview/doc-preview.tsx @@ -3,6 +3,8 @@ import { Spin } from '@/components/ui/spin'; import request from '@/utils/request'; import { DocxEditorViewer, + packageToArrayBuffer, + parseDocx, useDocxEditor, useDocxPageLayout, } from '@extend-ai/react-docx'; @@ -21,6 +23,120 @@ interface DocPreviewerProps { url: string; } +// @extend-ai/react-docx renders paragraphs without explicit line spacing at +// 0.88x the font size, which makes CJK glyph lines overlap. Word renders such +// paragraphs with the font's natural line height (~1.3x for CJK fonts). +// Inject a docDefaults-level line spacing (1.3 lines) so paragraphs that do +// not define their own line spacing get a consistent, readable line pitch. +// Paragraphs or styles with explicit line spacing are left untouched. +const DEFAULT_LINE_SPACING_TWIPS = 312; // 1.3 lines (240 twips per line) +const DEFAULT_LINE_SPACING_TAG = ``; + +const DEFAULT_LINE_SPACING_PPR_DEFAULT = `${DEFAULT_LINE_SPACING_TAG}`; + +// Ensure the docDefaults section of word/styles.xml defines a default line +// spacing. Returns the original XML when a default already exists. +const ensureDefaultLineSpacing = (stylesXml: string): string => { + const docDefaults = stylesXml.match( + /]*>[\s\S]*?<\/w:docDefaults>/i, + )?.[0]; + if (!docDefaults) { + const selfClosing = stylesXml.match(/]*\/>/i)?.[0]; + if (selfClosing) { + return stylesXml.replace( + selfClosing, + `${DEFAULT_LINE_SPACING_PPR_DEFAULT}`, + ); + } + const stylesOpen = stylesXml.match(/]*>/i)?.[0]; + if (!stylesOpen) return stylesXml; + return stylesXml.replace( + stylesOpen, + `${stylesOpen}${DEFAULT_LINE_SPACING_PPR_DEFAULT}`, + ); + } + + const replaceDocDefaults = (next: string) => + stylesXml.replace(docDefaults, next); + + const pprDefault = docDefaults.match( + /]*>[\s\S]*?<\/w:pPrDefault>/i, + )?.[0]; + if (!pprDefault) { + const selfClosing = docDefaults.match(/]*\/>/i)?.[0]; + if (selfClosing) { + return replaceDocDefaults( + docDefaults.replace(selfClosing, DEFAULT_LINE_SPACING_PPR_DEFAULT), + ); + } + const docDefaultsOpen = docDefaults.match(/]*>/i)?.[0]; + if (!docDefaultsOpen) return stylesXml; + return replaceDocDefaults( + docDefaults.replace( + docDefaultsOpen, + `${docDefaultsOpen}${DEFAULT_LINE_SPACING_PPR_DEFAULT}`, + ), + ); + } + + const replacePprDefault = (next: string) => + replaceDocDefaults(docDefaults.replace(pprDefault, next)); + + const spacingTag = pprDefault.match(/]*?\/?>/i)?.[0]; + if (spacingTag) { + // A default line spacing already exists; respect the document. + if (/\bw:line\s*=/i.test(spacingTag)) return stylesXml; + let tag = spacingTag.replace(/\s*\/?>$/, ''); + tag += ` w:line="${DEFAULT_LINE_SPACING_TWIPS}"`; + if (!/\bw:lineRule\s*=/i.test(spacingTag)) { + tag += ' w:lineRule="auto"'; + } + return replacePprDefault(pprDefault.replace(spacingTag, `${tag}/>`)); + } + + const pprTag = pprDefault.match(/]*>/i)?.[0]; + if (pprTag) { + if (pprTag.endsWith('/>')) { + return replacePprDefault( + pprDefault.replace( + pprTag, + `${DEFAULT_LINE_SPACING_TAG}`, + ), + ); + } + return replacePprDefault( + pprDefault.replace(pprTag, `${pprTag}${DEFAULT_LINE_SPACING_TAG}`), + ); + } + const pprDefaultOpen = pprDefault.match(/]*>/i)?.[0]; + if (!pprDefaultOpen) return stylesXml; + return replacePprDefault( + pprDefault.replace( + pprDefaultOpen, + `${pprDefaultOpen}${DEFAULT_LINE_SPACING_TAG}`, + ), + ); +}; + +// Repack the docx with a default line spacing so the preview renders +// consistent line pitch. Falls back to the original blob on any failure. +const normalizeDocxLineSpacing = async (blob: Blob): Promise => { + try { + const pkg = await parseDocx(await blob.arrayBuffer()); + const stylesPart = pkg.parts.get('word/styles.xml'); + if (!stylesPart) return blob; + const patched = ensureDefaultLineSpacing(stylesPart.content); + if (patched === stylesPart.content) return blob; + stylesPart.content = patched; + return new Blob([packageToArrayBuffer(pkg)], { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + } catch (error) { + console.warn('Failed to normalize docx line spacing:', error); + return blob; + } +}; + // Word document preview component. // Uses @extend-ai/react-docx for canvas-based page-level rendering. // Falls back to an unsupported notice for legacy .doc (non-ZIP) payloads. @@ -87,7 +203,11 @@ export const DocPreviewer: React.FC = ({ return; } - const file = new File([blob], 'document.docx', { + const normalizedBlob = await normalizeDocxLineSpacing(blob); + + if (cancelledRef.current) return; + + const file = new File([normalizedBlob], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', });