Fix overlapping CJK text lines in docx file preview (#17693)

This commit is contained in:
euvre
2026-08-03 17:56:06 +08:00
committed by GitHub
parent 8b83c52966
commit f9fd764c4e

View File

@@ -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 = `<w:spacing w:line="${DEFAULT_LINE_SPACING_TWIPS}" w:lineRule="auto"/>`;
const DEFAULT_LINE_SPACING_PPR_DEFAULT = `<w:pPrDefault><w:pPr>${DEFAULT_LINE_SPACING_TAG}</w:pPr></w:pPrDefault>`;
// 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(
/<w:docDefaults\b[^>]*>[\s\S]*?<\/w:docDefaults>/i,
)?.[0];
if (!docDefaults) {
const selfClosing = stylesXml.match(/<w:docDefaults\b[^>]*\/>/i)?.[0];
if (selfClosing) {
return stylesXml.replace(
selfClosing,
`<w:docDefaults>${DEFAULT_LINE_SPACING_PPR_DEFAULT}</w:docDefaults>`,
);
}
const stylesOpen = stylesXml.match(/<w:styles\b[^>]*>/i)?.[0];
if (!stylesOpen) return stylesXml;
return stylesXml.replace(
stylesOpen,
`${stylesOpen}<w:docDefaults>${DEFAULT_LINE_SPACING_PPR_DEFAULT}</w:docDefaults>`,
);
}
const replaceDocDefaults = (next: string) =>
stylesXml.replace(docDefaults, next);
const pprDefault = docDefaults.match(
/<w:pPrDefault\b[^>]*>[\s\S]*?<\/w:pPrDefault>/i,
)?.[0];
if (!pprDefault) {
const selfClosing = docDefaults.match(/<w:pPrDefault\b[^>]*\/>/i)?.[0];
if (selfClosing) {
return replaceDocDefaults(
docDefaults.replace(selfClosing, DEFAULT_LINE_SPACING_PPR_DEFAULT),
);
}
const docDefaultsOpen = docDefaults.match(/<w:docDefaults\b[^>]*>/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(/<w:spacing\b[^>]*?\/?>/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(/<w:pPr\b[^>]*>/i)?.[0];
if (pprTag) {
if (pprTag.endsWith('/>')) {
return replacePprDefault(
pprDefault.replace(
pprTag,
`<w:pPr>${DEFAULT_LINE_SPACING_TAG}</w:pPr>`,
),
);
}
return replacePprDefault(
pprDefault.replace(pprTag, `${pprTag}${DEFAULT_LINE_SPACING_TAG}`),
);
}
const pprDefaultOpen = pprDefault.match(/<w:pPrDefault\b[^>]*>/i)?.[0];
if (!pprDefaultOpen) return stylesXml;
return replacePprDefault(
pprDefault.replace(
pprDefaultOpen,
`${pprDefaultOpen}<w:pPr>${DEFAULT_LINE_SPACING_TAG}</w:pPr>`,
),
);
};
// 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<Blob> => {
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<DocPreviewerProps> = ({
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',
});