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',
});