Compare commits

..
10 Commits
18 changed files with 1310 additions and 82 deletions
+42 -1
View File
@@ -1,4 +1,4 @@
import { cleanup, render, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { initialState } from "./data/sampleData";
import type { QOfficeState } from "./types";
@@ -64,4 +64,45 @@ describe("qofficeWindowTitle", () => {
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("syncs QWord statusbar view buttons with the active document view mode", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
window.history.pushState({}, "", "/qword");
try {
render(<App />);
const stage = screen.getByLabelText("Редактор QWord");
const printButton = screen.getByRole("button", { name: "Разметка страницы" });
const webButton = screen.getByRole("button", { name: "Веб-документ" });
expect(stage).toHaveClass("qword-view-print");
expect(printButton).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByRole("button", { name: "Черновик" })).not.toBeInTheDocument();
fireEvent.click(webButton);
expect(stage).toHaveClass("qword-view-web");
expect(webButton).toHaveAttribute("aria-pressed", "true");
expect(printButton).toHaveAttribute("aria-pressed", "false");
fireEvent.click(screen.getByRole("tab", { name: "Вид" }));
fireEvent.click(screen.getByRole("button", { name: "Чтение" }));
expect(stage).toHaveClass("qword-view-read");
expect(screen.getByRole("button", { name: "Режим чтения" })).toHaveAttribute("aria-pressed", "true");
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
});
+39 -16
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { BookOpen, Columns2, FileText, Minus, Plus, Rows3 } from "lucide-react";
import { BookOpen, Columns2, FileText, Minus, Plus } from "lucide-react";
import { AppShell } from "./components/AppShell";
import { QOutlook } from "./features/qoutlook/QOutlook";
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
import { QWord, type QWordStatus } from "./features/qword/QWord";
import { QWord, type QWordStatus, type WordViewMode } from "./features/qword/QWord";
import { QExcell } from "./features/qexcell/QExcell";
import { initialState } from "./data/sampleData";
import { usePersistentState } from "./hooks/usePersistentState";
@@ -12,6 +12,11 @@ import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } fro
const STORAGE_KEY = "qoffice-state-v1";
const defaultQWordStatus: QWordStatus = { currentPage: 1, pageCount: 1, wordCount: 0 };
const wordStatusbarViewModes = [
{ id: "print", label: "Разметка страницы", Icon: FileText },
{ id: "read", label: "Режим чтения", Icon: BookOpen },
{ id: "web", label: "Веб-документ", Icon: Columns2 }
] as const;
function clampedWordZoom(value: number) {
return Math.min(200, Math.max(50, value));
@@ -48,11 +53,15 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
state.word.id === initialState.word.id &&
sampleWordHtml.includes("Ключевой результат") &&
!state.word.html.includes("Ключевой результат");
const needsDeckSlideSize = !state.deck.slideSize;
let changed = false;
if (needsSampleWordColor) {
changed = true;
}
if (needsDeckSlideSize) {
changed = true;
}
const messages = state.mail.messages.map((message) => {
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
return message;
@@ -112,7 +121,7 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
...state,
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
workbook: { ...state.workbook, sheets },
deck: { ...state.deck, slides },
deck: { ...state.deck, slideSize: state.deck.slideSize ?? initialState.deck.slideSize ?? "wide", slides },
mail: { ...state.mail, messages }
}
: state;
@@ -122,6 +131,7 @@ export function App() {
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
const [wordZoom, setWordZoom] = useState(100);
const [wordViewMode, setWordViewMode] = useState<WordViewMode>("print");
const activeApp = activeAppFromLocation(window.location);
const windowTitle = qofficeWindowTitle(state, activeApp);
@@ -164,18 +174,23 @@ export function App() {
trailing: (
<div className="word-statusbar-tools" aria-label="Режимы просмотра и масштаб QWord">
<div className="word-view-buttons" aria-label="Режимы просмотра">
<button className="is-active" type="button" title="Разметка страницы" aria-label="Разметка страницы" aria-pressed="true">
<FileText size={15} />
</button>
<button type="button" title="Режим чтения" aria-label="Режим чтения">
<BookOpen size={15} />
</button>
<button type="button" title="Веб-документ" aria-label="Веб-документ">
<Columns2 size={15} />
</button>
<button type="button" title="Черновик" aria-label="Черновик">
<Rows3 size={15} />
</button>
{wordStatusbarViewModes.map((mode) => {
const Icon = mode.Icon;
const isActive = wordViewMode === mode.id;
return (
<button
key={mode.id}
className={isActive ? "is-active" : undefined}
type="button"
title={mode.label}
aria-label={mode.label}
aria-pressed={isActive}
onClick={() => setWordViewMode(mode.id)}
>
<Icon size={15} />
</button>
);
})}
</div>
<button type="button" title="Уменьшить масштаб" aria-label="Уменьшить масштаб" onClick={() => adjustWordZoom(-10)}>
<Minus size={14} />
@@ -212,6 +227,7 @@ export function App() {
wordStatus.currentPage,
wordStatus.pageCount,
wordStatus.wordCount,
wordViewMode,
wordZoom
]);
@@ -244,7 +260,14 @@ export function App() {
const activeSurface =
activeApp === "word" ? (
<QWord document={state.word} zoom={wordZoom} onChange={updateWord} onStatusChange={updateWordStatus} />
<QWord
document={state.word}
zoom={wordZoom}
viewMode={wordViewMode}
onChange={updateWord}
onStatusChange={updateWordStatus}
onViewModeChange={setWordViewMode}
/>
) : activeApp === "sheets" ? (
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
) : activeApp === "slides" ? (
+3 -1
View File
@@ -31,7 +31,8 @@ export const initialState: QOfficeState = {
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
</tbody>
</table>
`
`,
pageSetup: { size: "a4", orientation: "portrait", marginPreset: "normal" }
},
workbook: {
id: "qexcell-quarter-plan",
@@ -108,6 +109,7 @@ export const initialState: QOfficeState = {
title: "Дорожная карта.qpptx",
updatedAt: new Date().toISOString(),
activeSlideId: "slide-1",
slideSize: "wide",
slides: [
{
id: "slide-1",
+70 -1
View File
@@ -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<SheetPageSetup>) {
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))}
/>
</label>
<label className="office-field">
<span>Размер</span>
<select value={activeSheetPageSetup.size} onChange={(event) => updateActiveSheetPageSetup({ size: event.target.value as SheetPageSizeId })}>
{sheetPageSizeOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<label className="office-field">
<span>Ориентация</span>
<select
value={activeSheetPageSetup.orientation}
onChange={(event) => updateActiveSheetPageSetup({ orientation: event.target.value as SheetPageOrientation })}
>
{sheetPageOrientationOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<label className="office-field">
<span>Поля</span>
<select value={activeSheetPageSetup.marginPreset} onChange={(event) => updateActiveSheetPageSetup({ marginPreset: event.target.value as SheetPageMarginPreset })}>
{sheetPageMarginOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="office-tool-section">
@@ -55,6 +55,21 @@ describe("QPowerPoint", () => {
expect(nextDeck.slides[0].images?.[0].hyperlink).toBe("https://example.com/updated-logo");
});
it("переключает размер слайда презентации", () => {
const onChange = vi.fn();
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
const slideSizeSelect = view.container.querySelector('select[aria-label="Размер слайда"]') as HTMLSelectElement;
const canvas = view.container.querySelector(".slide-canvas") as HTMLElement;
expect(slideSizeSelect).toHaveValue("wide");
expect(canvas.style.aspectRatio).toBe("13.333 / 7.5");
fireEvent.change(slideSizeSelect, { target: { value: "standard" } });
const nextDeck = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as SlideDeck;
expect(nextDeck.slideSize).toBe("standard");
});
it("переключает начертание текста слайда", () => {
const onChange = vi.fn();
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
+53 -22
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import { AlignCenter, AlignLeft, AlignRight, Bold, Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Italic, Link, List, ListOrdered, ListX, Palette, Plus, Save, Strikethrough, Trash2, Underline } from "lucide-react";
import type { Slide, SlideDeck, SlideImage, SlideListStyle, SlideTextAlign, SlideTransition } from "../../types";
import type { Slide, SlideDeck, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign, SlideTransition } from "../../types";
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
import { replaceFileExtension } from "../../io/fileHelpers";
@@ -58,8 +58,20 @@ const slideTransitionOptions: Array<{ value: SlideTransition | ""; label: string
{ value: "wipe", label: "Шторка" }
];
const slideWidthInches = 13.333;
const slideHeightInches = 7.5;
const slideSizeOptions: Array<{ id: SlideSizeId; label: string; width: number; height: number }> = [
{ id: "wide", label: "Широкий 16:9", width: 13.333, height: 7.5 },
{ id: "standard", label: "Стандартный 4:3", width: 10, height: 7.5 }
];
type SlideDimensions = { width: number; height: number };
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
return value === "standard" ? "standard" : "wide";
}
function slideDimensions(value?: SlideSizeId): SlideDimensions {
return slideSizeOptions.find((option) => option.id === normalizedSlideSizeId(value)) ?? slideSizeOptions[0];
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
@@ -99,18 +111,21 @@ function roundSlideNumber(value: number) {
return Math.round(value * 10) / 10;
}
function slideImageStyle(image: SlideImage) {
function slideImageStyle(image: SlideImage, dimensions: SlideDimensions) {
return {
left: `${(image.x / slideWidthInches) * 100}%`,
top: `${(image.y / slideHeightInches) * 100}%`,
width: `${(image.width / slideWidthInches) * 100}%`,
height: `${(image.height / slideHeightInches) * 100}%`
left: `${(image.x / dimensions.width) * 100}%`,
top: `${(image.y / dimensions.height) * 100}%`,
width: `${(image.width / dimensions.width) * 100}%`,
height: `${(image.height / dimensions.height) * 100}%`
};
}
function slideCanvasStyle(slide: Slide) {
function slideCanvasStyle(slide: Slide, dimensions: SlideDimensions): CSSProperties {
const backgroundColor = normalizedHexColor(slide.backgroundColor);
return backgroundColor ? { background: `#${backgroundColor}` } : undefined;
return {
aspectRatio: `${dimensions.width} / ${dimensions.height}`,
...(backgroundColor ? { background: `#${backgroundColor}` } : {})
};
}
function slideTextStyle(
@@ -167,6 +182,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
const activeSlide = deck.slides.find((slide) => slide.id === deck.activeSlideId) ?? deck.slides[0];
const activeImages = activeSlide.images ?? [];
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
const activeSlideSize = slideDimensions(deck.slideSize);
function updateSlide(nextSlide: Slide) {
onChange({
@@ -266,6 +282,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
...deck,
title: imported.title,
activeSlideId: imported.slides[0]?.id ?? "slide-1",
slideSize: imported.slideSize ?? deck.slideSize ?? "wide",
slides: imported.slides.length > 0 ? imported.slides : deck.slides
});
} catch (error) {
@@ -289,8 +306,8 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
id: `slide-image-${Date.now()}`,
src,
alt: file.name,
x: roundSlideNumber(Math.max(0.4, slideWidthInches - size.width - 0.9)),
y: roundSlideNumber(Math.max(1.4, slideHeightInches - size.height - 0.8)),
x: roundSlideNumber(Math.max(0.4, activeSlideSize.width - size.width - 0.9)),
y: roundSlideNumber(Math.max(1.4, activeSlideSize.height - size.height - 0.8)),
...size
};
@@ -466,6 +483,20 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
))}
</select>
</label>
<label className="office-field">
<span>Размер</span>
<select
value={normalizedSlideSizeId(deck.slideSize)}
onChange={(event) => onChange({ ...deck, slideSize: event.target.value as SlideSizeId })}
aria-label="Размер слайда"
>
{slideSizeOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<div className="slide-background-swatches" aria-label="Фон слайда">
<Palette size={16} aria-hidden="true" />
{backgroundColorSwatches.map((swatch) => (
@@ -783,10 +814,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<input
type="number"
min="0"
max={slideWidthInches}
max={activeSlideSize.width}
step="0.1"
value={selectedImage.x}
onChange={(event) => updateSelectedImageNumber("x", event.target.value, slideWidthInches)}
onChange={(event) => updateSelectedImageNumber("x", event.target.value, activeSlideSize.width)}
/>
</label>
<label>
@@ -794,10 +825,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<input
type="number"
min="0"
max={slideHeightInches}
max={activeSlideSize.height}
step="0.1"
value={selectedImage.y}
onChange={(event) => updateSelectedImageNumber("y", event.target.value, slideHeightInches)}
onChange={(event) => updateSelectedImageNumber("y", event.target.value, activeSlideSize.height)}
/>
</label>
<label>
@@ -805,10 +836,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<input
type="number"
min="0.1"
max={slideWidthInches}
max={activeSlideSize.width}
step="0.1"
value={selectedImage.width}
onChange={(event) => updateSelectedImageNumber("width", event.target.value, slideWidthInches)}
onChange={(event) => updateSelectedImageNumber("width", event.target.value, activeSlideSize.width)}
/>
</label>
<label>
@@ -816,10 +847,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<input
type="number"
min="0.1"
max={slideHeightInches}
max={activeSlideSize.height}
step="0.1"
value={selectedImage.height}
onChange={(event) => updateSelectedImageNumber("height", event.target.value, slideHeightInches)}
onChange={(event) => updateSelectedImageNumber("height", event.target.value, activeSlideSize.height)}
/>
</label>
</div>
@@ -854,13 +885,13 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</label>
</div>
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide)}>
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide, activeSlideSize)}>
{activeImages.map((image) => (
<button
key={image.id}
type="button"
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
style={slideImageStyle(image)}
style={slideImageStyle(image, activeSlideSize)}
onClick={() => setSelectedImageId(image.id)}
title={image.alt || "Изображение"}
aria-label={image.alt || "Изображение слайда"}
+219 -1
View File
@@ -3,7 +3,7 @@ import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { WordDocument } from "../../types";
import { QWord, qwordCurrentPageFromViewport } from "./QWord";
import { QWord, qwordCurrentPageFromViewport, qwordPageCountFromContent } from "./QWord";
afterEach(() => {
cleanup();
@@ -20,6 +20,21 @@ describe("qwordCurrentPageFromViewport", () => {
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(2);
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1.2 })).toBe(1);
});
it("does not advance the current page while the viewport is inside the inter-page gap", () => {
expect(qwordCurrentPageFromViewport({ stageTop: 0, paperTop: -110, pageHeight: 100, pageGap: 20, pageCount: 3, zoom: 1 })).toBe(1);
expect(qwordCurrentPageFromViewport({ stageTop: 0, paperTop: -125, pageHeight: 100, pageGap: 20, pageCount: 3, zoom: 1 })).toBe(2);
});
});
describe("qwordPageCountFromContent", () => {
it("keeps manual page breaks as a minimum page count even when content height is short", () => {
expect(qwordPageCountFromContent({ scrollHeight: 300, pageHeight: 1123, manualPageBreakCount: 2 })).toBe(3);
});
it("keeps content height page count when it exceeds manual page breaks", () => {
expect(qwordPageCountFromContent({ scrollHeight: 2400, pageHeight: 1123, manualPageBreakCount: 1 })).toBe(3);
});
});
describe("QWord", () => {
@@ -101,6 +116,140 @@ describe("QWord", () => {
}
});
it("показывает отдельный лист для ручного разрыва страницы даже при коротком содержимом", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-manual-break-pages-test",
title: "Разрыв.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: '<p>Первая страница</p><span data-qoffice-page-break="true" contenteditable="false"></span><p>Вторая страница</p>'
};
try {
const view = render(<QWord document={document} onChange={vi.fn()} />);
const stage = view.getByLabelText("Редактор QWord");
const paper = view.getByLabelText("Содержимое документа");
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
paper.style.setProperty("--qword-page-height", "1123px");
Object.defineProperty(paper, "scrollHeight", { configurable: true, value: 300 });
fireEvent.scroll(stage);
await waitFor(() => expect(view.container.querySelectorAll(".page-sheet")).toHaveLength(2));
expect(frame.style.getPropertyValue("--qword-page-count")).toBe("2");
expect(frame.style.minHeight).toBe("2274px");
expect(view.container.querySelectorAll(".page-break-guides span")).toHaveLength(1);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("меняет поля, размер и ориентацию страницы на вкладке Макет", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onChange = vi.fn();
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-page-setup-test",
title: "Макет.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: "<p>Текст страницы</p>"
};
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
onChange(nextDocument);
setCurrentDocument(nextDocument);
}}
/>
);
}
try {
const view = render(<StatefulQWord />);
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("794px");
expect(frame.style.getPropertyValue("--qword-page-height")).toBe("1123px");
expect(frame.style.getPropertyValue("--qword-page-margin-left")).toBe("86px");
fireEvent.click(screen.getByRole("tab", { name: "Макет" }));
fireEvent.change(screen.getByLabelText("Поля страницы"), { target: { value: "narrow" } });
fireEvent.change(screen.getByLabelText("Размер страницы"), { target: { value: "letter" } });
fireEvent.change(screen.getByLabelText("Ориентация страницы"), { target: { value: "landscape" } });
await waitFor(() => expect(frame.style.getPropertyValue("--qword-page-height")).toBe("816px"));
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("1056px");
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("48px");
expect(frame.style.getPropertyValue("--qword-page-margin-right")).toBe("48px");
expect(frame.style.minHeight).toBe("816px");
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" } }));
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("применяет сохраненные в документе параметры страницы при открытии QWord", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-saved-page-setup-test",
title: "Сохраненный макет.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: "<p>Текст страницы</p>",
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "wide" }
};
try {
const view = render(<QWord document={document} onChange={vi.fn()} />);
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("1056px");
expect(frame.style.getPropertyValue("--qword-page-height")).toBe("816px");
expect(frame.style.getPropertyValue("--qword-page-margin-left")).toBe("116px");
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("92px");
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("переключает вкладки ленты и показывает команды выбранной вкладки", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
@@ -241,6 +390,75 @@ describe("QWord", () => {
}
});
it("сохраняет позицию курсора после нормализации HTML родительским состоянием", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onChange = vi.fn();
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-caret-normalized-input-test",
title: "Курсор.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: "<p>Начало</p>"
};
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
const normalizedDocument = {
...nextDocument,
html: nextDocument.html.replace("<p>", '<p data-normalized="true">')
};
onChange(normalizedDocument);
setCurrentDocument(normalizedDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
const paper = screen.getByLabelText("Содержимое документа");
const textNode = paper.querySelector("p")?.firstChild;
expect(textNode).toBeInstanceOf(Text);
const typedText = "Начало текст";
textNode!.textContent = typedText;
const range = window.document.createRange();
range.setStart(textNode!, typedText.length);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
fireEvent.input(paper);
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ html: `<p data-normalized="true">${typedText}</p>` }))
);
await waitFor(() => {
const normalizedTextNode = paper.querySelector("p")?.firstChild;
const selection = window.getSelection();
expect(selection?.anchorNode).toBe(normalizedTextNode);
expect(selection?.anchorOffset).toBe(typedText.length);
});
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("вставляет таблицу выбранного размера через вкладку Вставка", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
+251 -23
View File
@@ -1,4 +1,4 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, MouseEvent } from "react";
import {
AlignCenter,
@@ -32,7 +32,7 @@ import {
Rows3,
Underline
} from "lucide-react";
import type { WordDocument } from "../../types";
import type { WordDocument, WordPageMarginPreset, WordPageOrientation, WordPageSetup, WordPageSizeId } from "../../types";
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { exportDocxBlob, importDocxFile } from "../../io/wordOffice";
import { replaceFileExtension } from "../../io/fileHelpers";
@@ -43,8 +43,10 @@ import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
interface QWordProps {
document: WordDocument;
zoom?: number;
viewMode?: WordViewMode;
onChange: (document: WordDocument) => void;
onStatusChange?: (status: QWordStatus) => void;
onViewModeChange?: (viewMode: WordViewMode) => void;
}
interface TableCellLocation {
@@ -53,6 +55,13 @@ interface TableCellLocation {
cellIndex: number;
}
interface EditorSelectionBookmark {
anchorOffset: number;
anchorPath: number[];
focusOffset: number;
focusPath: number[];
}
export interface QWordStatus {
currentPage: number;
pageCount: number;
@@ -153,8 +162,7 @@ const styleGalleryOptions = [
{ value: "blockquote", label: "Цитата" }
];
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
type WordViewMode = "print" | "read" | "web";
export type WordViewMode = "print" | "read" | "web";
const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [
{ id: "file", label: "Файл" },
{ id: "home", label: "Главная" },
@@ -173,6 +181,20 @@ const wordViewModes: Array<{ id: WordViewMode; label: string }> = [
{ id: "read", label: "Чтение" },
{ id: "web", label: "Веб-документ" }
];
const wordPageSizeOptions: Array<{ id: WordPageSizeId; label: string; width: number; height: number }> = [
{ id: "a4", label: "A4", width: 794, height: 1123 },
{ id: "letter", label: "Letter", width: 816, height: 1056 }
];
const wordPageOrientationOptions: Array<{ id: WordPageOrientation; label: string }> = [
{ id: "portrait", label: "Книжная" },
{ id: "landscape", label: "Альбомная" }
];
const wordPageMarginOptions: Array<{ id: WordPageMarginPreset; label: string; margins: { top: number; right: number; bottom: number; left: number } }> = [
{ id: "normal", label: "Обычные", margins: { top: 72, right: 86, bottom: 72, left: 86 } },
{ id: "narrow", label: "Узкие", margins: { top: 48, right: 48, bottom: 48, left: 48 } },
{ id: "wide", label: "Широкие", margins: { top: 92, right: 116, bottom: 92, left: 116 } }
];
const defaultWordPageSetup: WordPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
const tableSizeOptions = [1, 2, 3, 4, 5, 6, 7, 8];
function findInPage(query: string) {
@@ -187,6 +209,27 @@ function pageHeightFromStyles(element: HTMLElement) {
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
}
function qwordPageDimensions(setup: WordPageSetup) {
const size = wordPageSizeOptions.find((option) => option.id === setup.size) ?? wordPageSizeOptions[0];
return setup.orientation === "landscape" ? { width: size.height, height: size.width } : { width: size.width, height: size.height };
}
function qwordPageMargins(setup: WordPageSetup) {
return (wordPageMarginOptions.find((option) => option.id === setup.marginPreset) ?? wordPageMarginOptions[0]).margins;
}
function normalizedWordPageSetup(setup?: WordDocument["pageSetup"]): WordPageSetup {
const candidateSize = setup?.size;
const candidateOrientation = setup?.orientation;
const candidateMarginPreset = setup?.marginPreset;
const size = candidateSize && wordPageSizeOptions.some((option) => option.id === candidateSize) ? candidateSize : defaultWordPageSetup.size;
const orientation =
candidateOrientation && wordPageOrientationOptions.some((option) => option.id === candidateOrientation) ? candidateOrientation : defaultWordPageSetup.orientation;
const marginPreset =
candidateMarginPreset && wordPageMarginOptions.some((option) => option.id === candidateMarginPreset) ? candidateMarginPreset : defaultWordPageSetup.marginPreset;
return { size, orientation, marginPreset };
}
function clampedTableSize(value: number) {
return Number.isFinite(value) ? Math.min(8, Math.max(1, Math.trunc(value))) : 3;
}
@@ -231,6 +274,97 @@ function tableCellFromLocation(editor: HTMLElement, location: TableCellLocation)
return cell instanceof HTMLTableCellElement ? cell : undefined;
}
function editorContainsNode(editor: HTMLElement, node: Node) {
return node === editor || editor.contains(node);
}
function editorNodePath(editor: HTMLElement, node: Node): number[] | undefined {
if (node === editor) {
return [];
}
const path: number[] = [];
let current: Node | null = node;
while (current && current !== editor) {
const parent: Node | null = current.parentNode;
if (!parent) {
return undefined;
}
const siblings = Array.from(parent.childNodes) as Node[];
const index = siblings.indexOf(current);
if (index < 0) {
return undefined;
}
path.unshift(index);
current = parent;
}
return current === editor ? path : undefined;
}
function editorNodeFromPath(editor: HTMLElement, path: number[]): Node | undefined {
let current: Node = editor;
for (const index of path) {
const child = current.childNodes[index];
if (!child) {
return undefined;
}
current = child;
}
return current;
}
function clampedSelectionOffset(node: Node, offset: number) {
const maxOffset = node.nodeType === Node.TEXT_NODE ? (node.textContent ?? "").length : node.childNodes.length;
return Math.min(Math.max(0, offset), maxOffset);
}
function editorSelectionBookmark(editor: HTMLElement): EditorSelectionBookmark | undefined {
const selection = window.getSelection();
const anchorNode = selection?.anchorNode;
const focusNode = selection?.focusNode;
if (!selection || selection.rangeCount === 0 || !anchorNode || !focusNode || !editorContainsNode(editor, anchorNode) || !editorContainsNode(editor, focusNode)) {
return undefined;
}
const anchorPath = editorNodePath(editor, anchorNode);
const focusPath = editorNodePath(editor, focusNode);
if (!anchorPath || !focusPath) {
return undefined;
}
return {
anchorOffset: selection.anchorOffset,
anchorPath,
focusOffset: selection.focusOffset,
focusPath
};
}
function restoreEditorSelection(editor: HTMLElement, bookmark: EditorSelectionBookmark) {
const anchorNode = editorNodeFromPath(editor, bookmark.anchorPath);
const focusNode = editorNodeFromPath(editor, bookmark.focusPath);
const selection = window.getSelection();
if (!anchorNode || !focusNode || !selection) {
return;
}
const anchorOffset = clampedSelectionOffset(anchorNode, bookmark.anchorOffset);
const focusOffset = clampedSelectionOffset(focusNode, bookmark.focusOffset);
selection.removeAllRanges();
if (typeof selection.setBaseAndExtent === "function") {
selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
return;
}
const range = globalThis.document.createRange();
range.setStart(anchorNode, anchorOffset);
range.setEnd(focusNode, focusOffset);
selection.addRange(range);
}
function placeCaretInTableCell(cell: HTMLTableCellElement) {
if (cell.childNodes.length === 0) {
cell.append(globalThis.document.createElement("br"));
@@ -248,45 +382,87 @@ function keepEditorSelection(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
}
export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number }) {
export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number; pageGap?: number }) {
const pageCount = Math.max(1, input.pageCount);
const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1;
const visualPageHeight = Math.max(1, input.pageHeight * zoom);
const pageGap = Number.isFinite(input.pageGap) && input.pageGap ? Math.max(0, input.pageGap) : 0;
const visualPageStride = Math.max(1, (input.pageHeight + pageGap) * zoom);
const visiblePaperOffset = Math.max(0, input.stageTop - input.paperTop);
return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageHeight) + 1));
return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageStride) + 1));
}
export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordProps) {
export function qwordPageCountFromContent(input: { scrollHeight: number; pageHeight: number; manualPageBreakCount?: number }) {
const pageHeight = Number.isFinite(input.pageHeight) && input.pageHeight > 0 ? input.pageHeight : defaultPageViewHeight;
const scrollHeight = Number.isFinite(input.scrollHeight) && input.scrollHeight > 0 ? input.scrollHeight : 0;
const manualPageBreakCount =
Number.isFinite(input.manualPageBreakCount) && input.manualPageBreakCount ? Math.max(0, Math.trunc(input.manualPageBreakCount)) : 0;
return Math.max(1, Math.ceil(scrollHeight / pageHeight), manualPageBreakCount + 1);
}
export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange, onViewModeChange }: QWordProps) {
const stageRef = useRef<HTMLElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const activeTableCellRef = useRef<HTMLTableCellElement | null>(null);
const activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
const restoreActiveTableCellAfterRenderRef = useRef(false);
const pendingEditorSelectionRestoreRef = useRef<EditorSelectionBookmark | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
const [viewMode, setViewMode] = useState<WordViewMode>("print");
const [internalViewMode, setInternalViewMode] = useState<WordViewMode>("print");
const [tableRowCount, setTableRowCount] = useState(3);
const [tableColumnCount, setTableColumnCount] = useState(3);
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
const wordCount = useMemo(() => countWords(document.html), [document.html]);
const pageSetup = useMemo(() => normalizedWordPageSetup(document.pageSetup), [document.pageSetup]);
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
const pageDimensions = qwordPageDimensions(pageSetup);
const pageMargins = qwordPageMargins(pageSetup);
const pageFrameStyle = {
"--qword-page-count": String(pageView.count),
"--qword-page-gap": `${defaultPageGap}px`,
"--qword-page-width": `${pageDimensions.width}px`,
"--qword-page-height": `${pageDimensions.height}px`,
"--qword-page-margin-top": `${pageMargins.top}px`,
"--qword-page-margin-right": `${pageMargins.right}px`,
"--qword-page-margin-bottom": `${pageMargins.bottom}px`,
"--qword-page-margin-left": `${pageMargins.left}px`,
minHeight: `${pageView.count * pageView.height + Math.max(0, pageView.count - 1) * defaultPageGap}px`,
paddingBottom: pageView.count > 1 ? `${(pageView.count - 1) * defaultPageGap}px` : undefined
} as CSSProperties;
const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная";
const stageClassName = ["document-stage", `qword-view-${viewMode}`].join(" ");
const activeViewMode = viewMode ?? internalViewMode;
const stageClassName = ["document-stage", `qword-view-${activeViewMode}`].join(" ");
const changeViewMode = useCallback(
(nextViewMode: WordViewMode) => {
setInternalViewMode(nextViewMode);
onViewModeChange?.(nextViewMode);
},
[onViewModeChange]
);
const changePageSetup = useCallback(
(nextPageSetup: Partial<WordPageSetup>) => {
onChange({ ...document, pageSetup: normalizedWordPageSetup({ ...pageSetup, ...nextPageSetup }) });
refreshPageView();
},
[document, onChange, pageSetup]
);
useLayoutEffect(() => {
const editor = editorRef.current;
if (!editor || editor.innerHTML === document.html) {
pendingEditorSelectionRestoreRef.current = null;
return;
}
const pendingSelection = pendingEditorSelectionRestoreRef.current;
pendingEditorSelectionRestoreRef.current = null;
editor.innerHTML = document.html;
if (pendingSelection) {
restoreEditorSelection(editor, pendingSelection);
}
}, [document.html]);
function currentPageFromStage(pageCount: number, pageHeight: number) {
@@ -304,7 +480,8 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
paperTop: paperRect.top,
pageHeight,
pageCount,
zoom: Number.isFinite(currentZoom) ? currentZoom : 1
zoom: Number.isFinite(currentZoom) ? currentZoom : 1,
pageGap: defaultPageGap
});
}
@@ -316,7 +493,8 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
}
const height = pageHeightFromStyles(editor);
const count = Math.max(1, Math.ceil(editor.scrollHeight / height));
const manualPageBreakCount = editor.querySelectorAll('[data-qoffice-page-break="true"]').length;
const count = qwordPageCountFromContent({ scrollHeight: editor.scrollHeight, pageHeight: height, manualPageBreakCount });
const currentPage = currentPageFromStage(count, height);
setPageView((current) =>
current.count === count && current.height === height && current.current === currentPage ? current : { count, current: currentPage, height }
@@ -334,7 +512,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
const observer = new ResizeObserver(refreshPageView);
observer.observe(editor);
return () => observer.disconnect();
}, [document.html, zoom]);
}, [document.html, pageDimensions.height, pageDimensions.width, pageMargins.bottom, pageMargins.left, pageMargins.right, pageMargins.top, zoom]);
useLayoutEffect(() => {
if (!restoreActiveTableCellAfterRenderRef.current) {
@@ -362,9 +540,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
function runCommand(command: string, value?: string) {
editorRef.current?.focus();
globalThis.document.execCommand(command, false, value);
if (editorRef.current) {
onChange({ ...document, html: editorRef.current.innerHTML });
}
updateHtml();
}
function applyTextColor(color: string) {
@@ -637,8 +813,10 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
function updateHtml() {
rememberActiveTableCell();
if (editorRef.current) {
onChange({ ...document, html: editorRef.current.innerHTML });
const editor = editorRef.current;
if (editor) {
pendingEditorSelectionRestoreRef.current = editorSelectionBookmark(editor) ?? null;
onChange({ ...document, html: editor.innerHTML });
refreshPageView();
}
}
@@ -650,7 +828,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
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 {
@@ -663,7 +841,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
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;
@@ -864,6 +1042,56 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
</section>
) : null}
{activeRibbonTab === "layout" ? (
<section className="word-ribbon-group word-page-setup-group" aria-label="Параметры страницы">
<div className="word-page-setup-controls">
<label className="word-mini-field">
<span>Поля</span>
<select
aria-label="Поля страницы"
value={pageSetup.marginPreset}
onChange={(event) => changePageSetup({ marginPreset: event.target.value as WordPageMarginPreset })}
>
{wordPageMarginOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<label className="word-mini-field">
<span>Размер</span>
<select
aria-label="Размер страницы"
value={pageSetup.size}
onChange={(event) => changePageSetup({ size: event.target.value as WordPageSizeId })}
>
{wordPageSizeOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<label className="word-mini-field">
<span>Ориентация</span>
<select
aria-label="Ориентация страницы"
value={pageSetup.orientation}
onChange={(event) => changePageSetup({ orientation: event.target.value as WordPageOrientation })}
>
{wordPageOrientationOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
</div>
<span className="word-ribbon-title">Параметры страницы</span>
</section>
) : null}
{activeRibbonTab === "home" || activeRibbonTab === "layout" ? (
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
<div className="segmented-tools" aria-label="Списки">
@@ -970,11 +1198,11 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
{wordViewModes.map((mode) => (
<button
key={mode.id}
className={`compact-command${viewMode === mode.id ? " is-active" : ""}`}
className={`compact-command${activeViewMode === mode.id ? " is-active" : ""}`}
type="button"
aria-label={mode.label}
aria-pressed={viewMode === mode.id}
onClick={() => setViewMode(mode.id)}
aria-pressed={activeViewMode === mode.id}
onClick={() => changeViewMode(mode.id)}
>
<FileText size={16} />
{mode.label}
+42
View File
@@ -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(
'<pageMargins left="1" right="1" top="1" bottom="1" header="0.5" footer="0.5"/>'
);
expect(buildPageSetupXml({ size: "letter", orientation: "landscape", marginPreset: "normal" })).toBe(
'<pageSetup paperSize="1" orientation="landscape"/>'
);
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('<pageMargins left="0.25" right="0.25" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>');
expect(sheetXml).toContain('<pageSetup paperSize="1" orientation="landscape"/>');
expect(imported.sheets[0].pageSetup).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" });
});
it("сохраняет и импортирует скрытые листы XLSX через sheet state", async () => {
const blob = await exportXlsxBlob({
id: "book-1",
+115 -4
View File
@@ -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<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
@@ -77,6 +89,7 @@ interface ParsedSheet {
showGridLines?: boolean;
zoomScale?: number;
printArea?: string;
pageSetup?: SheetPageSetup;
cells: Record<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
@@ -210,6 +223,16 @@ const builtinNumberFormats: Record<number, string> = {
48: "##0.0E+0",
49: "@"
};
const defaultSheetPageSetup: SheetPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
const xlsxPageSizePaperIds: Record<SheetPageSizeId, number> = {
letter: 1,
a4: 9
};
const xlsxPageMargins: Record<SheetPageMarginPreset, { left: number; right: number; top: number; bottom: number; header: number; footer: number }> = {
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<ImportedExcellWorkbook> {
if (!file.name.toLowerCase().endsWith(".xlsx")) {
@@ -320,7 +343,8 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
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<string, boolean> = {},
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(
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}${legacyDrawingXml}</worksheet>`
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${pageMarginsXml}${pageSetupXml}${drawingXml}${legacyDrawingXml}</worksheet>`
);
}
@@ -940,6 +981,24 @@ export function buildSheetPrXml(tabColor?: string) {
return color ? `<sheetPr><tabColor rgb="FF${color}"/></sheetPr>` : "";
}
export function buildPageMarginsXml(pageSetup?: SheetPageSetup) {
if (!pageSetup) {
return "";
}
const margins = xlsxPageMargins[normalizedSheetPageSetup(pageSetup).marginPreset];
return `<pageMargins left="${formatSheetDimension(margins.left)}" right="${formatSheetDimension(margins.right)}" top="${formatSheetDimension(margins.top)}" bottom="${formatSheetDimension(margins.bottom)}" header="${formatSheetDimension(margins.header)}" footer="${formatSheetDimension(margins.footer)}"/>`;
}
export function buildPageSetupXml(pageSetup?: SheetPageSetup) {
if (!pageSetup) {
return "";
}
const normalized = normalizedSheetPageSetup(pageSetup);
return `<pageSetup paperSize="${xlsxPageSizePaperIds[normalized.size]}" orientation="${normalized.orientation}"/>`;
}
export function buildColumnWidthsXml(columnWidths: Record<string, number> = {}, hiddenColumns: Record<string, boolean> = {}) {
const columnsByNumber = new Map<number, { width?: number; hidden?: boolean }>();
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"' : ""}`;
}
+49 -1
View File
@@ -7,6 +7,7 @@ import {
listSlidePaths,
normalizeWhitespace,
presentationSlidePaths,
presentationSlideSize,
relationshipTargetByType,
resolvePptTarget
} from "./powerPointOffice";
@@ -81,6 +82,30 @@ describe("powerPointOffice helpers", () => {
]);
});
it("читает размер слайда из presentation.xml", () => {
expect(
presentationSlideSize({
presentation: {
sldSz: { cx: "12192000", cy: "6858000" }
}
})
).toBe("wide");
expect(
presentationSlideSize({
presentation: {
sldSz: { "@_cx": "9144000", "@_cy": "6858000" }
}
})
).toBe("standard");
expect(
presentationSlideSize({
presentation: {
sldSz: { cx: "9144000", cy: "5143500" }
}
})
).toBeUndefined();
});
it("разрешает относительные targets заметок от части слайда", () => {
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
expect(
@@ -104,7 +129,7 @@ describe("powerPointOffice helpers", () => {
const zip = new JSZip();
zip.file(
"ppt/presentation.xml",
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="255" r:id="rId1"/></p:sldIdLst></p:presentation>`
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldSz cx="9144000" cy="6858000"/><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="255" r:id="rId1"/></p:sldIdLst></p:presentation>`
);
zip.file(
"ppt/_rels/presentation.xml.rels",
@@ -125,6 +150,7 @@ describe("powerPointOffice helpers", () => {
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
expect(imported.slideSize).toBe("standard");
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
expect(imported.slides[0].notes).toBe("Заметка докладчика");
});
@@ -772,6 +798,28 @@ describe("powerPointOffice helpers", () => {
});
});
it("экспортирует и переимпортирует стандартный размер слайда PPTX 4:3", async () => {
const blob = await exportPptxBlob({
title: "Standard.pptx",
slideSize: "standard",
slides: [
{
id: "slide-1",
title: "Standard",
subtitle: "4:3",
notes: "",
theme: "classic"
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const presentationXml = (await zip.file("ppt/presentation.xml")?.async("string")) ?? "";
const reimported = await importPptxFile(new File([blob], "Standard.pptx", { type: blob.type }));
expect(presentationXml).toContain('<p:sldSz cx="9144000" cy="6858000"/>');
expect(reimported.slideSize).toBe("standard");
});
it("нормализует пробелы в импортированном тексте", () => {
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
});
+32 -6
View File
@@ -1,13 +1,15 @@
import type { Slide, SlideImage, SlideListStyle, SlideTextAlign } from "../types";
import type { Slide, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign } from "../types";
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
export interface ImportedPowerPointDeck {
title: string;
slideSize?: SlideSizeId;
slides: Slide[];
}
export interface ExportablePowerPointDeck {
title: string;
slideSize?: SlideSizeId;
slides: Slide[];
}
@@ -47,6 +49,10 @@ const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
const emuPerInch = 914400;
const slideTransitions = ["fade", "push", "wipe"] as const;
const slideSizeLayouts: Record<SlideSizeId, { width: number; height: number; cx: number; cy: number; pptxLayout: string }> = {
wide: { width: 13.333, height: 7.5, cx: 12192000, cy: 6858000, pptxLayout: "LAYOUT_WIDE" },
standard: { width: 10, height: 7.5, cx: 9144000, cy: 6858000, pptxLayout: "LAYOUT_4x3" }
};
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
@@ -65,6 +71,7 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
const presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
const slideSize = presentationSlideSize(presentationXml);
if (slidePaths.length === 0) {
throw new Error("В файле PowerPoint не найдены слайды.");
@@ -126,7 +133,7 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
})
);
return { title, slides };
return { title, ...(slideSize ? { slideSize } : {}), slides };
}
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
@@ -147,8 +154,10 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
};
write: (options: { outputType: "blob" | "arraybuffer" }) => Promise<Blob | ArrayBuffer>;
};
const slideSize = normalizedSlideSizeId(deck.slideSize);
const slideLayout = slideSizeLayouts[slideSize];
pptx.layout = "LAYOUT_WIDE";
pptx.layout = slideLayout.pptxLayout;
pptx.author = "QOffice";
pptx.company = "QOffice";
pptx.subject = "Экспорт QPowerPoint";
@@ -196,7 +205,7 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
slide.addText(sourceSlide.title || "Без заголовка", {
x: 0.7,
y: 0.65,
w: 11.9,
w: Math.max(1, slideLayout.width - 1.4),
h: 0.75,
fontFace: titleFontFamily,
fontSize: titleFontSize,
@@ -213,8 +222,8 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
slide.addText(sourceSlide.subtitle || "", {
x: 0.75,
y: 1.65,
w: 11.5,
h: 4.35,
w: Math.max(1, slideLayout.width - 1.5),
h: Math.max(1, slideLayout.height - 3.15),
fontFace: subtitleFontFamily,
fontSize: subtitleFontSize,
...(subtitleAlign ? { align: subtitleAlign } : {}),
@@ -411,6 +420,19 @@ export function presentationSlidePaths(presentationXml: unknown, presentationRel
return slideIds.map((id) => relationshipTargets[id]).filter((target): target is string => Boolean(target));
}
export function presentationSlideSize(presentationXml: unknown): SlideSizeId | undefined {
const slideSize = childRecord(childRecord(presentationXml, "presentation"), "sldSz");
const cx = numberAttribute(slideSize, "cx");
const cy = numberAttribute(slideSize, "cy");
if (!cx || !cy) {
return undefined;
}
return Object.entries(slideSizeLayouts).find(([, layout]) => Math.abs(cx - layout.cx) <= 5000 && Math.abs(cy - layout.cy) <= 5000)?.[0] as
| SlideSizeId
| undefined;
}
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
const relationships = childRecord(relationshipsXml, "Relationships");
const targets: Record<string, string> = {};
@@ -467,6 +489,10 @@ export function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
return value === "standard" ? "standard" : "wide";
}
function normalizeTextWithLineBreaks(value: string): string {
return value
.split("\n")
+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",
+179 -5
View File
@@ -1,8 +1,10 @@
import { DOCX_MIME_TYPE, officeTitleFromFileName, readOfficeFileAsArrayBuffer } from "./fileHelpers";
import type { WordPageMarginPreset, WordPageOrientation, WordPageSetup, WordPageSizeId } from "../types";
export interface ImportedWordDocument {
title: string;
html: string;
pageSetup?: WordPageSetup;
}
export interface WordInlineRun {
@@ -116,6 +118,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 +168,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")) {
@@ -173,7 +186,7 @@ export async function importDocxFile(file: File): Promise<ImportedWordDocument>
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 },
{
@@ -181,20 +194,23 @@ export async function importDocxFile(file: File): Promise<ImportedWordDocument>
...(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 } : {})
};
}
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 +237,7 @@ export async function exportDocxBlob(title: string, html: string): Promise<Blob>
},
sections: [
{
properties: {},
properties: sectionProperties,
children:
children.length > 0
? children
@@ -246,6 +262,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();
}
@@ -939,6 +1011,108 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) {
}
}
async function docxPageSetupFromArrayBuffer(arrayBuffer: ArrayBuffer): Promise<WordPageSetup | undefined> {
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 !/<w:(pict|sdt)\b/i.test(documentXml);
}
+12
View File
@@ -449,4 +449,16 @@ describe("wordOffice DOCX import", () => {
{ 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", "<p>Layout</p>", {
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" });
});
});
+15 -1
View File
@@ -516,6 +516,7 @@ button {
.word-clipboard-group .word-ribbon-title,
.word-font-group .word-ribbon-title,
.word-page-setup-group .word-ribbon-title,
.word-paragraph-group .word-ribbon-title,
.word-styles-group .word-ribbon-title,
.word-insert-group .word-ribbon-title,
@@ -621,6 +622,19 @@ button {
grid-template-columns: minmax(0, 1fr);
}
.word-page-setup-group {
flex: 0 0 272px;
grid-template-columns: minmax(0, 1fr);
}
.word-page-setup-controls {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-content: start;
gap: 7px;
min-width: 0;
}
.word-paragraph-group > .segmented-tools,
.word-paragraph-group > .compact-command {
align-self: start;
@@ -1086,7 +1100,7 @@ button {
z-index: 1;
width: 100%;
margin: 0;
padding: 72px 86px;
padding: var(--qword-page-margin-top, 72px) var(--qword-page-margin-right, 86px) var(--qword-page-margin-bottom, 72px) var(--qword-page-margin-left, 86px);
border: 0;
background: transparent;
box-shadow: none;
+24
View File
@@ -1,10 +1,30 @@
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;
orientation: WordPageOrientation;
marginPreset: WordPageMarginPreset;
}
export interface SheetPageSetup {
size: SheetPageSizeId;
orientation: SheetPageOrientation;
marginPreset: SheetPageMarginPreset;
}
export interface WordDocument {
id: string;
title: string;
updatedAt: string;
html: string;
pageSetup?: WordPageSetup;
}
export interface Workbook {
@@ -29,6 +49,7 @@ export interface Sheet {
showGridLines?: boolean;
zoomScale?: number;
printArea?: string;
pageSetup?: SheetPageSetup;
cells: Record<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
@@ -76,11 +97,14 @@ export interface SheetChart {
valueRange: string;
}
export type SlideSizeId = "wide" | "standard";
export interface SlideDeck {
id: string;
title: string;
updatedAt: string;
activeSlideId: string;
slideSize?: SlideSizeId;
slides: Slide[];
}
+139
View File
@@ -0,0 +1,139 @@
# Ollama/Gemma Task: QOffice Next Iteration
You are Gemma running through Ollama. Work on the local repository:
```text
G:\Repos\QOffice
```
Project objective: QOffice is a cross-platform Russian-language office suite intended to move toward Microsoft Office-like functionality. It contains separate applications: QWord, QExcell, QPowerPoint, and QOutlook. Microsoft Office file compatibility is a core requirement.
Current verified repository state:
- `git status --short --branch` returned `## main...origin/main`.
- Latest commit before this task: `5487d04 Add QPowerPoint slide size support`.
- QWord already has DOCX import/export, page setup controls, and a Microsoft Word-like ribbon.
- QWord does not yet implement DOCX header/footer support.
## Iteration Goal
Implement QWord support for Microsoft Word / DOCX headers and footers.
The feature must be real DOCX compatibility work. Do not implement this only as internal JSON state. Import and export must read and write actual DOCX header/footer parts or clearly explain why that is impossible after inspecting the local `docx` package API.
## Files To Inspect First
First check for local project instructions:
```powershell
rg --files -g AGENTS.md
```
Then inspect these files before editing:
- `src/types.ts`
- `src/data/sampleData.ts`
- `src/App.tsx`
- `src/io/wordOffice.ts`
- `src/io/wordOffice.test.ts`
- `src/io/wordOfficeImport.test.ts`
- `src/features/qword/QWord.tsx`
- `src/features/qword/QWord.test.tsx`
## Verified Starting Points
- `src/types.ts`: `WordDocument` currently has `id`, `title`, `updatedAt`, `html`, and optional `pageSetup`.
- `src/io/wordOffice.ts`: `importDocxFile` imports DOCX through `mammoth`, `styledDocxHtmlFromArrayBuffer`, and `docxPageSetupFromArrayBuffer`.
- `src/io/wordOffice.ts`: `exportDocxBlob` currently accepts only `pageSetup` in its options object.
- `src/io/wordOffice.ts`: `lastDocxSectionProperties` already finds the final `w:sectPr`.
- `src/features/qword/QWord.tsx`: `openDocx` applies imported `title`, `html`, and `pageSetup`.
- `src/features/qword/QWord.tsx`: `saveDocx` passes only `pageSetup` into `exportDocxBlob`.
- QWord's `layout` ribbon tab already contains page setup controls.
## Implementation Requirements
1. Data model:
Add optional QWord document fields. Preferred names:
```ts
headerHtml?: string;
footerHtml?: string;
```
Existing saved documents without these fields must remain valid.
2. DOCX import:
- Read `word/document.xml`.
- Find the final applicable `w:sectPr`.
- Read `w:headerReference` and `w:footerReference`.
- Resolve their relationship ids through `word/_rels/document.xml.rels`.
- Read the corresponding `word/header*.xml` and `word/footer*.xml` parts.
- For this first iteration, support the `default` reference type.
- Do not break documents that also contain `first` or `even` references; those can be ignored for now.
- Convert header/footer content into HTML using the existing `wordOffice.ts` helper patterns. Preserve at least paragraphs and basic inline text styling.
3. DOCX export:
- Extend `exportDocxBlob` options to accept `headerHtml` and `footerHtml`.
- Use the local `docx` package API to create real DOCX header/footer parts if available.
- Reuse the existing HTML-to-DOCX block pipeline where practical.
- Preserve existing `pageSetup` behavior.
- Generated DOCX must contain real header/footer parts and relationships.
4. QWord UI:
- Add Russian-language controls for header and footer.
- Preferred location: QWord `layout` ribbon tab near page setup.
- Do not add a right sidebar.
- Use clear Russian labels, for example:
- `Верхний колонтитул`
- `Нижний колонтитул`
- Editing these fields must call `onChange` with updated `document.headerHtml` and `document.footerHtml`.
- `openDocx` must apply imported header/footer fields.
- `saveDocx` must pass header/footer fields to `exportDocxBlob`.
5. Tests:
Add focused regression tests:
- Import DOCX with `headerReference` and `footerReference`; assert imported `headerHtml` and `footerHtml`.
- Export DOCX with header/footer; assert header/footer parts and relationships exist.
- Roundtrip export -> import; assert header/footer values survive.
- QWord UI test: changing header/footer controls calls `onChange` with `headerHtml` and `footerHtml`.
## Verification Commands
Run the narrow test set first:
```powershell
npx vitest run src\io\wordOffice.test.ts src\io\wordOfficeImport.test.ts src\features\qword\QWord.test.tsx
```
If the narrow tests pass, run:
```powershell
npm run build
npm test
npm audit --audit-level=high
```
## Constraints
- Do not touch unrelated files.
- Do not revert user changes.
- Do not run destructive git commands.
- Do not add dependencies unless necessary.
- Follow the existing project patterns.
- If you are only an LLM without filesystem access, return a unified diff patch and verification commands.
- If you are running inside an agent with filesystem access, edit the files directly but do not commit or push.
## Final Response Required
Report:
- changed files;
- tests and command results;
- exactly how DOCX import/export was implemented;
- any remaining risks or unverified behavior.