Compare commits
22 Commits
e5537b726e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b81341e6 | |||
| 5487d04cca | |||
| 5dfe97013d | |||
| 96aff5b76e | |||
| 217dafb760 | |||
| 3719cb7ec8 | |||
| e5cd922f7d | |||
| c1f8162afd | |||
| 0b993b7709 | |||
| 500d368af4 | |||
| 5def87e738 | |||
| 74db17e348 | |||
| 626576e156 | |||
| c16df37c95 | |||
| 231bb6c076 | |||
| 6251c70ddc | |||
| e857beabd0 | |||
| 81a0de2ba9 | |||
| d86e0470c2 | |||
| be7a0e5c98 | |||
| c72e78ac82 | |||
| ddb04c32a4 |
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+67
-17
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { BookOpen, Columns2, FileText, Minus, Plus, Rows3 } from "lucide-react";
|
import { BookOpen, Columns2, FileText, Minus, Plus } from "lucide-react";
|
||||||
import { AppShell } from "./components/AppShell";
|
import { AppShell } from "./components/AppShell";
|
||||||
import { QOutlook } from "./features/qoutlook/QOutlook";
|
import { QOutlook } from "./features/qoutlook/QOutlook";
|
||||||
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
|
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 { QExcell } from "./features/qexcell/QExcell";
|
||||||
import { initialState } from "./data/sampleData";
|
import { initialState } from "./data/sampleData";
|
||||||
import { usePersistentState } from "./hooks/usePersistentState";
|
import { usePersistentState } from "./hooks/usePersistentState";
|
||||||
@@ -12,11 +12,38 @@ import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } fro
|
|||||||
|
|
||||||
const STORAGE_KEY = "qoffice-state-v1";
|
const STORAGE_KEY = "qoffice-state-v1";
|
||||||
const defaultQWordStatus: QWordStatus = { currentPage: 1, pageCount: 1, wordCount: 0 };
|
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) {
|
function clampedWordZoom(value: number) {
|
||||||
return Math.min(200, Math.max(50, value));
|
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 {
|
function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
||||||
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
||||||
const sampleSheet = initialState.workbook.sheets.find((sheet) => sheet.id === "sheet-1");
|
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 &&
|
state.word.id === initialState.word.id &&
|
||||||
sampleWordHtml.includes("Ключевой результат") &&
|
sampleWordHtml.includes("Ключевой результат") &&
|
||||||
!state.word.html.includes("Ключевой результат");
|
!state.word.html.includes("Ключевой результат");
|
||||||
|
const needsDeckSlideSize = !state.deck.slideSize;
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (needsSampleWordColor) {
|
if (needsSampleWordColor) {
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (needsDeckSlideSize) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
const messages = state.mail.messages.map((message) => {
|
const messages = state.mail.messages.map((message) => {
|
||||||
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
|
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
|
||||||
return message;
|
return message;
|
||||||
@@ -90,7 +121,7 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
|||||||
...state,
|
...state,
|
||||||
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
|
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
|
||||||
workbook: { ...state.workbook, sheets },
|
workbook: { ...state.workbook, sheets },
|
||||||
deck: { ...state.deck, slides },
|
deck: { ...state.deck, slideSize: state.deck.slideSize ?? initialState.deck.slideSize ?? "wide", slides },
|
||||||
mail: { ...state.mail, messages }
|
mail: { ...state.mail, messages }
|
||||||
}
|
}
|
||||||
: state;
|
: state;
|
||||||
@@ -100,7 +131,13 @@ export function App() {
|
|||||||
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
|
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
|
||||||
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
|
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
|
||||||
const [wordZoom, setWordZoom] = useState(100);
|
const [wordZoom, setWordZoom] = useState(100);
|
||||||
|
const [wordViewMode, setWordViewMode] = useState<WordViewMode>("print");
|
||||||
const activeApp = activeAppFromLocation(window.location);
|
const activeApp = activeAppFromLocation(window.location);
|
||||||
|
const windowTitle = qofficeWindowTitle(state, activeApp);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.document.title = windowTitle;
|
||||||
|
}, [windowTitle]);
|
||||||
|
|
||||||
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
|
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
|
||||||
setWordStatus((current) =>
|
setWordStatus((current) =>
|
||||||
@@ -137,18 +174,23 @@ export function App() {
|
|||||||
trailing: (
|
trailing: (
|
||||||
<div className="word-statusbar-tools" aria-label="Режимы просмотра и масштаб QWord">
|
<div className="word-statusbar-tools" aria-label="Режимы просмотра и масштаб QWord">
|
||||||
<div className="word-view-buttons" aria-label="Режимы просмотра">
|
<div className="word-view-buttons" aria-label="Режимы просмотра">
|
||||||
<button className="is-active" type="button" title="Разметка страницы" aria-label="Разметка страницы" aria-pressed="true">
|
{wordStatusbarViewModes.map((mode) => {
|
||||||
<FileText size={15} />
|
const Icon = mode.Icon;
|
||||||
</button>
|
const isActive = wordViewMode === mode.id;
|
||||||
<button type="button" title="Режим чтения" aria-label="Режим чтения">
|
return (
|
||||||
<BookOpen size={15} />
|
<button
|
||||||
</button>
|
key={mode.id}
|
||||||
<button type="button" title="Веб-документ" aria-label="Веб-документ">
|
className={isActive ? "is-active" : undefined}
|
||||||
<Columns2 size={15} />
|
type="button"
|
||||||
</button>
|
title={mode.label}
|
||||||
<button type="button" title="Черновик" aria-label="Черновик">
|
aria-label={mode.label}
|
||||||
<Rows3 size={15} />
|
aria-pressed={isActive}
|
||||||
|
onClick={() => setWordViewMode(mode.id)}
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" title="Уменьшить масштаб" aria-label="Уменьшить масштаб" onClick={() => adjustWordZoom(-10)}>
|
<button type="button" title="Уменьшить масштаб" aria-label="Уменьшить масштаб" onClick={() => adjustWordZoom(-10)}>
|
||||||
<Minus size={14} />
|
<Minus size={14} />
|
||||||
@@ -185,6 +227,7 @@ export function App() {
|
|||||||
wordStatus.currentPage,
|
wordStatus.currentPage,
|
||||||
wordStatus.pageCount,
|
wordStatus.pageCount,
|
||||||
wordStatus.wordCount,
|
wordStatus.wordCount,
|
||||||
|
wordViewMode,
|
||||||
wordZoom
|
wordZoom
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -217,7 +260,14 @@ export function App() {
|
|||||||
|
|
||||||
const activeSurface =
|
const activeSurface =
|
||||||
activeApp === "word" ? (
|
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" ? (
|
) : activeApp === "sheets" ? (
|
||||||
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
|
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
|
||||||
) : activeApp === "slides" ? (
|
) : activeApp === "slides" ? (
|
||||||
@@ -227,7 +277,7 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell activeApp={activeApp} status={status}>
|
<AppShell activeApp={activeApp} windowTitle={windowTitle} status={status}>
|
||||||
{activeSurface}
|
{activeSurface}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { AppShell } from "./AppShell";
|
import { AppShell } from "./AppShell";
|
||||||
|
|
||||||
describe("AppShell", () => {
|
describe("AppShell", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
it("не показывает общую ленту навигации между приложениями", () => {
|
it("не показывает общую ленту навигации между приложениями", () => {
|
||||||
const view = render(
|
const view = render(
|
||||||
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
|
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
|
||||||
@@ -37,4 +41,39 @@ describe("AppShell", () => {
|
|||||||
expect(screen.getByText("Число слов: 42")).toBeInTheDocument();
|
expect(screen.getByText("Число слов: 42")).toBeInTheDocument();
|
||||||
expect(screen.getByText("125%")).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,
|
Share2,
|
||||||
Undo2
|
Undo2
|
||||||
} from "lucide-react";
|
} 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";
|
import type { AppId } from "../types";
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
activeApp: AppId;
|
activeApp: AppId;
|
||||||
|
windowTitle?: string;
|
||||||
status: {
|
status: {
|
||||||
savedLabel: string;
|
savedLabel: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -28,28 +30,37 @@ const appTitles: Record<AppId, string> = {
|
|||||||
mail: "Почта - QOutlook"
|
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 statusClassName = ["statusbar", status.className].filter(Boolean).join(" ");
|
||||||
|
const displayWindowTitle = windowTitle?.trim() || appTitles[activeApp];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="qoffice-shell">
|
<div className="qoffice-shell">
|
||||||
<main className="workspace">
|
<main className="workspace">
|
||||||
<header className="titlebar">
|
<header className="titlebar">
|
||||||
<div className="quick-access" aria-label="Панель быстрого доступа">
|
<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} />
|
<Save size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="Отменить" aria-label="Отменить">
|
<button type="button" title="Отменить" aria-label="Отменить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("undo")}>
|
||||||
<Undo2 size={16} />
|
<Undo2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="Повторить" aria-label="Повторить">
|
<button type="button" title="Повторить" aria-label="Повторить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("redo")}>
|
||||||
<Redo2 size={16} />
|
<Redo2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="Настроить панель" aria-label="Настроить панель">
|
<button type="button" title="Настроить панель" aria-label="Настроить панель">
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<strong className="window-title">{appTitles[activeApp]}</strong>
|
<strong className="window-title">{displayWindowTitle}</strong>
|
||||||
<div className="titlebar-actions">
|
<div className="titlebar-actions">
|
||||||
<label className="command-search">
|
<label className="command-search">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export const initialState: QOfficeState = {
|
|||||||
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
|
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
`
|
`,
|
||||||
|
pageSetup: { size: "a4", orientation: "portrait", marginPreset: "normal" }
|
||||||
},
|
},
|
||||||
workbook: {
|
workbook: {
|
||||||
id: "qexcell-quarter-plan",
|
id: "qexcell-quarter-plan",
|
||||||
@@ -108,6 +109,7 @@ export const initialState: QOfficeState = {
|
|||||||
title: "Дорожная карта.qpptx",
|
title: "Дорожная карта.qpptx",
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
activeSlideId: "slide-1",
|
activeSlideId: "slide-1",
|
||||||
|
slideSize: "wide",
|
||||||
slides: [
|
slides: [
|
||||||
{
|
{
|
||||||
id: "slide-1",
|
id: "slide-1",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
Underline,
|
Underline,
|
||||||
WrapText
|
WrapText
|
||||||
} from "lucide-react";
|
} 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 { cellId, columnLabel, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, parseCellId, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
|
||||||
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
|
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
|
||||||
@@ -95,6 +95,20 @@ const chartTypeOptions: Array<{ value: SheetChartType; label: string }> = [
|
|||||||
{ value: "line", label: "Линейная" },
|
{ value: "line", label: "Линейная" },
|
||||||
{ value: "pie", 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 = [
|
const horizontalAlignOptions = [
|
||||||
{ value: "left", label: "По левому краю", icon: AlignLeft },
|
{ value: "left", label: "По левому краю", icon: AlignLeft },
|
||||||
@@ -159,6 +173,18 @@ function normalizedCellFormat(format: CellFormat): CellFormat | undefined {
|
|||||||
return Object.keys(normalized).length > 0 ? normalized : 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 {
|
function cellInputStyle(format?: CellFormat): CSSProperties {
|
||||||
const textDecoration = [format?.underline ? "underline" : "", format?.strike ? "line-through" : ""].filter(Boolean).join(" ");
|
const textDecoration = [format?.underline ? "underline" : "", format?.strike ? "line-through" : ""].filter(Boolean).join(" ");
|
||||||
return {
|
return {
|
||||||
@@ -276,6 +302,7 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems;
|
const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems;
|
||||||
const activeHiddenRows = activeSheet.hiddenRows ?? emptyHiddenItems;
|
const activeHiddenRows = activeSheet.hiddenRows ?? emptyHiddenItems;
|
||||||
const activeCharts = activeSheet.charts ?? emptyCharts;
|
const activeCharts = activeSheet.charts ?? emptyCharts;
|
||||||
|
const activeSheetPageSetup = normalizedSheetPageSetup(activeSheet.pageSetup);
|
||||||
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
|
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
|
||||||
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
|
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
|
||||||
const selectedComment = activeComments[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() {
|
function addSheet() {
|
||||||
const id = `sheet-${workbook.sheets.length + 1}`;
|
const id = `sheet-${workbook.sheets.length + 1}`;
|
||||||
onChange({
|
onChange({
|
||||||
@@ -854,6 +890,39 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
onBlur={(event) => updateActiveSheetPrintArea(normalizedPrintAreaInput(event.target.value))}
|
onBlur={(event) => updateActiveSheetPrintArea(normalizedPrintAreaInput(event.target.value))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="office-tool-section">
|
<div className="office-tool-section">
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ describe("QPowerPoint", () => {
|
|||||||
expect(nextDeck.slides[0].images?.[0].hyperlink).toBe("https://example.com/updated-logo");
|
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("переключает начертание текста слайда", () => {
|
it("переключает начертание текста слайда", () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
|
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import type { CSSProperties } 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 { 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 { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
|
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
|
||||||
import { replaceFileExtension } from "../../io/fileHelpers";
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
@@ -58,8 +58,20 @@ const slideTransitionOptions: Array<{ value: SlideTransition | ""; label: string
|
|||||||
{ value: "wipe", label: "Шторка" }
|
{ value: "wipe", label: "Шторка" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const slideWidthInches = 13.333;
|
const slideSizeOptions: Array<{ id: SlideSizeId; label: string; width: number; height: number }> = [
|
||||||
const slideHeightInches = 7.5;
|
{ 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) {
|
function readFileAsDataUrl(file: File) {
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
@@ -99,18 +111,21 @@ function roundSlideNumber(value: number) {
|
|||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
function slideImageStyle(image: SlideImage) {
|
function slideImageStyle(image: SlideImage, dimensions: SlideDimensions) {
|
||||||
return {
|
return {
|
||||||
left: `${(image.x / slideWidthInches) * 100}%`,
|
left: `${(image.x / dimensions.width) * 100}%`,
|
||||||
top: `${(image.y / slideHeightInches) * 100}%`,
|
top: `${(image.y / dimensions.height) * 100}%`,
|
||||||
width: `${(image.width / slideWidthInches) * 100}%`,
|
width: `${(image.width / dimensions.width) * 100}%`,
|
||||||
height: `${(image.height / slideHeightInches) * 100}%`
|
height: `${(image.height / dimensions.height) * 100}%`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function slideCanvasStyle(slide: Slide) {
|
function slideCanvasStyle(slide: Slide, dimensions: SlideDimensions): CSSProperties {
|
||||||
const backgroundColor = normalizedHexColor(slide.backgroundColor);
|
const backgroundColor = normalizedHexColor(slide.backgroundColor);
|
||||||
return backgroundColor ? { background: `#${backgroundColor}` } : undefined;
|
return {
|
||||||
|
aspectRatio: `${dimensions.width} / ${dimensions.height}`,
|
||||||
|
...(backgroundColor ? { background: `#${backgroundColor}` } : {})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function slideTextStyle(
|
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 activeSlide = deck.slides.find((slide) => slide.id === deck.activeSlideId) ?? deck.slides[0];
|
||||||
const activeImages = activeSlide.images ?? [];
|
const activeImages = activeSlide.images ?? [];
|
||||||
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
|
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
|
||||||
|
const activeSlideSize = slideDimensions(deck.slideSize);
|
||||||
|
|
||||||
function updateSlide(nextSlide: Slide) {
|
function updateSlide(nextSlide: Slide) {
|
||||||
onChange({
|
onChange({
|
||||||
@@ -266,6 +282,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
...deck,
|
...deck,
|
||||||
title: imported.title,
|
title: imported.title,
|
||||||
activeSlideId: imported.slides[0]?.id ?? "slide-1",
|
activeSlideId: imported.slides[0]?.id ?? "slide-1",
|
||||||
|
slideSize: imported.slideSize ?? deck.slideSize ?? "wide",
|
||||||
slides: imported.slides.length > 0 ? imported.slides : deck.slides
|
slides: imported.slides.length > 0 ? imported.slides : deck.slides
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -289,8 +306,8 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
id: `slide-image-${Date.now()}`,
|
id: `slide-image-${Date.now()}`,
|
||||||
src,
|
src,
|
||||||
alt: file.name,
|
alt: file.name,
|
||||||
x: roundSlideNumber(Math.max(0.4, slideWidthInches - size.width - 0.9)),
|
x: roundSlideNumber(Math.max(0.4, activeSlideSize.width - size.width - 0.9)),
|
||||||
y: roundSlideNumber(Math.max(1.4, slideHeightInches - size.height - 0.8)),
|
y: roundSlideNumber(Math.max(1.4, activeSlideSize.height - size.height - 0.8)),
|
||||||
...size
|
...size
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -466,6 +483,20 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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="Фон слайда">
|
<div className="slide-background-swatches" aria-label="Фон слайда">
|
||||||
<Palette size={16} aria-hidden="true" />
|
<Palette size={16} aria-hidden="true" />
|
||||||
{backgroundColorSwatches.map((swatch) => (
|
{backgroundColorSwatches.map((swatch) => (
|
||||||
@@ -783,10 +814,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max={slideWidthInches}
|
max={activeSlideSize.width}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.x}
|
value={selectedImage.x}
|
||||||
onChange={(event) => updateSelectedImageNumber("x", event.target.value, slideWidthInches)}
|
onChange={(event) => updateSelectedImageNumber("x", event.target.value, activeSlideSize.width)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -794,10 +825,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max={slideHeightInches}
|
max={activeSlideSize.height}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.y}
|
value={selectedImage.y}
|
||||||
onChange={(event) => updateSelectedImageNumber("y", event.target.value, slideHeightInches)}
|
onChange={(event) => updateSelectedImageNumber("y", event.target.value, activeSlideSize.height)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -805,10 +836,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max={slideWidthInches}
|
max={activeSlideSize.width}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.width}
|
value={selectedImage.width}
|
||||||
onChange={(event) => updateSelectedImageNumber("width", event.target.value, slideWidthInches)}
|
onChange={(event) => updateSelectedImageNumber("width", event.target.value, activeSlideSize.width)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -816,10 +847,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max={slideHeightInches}
|
max={activeSlideSize.height}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.height}
|
value={selectedImage.height}
|
||||||
onChange={(event) => updateSelectedImageNumber("height", event.target.value, slideHeightInches)}
|
onChange={(event) => updateSelectedImageNumber("height", event.target.value, activeSlideSize.height)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -854,13 +885,13 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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) => (
|
{activeImages.map((image) => (
|
||||||
<button
|
<button
|
||||||
key={image.id}
|
key={image.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
|
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
|
||||||
style={slideImageStyle(image)}
|
style={slideImageStyle(image, activeSlideSize)}
|
||||||
onClick={() => setSelectedImageId(image.id)}
|
onClick={() => setSelectedImageId(image.id)}
|
||||||
title={image.alt || "Изображение"}
|
title={image.alt || "Изображение"}
|
||||||
aria-label={image.alt || "Изображение слайда"}
|
aria-label={image.alt || "Изображение слайда"}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import type { WordDocument } from "../../types";
|
import type { WordDocument } from "../../types";
|
||||||
import { QWord, qwordCurrentPageFromViewport } from "./QWord";
|
import { QWord, qwordCurrentPageFromViewport, qwordPageCountFromContent } from "./QWord";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -20,9 +20,236 @@ 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 })).toBe(2);
|
||||||
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1.2 })).toBe(1);
|
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", () => {
|
describe("QWord", () => {
|
||||||
|
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-ribbon-layout-test",
|
||||||
|
title: "Лента.docx",
|
||||||
|
updatedAt: "2026-06-02T00:00:00.000Z",
|
||||||
|
html: "<p>Текст</p>"
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const view = render(<QWord document={document} onChange={vi.fn()} />);
|
||||||
|
const toolbar = view.container.querySelector(".word-module-toolbar") as HTMLElement;
|
||||||
|
const stage = view.container.querySelector(".document-stage") as HTMLElement;
|
||||||
|
|
||||||
|
expect(toolbar).toBeInTheDocument();
|
||||||
|
expect(stage).toBeInTheDocument();
|
||||||
|
expect(stage).not.toContainElement(toolbar);
|
||||||
|
expect(toolbar.querySelector(".word-ribbon-tabs")).toBeInTheDocument();
|
||||||
|
expect(stage.querySelector(".page-ruler")).toBeInTheDocument();
|
||||||
|
expect(stage.querySelector(".paper-frame")).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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-print-pages-test",
|
||||||
|
title: "Страницы.docx",
|
||||||
|
updatedAt: "2026-06-02T00:00:00.000Z",
|
||||||
|
html: "<p>Первая страница</p><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: 2400 });
|
||||||
|
fireEvent.scroll(stage);
|
||||||
|
|
||||||
|
await waitFor(() => expect(view.container.querySelectorAll(".page-sheet")).toHaveLength(3));
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-count")).toBe("3");
|
||||||
|
expect(frame.style.minHeight).toBe("3425px");
|
||||||
|
expect(frame.style.paddingBottom).toBe("56px");
|
||||||
|
expect(view.container.querySelectorAll(".page-break-guides span")).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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("переключает вкладки ленты и показывает команды выбранной вкладки", () => {
|
it("переключает вкладки ленты и показывает команды выбранной вкладки", () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
@@ -103,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("вставляет таблицу выбранного размера через вкладку Вставка", () => {
|
it("вставляет таблицу выбранного размера через вкладку Вставка", () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|||||||
+275
-29
@@ -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 type { CSSProperties, MouseEvent } from "react";
|
||||||
import {
|
import {
|
||||||
AlignCenter,
|
AlignCenter,
|
||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
Rows3,
|
Rows3,
|
||||||
Underline
|
Underline
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { WordDocument } from "../../types";
|
import type { WordDocument, WordPageMarginPreset, WordPageOrientation, WordPageSetup, WordPageSizeId } from "../../types";
|
||||||
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
import { exportDocxBlob, importDocxFile } from "../../io/wordOffice";
|
import { exportDocxBlob, importDocxFile } from "../../io/wordOffice";
|
||||||
import { replaceFileExtension } from "../../io/fileHelpers";
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
@@ -43,8 +43,10 @@ import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
|
|||||||
interface QWordProps {
|
interface QWordProps {
|
||||||
document: WordDocument;
|
document: WordDocument;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
viewMode?: WordViewMode;
|
||||||
onChange: (document: WordDocument) => void;
|
onChange: (document: WordDocument) => void;
|
||||||
onStatusChange?: (status: QWordStatus) => void;
|
onStatusChange?: (status: QWordStatus) => void;
|
||||||
|
onViewModeChange?: (viewMode: WordViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TableCellLocation {
|
interface TableCellLocation {
|
||||||
@@ -53,6 +55,13 @@ interface TableCellLocation {
|
|||||||
cellIndex: number;
|
cellIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EditorSelectionBookmark {
|
||||||
|
anchorOffset: number;
|
||||||
|
anchorPath: number[];
|
||||||
|
focusOffset: number;
|
||||||
|
focusPath: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface QWordStatus {
|
export interface QWordStatus {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
@@ -153,8 +162,7 @@ const styleGalleryOptions = [
|
|||||||
{ value: "blockquote", label: "Цитата" }
|
{ value: "blockquote", label: "Цитата" }
|
||||||
];
|
];
|
||||||
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
|
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 }> = [
|
const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [
|
||||||
{ id: "file", label: "Файл" },
|
{ id: "file", label: "Файл" },
|
||||||
{ id: "home", label: "Главная" },
|
{ id: "home", label: "Главная" },
|
||||||
@@ -173,6 +181,20 @@ const wordViewModes: Array<{ id: WordViewMode; label: string }> = [
|
|||||||
{ id: "read", label: "Чтение" },
|
{ id: "read", label: "Чтение" },
|
||||||
{ id: "web", 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];
|
const tableSizeOptions = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
|
|
||||||
function findInPage(query: string) {
|
function findInPage(query: string) {
|
||||||
@@ -180,12 +202,34 @@ function findInPage(query: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultPageViewHeight = 1180;
|
const defaultPageViewHeight = 1180;
|
||||||
|
const defaultPageGap = 28;
|
||||||
|
|
||||||
function pageHeightFromStyles(element: HTMLElement) {
|
function pageHeightFromStyles(element: HTMLElement) {
|
||||||
const pageHeight = Number.parseFloat(window.getComputedStyle(element).getPropertyValue("--qword-page-height"));
|
const pageHeight = Number.parseFloat(window.getComputedStyle(element).getPropertyValue("--qword-page-height"));
|
||||||
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
|
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) {
|
function clampedTableSize(value: number) {
|
||||||
return Number.isFinite(value) ? Math.min(8, Math.max(1, Math.trunc(value))) : 3;
|
return Number.isFinite(value) ? Math.min(8, Math.max(1, Math.trunc(value))) : 3;
|
||||||
}
|
}
|
||||||
@@ -230,6 +274,97 @@ function tableCellFromLocation(editor: HTMLElement, location: TableCellLocation)
|
|||||||
return cell instanceof HTMLTableCellElement ? cell : undefined;
|
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) {
|
function placeCaretInTableCell(cell: HTMLTableCellElement) {
|
||||||
if (cell.childNodes.length === 0) {
|
if (cell.childNodes.length === 0) {
|
||||||
cell.append(globalThis.document.createElement("br"));
|
cell.append(globalThis.document.createElement("br"));
|
||||||
@@ -247,31 +382,88 @@ function keepEditorSelection(event: MouseEvent<HTMLButtonElement>) {
|
|||||||
event.preventDefault();
|
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 pageCount = Math.max(1, input.pageCount);
|
||||||
const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1;
|
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);
|
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 stageRef = useRef<HTMLElement>(null);
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const activeTableCellRef = useRef<HTMLTableCellElement | null>(null);
|
const activeTableCellRef = useRef<HTMLTableCellElement | null>(null);
|
||||||
const activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
|
const activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
|
||||||
const restoreActiveTableCellAfterRenderRef = useRef(false);
|
const restoreActiveTableCellAfterRenderRef = useRef(false);
|
||||||
|
const pendingEditorSelectionRestoreRef = useRef<EditorSelectionBookmark | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
||||||
const [viewMode, setViewMode] = useState<WordViewMode>("print");
|
const [internalViewMode, setInternalViewMode] = useState<WordViewMode>("print");
|
||||||
const [tableRowCount, setTableRowCount] = useState(3);
|
const [tableRowCount, setTableRowCount] = useState(3);
|
||||||
const [tableColumnCount, setTableColumnCount] = useState(3);
|
const [tableColumnCount, setTableColumnCount] = useState(3);
|
||||||
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
|
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
|
||||||
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
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 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 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) {
|
function currentPageFromStage(pageCount: number, pageHeight: number) {
|
||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
@@ -288,7 +480,8 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
paperTop: paperRect.top,
|
paperTop: paperRect.top,
|
||||||
pageHeight,
|
pageHeight,
|
||||||
pageCount,
|
pageCount,
|
||||||
zoom: Number.isFinite(currentZoom) ? currentZoom : 1
|
zoom: Number.isFinite(currentZoom) ? currentZoom : 1,
|
||||||
|
pageGap: defaultPageGap
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +493,8 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
}
|
}
|
||||||
|
|
||||||
const height = pageHeightFromStyles(editor);
|
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);
|
const currentPage = currentPageFromStage(count, height);
|
||||||
setPageView((current) =>
|
setPageView((current) =>
|
||||||
current.count === count && current.height === height && current.current === currentPage ? current : { count, current: currentPage, height }
|
current.count === count && current.height === height && current.current === currentPage ? current : { count, current: currentPage, height }
|
||||||
@@ -318,7 +512,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
const observer = new ResizeObserver(refreshPageView);
|
const observer = new ResizeObserver(refreshPageView);
|
||||||
observer.observe(editor);
|
observer.observe(editor);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [document.html, zoom]);
|
}, [document.html, pageDimensions.height, pageDimensions.width, pageMargins.bottom, pageMargins.left, pageMargins.right, pageMargins.top, zoom]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!restoreActiveTableCellAfterRenderRef.current) {
|
if (!restoreActiveTableCellAfterRenderRef.current) {
|
||||||
@@ -346,9 +540,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
function runCommand(command: string, value?: string) {
|
function runCommand(command: string, value?: string) {
|
||||||
editorRef.current?.focus();
|
editorRef.current?.focus();
|
||||||
globalThis.document.execCommand(command, false, value);
|
globalThis.document.execCommand(command, false, value);
|
||||||
if (editorRef.current) {
|
updateHtml();
|
||||||
onChange({ ...document, html: editorRef.current.innerHTML });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTextColor(color: string) {
|
function applyTextColor(color: string) {
|
||||||
@@ -621,8 +813,10 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
|
|
||||||
function updateHtml() {
|
function updateHtml() {
|
||||||
rememberActiveTableCell();
|
rememberActiveTableCell();
|
||||||
if (editorRef.current) {
|
const editor = editorRef.current;
|
||||||
onChange({ ...document, html: editorRef.current.innerHTML });
|
if (editor) {
|
||||||
|
pendingEditorSelectionRestoreRef.current = editorSelectionBookmark(editor) ?? null;
|
||||||
|
onChange({ ...document, html: editor.innerHTML });
|
||||||
refreshPageView();
|
refreshPageView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,7 +828,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const imported = await importDocxFile(file);
|
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) {
|
} catch (error) {
|
||||||
window.alert(error instanceof Error ? error.message : "Не удалось открыть DOCX.");
|
window.alert(error instanceof Error ? error.message : "Не удалось открыть DOCX.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -647,7 +841,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
async function saveDocx() {
|
async function saveDocx() {
|
||||||
try {
|
try {
|
||||||
const fileName = replaceFileExtension(document.title, ".docx");
|
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()) {
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
await saveDesktopOfficeFile("docx", fileName, blob);
|
await saveDesktopOfficeFile("docx", fileName, blob);
|
||||||
return;
|
return;
|
||||||
@@ -686,7 +880,6 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-layout qword-layout">
|
<div className="editor-layout qword-layout">
|
||||||
<section ref={stageRef} className={stageClassName} style={stageStyle} onScroll={refreshPageView} aria-label="Редактор QWord">
|
|
||||||
<div className="module-toolbar word-module-toolbar">
|
<div className="module-toolbar word-module-toolbar">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -849,6 +1042,56 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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" ? (
|
{activeRibbonTab === "home" || activeRibbonTab === "layout" ? (
|
||||||
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
|
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
|
||||||
<div className="segmented-tools" aria-label="Списки">
|
<div className="segmented-tools" aria-label="Списки">
|
||||||
@@ -955,11 +1198,11 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
{wordViewModes.map((mode) => (
|
{wordViewModes.map((mode) => (
|
||||||
<button
|
<button
|
||||||
key={mode.id}
|
key={mode.id}
|
||||||
className={`compact-command${viewMode === mode.id ? " is-active" : ""}`}
|
className={`compact-command${activeViewMode === mode.id ? " is-active" : ""}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={mode.label}
|
aria-label={mode.label}
|
||||||
aria-pressed={viewMode === mode.id}
|
aria-pressed={activeViewMode === mode.id}
|
||||||
onClick={() => setViewMode(mode.id)}
|
onClick={() => changeViewMode(mode.id)}
|
||||||
>
|
>
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
{mode.label}
|
{mode.label}
|
||||||
@@ -986,6 +1229,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section ref={stageRef} className={stageClassName} style={stageStyle} onScroll={refreshPageView} aria-label="Редактор QWord">
|
||||||
<div className="page-ruler" aria-hidden="true">
|
<div className="page-ruler" aria-hidden="true">
|
||||||
{Array.from({ length: 9 }, (_, index) => (
|
{Array.from({ length: 9 }, (_, index) => (
|
||||||
<span key={index}>{index + 1}</span>
|
<span key={index}>{index + 1}</span>
|
||||||
@@ -998,7 +1242,12 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="paper-frame">
|
<div className="paper-frame" style={pageFrameStyle}>
|
||||||
|
<div className="page-sheets" aria-hidden="true">
|
||||||
|
{Array.from({ length: pageView.count }, (_, index) => (
|
||||||
|
<span key={index} className="page-sheet" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<article
|
<article
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
className="paper document-content"
|
className="paper document-content"
|
||||||
@@ -1009,15 +1258,12 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
onKeyUp={rememberActiveTableCell}
|
onKeyUp={rememberActiveTableCell}
|
||||||
onMouseDown={rememberActiveTableCellFromEvent}
|
onMouseDown={rememberActiveTableCellFromEvent}
|
||||||
onMouseUp={rememberActiveTableCellFromEvent}
|
onMouseUp={rememberActiveTableCellFromEvent}
|
||||||
dangerouslySetInnerHTML={{ __html: document.html }}
|
|
||||||
aria-label="Содержимое документа"
|
aria-label="Содержимое документа"
|
||||||
/>
|
/>
|
||||||
{pageView.count > 1 ? (
|
{pageView.count > 1 ? (
|
||||||
<div className="page-break-guides" aria-hidden="true">
|
<div className="page-break-guides" aria-hidden="true">
|
||||||
{Array.from({ length: pageView.count - 1 }, (_, index) => (
|
{Array.from({ length: pageView.count - 1 }, (_, index) => (
|
||||||
<span key={index} style={{ top: `${(index + 1) * pageView.height}px` }}>
|
<span key={index} style={{ top: `${(index + 1) * pageView.height + index * defaultPageGap}px` }} />
|
||||||
Страница {index + 2}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
export {};
|
export {};
|
||||||
|
|
||||||
type DesktopOfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail";
|
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 {
|
interface DesktopOpenOfficeFileResult {
|
||||||
canceled: boolean;
|
canceled: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, waitFor } from "@testing-library/react";
|
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useQOfficeCommand } from "./useQOfficeCommand";
|
import { useQOfficeCommand } from "./useQOfficeCommand";
|
||||||
|
|
||||||
function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf?: () => void }) {
|
function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf?: () => void }) {
|
||||||
@@ -8,6 +8,10 @@ function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("useQOfficeCommand", () => {
|
describe("useQOfficeCommand", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
it("вызывает обработчик команды QOffice", async () => {
|
it("вызывает обработчик команды QOffice", async () => {
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn();
|
||||||
render(<CommandProbe onSave={onSave} />);
|
render(<CommandProbe onSave={onSave} />);
|
||||||
@@ -39,4 +43,23 @@ describe("useQOfficeCommand", () => {
|
|||||||
await waitFor(() => expect(onExportPdf).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(onExportPdf).toHaveBeenCalledTimes(1));
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
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";
|
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 type QOfficeCommandHandlers = Partial<Record<QOfficeCommand, () => void | Promise<void>>>;
|
||||||
|
|
||||||
export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
|
export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
|
||||||
@@ -16,6 +16,11 @@ export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
|
|||||||
const handler = handlersRef.current[command];
|
const handler = handlersRef.current[command];
|
||||||
if (handler) {
|
if (handler) {
|
||||||
void handler();
|
void handler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "undo" || command === "redo") {
|
||||||
|
globalThis.document.execCommand?.(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
buildColumnWidthsXml,
|
buildColumnWidthsXml,
|
||||||
buildHyperlinksXml,
|
buildHyperlinksXml,
|
||||||
buildMergeCellsXml,
|
buildMergeCellsXml,
|
||||||
|
buildPageMarginsXml,
|
||||||
|
buildPageSetupXml,
|
||||||
buildSheetPrXml,
|
buildSheetPrXml,
|
||||||
buildSheetViewsXml,
|
buildSheetViewsXml,
|
||||||
createCellStyleRegistry,
|
createCellStyleRegistry,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
workbookPrintAreas,
|
workbookPrintAreas,
|
||||||
workbookRelationshipTargets,
|
workbookRelationshipTargets,
|
||||||
workbookSheetRefs,
|
workbookSheetRefs,
|
||||||
|
workbookUsesDate1904,
|
||||||
worksheetXmlToAutoFilter,
|
worksheetXmlToAutoFilter,
|
||||||
worksheetXmlToCells,
|
worksheetXmlToCells,
|
||||||
worksheetXmlToCellFormats,
|
worksheetXmlToCellFormats,
|
||||||
@@ -31,12 +34,14 @@ import {
|
|||||||
worksheetXmlToHyperlinks,
|
worksheetXmlToHyperlinks,
|
||||||
worksheetCommentsXmlToComments,
|
worksheetCommentsXmlToComments,
|
||||||
worksheetXmlToMergedCells,
|
worksheetXmlToMergedCells,
|
||||||
|
worksheetXmlToPageSetup,
|
||||||
worksheetXmlToRowHeights,
|
worksheetXmlToRowHeights,
|
||||||
worksheetXmlToShowGridLines,
|
worksheetXmlToShowGridLines,
|
||||||
worksheetXmlToTabColor,
|
worksheetXmlToTabColor,
|
||||||
worksheetXmlToZoomScale,
|
worksheetXmlToZoomScale,
|
||||||
xlsxStylesXmlToCellFormatTable,
|
xlsxStylesXmlToCellFormatTable,
|
||||||
xlsxThemeXmlToColors,
|
xlsxThemeXmlToColors,
|
||||||
|
xlsxSafeSheetNames,
|
||||||
xlsxCellToText
|
xlsxCellToText
|
||||||
} from "./excellOffice";
|
} from "./excellOffice";
|
||||||
|
|
||||||
@@ -614,6 +619,38 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
).toBe("sheet-7");
|
).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 () => {
|
it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => {
|
||||||
const blob = await exportXlsxBlob({
|
const blob = await exportXlsxBlob({
|
||||||
id: "book-1",
|
id: "book-1",
|
||||||
@@ -633,6 +670,38 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
expect(imported.activeSheet).toBe("sheet-2");
|
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 () => {
|
it("сохраняет и импортирует область печати XLSX через definedName _xlnm.Print_Area", async () => {
|
||||||
expect(
|
expect(
|
||||||
workbookPrintAreas(
|
workbookPrintAreas(
|
||||||
@@ -673,6 +742,45 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
expect(imported.sheets[1].printArea).toBe("B2:C4,E1:E3");
|
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 () => {
|
it("сохраняет и импортирует скрытые листы XLSX через sheet state", async () => {
|
||||||
const blob = await exportXlsxBlob({
|
const blob = await exportXlsxBlob({
|
||||||
id: "book-1",
|
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 { evaluateCell, expandRange } from "../utils/spreadsheet";
|
||||||
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers";
|
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers";
|
||||||
|
|
||||||
@@ -19,6 +30,7 @@ export interface ImportedExcellWorkbook {
|
|||||||
showGridLines?: boolean;
|
showGridLines?: boolean;
|
||||||
zoomScale?: number;
|
zoomScale?: number;
|
||||||
printArea?: string;
|
printArea?: string;
|
||||||
|
pageSetup?: SheetPageSetup;
|
||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
@@ -77,6 +89,7 @@ interface ParsedSheet {
|
|||||||
showGridLines?: boolean;
|
showGridLines?: boolean;
|
||||||
zoomScale?: number;
|
zoomScale?: number;
|
||||||
printArea?: string;
|
printArea?: string;
|
||||||
|
pageSetup?: SheetPageSetup;
|
||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
@@ -210,6 +223,16 @@ const builtinNumberFormats: Record<number, string> = {
|
|||||||
48: "##0.0E+0",
|
48: "##0.0E+0",
|
||||||
49: "@"
|
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> {
|
export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook> {
|
||||||
if (!file.name.toLowerCase().endsWith(".xlsx")) {
|
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 themeColors = await readOptionalWorkbookThemeColors(zip, parser);
|
||||||
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
|
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
|
||||||
const sheetRefs = workbookSheetRefs(workbookXml);
|
const sheetRefs = workbookSheetRefs(workbookXml);
|
||||||
|
const date1904 = workbookUsesDate1904(workbookXml);
|
||||||
const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
|
const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
|
||||||
const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
|
const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
|
||||||
const sheets = await Promise.all(
|
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 sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath);
|
||||||
const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath);
|
const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath);
|
||||||
const commentsXml = commentsPath ? await readOptionalParsedXml(zip, parser, commentsPath) : undefined;
|
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);
|
const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name);
|
||||||
return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts };
|
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 JSZip = await loadJsZip();
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const sheets = workbook.sheets.length > 0 ? workbook.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [], hyperlinks: {} }];
|
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 styleRegistry = createCellStyleRegistry(safeSheets.map((sheet) => sheet.cellFormats));
|
||||||
const chartParts = chartPartRefsForSheets(sheets);
|
const chartParts = chartPartRefsForSheets(safeSheets);
|
||||||
const commentParts = commentPartRefsForSheets(sheets);
|
const commentParts = commentPartRefsForSheets(safeSheets);
|
||||||
const activeSheetIndex = Math.max(0, sheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
|
const activeSheetIndex = Math.max(0, safeSheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
|
||||||
|
|
||||||
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts, commentParts));
|
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts, commentParts));
|
||||||
zip.file("_rels/.rels", buildRootRelationshipsXml());
|
zip.file("_rels/.rels", buildRootRelationshipsXml());
|
||||||
@@ -280,8 +306,8 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
zip.file(
|
zip.file(
|
||||||
"xl/workbook.xml",
|
"xl/workbook.xml",
|
||||||
buildWorkbookXml(
|
buildWorkbookXml(
|
||||||
sheets.map((sheet, index) => ({
|
safeSheets.map((sheet) => ({
|
||||||
name: sheet.name.trim() || `Лист ${index + 1}`,
|
name: sheet.name,
|
||||||
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
|
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
|
||||||
...(sheet.printArea ? { printArea: sheet.printArea } : {})
|
...(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/_rels/workbook.xml.rels", buildWorkbookRelationshipsXml(sheets.length));
|
||||||
zip.file("xl/styles.xml", buildStylesXml(styleRegistry));
|
zip.file("xl/styles.xml", buildStylesXml(styleRegistry));
|
||||||
|
|
||||||
sheets.forEach((sheet, index) => {
|
safeSheets.forEach((sheet, index) => {
|
||||||
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
|
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
|
||||||
const sheetCommentPart = commentParts.find((part) => part.sheetIndex === index);
|
const sheetCommentPart = commentParts.find((part) => part.sheetIndex === index);
|
||||||
const externalHyperlinkCount = sortedExternalHyperlinkEntries(sheet.hyperlinks).length;
|
const externalHyperlinkCount = sortedExternalHyperlinkEntries(sheet.hyperlinks).length;
|
||||||
@@ -317,7 +343,8 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
sheet.hiddenRows,
|
sheet.hiddenRows,
|
||||||
sheet.showGridLines,
|
sheet.showGridLines,
|
||||||
sheet.zoomScale,
|
sheet.zoomScale,
|
||||||
legacyDrawingRelationshipId
|
legacyDrawingRelationshipId,
|
||||||
|
sheet.pageSetup
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const relationshipsXml = buildWorksheetRelationshipsXml(
|
const relationshipsXml = buildWorksheetRelationshipsXml(
|
||||||
@@ -356,7 +383,8 @@ export function parsedWorksheetToSheet(
|
|||||||
relationshipsXml?: unknown,
|
relationshipsXml?: unknown,
|
||||||
styleTable: Array<CellFormat | undefined> = [],
|
styleTable: Array<CellFormat | undefined> = [],
|
||||||
themeColors: string[] = defaultXlsxThemeColors,
|
themeColors: string[] = defaultXlsxThemeColors,
|
||||||
commentsXml?: unknown
|
commentsXml?: unknown,
|
||||||
|
date1904 = false
|
||||||
): ParsedSheet {
|
): ParsedSheet {
|
||||||
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
|
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
|
||||||
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
|
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
|
||||||
@@ -367,6 +395,7 @@ export function parsedWorksheetToSheet(
|
|||||||
const hiddenRows = worksheetXmlToHiddenRows(worksheetXml);
|
const hiddenRows = worksheetXmlToHiddenRows(worksheetXml);
|
||||||
const showGridLines = worksheetXmlToShowGridLines(worksheetXml);
|
const showGridLines = worksheetXmlToShowGridLines(worksheetXml);
|
||||||
const zoomScale = worksheetXmlToZoomScale(worksheetXml);
|
const zoomScale = worksheetXmlToZoomScale(worksheetXml);
|
||||||
|
const pageSetup = worksheetXmlToPageSetup(worksheetXml);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sheet.id || `sheet-${index + 1}`,
|
id: sheet.id || `sheet-${index + 1}`,
|
||||||
@@ -381,7 +410,8 @@ export function parsedWorksheetToSheet(
|
|||||||
...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}),
|
...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}),
|
||||||
...(showGridLines === false ? { showGridLines } : {}),
|
...(showGridLines === false ? { showGridLines } : {}),
|
||||||
...(zoomScale ? { zoomScale } : {}),
|
...(zoomScale ? { zoomScale } : {}),
|
||||||
cells: worksheetXmlToCells(worksheetXml, sharedStrings),
|
...(pageSetup ? { pageSetup } : {}),
|
||||||
|
cells: worksheetXmlToCells(worksheetXml, sharedStrings, styleTable, date1904),
|
||||||
mergedCells: worksheetXmlToMergedCells(worksheetXml),
|
mergedCells: worksheetXmlToMergedCells(worksheetXml),
|
||||||
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
|
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
|
||||||
comments: worksheetCommentsXmlToComments(commentsXml),
|
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 cells: Record<string, string> = {};
|
||||||
const sharedFormulas: SharedFormulaMap = {};
|
const sharedFormulas: SharedFormulaMap = {};
|
||||||
const parsedCells = worksheetCellRecords(worksheetXml);
|
const parsedCells = worksheetCellRecords(worksheetXml);
|
||||||
@@ -406,9 +436,10 @@ export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string
|
|||||||
|
|
||||||
parsedCells.forEach(({ address, cell }) => {
|
parsedCells.forEach(({ address, cell }) => {
|
||||||
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
|
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
|
||||||
|
const importedText = date1904 ? date1904AdjustedCellText(cell, text, styleTable[numberAttribute(cell.s)]) : text;
|
||||||
|
|
||||||
if (text !== "") {
|
if (importedText !== "") {
|
||||||
cells[address] = text;
|
cells[address] = importedText;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -535,6 +566,18 @@ export function worksheetXmlToZoomScale(worksheetXml: unknown) {
|
|||||||
return normalizedSheetZoomScale(sheetView.zoomScale);
|
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) {
|
export function worksheetXmlToAutoFilter(worksheetXml: unknown) {
|
||||||
const worksheet = childRecord(worksheetXml, "worksheet");
|
const worksheet = childRecord(worksheetXml, "worksheet");
|
||||||
const autoFilter = childRecord(worksheet, "autoFilter");
|
const autoFilter = childRecord(worksheet, "autoFilter");
|
||||||
@@ -799,7 +842,8 @@ export function buildWorksheetXml(
|
|||||||
hiddenRows: Record<string, boolean> = {},
|
hiddenRows: Record<string, boolean> = {},
|
||||||
showGridLines?: boolean,
|
showGridLines?: boolean,
|
||||||
zoomScale?: number,
|
zoomScale?: number,
|
||||||
legacyDrawingRelationshipId = ""
|
legacyDrawingRelationshipId = "",
|
||||||
|
pageSetup?: SheetPageSetup
|
||||||
) {
|
) {
|
||||||
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
|
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
|
||||||
const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows);
|
const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows);
|
||||||
@@ -840,9 +884,11 @@ export function buildWorksheetXml(
|
|||||||
const sheetPrXml = buildSheetPrXml(tabColor);
|
const sheetPrXml = buildSheetPrXml(tabColor);
|
||||||
const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale);
|
const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale);
|
||||||
const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns);
|
const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns);
|
||||||
|
const pageMarginsXml = buildPageMarginsXml(pageSetup);
|
||||||
|
const pageSetupXml = buildPageSetupXml(pageSetup);
|
||||||
|
|
||||||
return xmlDeclaration(
|
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>` : "";
|
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> = {}) {
|
export function buildColumnWidthsXml(columnWidths: Record<string, number> = {}, hiddenColumns: Record<string, boolean> = {}) {
|
||||||
const columnsByNumber = new Map<number, { width?: number; hidden?: boolean }>();
|
const columnsByNumber = new Map<number, { width?: number; hidden?: boolean }>();
|
||||||
normalizedSheetColumnWidths(columnWidths).forEach(({ column, width }) => {
|
normalizedSheetColumnWidths(columnWidths).forEach(({ column, width }) => {
|
||||||
@@ -1030,6 +1094,12 @@ export function workbookActiveSheetId(workbookXml: unknown, sheets: Array<{ id:
|
|||||||
return sheets[activeTab]?.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> {
|
export function workbookPrintAreas(workbookXml: unknown, sheets: Array<{ id: string; name: string }>): Record<string, string> {
|
||||||
const workbook = childRecord(workbookXml, "workbook");
|
const workbook = childRecord(workbookXml, "workbook");
|
||||||
const definedNames = childRecord(workbook, "definedNames");
|
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) {
|
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 activeTab = Math.min(Math.max(0, activeSheetIndex), Math.max(0, sheets.length - 1));
|
||||||
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;
|
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;
|
||||||
@@ -1748,6 +1857,37 @@ function normalizedNumberFormat(value?: string) {
|
|||||||
return raw.slice(0, 255);
|
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) {
|
function xlsxNumberFormats(styleSheet: XmlRecord) {
|
||||||
const formats = { ...builtinNumberFormats };
|
const formats = { ...builtinNumberFormats };
|
||||||
toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {
|
toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {
|
||||||
@@ -1833,6 +1973,58 @@ function normalizedSheetZoomScale(value: unknown) {
|
|||||||
return zoomScale >= 10 && zoomScale <= 400 && zoomScale !== 100 ? zoomScale : 0;
|
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) {
|
function buildRowAttributes(height?: number, hidden?: boolean) {
|
||||||
return `${height ? ` ht="${formatSheetDimension(height)}" customHeight="1"` : ""}${hidden ? ' hidden="1"' : ""}`;
|
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 () => {
|
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
|
||||||
const message = msgDataToMailMessage(
|
const message = msgDataToMailMessage(
|
||||||
{
|
{
|
||||||
@@ -381,6 +457,50 @@ describe("outlookMail helpers", () => {
|
|||||||
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>HTML text</strong></p>");
|
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", () => {
|
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
|
||||||
const message: MailMessage = {
|
const message: MailMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
|
|||||||
+166
-5
@@ -81,7 +81,8 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
|||||||
|
|
||||||
const raw = await file.text();
|
const raw = await file.text();
|
||||||
const parsed = parseEmlSource(raw);
|
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 {
|
return {
|
||||||
id: `eml-${Date.now()}`,
|
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"),
|
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
|
||||||
read: false,
|
read: false,
|
||||||
flagged: 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));
|
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> {
|
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
||||||
return {
|
return {
|
||||||
id: createAttachmentId(file.name),
|
id: createAttachmentId(file.name),
|
||||||
@@ -463,6 +539,28 @@ export function buildEmlSource(message: MailMessage): string {
|
|||||||
if (attachments.length > 0) {
|
if (attachments.length > 0) {
|
||||||
const boundary = createMultipartBoundary(message);
|
const boundary = createMultipartBoundary(message);
|
||||||
const alternativeBoundary = createAlternativeBoundary(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 = [
|
const lines = [
|
||||||
`From: ${sanitizeHeaderValue(message.from)}`,
|
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||||
`To: ${sanitizeHeaderValue(message.to)}`,
|
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||||
@@ -475,8 +573,12 @@ export function buildEmlSource(message: MailMessage): string {
|
|||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||||
"",
|
"",
|
||||||
`--${boundary}`,
|
`--${boundary}`,
|
||||||
...(hasHtmlBody ? buildAlternativePartLines(alternativeBoundary, message) : buildPlainTextPartLines(message)),
|
...(inlineRelatedAttachments.length > 0
|
||||||
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
? buildRelatedPartLines(createRelatedBoundary(message), alternativeBoundary, message, inlineRelatedAttachments)
|
||||||
|
: hasHtmlBody
|
||||||
|
? buildAlternativePartLines(alternativeBoundary, message)
|
||||||
|
: buildPlainTextPartLines(message)),
|
||||||
|
...mixedAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
""
|
""
|
||||||
];
|
];
|
||||||
@@ -531,7 +633,12 @@ function buildPlainTextPartLines(message: MailMessage): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildHtmlPartLines(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[] {
|
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[] {
|
function optionalAddressHeaders(message: MailMessage): string[] {
|
||||||
return [
|
return [
|
||||||
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
||||||
@@ -748,6 +867,10 @@ function createAlternativeBoundary(message: MailMessage): string {
|
|||||||
return `${createMultipartBoundary(message)}-alternative`;
|
return `${createMultipartBoundary(message)}-alternative`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRelatedBoundary(message: MailMessage): string {
|
||||||
|
return `${createMultipartBoundary(message)}-related`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
||||||
const safeContentType = sanitizeMimeType(attachment.contentType);
|
const safeContentType = sanitizeMimeType(attachment.contentType);
|
||||||
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
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";
|
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 {
|
function wrapBase64(value: string): string {
|
||||||
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
|
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
listSlidePaths,
|
listSlidePaths,
|
||||||
normalizeWhitespace,
|
normalizeWhitespace,
|
||||||
presentationSlidePaths,
|
presentationSlidePaths,
|
||||||
|
presentationSlideSize,
|
||||||
relationshipTargetByType,
|
relationshipTargetByType,
|
||||||
resolvePptTarget
|
resolvePptTarget
|
||||||
} from "./powerPointOffice";
|
} 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 заметок от части слайда", () => {
|
it("разрешает относительные targets заметок от части слайда", () => {
|
||||||
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
|
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
|
||||||
expect(
|
expect(
|
||||||
@@ -104,7 +129,7 @@ describe("powerPointOffice helpers", () => {
|
|||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
zip.file(
|
zip.file(
|
||||||
"ppt/presentation.xml",
|
"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(
|
zip.file(
|
||||||
"ppt/_rels/presentation.xml.rels",
|
"ppt/_rels/presentation.xml.rels",
|
||||||
@@ -125,6 +150,7 @@ describe("powerPointOffice helpers", () => {
|
|||||||
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
|
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
|
||||||
|
|
||||||
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
|
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
|
||||||
|
expect(imported.slideSize).toBe("standard");
|
||||||
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
|
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
|
||||||
expect(imported.slides[0].notes).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("нормализует пробелы в импортированном тексте", () => {
|
it("нормализует пробелы в импортированном тексте", () => {
|
||||||
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
|
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";
|
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
||||||
|
|
||||||
export interface ImportedPowerPointDeck {
|
export interface ImportedPowerPointDeck {
|
||||||
title: string;
|
title: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
slides: Slide[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportablePowerPointDeck {
|
export interface ExportablePowerPointDeck {
|
||||||
title: string;
|
title: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
slides: Slide[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,10 @@ const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/
|
|||||||
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
||||||
const emuPerInch = 914400;
|
const emuPerInch = 914400;
|
||||||
const slideTransitions = ["fade", "push", "wipe"] as const;
|
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 }> = {
|
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
|
||||||
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
|
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
|
||||||
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
|
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 presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
|
||||||
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
|
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
|
||||||
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
|
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
|
||||||
|
const slideSize = presentationSlideSize(presentationXml);
|
||||||
|
|
||||||
if (slidePaths.length === 0) {
|
if (slidePaths.length === 0) {
|
||||||
throw new Error("В файле PowerPoint не найдены слайды.");
|
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> {
|
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>;
|
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.author = "QOffice";
|
||||||
pptx.company = "QOffice";
|
pptx.company = "QOffice";
|
||||||
pptx.subject = "Экспорт QPowerPoint";
|
pptx.subject = "Экспорт QPowerPoint";
|
||||||
@@ -196,7 +205,7 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
|
|||||||
slide.addText(sourceSlide.title || "Без заголовка", {
|
slide.addText(sourceSlide.title || "Без заголовка", {
|
||||||
x: 0.7,
|
x: 0.7,
|
||||||
y: 0.65,
|
y: 0.65,
|
||||||
w: 11.9,
|
w: Math.max(1, slideLayout.width - 1.4),
|
||||||
h: 0.75,
|
h: 0.75,
|
||||||
fontFace: titleFontFamily,
|
fontFace: titleFontFamily,
|
||||||
fontSize: titleFontSize,
|
fontSize: titleFontSize,
|
||||||
@@ -213,8 +222,8 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
|
|||||||
slide.addText(sourceSlide.subtitle || "", {
|
slide.addText(sourceSlide.subtitle || "", {
|
||||||
x: 0.75,
|
x: 0.75,
|
||||||
y: 1.65,
|
y: 1.65,
|
||||||
w: 11.5,
|
w: Math.max(1, slideLayout.width - 1.5),
|
||||||
h: 4.35,
|
h: Math.max(1, slideLayout.height - 3.15),
|
||||||
fontFace: subtitleFontFamily,
|
fontFace: subtitleFontFamily,
|
||||||
fontSize: subtitleFontSize,
|
fontSize: subtitleFontSize,
|
||||||
...(subtitleAlign ? { align: subtitleAlign } : {}),
|
...(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));
|
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> {
|
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
|
||||||
const relationships = childRecord(relationshipsXml, "Relationships");
|
const relationships = childRecord(relationshipsXml, "Relationships");
|
||||||
const targets: Record<string, string> = {};
|
const targets: Record<string, string> = {};
|
||||||
@@ -467,6 +489,10 @@ export function normalizeWhitespace(value: string): string {
|
|||||||
return value.replace(/\s+/g, " ").trim();
|
return value.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
|
||||||
|
return value === "standard" ? "standard" : "wide";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTextWithLineBreaks(value: string): string {
|
function normalizeTextWithLineBreaks(value: string): string {
|
||||||
return value
|
return value
|
||||||
.split("\n")
|
.split("\n")
|
||||||
|
|||||||
@@ -279,6 +279,17 @@ describe("wordOffice helpers", () => {
|
|||||||
expect(documentXml).toContain('<w:jc w:val="right"/>');
|
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 () => {
|
it("записывает внешние гиперссылки в DOCX relationships", async () => {
|
||||||
const blob = await exportDocxBlob(
|
const blob = await exportDocxBlob(
|
||||||
"Ссылки.docx",
|
"Ссылки.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 () => {
|
it("сохраняет пустые ячейки и строки таблицы для DOCX", async () => {
|
||||||
const html = `
|
const html = `
|
||||||
<table>
|
<table>
|
||||||
@@ -376,6 +403,39 @@ describe("wordOffice helpers", () => {
|
|||||||
expect(documentXml).toContain("Итог");
|
expect(documentXml).toContain("Итог");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("сохраняет inline-форматирование ячеек таблицы для DOCX", async () => {
|
||||||
|
const html = `
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Итог</strong> <em>готов</em></td>
|
||||||
|
<td><a href="https://example.com/report">Отчет</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
const blocks = htmlToWordBlocks(html);
|
||||||
|
const blob = await exportDocxBlob("Формат таблицы.docx", html);
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
|
||||||
|
const relationshipsXml = (await zip.file("word/_rels/document.xml.rels")?.async("string")) ?? "";
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
rows: [
|
||||||
|
[
|
||||||
|
{ text: "Итог готов", runs: [{ text: "Итог", bold: true }, { text: " " }, { text: "готов", italics: true }] },
|
||||||
|
{ text: "Отчет", runs: [{ text: "Отчет", href: "https://example.com/report" }] }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(documentXml).toContain("<w:tbl>");
|
||||||
|
expect(documentXml).toContain("<w:b/>");
|
||||||
|
expect(documentXml).toContain("<w:i/>");
|
||||||
|
expect(documentXml).toContain("<w:hyperlink");
|
||||||
|
expect(relationshipsXml).toContain('Target="https://example.com/report"');
|
||||||
|
});
|
||||||
|
|
||||||
it("сохраняет размер шрифта из HTML inline-стилей для DOCX", () => {
|
it("сохраняет размер шрифта из HTML inline-стилей для DOCX", () => {
|
||||||
const blocks = htmlToWordBlocks(`
|
const blocks = htmlToWordBlocks(`
|
||||||
<p>Normal <span style="font-size: 18pt;">Large</span> <span style="font-size: 24px;">Pixel size</span></p>
|
<p>Normal <span style="font-size: 18pt;">Large</span> <span style="font-size: 24px;">Pixel size</span></p>
|
||||||
|
|||||||
+240
-18
@@ -1,8 +1,10 @@
|
|||||||
import { DOCX_MIME_TYPE, officeTitleFromFileName, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
import { DOCX_MIME_TYPE, officeTitleFromFileName, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
||||||
|
import type { WordPageMarginPreset, WordPageOrientation, WordPageSetup, WordPageSizeId } from "../types";
|
||||||
|
|
||||||
export interface ImportedWordDocument {
|
export interface ImportedWordDocument {
|
||||||
title: string;
|
title: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
pageSetup?: WordPageSetup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WordInlineRun {
|
export interface WordInlineRun {
|
||||||
@@ -25,6 +27,8 @@ export interface WordListItem {
|
|||||||
runs: WordInlineRun[];
|
runs: WordInlineRun[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WordTableCell = string | { text: string; runs: WordInlineRun[]; colSpan?: number };
|
||||||
|
|
||||||
export interface WordImageBlock {
|
export interface WordImageBlock {
|
||||||
type: "image";
|
type: "image";
|
||||||
src: string;
|
src: string;
|
||||||
@@ -74,7 +78,7 @@ export type WordBlock =
|
|||||||
quote?: boolean;
|
quote?: boolean;
|
||||||
}
|
}
|
||||||
| { type: "list"; ordered: boolean; items: WordListItem[] }
|
| { type: "list"; ordered: boolean; items: WordListItem[] }
|
||||||
| { type: "table"; rows: string[][] }
|
| { type: "table"; rows: WordTableCell[][] }
|
||||||
| WordImageBlock;
|
| WordImageBlock;
|
||||||
|
|
||||||
type MammothModule = {
|
type MammothModule = {
|
||||||
@@ -114,6 +118,7 @@ type DocxModule = {
|
|||||||
Document: new (options: Record<string, unknown>) => unknown;
|
Document: new (options: Record<string, unknown>) => unknown;
|
||||||
HeadingLevel: Record<string, string>;
|
HeadingLevel: Record<string, string>;
|
||||||
LineRuleType?: Record<string, string>;
|
LineRuleType?: Record<string, string>;
|
||||||
|
PageOrientation?: Record<string, string>;
|
||||||
Packer: {
|
Packer: {
|
||||||
toBlob?: (document: unknown) => Promise<Blob>;
|
toBlob?: (document: unknown) => Promise<Blob>;
|
||||||
toBuffer?: (document: unknown) => Promise<ArrayBuffer | Uint8Array>;
|
toBuffer?: (document: unknown) => Promise<ArrayBuffer | Uint8Array>;
|
||||||
@@ -163,6 +168,16 @@ const wordImageRelationshipType = "http://schemas.openxmlformats.org/officeDocum
|
|||||||
const emptyDocxRelationshipTargets = { hyperlinks: {}, images: {} };
|
const emptyDocxRelationshipTargets = { hyperlinks: {}, images: {} };
|
||||||
const pageBreakMarker = "\f";
|
const pageBreakMarker = "\f";
|
||||||
const pageBreakHtml = '<span data-qoffice-page-break="true"></span>';
|
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> {
|
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
|
||||||
if (!file.name.toLowerCase().endsWith(".docx")) {
|
if (!file.name.toLowerCase().endsWith(".docx")) {
|
||||||
@@ -171,7 +186,7 @@ export async function importDocxFile(file: File): Promise<ImportedWordDocument>
|
|||||||
|
|
||||||
const mammoth = await loadMammoth();
|
const mammoth = await loadMammoth();
|
||||||
const arrayBuffer = await readOfficeFileAsArrayBuffer(file);
|
const arrayBuffer = await readOfficeFileAsArrayBuffer(file);
|
||||||
const [result, styledHtml] = await Promise.all([
|
const [result, styledHtml, pageSetup] = await Promise.all([
|
||||||
mammoth.convertToHtml(
|
mammoth.convertToHtml(
|
||||||
{ arrayBuffer, buffer: arrayBuffer },
|
{ arrayBuffer, buffer: arrayBuffer },
|
||||||
{
|
{
|
||||||
@@ -179,20 +194,23 @@ export async function importDocxFile(file: File): Promise<ImportedWordDocument>
|
|||||||
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
|
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
styledDocxHtmlFromArrayBuffer(arrayBuffer)
|
styledDocxHtmlFromArrayBuffer(arrayBuffer),
|
||||||
|
docxPageSetupFromArrayBuffer(arrayBuffer)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: officeTitleFromFileName(file.name, "Документ QWord.docx"),
|
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 docx = await loadDocx();
|
||||||
const blocks = htmlToWordBlocks(html);
|
const blocks = htmlToWordBlocks(html);
|
||||||
const children = blocks.flatMap((block) => wordBlockToDocxChildren(block, docx));
|
const children = blocks.flatMap((block) => wordBlockToDocxChildren(block, docx));
|
||||||
const documentTitle = title.trim() || "Документ QWord";
|
const documentTitle = title.trim() || "Документ QWord";
|
||||||
|
const sectionProperties = wordSectionProperties(options.pageSetup, docx);
|
||||||
|
|
||||||
const document = new docx.Document({
|
const document = new docx.Document({
|
||||||
creator: "QOffice",
|
creator: "QOffice",
|
||||||
@@ -219,7 +237,7 @@ export async function exportDocxBlob(title: string, html: string): Promise<Blob>
|
|||||||
},
|
},
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
properties: {},
|
properties: sectionProperties,
|
||||||
children:
|
children:
|
||||||
children.length > 0
|
children.length > 0
|
||||||
? children
|
? children
|
||||||
@@ -244,6 +262,62 @@ export async function exportDocxBlob(title: string, html: string): Promise<Blob>
|
|||||||
throw new Error("Не удалось создать DOCX: библиотека docx не вернула поддерживаемый упаковщик.");
|
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) {
|
export function normalizeImportedDocxHtml(html: string) {
|
||||||
return html.trim();
|
return html.trim();
|
||||||
}
|
}
|
||||||
@@ -404,16 +478,27 @@ function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) {
|
|||||||
new docx.TableRow({
|
new docx.TableRow({
|
||||||
children: row.map(
|
children: row.map(
|
||||||
(cell) =>
|
(cell) =>
|
||||||
new docx.TableCell({
|
new docx.TableCell(wordTableCellToDocxOptions(cell, docx))
|
||||||
children: [
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [
|
||||||
new docx.Paragraph({
|
new docx.Paragraph({
|
||||||
children: [new docx.TextRun(cell)]
|
children: wordInlineRunsToTextRuns(runs, docx)
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -647,10 +732,18 @@ function parseHtmlFragment(html: string) {
|
|||||||
|
|
||||||
function tableRows(table: Element) {
|
function tableRows(table: Element) {
|
||||||
return Array.from(table.querySelectorAll("tr"))
|
return Array.from(table.querySelectorAll("tr"))
|
||||||
.map((row) => Array.from(row.querySelectorAll("th,td")).map(elementText))
|
.map((row) => Array.from(row.querySelectorAll("th,td")).map(tableCellValue))
|
||||||
.filter((row) => row.length > 0);
|
.filter((row) => row.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tableCellValue(cell: Element): WordTableCell {
|
||||||
|
const runs = elementInlineRuns(cell);
|
||||||
|
const text = inlineRunsText(runs);
|
||||||
|
const hasFormattedRuns = runs.some((run) => Object.keys(cleanInlineFormat(run)).length > 0);
|
||||||
|
const colSpan = normalizedTableSpan(cell.getAttribute("colspan"));
|
||||||
|
return hasFormattedRuns || colSpan ? { text, runs, ...(colSpan ? { colSpan } : {}) } : text;
|
||||||
|
}
|
||||||
|
|
||||||
function paragraphElementToBlocks(element: Element, options: { quote?: boolean } = {}): WordBlock[] {
|
function paragraphElementToBlocks(element: Element, options: { quote?: boolean } = {}): WordBlock[] {
|
||||||
const segments = collectInlineSegments(Array.from(element.childNodes), {});
|
const segments = collectInlineSegments(Array.from(element.childNodes), {});
|
||||||
const alignment = paragraphAlignmentFromElement(element);
|
const alignment = paragraphAlignmentFromElement(element);
|
||||||
@@ -918,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) {
|
function isSimpleStyledDocxDocument(documentXml: string) {
|
||||||
return !/<w:(pict|sdt)\b/i.test(documentXml);
|
return !/<w:(pict|sdt)\b/i.test(documentXml);
|
||||||
}
|
}
|
||||||
@@ -1108,7 +1303,9 @@ function orderedDocxTableToHtml(
|
|||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell));
|
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("");
|
.join("");
|
||||||
|
|
||||||
@@ -1119,6 +1316,12 @@ function orderedDocxTableToHtml(
|
|||||||
return { html: rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : "", hasImage, hasLineBreak, hasPageBreak, hasTab };
|
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) {
|
function orderedDocxCellText(cell: unknown) {
|
||||||
return orderedDirectElements(cell, "w:p")
|
return orderedDirectElements(cell, "w:p")
|
||||||
.flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {}))
|
.flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {}))
|
||||||
@@ -1479,7 +1682,7 @@ function docxRunToHtml(run: unknown, imageDataByRelationshipId: DocxImageDataByR
|
|||||||
if (hasDocxBoolean(properties, "w:i")) {
|
if (hasDocxBoolean(properties, "w:i")) {
|
||||||
html = `<em>${html}</em>`;
|
html = `<em>${html}</em>`;
|
||||||
}
|
}
|
||||||
if (hasDocxBoolean(properties, "w:u")) {
|
if (hasDocxUnderline(properties)) {
|
||||||
html = `<u>${html}</u>`;
|
html = `<u>${html}</u>`;
|
||||||
}
|
}
|
||||||
if (hasStrike) {
|
if (hasStrike) {
|
||||||
@@ -1693,6 +1896,15 @@ function hasDocxBoolean(properties: XmlRecord, key: string) {
|
|||||||
return !["0", "false", "off"].includes(value.toLowerCase());
|
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 {
|
function childRecord(value: unknown, key: string): XmlRecord {
|
||||||
const child = asRecord(value)[key];
|
const child = asRecord(value)[key];
|
||||||
if (Array.isArray(child)) {
|
if (Array.isArray(child)) {
|
||||||
@@ -1975,6 +2187,16 @@ function numericAttribute(element: Element, name: string) {
|
|||||||
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
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") {
|
function cssPixelValue(style: string, property: "width" | "height") {
|
||||||
const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style);
|
const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import JSZip from "jszip";
|
||||||
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
|
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
|
||||||
|
|
||||||
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
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 () => {
|
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 blob = await exportDocxBlob("Indexes.docx", "<p>H<sub>2</sub>O m<sup>2</sup></p>");
|
||||||
const file = new File([blob], "Indexes.docx", {
|
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 () => {
|
it("imports DOCX bullet and numbered lists from OOXML numbering", async () => {
|
||||||
const blob = await exportDocxBlob(
|
const blob = await exportDocxBlob(
|
||||||
"Lists.docx",
|
"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" } }
|
{ 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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-30
@@ -365,6 +365,10 @@ button {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qword-layout {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.document-stage,
|
.document-stage,
|
||||||
.spreadsheet-stage,
|
.spreadsheet-stage,
|
||||||
.slide-stage {
|
.slide-stage {
|
||||||
@@ -393,6 +397,9 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.word-module-toolbar {
|
.word-module-toolbar {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
z-index: 3;
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 154px;
|
min-height: 154px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -509,6 +516,7 @@ button {
|
|||||||
|
|
||||||
.word-clipboard-group .word-ribbon-title,
|
.word-clipboard-group .word-ribbon-title,
|
||||||
.word-font-group .word-ribbon-title,
|
.word-font-group .word-ribbon-title,
|
||||||
|
.word-page-setup-group .word-ribbon-title,
|
||||||
.word-paragraph-group .word-ribbon-title,
|
.word-paragraph-group .word-ribbon-title,
|
||||||
.word-styles-group .word-ribbon-title,
|
.word-styles-group .word-ribbon-title,
|
||||||
.word-insert-group .word-ribbon-title,
|
.word-insert-group .word-ribbon-title,
|
||||||
@@ -614,6 +622,19 @@ button {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
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 > .segmented-tools,
|
||||||
.word-paragraph-group > .compact-command {
|
.word-paragraph-group > .compact-command {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
@@ -1046,28 +1067,54 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.paper-frame {
|
.paper-frame {
|
||||||
|
--qword-page-gap: 28px;
|
||||||
|
--qword-page-height: 1123px;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: var(--qword-page-width, 794px);
|
width: var(--qword-page-width, 794px);
|
||||||
zoom: var(--qword-zoom, 1);
|
zoom: var(--qword-zoom, 1);
|
||||||
margin: 0 auto 38px;
|
margin: 0 auto 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper {
|
.page-sheets {
|
||||||
--qword-page-height: 1123px;
|
position: absolute;
|
||||||
min-height: var(--qword-page-height);
|
inset: 0 0 auto;
|
||||||
|
z-index: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--qword-page-gap);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-sheet {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
height: var(--qword-page-height);
|
||||||
padding: 72px 86px;
|
|
||||||
border: 1px solid #c7d1dd;
|
border: 1px solid #c7d1dd;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper {
|
||||||
|
min-height: var(--qword-page-height);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
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;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qword-view-read .page-ruler,
|
.qword-view-read .page-ruler,
|
||||||
.qword-view-read .vertical-page-ruler,
|
.qword-view-read .vertical-page-ruler,
|
||||||
|
.qword-view-read .page-sheets,
|
||||||
|
.qword-view-read .page-break-guides,
|
||||||
.qword-view-web .page-ruler,
|
.qword-view-web .page-ruler,
|
||||||
.qword-view-web .vertical-page-ruler {
|
.qword-view-web .vertical-page-ruler,
|
||||||
|
.qword-view-web .page-sheets,
|
||||||
|
.qword-view-web .page-break-guides {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,6 +1126,9 @@ button {
|
|||||||
.qword-view-read .paper {
|
.qword-view-read .paper {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
padding: 56px 72px;
|
padding: 56px 72px;
|
||||||
|
border: 1px solid #c7d1dd;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qword-view-web .paper-frame {
|
.qword-view-web .paper-frame {
|
||||||
@@ -1089,6 +1139,7 @@ button {
|
|||||||
.qword-view-web .paper {
|
.qword-view-web .paper {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
padding: 36px 48px;
|
padding: 36px 48px;
|
||||||
|
background: #ffffff;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -1097,6 +1148,7 @@ button {
|
|||||||
.page-break-guides {
|
.page-break-guides {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,30 +1156,8 @@ button {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: grid;
|
height: var(--qword-page-gap);
|
||||||
place-items: center;
|
background: #dedede;
|
||||||
height: 24px;
|
|
||||||
border-top: 1px dashed #94a3b8;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 11px;
|
|
||||||
transform: translateY(-12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-break-guides span::before,
|
|
||||||
.page-break-guides span::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 11px;
|
|
||||||
width: calc(50% - 58px);
|
|
||||||
border-top: 1px dashed #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-break-guides span::before {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-break-guides span::after {
|
|
||||||
right: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-content h1 {
|
.document-content h1 {
|
||||||
@@ -2227,6 +2257,11 @@ button {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.word-module-toolbar {
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.file-title-input {
|
.file-title-input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 220px;
|
flex: 1 1 220px;
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
export type AppId = "word" | "sheets" | "slides" | "mail";
|
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 {
|
export interface WordDocument {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
pageSetup?: WordPageSetup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Workbook {
|
export interface Workbook {
|
||||||
@@ -29,6 +49,7 @@ export interface Sheet {
|
|||||||
showGridLines?: boolean;
|
showGridLines?: boolean;
|
||||||
zoomScale?: number;
|
zoomScale?: number;
|
||||||
printArea?: string;
|
printArea?: string;
|
||||||
|
pageSetup?: SheetPageSetup;
|
||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
@@ -76,11 +97,14 @@ export interface SheetChart {
|
|||||||
valueRange: string;
|
valueRange: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SlideSizeId = "wide" | "standard";
|
||||||
|
|
||||||
export interface SlideDeck {
|
export interface SlideDeck {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
activeSlideId: string;
|
activeSlideId: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
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