From 3719cb7ec823bee171a3f22fefc32d44532476a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Thu, 4 Jun 2026 21:40:17 +0300 Subject: [PATCH] Export QWord page setup to DOCX --- src/features/qword/QWord.tsx | 2 +- src/io/wordOffice.test.ts | 11 ++++++ src/io/wordOffice.ts | 73 +++++++++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/features/qword/QWord.tsx b/src/features/qword/QWord.tsx index ee7f3b5..c1b099b 100644 --- a/src/features/qword/QWord.tsx +++ b/src/features/qword/QWord.tsx @@ -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; diff --git a/src/io/wordOffice.test.ts b/src/io/wordOffice.test.ts index 6a337e8..15c75fb 100644 --- a/src/io/wordOffice.test.ts +++ b/src/io/wordOffice.test.ts @@ -279,6 +279,17 @@ describe("wordOffice helpers", () => { expect(documentXml).toContain(''); }); + it("writes QWord page setup to exported DOCX section properties", async () => { + const blob = await exportDocxBlob("Макет.docx", "

Landscape letter

", { + 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:w="15840"[^>]*w:h="12240"[^>]*w:orient="landscape"[^>]*\/>/); + expect(documentXml).toMatch(/]*w:top="720"[^>]*w:right="720"[^>]*w:bottom="720"[^>]*w:left="720"[^>]*\/>/); + }); + it("записывает внешние гиперссылки в DOCX relationships", async () => { const blob = await exportDocxBlob( "Ссылки.docx", diff --git a/src/io/wordOffice.ts b/src/io/wordOffice.ts index 77ab939..20b0542 100644 --- a/src/io/wordOffice.ts +++ b/src/io/wordOffice.ts @@ -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) => unknown; HeadingLevel: Record; LineRuleType?: Record; + PageOrientation?: Record; Packer: { toBlob?: (document: unknown) => Promise; toBuffer?: (document: unknown) => Promise; @@ -165,6 +167,16 @@ const wordImageRelationshipType = "http://schemas.openxmlformats.org/officeDocum const emptyDocxRelationshipTargets = { hyperlinks: {}, images: {} }; const pageBreakMarker = "\f"; const pageBreakHtml = ''; +const defaultWordPageSetup: WordPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" }; +const wordPageSizes: Record = { + a4: { width: 794, height: 1123 }, + letter: { width: 816, height: 1056 } +}; +const wordPageMargins: Record = { + 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 { if (!file.name.toLowerCase().endsWith(".docx")) { @@ -190,11 +202,12 @@ export async function importDocxFile(file: File): Promise }; } -export async function exportDocxBlob(title: string, html: string): Promise { +export async function exportDocxBlob(title: string, html: string, options: { pageSetup?: WordPageSetup } = {}): Promise { 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 }, sections: [ { - properties: {}, + properties: sectionProperties, children: children.length > 0 ? children @@ -246,6 +259,62 @@ export async function exportDocxBlob(title: string, html: string): Promise 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(); }