Export QWord page setup to DOCX

This commit is contained in:
Курнат Андрей
2026-06-04 21:40:17 +03:00
parent e5cd922f7d
commit 3719cb7ec8
3 changed files with 83 additions and 3 deletions
+1 -1
View File
@@ -736,7 +736,7 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
async function saveDocx() {
try {
const fileName = replaceFileExtension(document.title, ".docx");
const blob = await exportDocxBlob(document.title, editorRef.current?.innerHTML ?? document.html);
const blob = await exportDocxBlob(document.title, editorRef.current?.innerHTML ?? document.html, { pageSetup: document.pageSetup });
if (isDesktopOfficeBridgeAvailable()) {
await saveDesktopOfficeFile("docx", fileName, blob);
return;
+11
View File
@@ -279,6 +279,17 @@ describe("wordOffice helpers", () => {
expect(documentXml).toContain('<w:jc w:val="right"/>');
});
it("writes QWord page setup to exported DOCX section properties", async () => {
const blob = await exportDocxBlob("Макет.docx", "<p>Landscape letter</p>", {
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" }
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/<w:pgSz\b[^>]*w:w="15840"[^>]*w:h="12240"[^>]*w:orient="landscape"[^>]*\/>/);
expect(documentXml).toMatch(/<w:pgMar\b[^>]*w:top="720"[^>]*w:right="720"[^>]*w:bottom="720"[^>]*w:left="720"[^>]*\/>/);
});
it("записывает внешние гиперссылки в DOCX relationships", async () => {
const blob = await exportDocxBlob(
"Ссылки.docx",
+71 -2
View File
@@ -1,4 +1,5 @@
import { DOCX_MIME_TYPE, officeTitleFromFileName, readOfficeFileAsArrayBuffer } from "./fileHelpers";
import type { WordPageMarginPreset, WordPageOrientation, WordPageSetup, WordPageSizeId } from "../types";
export interface ImportedWordDocument {
title: string;
@@ -116,6 +117,7 @@ type DocxModule = {
Document: new (options: Record<string, unknown>) => unknown;
HeadingLevel: Record<string, string>;
LineRuleType?: Record<string, string>;
PageOrientation?: Record<string, string>;
Packer: {
toBlob?: (document: unknown) => Promise<Blob>;
toBuffer?: (document: unknown) => Promise<ArrayBuffer | Uint8Array>;
@@ -165,6 +167,16 @@ const wordImageRelationshipType = "http://schemas.openxmlformats.org/officeDocum
const emptyDocxRelationshipTargets = { hyperlinks: {}, images: {} };
const pageBreakMarker = "\f";
const pageBreakHtml = '<span data-qoffice-page-break="true"></span>';
const defaultWordPageSetup: WordPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
const wordPageSizes: Record<WordPageSizeId, { width: number; height: number }> = {
a4: { width: 794, height: 1123 },
letter: { width: 816, height: 1056 }
};
const wordPageMargins: Record<WordPageMarginPreset, { top: number; right: number; bottom: number; left: number }> = {
normal: { top: 72, right: 86, bottom: 72, left: 86 },
narrow: { top: 48, right: 48, bottom: 48, left: 48 },
wide: { top: 92, right: 116, bottom: 92, left: 116 }
};
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
if (!file.name.toLowerCase().endsWith(".docx")) {
@@ -190,11 +202,12 @@ export async function importDocxFile(file: File): Promise<ImportedWordDocument>
};
}
export async function exportDocxBlob(title: string, html: string): Promise<Blob> {
export async function exportDocxBlob(title: string, html: string, options: { pageSetup?: WordPageSetup } = {}): Promise<Blob> {
const docx = await loadDocx();
const blocks = htmlToWordBlocks(html);
const children = blocks.flatMap((block) => wordBlockToDocxChildren(block, docx));
const documentTitle = title.trim() || "Документ QWord";
const sectionProperties = wordSectionProperties(options.pageSetup, docx);
const document = new docx.Document({
creator: "QOffice",
@@ -221,7 +234,7 @@ export async function exportDocxBlob(title: string, html: string): Promise<Blob>
},
sections: [
{
properties: {},
properties: sectionProperties,
children:
children.length > 0
? children
@@ -246,6 +259,62 @@ export async function exportDocxBlob(title: string, html: string): Promise<Blob>
throw new Error("Не удалось создать DOCX: библиотека docx не вернула поддерживаемый упаковщик.");
}
function wordSectionProperties(pageSetup: WordPageSetup | undefined, docx: DocxModule) {
const normalizedPageSetup = normalizedWordPageSetup(pageSetup);
const size = wordPageSizeInTwips(normalizedPageSetup);
const margins = wordPageMargins[normalizedPageSetup.marginPreset];
return {
page: {
size: {
width: size.width,
height: size.height,
orientation:
normalizedPageSetup.orientation === "landscape"
? (docx.PageOrientation?.LANDSCAPE ?? "landscape")
: (docx.PageOrientation?.PORTRAIT ?? "portrait")
},
margin: {
top: pxToTwips(margins.top),
right: pxToTwips(margins.right),
bottom: pxToTwips(margins.bottom),
left: pxToTwips(margins.left)
}
}
};
}
function normalizedWordPageSetup(pageSetup?: WordPageSetup): WordPageSetup {
const size = isWordPageSizeId(pageSetup?.size) ? pageSetup.size : defaultWordPageSetup.size;
const orientation = isWordPageOrientation(pageSetup?.orientation) ? pageSetup.orientation : defaultWordPageSetup.orientation;
const marginPreset = isWordPageMarginPreset(pageSetup?.marginPreset) ? pageSetup.marginPreset : defaultWordPageSetup.marginPreset;
return { size, orientation, marginPreset };
}
function wordPageSizeInTwips(pageSetup: WordPageSetup) {
const size = wordPageSizes[pageSetup.size];
return {
width: pxToTwips(size.width),
height: pxToTwips(size.height)
};
}
function pxToTwips(value: number) {
return Math.round(value * 15);
}
function isWordPageSizeId(value: unknown): value is WordPageSizeId {
return value === "a4" || value === "letter";
}
function isWordPageOrientation(value: unknown): value is WordPageOrientation {
return value === "portrait" || value === "landscape";
}
function isWordPageMarginPreset(value: unknown): value is WordPageMarginPreset {
return value === "normal" || value === "narrow" || value === "wide";
}
export function normalizeImportedDocxHtml(html: string) {
return html.trim();
}