Compare commits
19 Commits
be7a0e5c98
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b81341e6 | |||
| 5487d04cca | |||
| 5dfe97013d | |||
| 96aff5b76e | |||
| 217dafb760 | |||
| 3719cb7ec8 | |||
| e5cd922f7d | |||
| c1f8162afd | |||
| 0b993b7709 | |||
| 500d368af4 | |||
| 5def87e738 | |||
| 74db17e348 | |||
| 626576e156 | |||
| c16df37c95 | |||
| 231bb6c076 | |||
| 6251c70ddc | |||
| e857beabd0 | |||
| 81a0de2ba9 | |||
| d86e0470c2 |
@@ -0,0 +1,108 @@
|
||||
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";
|
||||
import { App, qofficeWindowTitle } from "./App";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.localStorage.clear();
|
||||
window.history.pushState({}, "", "/");
|
||||
window.document.title = "";
|
||||
});
|
||||
|
||||
describe("qofficeWindowTitle", () => {
|
||||
it("uses the current artifact name for standalone application title bars", () => {
|
||||
expect(qofficeWindowTitle(initialState, "word")).toBe(`${initialState.word.title} - QWord`);
|
||||
expect(qofficeWindowTitle(initialState, "sheets")).toBe(`${initialState.workbook.title} - QExcell`);
|
||||
expect(qofficeWindowTitle(initialState, "slides")).toBe(`${initialState.deck.title} - QPowerPoint`);
|
||||
|
||||
const activeMessage = initialState.mail.messages.find((message) => message.id === initialState.mail.activeMessageId);
|
||||
expect(qofficeWindowTitle(initialState, "mail")).toBe(`${activeMessage?.subject} - QOutlook`);
|
||||
});
|
||||
|
||||
it("falls back to default standalone titles when names are blank", () => {
|
||||
const state: QOfficeState = {
|
||||
...initialState,
|
||||
word: { ...initialState.word, title: " " },
|
||||
workbook: { ...initialState.workbook, title: "" },
|
||||
deck: { ...initialState.deck, title: "\n\t" },
|
||||
mail: {
|
||||
...initialState.mail,
|
||||
messages: initialState.mail.messages.map((message) =>
|
||||
message.id === initialState.mail.activeMessageId ? { ...message, subject: "" } : message
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
expect(qofficeWindowTitle(state, "word")).toBe("Документ1 - QWord");
|
||||
expect(qofficeWindowTitle(state, "sheets")).toBe("Книга1 - QExcell");
|
||||
expect(qofficeWindowTitle(state, "slides")).toBe("Презентация1 - QPowerPoint");
|
||||
expect(qofficeWindowTitle(state, "mail")).toBe("Почта - QOutlook");
|
||||
});
|
||||
|
||||
it("syncs the browser document title with the active standalone application", 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;
|
||||
window.history.pushState({}, "", "/qword");
|
||||
|
||||
try {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(window.document.title).toBe(qofficeWindowTitle(initialState, "word")));
|
||||
} finally {
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
+68
-18
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { BookOpen, Columns2, FileText, Minus, Plus, Rows3 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "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,11 +12,38 @@ 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));
|
||||
}
|
||||
|
||||
function normalizedWindowTitlePart(value: string | undefined, fallback: string) {
|
||||
const normalized = value?.replace(/\s+/g, " ").trim();
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function qofficeWindowTitle(state: QOfficeState, activeApp: QOfficeState["activeApp"]) {
|
||||
if (activeApp === "word") {
|
||||
return `${normalizedWindowTitlePart(state.word.title, "Документ1")} - QWord`;
|
||||
}
|
||||
|
||||
if (activeApp === "sheets") {
|
||||
return `${normalizedWindowTitlePart(state.workbook.title, "Книга1")} - QExcell`;
|
||||
}
|
||||
|
||||
if (activeApp === "slides") {
|
||||
return `${normalizedWindowTitlePart(state.deck.title, "Презентация1")} - QPowerPoint`;
|
||||
}
|
||||
|
||||
const activeMessage = state.mail.messages.find((message) => message.id === state.mail.activeMessageId);
|
||||
return `${normalizedWindowTitlePart(activeMessage?.subject, "Почта")} - QOutlook`;
|
||||
}
|
||||
|
||||
function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
||||
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
||||
const sampleSheet = initialState.workbook.sheets.find((sheet) => sheet.id === "sheet-1");
|
||||
@@ -26,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;
|
||||
@@ -90,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;
|
||||
@@ -100,7 +131,13 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.title = windowTitle;
|
||||
}, [windowTitle]);
|
||||
|
||||
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
|
||||
setWordStatus((current) =>
|
||||
@@ -137,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} />
|
||||
@@ -185,6 +227,7 @@ export function App() {
|
||||
wordStatus.currentPage,
|
||||
wordStatus.pageCount,
|
||||
wordStatus.wordCount,
|
||||
wordViewMode,
|
||||
wordZoom
|
||||
]);
|
||||
|
||||
@@ -217,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" ? (
|
||||
@@ -227,7 +277,7 @@ export function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell activeApp={activeApp} status={status}>
|
||||
<AppShell activeApp={activeApp} windowTitle={windowTitle} status={status}>
|
||||
{activeSurface}
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppShell } from "./AppShell";
|
||||
|
||||
describe("AppShell", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("не показывает общую ленту навигации между приложениями", () => {
|
||||
const view = render(
|
||||
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
|
||||
@@ -37,4 +41,39 @@ describe("AppShell", () => {
|
||||
expect(screen.getByText("Число слов: 42")).toBeInTheDocument();
|
||||
expect(screen.getByText("125%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("показывает заголовок текущего файла активного приложения", () => {
|
||||
render(
|
||||
<AppShell activeApp="word" windowTitle="Отчет.docx - QWord" status={{ savedLabel: "Сохранено локально" }}>
|
||||
<div>Рабочая область QWord</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Отчет.docx - QWord")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("отправляет команды панели быстрого доступа активному приложению", () => {
|
||||
const commands: string[] = [];
|
||||
function onCommand(event: WindowEventMap["qoffice-command"]) {
|
||||
commands.push(event.detail.command);
|
||||
}
|
||||
window.addEventListener("qoffice-command", onCommand);
|
||||
|
||||
try {
|
||||
const view = render(
|
||||
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
|
||||
<div>Рабочая область QWord</div>
|
||||
</AppShell>
|
||||
);
|
||||
const quickAccessButtons = view.container.querySelectorAll(".quick-access button");
|
||||
|
||||
fireEvent.click(quickAccessButtons[0]);
|
||||
fireEvent.click(quickAccessButtons[1]);
|
||||
fireEvent.click(quickAccessButtons[2]);
|
||||
|
||||
expect(commands).toEqual(["save", "undo", "redo"]);
|
||||
} finally {
|
||||
window.removeEventListener("qoffice-command", onCommand);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
Share2,
|
||||
Undo2
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { MouseEvent, ReactNode } from "react";
|
||||
import type { QOfficeCommand } from "../hooks/useQOfficeCommand";
|
||||
import type { AppId } from "../types";
|
||||
|
||||
interface AppShellProps {
|
||||
activeApp: AppId;
|
||||
windowTitle?: string;
|
||||
status: {
|
||||
savedLabel: string;
|
||||
updatedAt?: string;
|
||||
@@ -28,28 +30,37 @@ const appTitles: Record<AppId, string> = {
|
||||
mail: "Почта - QOutlook"
|
||||
};
|
||||
|
||||
export function AppShell({ activeApp, status, children }: AppShellProps) {
|
||||
function dispatchQOfficeCommand(command: QOfficeCommand) {
|
||||
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command } }));
|
||||
}
|
||||
|
||||
function keepActiveEditorFocus(event: MouseEvent<HTMLButtonElement>) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
export function AppShell({ activeApp, windowTitle, status, children }: AppShellProps) {
|
||||
const statusClassName = ["statusbar", status.className].filter(Boolean).join(" ");
|
||||
const displayWindowTitle = windowTitle?.trim() || appTitles[activeApp];
|
||||
|
||||
return (
|
||||
<div className="qoffice-shell">
|
||||
<main className="workspace">
|
||||
<header className="titlebar">
|
||||
<div className="quick-access" aria-label="Панель быстрого доступа">
|
||||
<button type="button" title="Сохранить" aria-label="Сохранить">
|
||||
<button type="button" title="Сохранить" aria-label="Сохранить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("save")}>
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button type="button" title="Отменить" aria-label="Отменить">
|
||||
<button type="button" title="Отменить" aria-label="Отменить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("undo")}>
|
||||
<Undo2 size={16} />
|
||||
</button>
|
||||
<button type="button" title="Повторить" aria-label="Повторить">
|
||||
<button type="button" title="Повторить" aria-label="Повторить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("redo")}>
|
||||
<Redo2 size={16} />
|
||||
</button>
|
||||
<button type="button" title="Настроить панель" aria-label="Настроить панель">
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<strong className="window-title">{appTitles[activeApp]}</strong>
|
||||
<strong className="window-title">{displayWindowTitle}</strong>
|
||||
<div className="titlebar-actions">
|
||||
<label className="command-search">
|
||||
<Search size={16} />
|
||||
|
||||
@@ -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;
|
||||
@@ -181,6 +330,135 @@ describe("QWord", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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-caret-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) => {
|
||||
onChange(nextDocument);
|
||||
setCurrentDocument(nextDocument);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>${typedText}</p>` })));
|
||||
const selection = window.getSelection();
|
||||
expect(selection?.anchorNode).toBe(textNode);
|
||||
expect(selection?.anchorOffset).toBe(typedText.length);
|
||||
} finally {
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
+260
-24
@@ -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,37 +382,88 @@ 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) {
|
||||
const stage = stageRef.current;
|
||||
@@ -295,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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,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 }
|
||||
@@ -325,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) {
|
||||
@@ -353,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) {
|
||||
@@ -628,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();
|
||||
}
|
||||
}
|
||||
@@ -641,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 {
|
||||
@@ -654,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;
|
||||
@@ -855,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="Списки">
|
||||
@@ -961,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}
|
||||
@@ -1021,7 +1258,6 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
||||
onKeyUp={rememberActiveTableCell}
|
||||
onMouseDown={rememberActiveTableCellFromEvent}
|
||||
onMouseUp={rememberActiveTableCellFromEvent}
|
||||
dangerouslySetInnerHTML={{ __html: document.html }}
|
||||
aria-label="Содержимое документа"
|
||||
/>
|
||||
{pageView.count > 1 ? (
|
||||
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
export {};
|
||||
|
||||
type DesktopOfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail";
|
||||
type QOfficeDesktopCommand = "open" | "save" | "export-pdf" | "print";
|
||||
type QOfficeDesktopCommand = "open" | "save" | "export-pdf" | "print" | "undo" | "redo";
|
||||
|
||||
interface DesktopOpenOfficeFileResult {
|
||||
canceled: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useQOfficeCommand } from "./useQOfficeCommand";
|
||||
|
||||
function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf?: () => void }) {
|
||||
@@ -8,6 +8,10 @@ function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf
|
||||
}
|
||||
|
||||
describe("useQOfficeCommand", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("вызывает обработчик команды QOffice", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<CommandProbe onSave={onSave} />);
|
||||
@@ -39,4 +43,23 @@ describe("useQOfficeCommand", () => {
|
||||
await waitFor(() => expect(onExportPdf).toHaveBeenCalledTimes(1));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("использует команду документа для undo и redo без обработчика приложения", async () => {
|
||||
const originalExecCommand = document.execCommand;
|
||||
const execCommand = vi.fn();
|
||||
Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
|
||||
|
||||
try {
|
||||
render(<CommandProbe onSave={vi.fn()} />);
|
||||
|
||||
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "undo" } }));
|
||||
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "redo" } }));
|
||||
|
||||
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("redo"));
|
||||
expect(execCommand).toHaveBeenNthCalledWith(1, "undo");
|
||||
expect(execCommand).toHaveBeenNthCalledWith(2, "redo");
|
||||
} finally {
|
||||
Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type QOfficeCommand = "open" | "save" | "export-pdf" | "print";
|
||||
export type QOfficeCommand = "open" | "save" | "export-pdf" | "print" | "undo" | "redo";
|
||||
export type QOfficeCommandHandlers = Partial<Record<QOfficeCommand, () => void | Promise<void>>>;
|
||||
|
||||
export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
|
||||
@@ -16,6 +16,11 @@ export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
|
||||
const handler = handlersRef.current[command];
|
||||
if (handler) {
|
||||
void handler();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "undo" || command === "redo") {
|
||||
globalThis.document.execCommand?.(command);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
buildColumnWidthsXml,
|
||||
buildHyperlinksXml,
|
||||
buildMergeCellsXml,
|
||||
buildPageMarginsXml,
|
||||
buildPageSetupXml,
|
||||
buildSheetPrXml,
|
||||
buildSheetViewsXml,
|
||||
createCellStyleRegistry,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
workbookPrintAreas,
|
||||
workbookRelationshipTargets,
|
||||
workbookSheetRefs,
|
||||
workbookUsesDate1904,
|
||||
worksheetXmlToAutoFilter,
|
||||
worksheetXmlToCells,
|
||||
worksheetXmlToCellFormats,
|
||||
@@ -31,12 +34,14 @@ import {
|
||||
worksheetXmlToHyperlinks,
|
||||
worksheetCommentsXmlToComments,
|
||||
worksheetXmlToMergedCells,
|
||||
worksheetXmlToPageSetup,
|
||||
worksheetXmlToRowHeights,
|
||||
worksheetXmlToShowGridLines,
|
||||
worksheetXmlToTabColor,
|
||||
worksheetXmlToZoomScale,
|
||||
xlsxStylesXmlToCellFormatTable,
|
||||
xlsxThemeXmlToColors,
|
||||
xlsxSafeSheetNames,
|
||||
xlsxCellToText
|
||||
} from "./excellOffice";
|
||||
|
||||
@@ -614,6 +619,38 @@ describe("excellOffice OOXML helpers", () => {
|
||||
).toBe("sheet-7");
|
||||
});
|
||||
|
||||
it("detects the XLSX 1904 date system flag from workbook properties", () => {
|
||||
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "1" } } })).toBe(true);
|
||||
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "true" } } })).toBe(true);
|
||||
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "0" } } })).toBe(false);
|
||||
});
|
||||
|
||||
it("converts imported XLSX 1904 date serials to the QExcell 1900 serial system", async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
"xl/workbook.xml",
|
||||
`<workbook><workbookPr date1904="1"/><sheets><sheet name="Dates" sheetId="1" r:id="rId1"/></sheets></workbook>`
|
||||
);
|
||||
zip.file(
|
||||
"xl/_rels/workbook.xml.rels",
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>`
|
||||
);
|
||||
zip.file("xl/styles.xml", `<styleSheet><cellXfs count="3"><xf numFmtId="0"/><xf numFmtId="14"/><xf numFmtId="20"/></cellXfs></styleSheet>`);
|
||||
zip.file(
|
||||
"xl/worksheets/sheet1.xml",
|
||||
`<worksheet><sheetData><row r="1"><c r="A1" s="1"><v>43465</v></c><c r="B1"><v>5</v></c><c r="C1" s="2"><v>0.5</v></c></row></sheetData></worksheet>`
|
||||
);
|
||||
|
||||
const blob = await zip.generateAsync({
|
||||
type: "blob",
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
});
|
||||
const imported = await importXlsxFile(new File([blob], "Date1904.xlsx", { type: blob.type }));
|
||||
|
||||
expect(imported.sheets[0].cells).toEqual({ A1: "44927", B1: "5", C1: "0.5" });
|
||||
expect(imported.sheets[0].cellFormats).toEqual({ A1: { numberFormat: "m/d/yy" }, C1: { numberFormat: "h:mm" } });
|
||||
});
|
||||
|
||||
it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => {
|
||||
const blob = await exportXlsxBlob({
|
||||
id: "book-1",
|
||||
@@ -633,6 +670,38 @@ describe("excellOffice OOXML helpers", () => {
|
||||
expect(imported.activeSheet).toBe("sheet-2");
|
||||
});
|
||||
|
||||
it("normalizes XLSX sheet names to Excel-safe unique names on export", async () => {
|
||||
expect(
|
||||
xlsxSafeSheetNames([
|
||||
{ name: "Sales/2026:Q1*?" },
|
||||
{ name: "'Very long worksheet name that exceeds thirty one characters'" },
|
||||
{ name: "Sales 2026 Q1" },
|
||||
{ name: "'[]:*?/\\'" },
|
||||
{ name: "History" }
|
||||
])
|
||||
).toEqual(["Sales 2026 Q1", "Very long worksheet name that e", "Sales 2026 Q1 (2)", "Лист 4", "History 1"]);
|
||||
|
||||
const blob = await exportXlsxBlob({
|
||||
id: "book-1",
|
||||
title: "Safe sheet names.xlsx",
|
||||
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||
activeSheet: "sheet-1",
|
||||
sheets: [
|
||||
{ id: "sheet-1", name: "Sales/2026:Q1*?", printArea: "A1:B2", cells: { A1: "Sales" } },
|
||||
{ id: "sheet-2", name: "Sales 2026 Q1", cells: { A1: "Duplicate" } }
|
||||
]
|
||||
});
|
||||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||
const workbookXml = await zip.file("xl/workbook.xml")?.async("string");
|
||||
const imported = await importXlsxFile(new File([blob], "Safe sheet names.xlsx", { type: blob.type }));
|
||||
|
||||
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1" sheetId="1" r:id="rId1"/>');
|
||||
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1 (2)" sheetId="2" r:id="rId2"/>');
|
||||
expect(workbookXml).toContain('<definedName name="_xlnm.Print_Area" localSheetId="0">\'Sales 2026 Q1\'!$A$1:$B$2</definedName>');
|
||||
expect(imported.sheets.map((sheet) => sheet.name)).toEqual(["Sales 2026 Q1", "Sales 2026 Q1 (2)"]);
|
||||
expect(imported.sheets[0].printArea).toBe("A1:B2");
|
||||
});
|
||||
|
||||
it("сохраняет и импортирует область печати XLSX через definedName _xlnm.Print_Area", async () => {
|
||||
expect(
|
||||
workbookPrintAreas(
|
||||
@@ -673,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",
|
||||
|
||||
+209
-17
@@ -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")) {
|
||||
@@ -226,6 +249,7 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
|
||||
const themeColors = await readOptionalWorkbookThemeColors(zip, parser);
|
||||
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
|
||||
const sheetRefs = workbookSheetRefs(workbookXml);
|
||||
const date1904 = workbookUsesDate1904(workbookXml);
|
||||
const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
|
||||
const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
|
||||
const sheets = await Promise.all(
|
||||
@@ -249,7 +273,7 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
|
||||
const sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath);
|
||||
const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath);
|
||||
const commentsXml = commentsPath ? await readOptionalParsedXml(zip, parser, commentsPath) : undefined;
|
||||
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors, commentsXml);
|
||||
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors, commentsXml, date1904);
|
||||
const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name);
|
||||
return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts };
|
||||
})
|
||||
@@ -267,11 +291,13 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
||||
const JSZip = await loadJsZip();
|
||||
const zip = new JSZip();
|
||||
const sheets = workbook.sheets.length > 0 ? workbook.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [], hyperlinks: {} }];
|
||||
const safeSheetNames = xlsxSafeSheetNames(sheets);
|
||||
const safeSheets = sheets.map((sheet, index) => ({ ...sheet, name: safeSheetNames[index] }));
|
||||
|
||||
const styleRegistry = createCellStyleRegistry(sheets.map((sheet) => sheet.cellFormats));
|
||||
const chartParts = chartPartRefsForSheets(sheets);
|
||||
const commentParts = commentPartRefsForSheets(sheets);
|
||||
const activeSheetIndex = Math.max(0, sheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
|
||||
const styleRegistry = createCellStyleRegistry(safeSheets.map((sheet) => sheet.cellFormats));
|
||||
const chartParts = chartPartRefsForSheets(safeSheets);
|
||||
const commentParts = commentPartRefsForSheets(safeSheets);
|
||||
const activeSheetIndex = Math.max(0, safeSheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
|
||||
|
||||
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts, commentParts));
|
||||
zip.file("_rels/.rels", buildRootRelationshipsXml());
|
||||
@@ -280,8 +306,8 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
||||
zip.file(
|
||||
"xl/workbook.xml",
|
||||
buildWorkbookXml(
|
||||
sheets.map((sheet, index) => ({
|
||||
name: sheet.name.trim() || `Лист ${index + 1}`,
|
||||
safeSheets.map((sheet) => ({
|
||||
name: sheet.name,
|
||||
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
|
||||
...(sheet.printArea ? { printArea: sheet.printArea } : {})
|
||||
})),
|
||||
@@ -291,7 +317,7 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
||||
zip.file("xl/_rels/workbook.xml.rels", buildWorkbookRelationshipsXml(sheets.length));
|
||||
zip.file("xl/styles.xml", buildStylesXml(styleRegistry));
|
||||
|
||||
sheets.forEach((sheet, index) => {
|
||||
safeSheets.forEach((sheet, index) => {
|
||||
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
|
||||
const sheetCommentPart = commentParts.find((part) => part.sheetIndex === index);
|
||||
const externalHyperlinkCount = sortedExternalHyperlinkEntries(sheet.hyperlinks).length;
|
||||
@@ -317,7 +343,8 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
||||
sheet.hiddenRows,
|
||||
sheet.showGridLines,
|
||||
sheet.zoomScale,
|
||||
legacyDrawingRelationshipId
|
||||
legacyDrawingRelationshipId,
|
||||
sheet.pageSetup
|
||||
)
|
||||
);
|
||||
const relationshipsXml = buildWorksheetRelationshipsXml(
|
||||
@@ -356,7 +383,8 @@ export function parsedWorksheetToSheet(
|
||||
relationshipsXml?: unknown,
|
||||
styleTable: Array<CellFormat | undefined> = [],
|
||||
themeColors: string[] = defaultXlsxThemeColors,
|
||||
commentsXml?: unknown
|
||||
commentsXml?: unknown,
|
||||
date1904 = false
|
||||
): ParsedSheet {
|
||||
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
|
||||
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
|
||||
@@ -367,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}`,
|
||||
@@ -381,7 +410,8 @@ export function parsedWorksheetToSheet(
|
||||
...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}),
|
||||
...(showGridLines === false ? { showGridLines } : {}),
|
||||
...(zoomScale ? { zoomScale } : {}),
|
||||
cells: worksheetXmlToCells(worksheetXml, sharedStrings),
|
||||
...(pageSetup ? { pageSetup } : {}),
|
||||
cells: worksheetXmlToCells(worksheetXml, sharedStrings, styleTable, date1904),
|
||||
mergedCells: worksheetXmlToMergedCells(worksheetXml),
|
||||
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
|
||||
comments: worksheetCommentsXmlToComments(commentsXml),
|
||||
@@ -390,7 +420,7 @@ export function parsedWorksheetToSheet(
|
||||
};
|
||||
}
|
||||
|
||||
export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[]) {
|
||||
export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[], styleTable: Array<CellFormat | undefined> = [], date1904 = false) {
|
||||
const cells: Record<string, string> = {};
|
||||
const sharedFormulas: SharedFormulaMap = {};
|
||||
const parsedCells = worksheetCellRecords(worksheetXml);
|
||||
@@ -406,9 +436,10 @@ export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string
|
||||
|
||||
parsedCells.forEach(({ address, cell }) => {
|
||||
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
|
||||
const importedText = date1904 ? date1904AdjustedCellText(cell, text, styleTable[numberAttribute(cell.s)]) : text;
|
||||
|
||||
if (text !== "") {
|
||||
cells[address] = text;
|
||||
if (importedText !== "") {
|
||||
cells[address] = importedText;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -535,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");
|
||||
@@ -799,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);
|
||||
@@ -840,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>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -935,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 }) => {
|
||||
@@ -1030,6 +1094,12 @@ export function workbookActiveSheetId(workbookXml: unknown, sheets: Array<{ id:
|
||||
return sheets[activeTab]?.id ?? "";
|
||||
}
|
||||
|
||||
export function workbookUsesDate1904(workbookXml: unknown) {
|
||||
const workbook = childRecord(workbookXml, "workbook");
|
||||
const workbookPr = childRecord(workbook, "workbookPr");
|
||||
return booleanAttribute(workbookPr.date1904 ?? workbookPr["@_date1904"]);
|
||||
}
|
||||
|
||||
export function workbookPrintAreas(workbookXml: unknown, sheets: Array<{ id: string; name: string }>): Record<string, string> {
|
||||
const workbook = childRecord(workbookXml, "workbook");
|
||||
const definedNames = childRecord(workbook, "definedNames");
|
||||
@@ -1109,6 +1179,45 @@ function buildRootRelationshipsXml() {
|
||||
);
|
||||
}
|
||||
|
||||
export function xlsxSafeSheetNames(sheets: Array<{ name: string }>) {
|
||||
const usedNames = new Set<string>();
|
||||
return sheets.map((sheet, index) => uniqueXlsxSheetName(sheet.name, index, usedNames));
|
||||
}
|
||||
|
||||
function uniqueXlsxSheetName(value: string, index: number, usedNames: Set<string>) {
|
||||
const fallback = `Лист ${index + 1}`;
|
||||
const normalizedName = normalizedXlsxSheetName(value);
|
||||
const baseName = xlsxReservedSheetName(normalizedName) ? `${normalizedName} 1` : normalizedName || fallback;
|
||||
let suffixNumber = 1;
|
||||
let candidate = truncateXlsxSheetName(baseName);
|
||||
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
suffixNumber += 1;
|
||||
const suffix = ` (${suffixNumber})`;
|
||||
candidate = `${truncateXlsxSheetName(baseName, 31 - suffix.length)}${suffix}`;
|
||||
}
|
||||
|
||||
usedNames.add(candidate.toLowerCase());
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizedXlsxSheetName(value: string) {
|
||||
return value
|
||||
.replace(/[\u0000-\u001f\\/?*:[\]]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^'+|'+$/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function xlsxReservedSheetName(value: string) {
|
||||
return value.trim().toLowerCase() === "history";
|
||||
}
|
||||
|
||||
function truncateXlsxSheetName(value: string, maxLength = 31) {
|
||||
return Array.from(value).slice(0, Math.max(1, maxLength)).join("");
|
||||
}
|
||||
|
||||
function buildWorkbookXml(sheets: Array<{ name: string; visibility?: WorkbookSheetRef["visibility"]; printArea?: string }>, activeSheetIndex = 0) {
|
||||
const activeTab = Math.min(Math.max(0, activeSheetIndex), Math.max(0, sheets.length - 1));
|
||||
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;
|
||||
@@ -1748,6 +1857,37 @@ function normalizedNumberFormat(value?: string) {
|
||||
return raw.slice(0, 255);
|
||||
}
|
||||
|
||||
function date1904AdjustedCellText(cell: XmlRecord, text: string, format?: CellFormat) {
|
||||
if (!isNumericXlsxCell(cell) || !isDateSerialXlsxNumberFormat(format?.numberFormat)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const serial = Number(text);
|
||||
if (!Number.isFinite(serial) || serial < 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return normalizedXlsxNumberText(serial + 1462);
|
||||
}
|
||||
|
||||
function isNumericXlsxCell(cell: XmlRecord) {
|
||||
const type = typeof cell.t === "string" ? cell.t.trim() : "";
|
||||
return type === "" || type === "n";
|
||||
}
|
||||
|
||||
function isDateSerialXlsxNumberFormat(value?: string) {
|
||||
const pattern = normalizedNumberFormat(value)
|
||||
.replace(/"[^"]*"/g, "")
|
||||
.replace(/\\./g, "")
|
||||
.replace(/\[[^\]]+]/g, "")
|
||||
.toLowerCase();
|
||||
return /[dy]/.test(pattern) || (pattern.includes("m") && !/[hs]/.test(pattern));
|
||||
}
|
||||
|
||||
function normalizedXlsxNumberText(value: number) {
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(10)));
|
||||
}
|
||||
|
||||
function xlsxNumberFormats(styleSheet: XmlRecord) {
|
||||
const formats = { ...builtinNumberFormats };
|
||||
toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {
|
||||
@@ -1833,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"' : ""}`;
|
||||
}
|
||||
|
||||
@@ -254,6 +254,82 @@ describe("outlookMail helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("embeds inline CID images in imported EML HTML body", async () => {
|
||||
const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
const eml = [
|
||||
"From: sender@example.com",
|
||||
"To: reader@example.com",
|
||||
"Subject: Inline logo",
|
||||
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
|
||||
'Content-Type: multipart/related; boundary="related"',
|
||||
"",
|
||||
"--related",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"",
|
||||
'<p>Logo</p><img alt="logo" src="cid:Logo%40Example.com">',
|
||||
"--related",
|
||||
'Content-Type: image/png; name="logo.png"',
|
||||
'Content-Disposition: inline; filename="logo.png"',
|
||||
"Content-ID: <logo@example.com>",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
imageBase64,
|
||||
"--related--"
|
||||
].join("\r\n");
|
||||
|
||||
const imported = await importEmlFile(new File([eml], "inline.eml", { type: "message/rfc822" }));
|
||||
|
||||
expect(imported.body).toBe("Logo");
|
||||
expect(imported.bodyHtml).toContain(`src="data:image/png;base64,${imageBase64}"`);
|
||||
expect(imported.bodyHtml).not.toContain("cid:");
|
||||
expect(imported.attachments).toEqual([
|
||||
{
|
||||
id: "attachment-logo-png-1",
|
||||
fileName: "logo.png",
|
||||
contentType: "image/png",
|
||||
size: 68,
|
||||
contentBase64: imageBase64,
|
||||
disposition: "inline",
|
||||
contentId: "logo@example.com"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps non-image CID references in imported EML HTML body", async () => {
|
||||
const eml = [
|
||||
"From: sender@example.com",
|
||||
"To: reader@example.com",
|
||||
"Subject: Inline document",
|
||||
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
|
||||
'Content-Type: multipart/related; boundary="related"',
|
||||
"",
|
||||
"--related",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"",
|
||||
'<p>Document</p><img src="cid:document">',
|
||||
"--related",
|
||||
'Content-Type: text/plain; name="document.txt"',
|
||||
'Content-Disposition: inline; filename="document.txt"',
|
||||
"Content-ID: <document>",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
"SGVsbG8=",
|
||||
"--related--"
|
||||
].join("\r\n");
|
||||
|
||||
const imported = await importEmlFile(new File([eml], "inline.eml", { type: "message/rfc822" }));
|
||||
|
||||
expect(imported.bodyHtml).toContain('src="cid:document"');
|
||||
expect(imported.bodyHtml).not.toContain("data:text/plain");
|
||||
expect(imported.attachments?.[0]).toMatchObject({
|
||||
fileName: "document.txt",
|
||||
contentType: "text/plain",
|
||||
contentBase64: "SGVsbG8=",
|
||||
disposition: "inline",
|
||||
contentId: "document"
|
||||
});
|
||||
});
|
||||
|
||||
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
|
||||
const message = msgDataToMailMessage(
|
||||
{
|
||||
@@ -381,6 +457,50 @@ describe("outlookMail helpers", () => {
|
||||
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>HTML text</strong></p>");
|
||||
});
|
||||
|
||||
it("exports inline data images as CID references in related EML", () => {
|
||||
const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
const message: MailMessage = {
|
||||
id: "inline-image",
|
||||
folder: "sent",
|
||||
from: "sender@example.com",
|
||||
to: "reader@example.com",
|
||||
subject: "Inline image",
|
||||
body: "Logo",
|
||||
bodyHtml: `<p>Logo</p><img alt="logo" src="data:image/png;base64,${imageBase64}">`,
|
||||
time: "Mon, 01 Jan 2024 10:00:00 +0000",
|
||||
read: true,
|
||||
flagged: false,
|
||||
attachments: [
|
||||
{
|
||||
id: "logo",
|
||||
fileName: "logo.png",
|
||||
contentType: "image/png",
|
||||
size: 68,
|
||||
contentBase64: imageBase64,
|
||||
disposition: "inline",
|
||||
contentId: "logo@example.com"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const eml = buildEmlSource(message);
|
||||
const parsed = parseEmlSource(eml);
|
||||
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
|
||||
|
||||
expect(eml).toContain("Content-Type: multipart/related");
|
||||
expect(eml).toContain("Content-ID: <logo@example.com>");
|
||||
expect(eml).toContain('Content-Disposition: inline; filename="logo.png"');
|
||||
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe('<p>Logo</p><img alt="logo" src="cid:logo@example.com">');
|
||||
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).not.toContain("data:image/png;base64");
|
||||
expect(attachments[0]).toMatchObject({
|
||||
fileName: "logo.png",
|
||||
contentType: "image/png",
|
||||
contentBase64: imageBase64,
|
||||
disposition: "inline",
|
||||
contentId: "logo@example.com"
|
||||
});
|
||||
});
|
||||
|
||||
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
|
||||
const message: MailMessage = {
|
||||
id: "1",
|
||||
|
||||
+166
-5
@@ -81,7 +81,8 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
||||
|
||||
const raw = await file.text();
|
||||
const parsed = parseEmlSource(raw);
|
||||
const bodyHtml = extractMessageHtmlBody(parsed.headers, parsed.body);
|
||||
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
|
||||
const bodyHtml = htmlWithEmbeddedCidAttachments(extractMessageHtmlBody(parsed.headers, parsed.body), attachments);
|
||||
|
||||
return {
|
||||
id: `eml-${Date.now()}`,
|
||||
@@ -99,7 +100,7 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
||||
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
|
||||
read: false,
|
||||
flagged: false,
|
||||
attachments: extractMessageAttachments(parsed.headers, parsed.body)
|
||||
attachments
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,6 +263,81 @@ export function extractMessageAttachments(headers: MailHeaders, body: string): M
|
||||
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
|
||||
}
|
||||
|
||||
export function htmlWithEmbeddedCidAttachments(html: string, attachments: MailAttachment[] = []): string {
|
||||
if (!html.trim() || attachments.length === 0) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const inlineImagesByContentId = new Map<string, { contentBase64: string; contentType: string }>();
|
||||
attachments.forEach((attachment) => {
|
||||
const contentId = normalizedContentId(attachment.contentId);
|
||||
const contentType = sanitizeMimeType(attachment.contentType);
|
||||
const contentBase64 = attachment.contentBase64.trim();
|
||||
if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inlineImagesByContentId.has(contentId)) {
|
||||
inlineImagesByContentId.set(contentId, { contentBase64, contentType });
|
||||
}
|
||||
});
|
||||
|
||||
if (inlineImagesByContentId.size === 0) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return html.replace(/\bsrc\s*=\s*(?:(["'])\s*cid:([^"'>\s]+)\1|cid:([^\s"'<>]+))/gi, (match, quote: string | undefined, quotedCid: string | undefined, unquotedCid: string | undefined) => {
|
||||
const contentId = normalizedContentId(decodeUriComponentSafe(quotedCid ?? unquotedCid ?? ""));
|
||||
const attachment = inlineImagesByContentId.get(contentId);
|
||||
if (!attachment) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const dataUri = `data:${attachment.contentType};base64,${attachment.contentBase64}`;
|
||||
return quote ? `src=${quote}${dataUri}${quote}` : `src="${dataUri}"`;
|
||||
});
|
||||
}
|
||||
|
||||
function htmlWithCidReferencesForInlineAttachments(html: string, attachments: MailAttachment[] = []): string {
|
||||
if (!html.trim() || attachments.length === 0) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const contentIdsByDataUri = new Map<string, string>();
|
||||
attachments.forEach((attachment) => {
|
||||
const contentId = cleanContentId(attachment.contentId);
|
||||
const contentType = sanitizeMimeType(attachment.contentType);
|
||||
const contentBase64 = attachment.contentBase64.trim();
|
||||
if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = cidImageDataUriKey(contentType, contentBase64);
|
||||
if (!contentIdsByDataUri.has(key)) {
|
||||
contentIdsByDataUri.set(key, contentId);
|
||||
}
|
||||
});
|
||||
|
||||
if (contentIdsByDataUri.size === 0) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return html.replace(
|
||||
/\bsrc\s*=\s*(?:(["'])\s*data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^"'>\s]+)\1|data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^\s"'<>]+))/gi,
|
||||
(match, quote: string | undefined, quotedType: string | undefined, quotedBase64: string | undefined, unquotedType: string | undefined, unquotedBase64: string | undefined) => {
|
||||
const contentType = quotedType ?? unquotedType ?? "";
|
||||
const contentBase64 = (quotedBase64 ?? unquotedBase64 ?? "").trim();
|
||||
const contentId = contentIdsByDataUri.get(cidImageDataUriKey(contentType, contentBase64));
|
||||
if (!contentId) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const cidReference = escapeHtmlAttribute(`cid:${contentId}`);
|
||||
return quote ? `src=${quote}${cidReference}${quote}` : `src="${cidReference}"`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
||||
return {
|
||||
id: createAttachmentId(file.name),
|
||||
@@ -463,6 +539,28 @@ export function buildEmlSource(message: MailMessage): string {
|
||||
if (attachments.length > 0) {
|
||||
const boundary = createMultipartBoundary(message);
|
||||
const alternativeBoundary = createAlternativeBoundary(message);
|
||||
const inlineRelatedAttachments = hasHtmlBody ? attachments.filter(isInlineRelatedAttachment) : [];
|
||||
const mixedAttachments = inlineRelatedAttachments.length > 0 ? attachments.filter((attachment) => !isInlineRelatedAttachment(attachment)) : attachments;
|
||||
if (hasHtmlBody && inlineRelatedAttachments.length > 0 && mixedAttachments.length === 0) {
|
||||
const relatedBoundary = createRelatedBoundary(message);
|
||||
const lines = [
|
||||
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||
...optionalAddressHeaders(message),
|
||||
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
|
||||
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
|
||||
...optionalThreadHeaders(message),
|
||||
...optionalPriorityHeaders(message),
|
||||
"MIME-Version: 1.0",
|
||||
`Content-Type: multipart/related; boundary="${relatedBoundary}"`,
|
||||
"",
|
||||
...buildRelatedBodyLines(relatedBoundary, alternativeBoundary, message, inlineRelatedAttachments),
|
||||
""
|
||||
];
|
||||
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||
@@ -475,8 +573,12 @@ export function buildEmlSource(message: MailMessage): string {
|
||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||
"",
|
||||
`--${boundary}`,
|
||||
...(hasHtmlBody ? buildAlternativePartLines(alternativeBoundary, message) : buildPlainTextPartLines(message)),
|
||||
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
||||
...(inlineRelatedAttachments.length > 0
|
||||
? buildRelatedPartLines(createRelatedBoundary(message), alternativeBoundary, message, inlineRelatedAttachments)
|
||||
: hasHtmlBody
|
||||
? buildAlternativePartLines(alternativeBoundary, message)
|
||||
: buildPlainTextPartLines(message)),
|
||||
...mixedAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
||||
`--${boundary}--`,
|
||||
""
|
||||
];
|
||||
@@ -531,7 +633,12 @@ function buildPlainTextPartLines(message: MailMessage): string[] {
|
||||
}
|
||||
|
||||
function buildHtmlPartLines(message: MailMessage): string[] {
|
||||
return ["Content-Type: text/html; charset=utf-8", "Content-Transfer-Encoding: 8bit", "", normalizeMailNewlines(message.bodyHtml || "")];
|
||||
return [
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
normalizeMailNewlines(htmlWithCidReferencesForInlineAttachments(message.bodyHtml || "", message.attachments ?? []))
|
||||
];
|
||||
}
|
||||
|
||||
function buildAlternativePartLines(boundary: string, message: MailMessage): string[] {
|
||||
@@ -546,6 +653,18 @@ function buildAlternativePartLines(boundary: string, message: MailMessage): stri
|
||||
];
|
||||
}
|
||||
|
||||
function buildRelatedPartLines(boundary: string, alternativeBoundary: string, message: MailMessage, inlineAttachments: MailAttachment[]): string[] {
|
||||
return [`Content-Type: multipart/related; boundary="${boundary}"`, "", ...buildRelatedBodyLines(boundary, alternativeBoundary, message, inlineAttachments)];
|
||||
}
|
||||
|
||||
function buildRelatedBodyLines(boundary: string, alternativeBoundary: string, message: MailMessage, inlineAttachments: MailAttachment[]): string[] {
|
||||
return [`--${boundary}`, ...buildAlternativePartLines(alternativeBoundary, message), ...inlineAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)), `--${boundary}--`];
|
||||
}
|
||||
|
||||
function isInlineRelatedAttachment(attachment: MailAttachment): boolean {
|
||||
return attachment.disposition === "inline" && Boolean(cleanContentId(attachment.contentId));
|
||||
}
|
||||
|
||||
function optionalAddressHeaders(message: MailMessage): string[] {
|
||||
return [
|
||||
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
||||
@@ -748,6 +867,10 @@ function createAlternativeBoundary(message: MailMessage): string {
|
||||
return `${createMultipartBoundary(message)}-alternative`;
|
||||
}
|
||||
|
||||
function createRelatedBoundary(message: MailMessage): string {
|
||||
return `${createMultipartBoundary(message)}-related`;
|
||||
}
|
||||
|
||||
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
||||
const safeContentType = sanitizeMimeType(attachment.contentType);
|
||||
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
||||
@@ -783,6 +906,44 @@ function sanitizeMimeType(value = "application/octet-stream"): string {
|
||||
return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mediaType) ? mediaType : "application/octet-stream";
|
||||
}
|
||||
|
||||
function isEmbeddableCidImageContentType(value: string): boolean {
|
||||
return ["image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp"].includes(normalizedCidImageContentType(value));
|
||||
}
|
||||
|
||||
function normalizedCidImageContentType(value: string): string {
|
||||
const contentType = value.toLowerCase();
|
||||
return contentType === "image/jpg" ? "image/jpeg" : contentType;
|
||||
}
|
||||
|
||||
function cidImageDataUriKey(contentType: string, contentBase64: string): string {
|
||||
return `${normalizedCidImageContentType(contentType)}:${contentBase64.trim()}`;
|
||||
}
|
||||
|
||||
function cleanContentId(value = ""): string {
|
||||
return value.trim().replace(/^<|>$/g, "");
|
||||
}
|
||||
|
||||
function normalizedContentId(value = ""): string {
|
||||
return cleanContentId(value).toLowerCase();
|
||||
}
|
||||
|
||||
function decodeUriComponentSafe(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function wrapBase64(value: string): string {
|
||||
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -349,6 +360,22 @@ describe("wordOffice helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves HTML table colspan as DOCX gridSpan", async () => {
|
||||
const html = '<table><tr><td colspan="2">Merged</td><td>Tail</td></tr></table>';
|
||||
const blocks = htmlToWordBlocks(html);
|
||||
const blob = await exportDocxBlob("Table colspan.docx", html);
|
||||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
|
||||
|
||||
expect(blocks).toEqual([
|
||||
{
|
||||
type: "table",
|
||||
rows: [[{ text: "Merged", runs: [{ text: "Merged" }], colSpan: 2 }, "Tail"]]
|
||||
}
|
||||
]);
|
||||
expect(documentXml).toMatch(/<w:gridSpan\b[^>]*w:val="2"[^>]*\/>/);
|
||||
});
|
||||
|
||||
it("сохраняет пустые ячейки и строки таблицы для DOCX", async () => {
|
||||
const html = `
|
||||
<table>
|
||||
|
||||
+220
-12
@@ -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 {
|
||||
@@ -25,7 +27,7 @@ export interface WordListItem {
|
||||
runs: WordInlineRun[];
|
||||
}
|
||||
|
||||
export type WordTableCell = string | { text: string; runs: WordInlineRun[] };
|
||||
export type WordTableCell = string | { text: string; runs: WordInlineRun[]; colSpan?: number };
|
||||
|
||||
export interface WordImageBlock {
|
||||
type: "image";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -406,9 +478,7 @@ function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) {
|
||||
new docx.TableRow({
|
||||
children: row.map(
|
||||
(cell) =>
|
||||
new docx.TableCell({
|
||||
children: wordTableCellToDocxParagraphs(cell, docx)
|
||||
})
|
||||
new docx.TableCell(wordTableCellToDocxOptions(cell, docx))
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -416,6 +486,14 @@ function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) {
|
||||
];
|
||||
}
|
||||
|
||||
function wordTableCellToDocxOptions(cell: WordTableCell, docx: DocxModule) {
|
||||
const colSpan = typeof cell === "string" ? undefined : cell.colSpan;
|
||||
return {
|
||||
children: wordTableCellToDocxParagraphs(cell, docx),
|
||||
...(colSpan ? { columnSpan: colSpan } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function wordTableCellToDocxParagraphs(cell: WordTableCell, docx: DocxModule) {
|
||||
const runs = typeof cell === "string" ? [{ text: cell }] : cell.runs;
|
||||
return [
|
||||
@@ -662,7 +740,8 @@ function tableCellValue(cell: Element): WordTableCell {
|
||||
const runs = elementInlineRuns(cell);
|
||||
const text = inlineRunsText(runs);
|
||||
const hasFormattedRuns = runs.some((run) => Object.keys(cleanInlineFormat(run)).length > 0);
|
||||
return hasFormattedRuns ? { text, runs } : text;
|
||||
const colSpan = normalizedTableSpan(cell.getAttribute("colspan"));
|
||||
return hasFormattedRuns || colSpan ? { text, runs, ...(colSpan ? { colSpan } : {}) } : text;
|
||||
}
|
||||
|
||||
function paragraphElementToBlocks(element: Element, options: { quote?: boolean } = {}): WordBlock[] {
|
||||
@@ -932,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);
|
||||
}
|
||||
@@ -1122,7 +1303,9 @@ function orderedDocxTableToHtml(
|
||||
})
|
||||
.filter(Boolean);
|
||||
const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell));
|
||||
return content ? `<td>${content}</td>` : "<td></td>";
|
||||
const colSpan = orderedDocxTableCellColspan(cell);
|
||||
const attributes = colSpan ? ` colspan="${colSpan}"` : "";
|
||||
return content ? `<td${attributes}>${content}</td>` : `<td${attributes}></td>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
@@ -1133,6 +1316,12 @@ function orderedDocxTableToHtml(
|
||||
return { html: rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : "", hasImage, hasLineBreak, hasPageBreak, hasTab };
|
||||
}
|
||||
|
||||
function orderedDocxTableCellColspan(cell: unknown) {
|
||||
const properties = firstOrderedElement(orderedElementChildren(cell), "w:tcPr");
|
||||
const gridSpan = firstOrderedElement(orderedElementChildren(properties), "w:gridSpan");
|
||||
return normalizedTableSpan(docxAttribute(orderedElementAttributes(gridSpan), "w:val") || docxAttribute(orderedElementAttributes(gridSpan), "val"));
|
||||
}
|
||||
|
||||
function orderedDocxCellText(cell: unknown) {
|
||||
return orderedDirectElements(cell, "w:p")
|
||||
.flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {}))
|
||||
@@ -1493,7 +1682,7 @@ function docxRunToHtml(run: unknown, imageDataByRelationshipId: DocxImageDataByR
|
||||
if (hasDocxBoolean(properties, "w:i")) {
|
||||
html = `<em>${html}</em>`;
|
||||
}
|
||||
if (hasDocxBoolean(properties, "w:u")) {
|
||||
if (hasDocxUnderline(properties)) {
|
||||
html = `<u>${html}</u>`;
|
||||
}
|
||||
if (hasStrike) {
|
||||
@@ -1707,6 +1896,15 @@ function hasDocxBoolean(properties: XmlRecord, key: string) {
|
||||
return !["0", "false", "off"].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function hasDocxUnderline(properties: XmlRecord) {
|
||||
if (!Object.prototype.hasOwnProperty.call(properties, "w:u")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = (docxAttribute(childRecord(properties, "w:u"), "w:val") || docxAttribute(childRecord(properties, "w:u"), "val")).trim().toLowerCase();
|
||||
return !["0", "false", "off", "none"].includes(value);
|
||||
}
|
||||
|
||||
function childRecord(value: unknown, key: string): XmlRecord {
|
||||
const child = asRecord(value)[key];
|
||||
if (Array.isArray(child)) {
|
||||
@@ -1989,6 +2187,16 @@ function numericAttribute(element: Element, name: string) {
|
||||
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
||||
}
|
||||
|
||||
function normalizedTableSpan(value?: string | null) {
|
||||
const raw = value?.trim() ?? "";
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Number.parseInt(raw, 10);
|
||||
return Number.isSafeInteger(span) && span > 1 ? Math.min(span, 63) : undefined;
|
||||
}
|
||||
|
||||
function cssPixelValue(style: string, property: "width" | "height") {
|
||||
const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style);
|
||||
if (!match) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import JSZip from "jszip";
|
||||
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
|
||||
|
||||
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
@@ -128,6 +129,36 @@ describe("wordOffice DOCX import", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not import DOCX underline when w:u val is none", async () => {
|
||||
const sourceBlob = await exportDocxBlob("UnderlineNone.docx", '<p><span style="color: #0f5fae;">Not underlined</span></p>');
|
||||
const zip = await JSZip.loadAsync(await sourceBlob.arrayBuffer());
|
||||
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
|
||||
const patchedDocumentXml = documentXml.replace(/(<w:rPr\b[^>]*>[\s\S]*?)(<\/w:rPr>)/, '$1<w:u w:val="none"/>$2');
|
||||
expect(patchedDocumentXml).toContain('<w:u w:val="none"/>');
|
||||
zip.file("word/document.xml", patchedDocumentXml);
|
||||
const patchedBlob = await zip.generateAsync({
|
||||
type: "blob",
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
});
|
||||
|
||||
const imported = await importDocxFile(
|
||||
new File([patchedBlob], "UnderlineNone.docx", {
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
})
|
||||
);
|
||||
|
||||
expect(imported.html).not.toContain("<u>Not underlined</u>");
|
||||
expect(imported.html).not.toContain("<u>");
|
||||
expect(imported.html).toContain("color: #0F5FAE;");
|
||||
expect(htmlToWordBlocks(imported.html)).toEqual([
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Not underlined",
|
||||
runs: [{ text: "Not underlined", color: "0F5FAE" }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports DOCX subscript and superscript from OOXML", async () => {
|
||||
const blob = await exportDocxBlob("Indexes.docx", "<p>H<sub>2</sub>O m<sup>2</sup></p>");
|
||||
const file = new File([blob], "Indexes.docx", {
|
||||
@@ -281,6 +312,25 @@ describe("wordOffice DOCX import", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports DOCX horizontal merged table cells as HTML colspan", async () => {
|
||||
const blob = await exportDocxBlob(
|
||||
"Table colspan.docx",
|
||||
'<table><tr><td colspan="2">Merged</td><td>Tail</td></tr></table>'
|
||||
);
|
||||
const file = new File([blob], "Table colspan.docx", {
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
});
|
||||
const imported = await importDocxFile(file);
|
||||
|
||||
expect(imported.html).toContain('<td colspan="2"><p>Merged</p></td>');
|
||||
expect(htmlToWordBlocks(imported.html)).toEqual([
|
||||
{
|
||||
type: "table",
|
||||
rows: [[{ text: "Merged", runs: [{ text: "Merged" }], colSpan: 2 }, "Tail"]]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports DOCX bullet and numbered lists from OOXML numbering", async () => {
|
||||
const blob = await exportDocxBlob(
|
||||
"Lists.docx",
|
||||
@@ -399,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