From 96aff5b76e4ba2fcf2b05394dec0aaa9cf1bf0a0 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:58:24 +0300 Subject: [PATCH] Import DOCX page setup into QWord --- src/features/qword/QWord.tsx | 2 +- src/io/wordOffice.ts | 111 +++++++++++++++++++++++++++++++- src/io/wordOfficeImport.test.ts | 12 ++++ 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/features/qword/QWord.tsx b/src/features/qword/QWord.tsx index 3d24cd1..c2c01b0 100644 --- a/src/features/qword/QWord.tsx +++ b/src/features/qword/QWord.tsx @@ -828,7 +828,7 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange try { const imported = await importDocxFile(file); - onChange({ ...document, title: imported.title, html: imported.html }); + onChange({ ...document, title: imported.title, html: imported.html, ...(imported.pageSetup ? { pageSetup: imported.pageSetup } : {}) }); } catch (error) { window.alert(error instanceof Error ? error.message : "Не удалось открыть DOCX."); } finally { diff --git a/src/io/wordOffice.ts b/src/io/wordOffice.ts index 20b0542..c227c74 100644 --- a/src/io/wordOffice.ts +++ b/src/io/wordOffice.ts @@ -4,6 +4,7 @@ import type { WordPageMarginPreset, WordPageOrientation, WordPageSetup, WordPage export interface ImportedWordDocument { title: string; html: string; + pageSetup?: WordPageSetup; } export interface WordInlineRun { @@ -185,7 +186,7 @@ export async function importDocxFile(file: File): Promise const mammoth = await loadMammoth(); const arrayBuffer = await readOfficeFileAsArrayBuffer(file); - const [result, styledHtml] = await Promise.all([ + const [result, styledHtml, pageSetup] = await Promise.all([ mammoth.convertToHtml( { arrayBuffer, buffer: arrayBuffer }, { @@ -193,12 +194,14 @@ export async function importDocxFile(file: File): Promise ...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {}) } ), - styledDocxHtmlFromArrayBuffer(arrayBuffer) + styledDocxHtmlFromArrayBuffer(arrayBuffer), + docxPageSetupFromArrayBuffer(arrayBuffer) ]); return { title: officeTitleFromFileName(file.name, "Документ QWord.docx"), - html: normalizeImportedDocxHtml(styledHtml || result.value) + html: normalizeImportedDocxHtml(styledHtml || result.value), + ...(pageSetup ? { pageSetup } : {}) }; } @@ -1008,6 +1011,108 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { } } +async function docxPageSetupFromArrayBuffer(arrayBuffer: ArrayBuffer): Promise { + try { + const [JSZip, XMLParser] = await Promise.all([loadJsZip(), loadXmlParser()]); + const zip = await JSZip.loadAsync(arrayBuffer); + const documentXml = await zip.file("word/document.xml")?.async("string"); + if (!documentXml) { + return undefined; + } + + const parser = new XMLParser(xmlParserOptions); + return docxPageSetupFromDocumentXml(parser.parse(documentXml)); + } catch { + return undefined; + } +} + +function docxPageSetupFromDocumentXml(documentXml: unknown): WordPageSetup | undefined { + const body = childRecord(childRecord(documentXml, "w:document"), "w:body"); + const sectionProperties = lastDocxSectionProperties(body); + if (!hasXmlRecord(sectionProperties)) { + return undefined; + } + + return docxPageSetupFromSectionProperties(sectionProperties); +} + +function lastDocxSectionProperties(body: XmlRecord): XmlRecord { + const paragraphSectionProperties = toArray(asRecord(body)["w:p"]) + .map((paragraph) => childRecord(childRecord(paragraph, "w:pPr"), "w:sectPr")) + .filter(hasXmlRecord); + const bodySectionProperties = childRecord(body, "w:sectPr"); + const sections = hasXmlRecord(bodySectionProperties) ? [...paragraphSectionProperties, bodySectionProperties] : paragraphSectionProperties; + return sections[sections.length - 1] ?? {}; +} + +function docxPageSetupFromSectionProperties(sectionProperties: XmlRecord): WordPageSetup | undefined { + const pageSize = childRecord(sectionProperties, "w:pgSz"); + const pageMargins = childRecord(sectionProperties, "w:pgMar"); + const width = docxTwipsAttribute(pageSize, ["w:w"]); + const height = docxTwipsAttribute(pageSize, ["w:h"]); + if (!width || !height) { + return undefined; + } + + const orientation = docxPageOrientation(pageSize, width, height); + const baseWidth = orientation === "landscape" ? height : width; + const baseHeight = orientation === "landscape" ? width : height; + const size = wordPageSizeIdFromTwips(baseWidth, baseHeight); + if (!size) { + return undefined; + } + + const marginPreset = hasXmlRecord(pageMargins) ? (wordPageMarginPresetFromTwips(pageMargins) ?? defaultWordPageSetup.marginPreset) : defaultWordPageSetup.marginPreset; + return { size, orientation, marginPreset }; +} + +function docxPageOrientation(pageSize: XmlRecord, width: number, height: number): WordPageOrientation { + const value = docxAttribute(pageSize, "w:orient").trim().toLowerCase(); + if (value === "landscape") { + return "landscape"; + } + if (value === "portrait") { + return "portrait"; + } + return width > height ? "landscape" : "portrait"; +} + +function wordPageSizeIdFromTwips(width: number, height: number): WordPageSizeId | undefined { + return (Object.keys(wordPageSizes) as WordPageSizeId[]).find((sizeId) => { + const size = wordPageSizes[sizeId]; + return isCloseTwips(width, pxToTwips(size.width)) && isCloseTwips(height, pxToTwips(size.height)); + }); +} + +function wordPageMarginPresetFromTwips(pageMargins: XmlRecord): WordPageMarginPreset | undefined { + const top = docxTwipsAttribute(pageMargins, ["w:top"]); + const right = docxTwipsAttribute(pageMargins, ["w:right"]); + const bottom = docxTwipsAttribute(pageMargins, ["w:bottom"]); + const left = docxTwipsAttribute(pageMargins, ["w:left"]); + if (!top || !right || !bottom || !left) { + return undefined; + } + + return (Object.keys(wordPageMargins) as WordPageMarginPreset[]).find((preset) => { + const margins = wordPageMargins[preset]; + return ( + isCloseTwips(top, pxToTwips(margins.top)) && + isCloseTwips(right, pxToTwips(margins.right)) && + isCloseTwips(bottom, pxToTwips(margins.bottom)) && + isCloseTwips(left, pxToTwips(margins.left)) + ); + }); +} + +function isCloseTwips(actual: number, expected: number) { + return Math.abs(actual - expected) <= 40; +} + +function hasXmlRecord(record: XmlRecord) { + return Object.keys(record).length > 0; +} + function isSimpleStyledDocxDocument(documentXml: string) { return !/ { { type: "paragraph", text: "Exact line height", runs: [{ text: "Exact line height" }], spacing: { line: 480, lineRule: "exact" } } ]); }); + + it("imports DOCX page setup from OOXML section properties", async () => { + const blob = await exportDocxBlob("PageSetup.docx", "

Layout

", { + pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" } + }); + const file = new File([blob], "PageSetup.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + const imported = await importDocxFile(file); + + expect(imported.pageSetup).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" }); + }); });