Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b81341e6 | ||
|
|
5487d04cca | ||
|
|
5dfe97013d | ||
|
|
96aff5b76e | ||
|
|
217dafb760 | ||
|
|
3719cb7ec8 | ||
|
|
e5cd922f7d | ||
|
|
c1f8162afd | ||
|
|
0b993b7709 | ||
|
|
500d368af4 |
+42
-1
@@ -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
@@ -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" ? (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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 || "Изображение слайда"}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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"' : ""}`;
|
||||
}
|
||||
|
||||
@@ -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("Строка с пробелами");
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user