From 5dfe97013da5345f8bce275ed3afa0f812f6503f 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 22:08:58 +0300 Subject: [PATCH] Add QExcell XLSX page setup support --- src/features/qexcell/QExcell.tsx | 71 +++++++++++++++++- src/io/excellOffice.test.ts | 42 +++++++++++ src/io/excellOffice.ts | 119 +++++++++++++++++++++++++++++-- src/types.ts | 10 +++ 4 files changed, 237 insertions(+), 5 deletions(-) diff --git a/src/features/qexcell/QExcell.tsx b/src/features/qexcell/QExcell.tsx index abf37c6..1988d87 100644 --- a/src/features/qexcell/QExcell.tsx +++ b/src/features/qexcell/QExcell.tsx @@ -32,7 +32,7 @@ import { Underline, WrapText } from "lucide-react"; -import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types"; +import type { CellFormat, SheetChart, SheetChartType, SheetPageMarginPreset, SheetPageOrientation, SheetPageSetup, SheetPageSizeId, Workbook } from "../../types"; import { cellId, columnLabel, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, parseCellId, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet"; import { downloadBlobFile, downloadTextFile } from "../../utils/download"; import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice"; @@ -95,6 +95,20 @@ const chartTypeOptions: Array<{ value: SheetChartType; label: string }> = [ { value: "line", label: "Линейная" }, { value: "pie", label: "Круговая" } ]; +const sheetPageSizeOptions: Array<{ id: SheetPageSizeId; label: string }> = [ + { id: "a4", label: "A4" }, + { id: "letter", label: "Letter" } +]; +const sheetPageOrientationOptions: Array<{ id: SheetPageOrientation; label: string }> = [ + { id: "portrait", label: "Книжная" }, + { id: "landscape", label: "Альбомная" } +]; +const sheetPageMarginOptions: Array<{ id: SheetPageMarginPreset; label: string }> = [ + { id: "normal", label: "Обычные" }, + { id: "narrow", label: "Узкие" }, + { id: "wide", label: "Широкие" } +]; +const defaultSheetPageSetup: SheetPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" }; const horizontalAlignOptions = [ { value: "left", label: "По левому краю", icon: AlignLeft }, @@ -159,6 +173,18 @@ function normalizedCellFormat(format: CellFormat): CellFormat | undefined { return Object.keys(normalized).length > 0 ? normalized : undefined; } +function normalizedSheetPageSetup(setup?: SheetPageSetup): SheetPageSetup { + const candidateSize = setup?.size; + const candidateOrientation = setup?.orientation; + const candidateMarginPreset = setup?.marginPreset; + const size = candidateSize && sheetPageSizeOptions.some((option) => option.id === candidateSize) ? candidateSize : defaultSheetPageSetup.size; + const orientation = + candidateOrientation && sheetPageOrientationOptions.some((option) => option.id === candidateOrientation) ? candidateOrientation : defaultSheetPageSetup.orientation; + const marginPreset = + candidateMarginPreset && sheetPageMarginOptions.some((option) => option.id === candidateMarginPreset) ? candidateMarginPreset : defaultSheetPageSetup.marginPreset; + return { size, orientation, marginPreset }; +} + function cellInputStyle(format?: CellFormat): CSSProperties { const textDecoration = [format?.underline ? "underline" : "", format?.strike ? "line-through" : ""].filter(Boolean).join(" "); return { @@ -276,6 +302,7 @@ export function QExcell({ workbook, onChange }: QExcellProps) { const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems; const activeHiddenRows = activeSheet.hiddenRows ?? emptyHiddenItems; const activeCharts = activeSheet.charts ?? emptyCharts; + const activeSheetPageSetup = normalizedSheetPageSetup(activeSheet.pageSetup); const selectedRawValue = activeSheet.cells[selectedCell] ?? ""; const selectedHyperlink = activeHyperlinks[selectedCell] ?? ""; const selectedComment = activeComments[selectedCell] ?? ""; @@ -418,6 +445,15 @@ export function QExcell({ workbook, onChange }: QExcellProps) { }); } + function updateActiveSheetPageSetup(pageSetupPatch: Partial) { + onChange({ + ...workbook, + sheets: workbook.sheets.map((sheet) => + sheet.id === activeSheet.id ? { ...sheet, pageSetup: normalizedSheetPageSetup({ ...activeSheetPageSetup, ...pageSetupPatch }) } : sheet + ) + }); + } + function addSheet() { const id = `sheet-${workbook.sheets.length + 1}`; onChange({ @@ -854,6 +890,39 @@ export function QExcell({ workbook, onChange }: QExcellProps) { onBlur={(event) => updateActiveSheetPrintArea(normalizedPrintAreaInput(event.target.value))} /> + + +
diff --git a/src/io/excellOffice.test.ts b/src/io/excellOffice.test.ts index 863b2e9..1ced0f5 100644 --- a/src/io/excellOffice.test.ts +++ b/src/io/excellOffice.test.ts @@ -8,6 +8,8 @@ import { buildColumnWidthsXml, buildHyperlinksXml, buildMergeCellsXml, + buildPageMarginsXml, + buildPageSetupXml, buildSheetPrXml, buildSheetViewsXml, createCellStyleRegistry, @@ -32,6 +34,7 @@ import { worksheetXmlToHyperlinks, worksheetCommentsXmlToComments, worksheetXmlToMergedCells, + worksheetXmlToPageSetup, worksheetXmlToRowHeights, worksheetXmlToShowGridLines, worksheetXmlToTabColor, @@ -739,6 +742,45 @@ describe("excellOffice OOXML helpers", () => { expect(imported.sheets[1].printArea).toBe("B2:C4,E1:E3"); }); + it("сохраняет и импортирует параметры страницы XLSX через pageMargins и pageSetup", async () => { + expect( + worksheetXmlToPageSetup({ + worksheet: { + pageMargins: { left: "0.25", right: "0.25", top: "0.75", bottom: "0.75", header: "0.3", footer: "0.3" }, + pageSetup: { paperSize: "1", orientation: "landscape" } + } + }) + ).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" }); + expect(buildPageMarginsXml({ size: "a4", orientation: "portrait", marginPreset: "wide" })).toBe( + '' + ); + expect(buildPageSetupXml({ size: "letter", orientation: "landscape", marginPreset: "normal" })).toBe( + '' + ); + + const blob = await exportXlsxBlob({ + id: "book-1", + title: "Page setup.xlsx", + updatedAt: "2026-06-01T00:00:00.000Z", + activeSheet: "sheet-1", + sheets: [ + { + id: "sheet-1", + name: "Layout", + pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" }, + cells: { A1: "Layout" } + } + ] + }); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? ""; + const imported = await importXlsxFile(new File([blob], "Page setup.xlsx", { type: blob.type })); + + expect(sheetXml).toContain(''); + expect(sheetXml).toContain(''); + expect(imported.sheets[0].pageSetup).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" }); + }); + it("сохраняет и импортирует скрытые листы XLSX через sheet state", async () => { const blob = await exportXlsxBlob({ id: "book-1", diff --git a/src/io/excellOffice.ts b/src/io/excellOffice.ts index c306967..8ad21ed 100644 --- a/src/io/excellOffice.ts +++ b/src/io/excellOffice.ts @@ -1,4 +1,15 @@ -import type { CellFormat, MergedCellRange, SheetChart, SheetChartType, SheetFrozenPane, Workbook } from "../types"; +import type { + CellFormat, + MergedCellRange, + SheetChart, + SheetChartType, + SheetFrozenPane, + SheetPageMarginPreset, + SheetPageOrientation, + SheetPageSetup, + SheetPageSizeId, + Workbook +} from "../types"; import { evaluateCell, expandRange } from "../utils/spreadsheet"; import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers"; @@ -19,6 +30,7 @@ export interface ImportedExcellWorkbook { showGridLines?: boolean; zoomScale?: number; printArea?: string; + pageSetup?: SheetPageSetup; cells: Record; mergedCells?: MergedCellRange[]; hyperlinks?: Record; @@ -77,6 +89,7 @@ interface ParsedSheet { showGridLines?: boolean; zoomScale?: number; printArea?: string; + pageSetup?: SheetPageSetup; cells: Record; mergedCells?: MergedCellRange[]; hyperlinks?: Record; @@ -210,6 +223,16 @@ const builtinNumberFormats: Record = { 48: "##0.0E+0", 49: "@" }; +const defaultSheetPageSetup: SheetPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" }; +const xlsxPageSizePaperIds: Record = { + letter: 1, + a4: 9 +}; +const xlsxPageMargins: Record = { + normal: { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 }, + narrow: { left: 0.25, right: 0.25, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 }, + wide: { left: 1, right: 1, top: 1, bottom: 1, header: 0.5, footer: 0.5 } +}; export async function importXlsxFile(file: File): Promise { if (!file.name.toLowerCase().endsWith(".xlsx")) { @@ -320,7 +343,8 @@ export async function exportXlsxBlob(workbook: Workbook): Promise { sheet.hiddenRows, sheet.showGridLines, sheet.zoomScale, - legacyDrawingRelationshipId + legacyDrawingRelationshipId, + sheet.pageSetup ) ); const relationshipsXml = buildWorksheetRelationshipsXml( @@ -371,6 +395,7 @@ export function parsedWorksheetToSheet( const hiddenRows = worksheetXmlToHiddenRows(worksheetXml); const showGridLines = worksheetXmlToShowGridLines(worksheetXml); const zoomScale = worksheetXmlToZoomScale(worksheetXml); + const pageSetup = worksheetXmlToPageSetup(worksheetXml); return { id: sheet.id || `sheet-${index + 1}`, @@ -385,6 +410,7 @@ export function parsedWorksheetToSheet( ...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}), ...(showGridLines === false ? { showGridLines } : {}), ...(zoomScale ? { zoomScale } : {}), + ...(pageSetup ? { pageSetup } : {}), cells: worksheetXmlToCells(worksheetXml, sharedStrings, styleTable, date1904), mergedCells: worksheetXmlToMergedCells(worksheetXml), hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml), @@ -540,6 +566,18 @@ export function worksheetXmlToZoomScale(worksheetXml: unknown) { return normalizedSheetZoomScale(sheetView.zoomScale); } +export function worksheetXmlToPageSetup(worksheetXml: unknown): SheetPageSetup | undefined { + const worksheet = childRecord(worksheetXml, "worksheet"); + const pageSetup = childRecord(worksheet, "pageSetup"); + const pageMargins = childRecord(worksheet, "pageMargins"); + const size = sheetPageSizeFromPaperId(pageSetup.paperSize) ?? defaultSheetPageSetup.size; + const orientation = normalizedSheetPageOrientation(pageSetup.orientation) ?? defaultSheetPageSetup.orientation; + const marginPreset = sheetPageMarginPresetFromRecord(pageMargins) ?? defaultSheetPageSetup.marginPreset; + const hasPageSetup = Object.keys(pageSetup).length > 0; + const hasPageMargins = Object.keys(pageMargins).length > 0; + return hasPageSetup || hasPageMargins ? { size, orientation, marginPreset } : undefined; +} + export function worksheetXmlToAutoFilter(worksheetXml: unknown) { const worksheet = childRecord(worksheetXml, "worksheet"); const autoFilter = childRecord(worksheet, "autoFilter"); @@ -804,7 +842,8 @@ export function buildWorksheetXml( hiddenRows: Record = {}, showGridLines?: boolean, zoomScale?: number, - legacyDrawingRelationshipId = "" + legacyDrawingRelationshipId = "", + pageSetup?: SheetPageSetup ) { const normalizedRowHeights = normalizedSheetRowHeights(rowHeights); const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows); @@ -845,9 +884,11 @@ export function buildWorksheetXml( const sheetPrXml = buildSheetPrXml(tabColor); const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale); const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns); + const pageMarginsXml = buildPageMarginsXml(pageSetup); + const pageSetupXml = buildPageSetupXml(pageSetup); return xmlDeclaration( - `${sheetPrXml}${sheetViewsXml}${colsXml}${rowXml}${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}${legacyDrawingXml}` + `${sheetPrXml}${sheetViewsXml}${colsXml}${rowXml}${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${pageMarginsXml}${pageSetupXml}${drawingXml}${legacyDrawingXml}` ); } @@ -940,6 +981,24 @@ export function buildSheetPrXml(tabColor?: string) { return color ? `` : ""; } +export function buildPageMarginsXml(pageSetup?: SheetPageSetup) { + if (!pageSetup) { + return ""; + } + + const margins = xlsxPageMargins[normalizedSheetPageSetup(pageSetup).marginPreset]; + return ``; +} + +export function buildPageSetupXml(pageSetup?: SheetPageSetup) { + if (!pageSetup) { + return ""; + } + + const normalized = normalizedSheetPageSetup(pageSetup); + return ``; +} + export function buildColumnWidthsXml(columnWidths: Record = {}, hiddenColumns: Record = {}) { const columnsByNumber = new Map(); normalizedSheetColumnWidths(columnWidths).forEach(({ column, width }) => { @@ -1914,6 +1973,58 @@ function normalizedSheetZoomScale(value: unknown) { return zoomScale >= 10 && zoomScale <= 400 && zoomScale !== 100 ? zoomScale : 0; } +function normalizedSheetPageSetup(pageSetup?: SheetPageSetup): SheetPageSetup { + const size = isSheetPageSizeId(pageSetup?.size) ? pageSetup.size : defaultSheetPageSetup.size; + const orientation = normalizedSheetPageOrientation(pageSetup?.orientation) ?? defaultSheetPageSetup.orientation; + const marginPreset = isSheetPageMarginPreset(pageSetup?.marginPreset) ? pageSetup.marginPreset : defaultSheetPageSetup.marginPreset; + return { size, orientation, marginPreset }; +} + +function normalizedSheetPageOrientation(value: unknown): SheetPageOrientation | undefined { + return value === "portrait" || value === "landscape" ? value : undefined; +} + +function isSheetPageSizeId(value: unknown): value is SheetPageSizeId { + return value === "a4" || value === "letter"; +} + +function isSheetPageMarginPreset(value: unknown): value is SheetPageMarginPreset { + return value === "normal" || value === "narrow" || value === "wide"; +} + +function sheetPageSizeFromPaperId(value: unknown): SheetPageSizeId | undefined { + const paperSize = positiveIntegerAttribute(value); + return (Object.keys(xlsxPageSizePaperIds) as SheetPageSizeId[]).find((sizeId) => xlsxPageSizePaperIds[sizeId] === paperSize); +} + +function sheetPageMarginPresetFromRecord(record: XmlRecord): SheetPageMarginPreset | undefined { + const left = positiveNumberAttribute(record.left); + const right = positiveNumberAttribute(record.right); + const top = positiveNumberAttribute(record.top); + const bottom = positiveNumberAttribute(record.bottom); + const header = positiveNumberAttribute(record.header); + const footer = positiveNumberAttribute(record.footer); + if (!left || !right || !top || !bottom || !header || !footer) { + return undefined; + } + + return (Object.keys(xlsxPageMargins) as SheetPageMarginPreset[]).find((preset) => { + const margins = xlsxPageMargins[preset]; + return ( + isCloseDimension(left, margins.left) && + isCloseDimension(right, margins.right) && + isCloseDimension(top, margins.top) && + isCloseDimension(bottom, margins.bottom) && + isCloseDimension(header, margins.header) && + isCloseDimension(footer, margins.footer) + ); + }); +} + +function isCloseDimension(actual: number, expected: number) { + return Math.abs(actual - expected) <= 0.01; +} + function buildRowAttributes(height?: number, hidden?: boolean) { return `${height ? ` ht="${formatSheetDimension(height)}" customHeight="1"` : ""}${hidden ? ' hidden="1"' : ""}`; } diff --git a/src/types.ts b/src/types.ts index 5103151..9da032b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,9 @@ export type AppId = "word" | "sheets" | "slides" | "mail"; export type WordPageSizeId = "a4" | "letter"; export type WordPageOrientation = "portrait" | "landscape"; export type WordPageMarginPreset = "normal" | "narrow" | "wide"; +export type SheetPageSizeId = "a4" | "letter"; +export type SheetPageOrientation = "portrait" | "landscape"; +export type SheetPageMarginPreset = "normal" | "narrow" | "wide"; export interface WordPageSetup { size: WordPageSizeId; @@ -10,6 +13,12 @@ export interface WordPageSetup { marginPreset: WordPageMarginPreset; } +export interface SheetPageSetup { + size: SheetPageSizeId; + orientation: SheetPageOrientation; + marginPreset: SheetPageMarginPreset; +} + export interface WordDocument { id: string; title: string; @@ -40,6 +49,7 @@ export interface Sheet { showGridLines?: boolean; zoomScale?: number; printArea?: string; + pageSetup?: SheetPageSetup; cells: Record; mergedCells?: MergedCellRange[]; hyperlinks?: Record;