Compare commits

...

24 Commits

Author SHA1 Message Date
Курнат Андрей 74b81341e6 Add Ollama Gemma QWord task prompt 2026-06-10 19:57:58 +03:00
Курнат Андрей 5487d04cca Add QPowerPoint slide size support 2026-06-04 22:24:33 +03:00
Курнат Андрей 5dfe97013d Add QExcell XLSX page setup support 2026-06-04 22:08:58 +03:00
Курнат Андрей 96aff5b76e Import DOCX page setup into QWord 2026-06-04 21:58:24 +03:00
Курнат Андрей 217dafb760 Preserve QWord caret during input 2026-06-04 21:53:31 +03:00
Курнат Андрей 3719cb7ec8 Export QWord page setup to DOCX 2026-06-04 21:40:17 +03:00
Курнат Андрей e5cd922f7d Persist QWord page setup in documents 2026-06-04 21:35:37 +03:00
Курнат Андрей c1f8162afd Add QWord page setup controls 2026-06-04 21:30:19 +03:00
Курнат Андрей 0b993b7709 Respect QWord page breaks in page layout 2026-06-04 21:24:43 +03:00
Курнат Андрей 500d368af4 Sync QWord statusbar view modes 2026-06-04 21:20:29 +03:00
Курнат Андрей 5def87e738 Preserve DOCX table column spans 2026-06-04 21:14:53 +03:00
Курнат Андрей 74db17e348 Sync app window title with active file 2026-06-04 21:06:02 +03:00
Курнат Андрей 626576e156 Preserve QWord caret while typing 2026-06-04 21:00:40 +03:00
Курнат Андрей c16df37c95 Wire quick access commands 2026-06-02 20:39:30 +03:00
Курнат Андрей 231bb6c076 Support XLSX 1904 date imports 2026-06-02 19:42:51 +03:00
Курнат Андрей 6251c70ddc Normalize QExcell sheet names for XLSX export 2026-06-02 19:13:50 +03:00
Курнат Андрей e857beabd0 Respect DOCX underline none on import 2026-06-02 12:53:58 +03:00
Курнат Андрей 81a0de2ba9 Export QOutlook inline images as related CID parts 2026-06-02 12:26:58 +03:00
Курнат Андрей d86e0470c2 Embed QOutlook inline CID images 2026-06-02 12:22:22 +03:00
Курнат Андрей be7a0e5c98 Render QWord print pages as separate sheets 2026-06-02 09:27:13 +03:00
Курнат Андрей c72e78ac82 Keep QWord ribbon above document viewport 2026-06-02 09:19:05 +03:00
Курнат Андрей ddb04c32a4 Preserve QWord table cell inline formatting 2026-06-02 09:12:40 +03:00
Курнат Андрей e5537b726e Preserve empty QWord tables in DOCX export 2026-06-02 09:06:22 +03:00
Курнат Андрей a9f9aa6cca Add QWord table structure commands 2026-06-02 09:01:20 +03:00
25 changed files with 2706 additions and 173 deletions
+108
View File
@@ -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
View File
@@ -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>
); );
+41 -2
View File
@@ -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);
}
});
}); });
+17 -6
View File
@@ -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} />
+3 -1
View File
@@ -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",
+70 -1
View File
@@ -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} />);
+53 -22
View File
@@ -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 || "Изображение слайда"}
+483 -1
View File
@@ -1,8 +1,9 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
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();
@@ -19,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;
@@ -102,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;
@@ -141,6 +498,131 @@ describe("QWord", () => {
} }
}); });
it("сохраняет активную ячейку после вставки таблицы и перерендера", () => {
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-insert-table-restore-test",
title: "Активная таблица.docx",
updatedAt: "2026-06-02T00: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 />);
fireEvent.click(screen.getByRole("tab", { name: "Вставка" }));
const tableButton = screen.getByRole("button", { name: "Таблица" });
expect(fireEvent.mouseDown(tableButton)).toBe(false);
fireEvent.click(tableButton);
const rowButton = screen.getByRole("button", { name: "Строка ниже" });
expect(fireEvent.mouseDown(rowButton)).toBe(false);
fireEvent.click(rowButton);
const updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
expect(updatedDocument?.html.match(/<tr/g)).toHaveLength(4);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(12);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("добавляет строку и столбец к выбранной таблице", () => {
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-table-structure-test",
title: "Структура таблицы.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<table><tbody><tr><td>A</td><td>B</td></tr></tbody></table>"
};
function selectCell(text: string) {
const cell = screen.getByText(text).closest("td");
expect(cell).toBeInTheDocument();
const range = window.document.createRange();
range.selectNodeContents(cell as HTMLTableCellElement);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
}
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
onChange(nextDocument);
setCurrentDocument(nextDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
fireEvent.click(screen.getByRole("tab", { name: "Вставка" }));
selectCell("A");
const rowButton = screen.getByRole("button", { name: "Строка ниже" });
expect(fireEvent.mouseDown(rowButton)).toBe(false);
fireEvent.click(rowButton);
let updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
expect(updatedDocument?.html.match(/<tr/g)).toHaveLength(2);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(4);
selectCell("A");
const columnButton = screen.getByRole("button", { name: "Столбец справа" });
expect(fireEvent.mouseDown(columnButton)).toBe(false);
fireEvent.click(columnButton);
updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
expect(updatedDocument?.html.match(/<tr/g)).toHaveLength(2);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(6);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("обновляет текущую страницу в статусе при прокрутке документа", async () => { it("обновляет текущую страницу в статусе при прокрутке документа", async () => {
const originalResizeObserver = globalThis.ResizeObserver; const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame; const originalRequestAnimationFrame = window.requestAnimationFrame;
+508 -35
View File
@@ -1,5 +1,5 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react"; import type { CSSProperties, MouseEvent } from "react";
import { import {
AlignCenter, AlignCenter,
AlignJustify, AlignJustify,
@@ -7,6 +7,7 @@ import {
AlignRight, AlignRight,
Bold, Bold,
ClipboardPaste, ClipboardPaste,
Columns2,
Copy, Copy,
Download, Download,
FileText, FileText,
@@ -28,9 +29,10 @@ import {
Superscript, Superscript,
Table2, Table2,
Type, Type,
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";
@@ -41,8 +43,23 @@ 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 {
tableIndex: number;
rowIndex: number;
cellIndex: number;
}
interface EditorSelectionBookmark {
anchorOffset: number;
anchorPath: number[];
focusOffset: number;
focusPath: number[];
} }
export interface QWordStatus { export interface QWordStatus {
@@ -145,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: "Главная" },
@@ -165,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) {
@@ -172,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;
} }
@@ -190,28 +242,228 @@ function qwordTableHtml(rows: number, columns: number) {
return `<table><tbody>${tableRows}</tbody></table>`; return `<table><tbody>${tableRows}</tbody></table>`;
} }
export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number }) { function createBlankTableCell(tagName: "td" | "th" = "td") {
const pageCount = Math.max(1, input.pageCount); const cell = globalThis.document.createElement(tagName);
const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1; cell.append(globalThis.document.createElement("br"));
const visualPageHeight = Math.max(1, input.pageHeight * zoom); return cell;
const visiblePaperOffset = Math.max(0, input.stageTop - input.paperTop);
return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageHeight) + 1));
} }
export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordProps) { function tableCellFromSelection(editor: HTMLElement): HTMLTableCellElement | undefined {
const node = window.getSelection()?.anchorNode;
const element = node instanceof Element ? node : node?.parentElement;
const cell = element?.closest("td,th");
return cell instanceof HTMLTableCellElement && editor.contains(cell) ? cell : undefined;
}
function tableCellLocation(editor: HTMLElement, cell: HTMLTableCellElement): TableCellLocation | undefined {
const table = cell.closest("table");
const row = cell.parentElement;
if (!(table instanceof HTMLTableElement) || !(row instanceof HTMLTableRowElement) || !editor.contains(table)) {
return undefined;
}
const tableIndex = Array.from(editor.querySelectorAll("table")).indexOf(table);
const rowIndex = Array.from(table.rows).indexOf(row);
const cellIndex = cell.cellIndex;
return tableIndex >= 0 && rowIndex >= 0 && cellIndex >= 0 ? { cellIndex, rowIndex, tableIndex } : undefined;
}
function tableCellFromLocation(editor: HTMLElement, location: TableCellLocation): HTMLTableCellElement | undefined {
const table = Array.from(editor.querySelectorAll("table"))[location.tableIndex];
const cell = table?.rows[location.rowIndex]?.cells[location.cellIndex];
return cell instanceof HTMLTableCellElement ? cell : undefined;
}
function editorContainsNode(editor: HTMLElement, node: Node) {
return node === editor || editor.contains(node);
}
function editorNodePath(editor: HTMLElement, node: Node): number[] | undefined {
if (node === editor) {
return [];
}
const path: number[] = [];
let current: Node | null = node;
while (current && current !== editor) {
const parent: Node | null = current.parentNode;
if (!parent) {
return undefined;
}
const siblings = Array.from(parent.childNodes) as Node[];
const index = siblings.indexOf(current);
if (index < 0) {
return undefined;
}
path.unshift(index);
current = parent;
}
return current === editor ? path : undefined;
}
function editorNodeFromPath(editor: HTMLElement, path: number[]): Node | undefined {
let current: Node = editor;
for (const index of path) {
const child = current.childNodes[index];
if (!child) {
return undefined;
}
current = child;
}
return current;
}
function clampedSelectionOffset(node: Node, offset: number) {
const maxOffset = node.nodeType === Node.TEXT_NODE ? (node.textContent ?? "").length : node.childNodes.length;
return Math.min(Math.max(0, offset), maxOffset);
}
function editorSelectionBookmark(editor: HTMLElement): EditorSelectionBookmark | undefined {
const selection = window.getSelection();
const anchorNode = selection?.anchorNode;
const focusNode = selection?.focusNode;
if (!selection || selection.rangeCount === 0 || !anchorNode || !focusNode || !editorContainsNode(editor, anchorNode) || !editorContainsNode(editor, focusNode)) {
return undefined;
}
const anchorPath = editorNodePath(editor, anchorNode);
const focusPath = editorNodePath(editor, focusNode);
if (!anchorPath || !focusPath) {
return undefined;
}
return {
anchorOffset: selection.anchorOffset,
anchorPath,
focusOffset: selection.focusOffset,
focusPath
};
}
function restoreEditorSelection(editor: HTMLElement, bookmark: EditorSelectionBookmark) {
const anchorNode = editorNodeFromPath(editor, bookmark.anchorPath);
const focusNode = editorNodeFromPath(editor, bookmark.focusPath);
const selection = window.getSelection();
if (!anchorNode || !focusNode || !selection) {
return;
}
const anchorOffset = clampedSelectionOffset(anchorNode, bookmark.anchorOffset);
const focusOffset = clampedSelectionOffset(focusNode, bookmark.focusOffset);
selection.removeAllRanges();
if (typeof selection.setBaseAndExtent === "function") {
selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
return;
}
const range = globalThis.document.createRange();
range.setStart(anchorNode, anchorOffset);
range.setEnd(focusNode, focusOffset);
selection.addRange(range);
}
function placeCaretInTableCell(cell: HTMLTableCellElement) {
if (cell.childNodes.length === 0) {
cell.append(globalThis.document.createElement("br"));
}
const range = globalThis.document.createRange();
range.selectNodeContents(cell);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function keepEditorSelection(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
}
export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number; pageGap?: number }) {
const pageCount = Math.max(1, input.pageCount);
const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1;
const pageGap = Number.isFinite(input.pageGap) && input.pageGap ? Math.max(0, input.pageGap) : 0;
const visualPageStride = Math.max(1, (input.pageHeight + pageGap) * zoom);
const visiblePaperOffset = Math.max(0, input.stageTop - input.paperTop);
return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageStride) + 1));
}
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 activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
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;
@@ -228,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
}); });
} }
@@ -240,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 }
@@ -258,7 +512,26 @@ 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(() => {
if (!restoreActiveTableCellAfterRenderRef.current) {
return;
}
restoreActiveTableCellAfterRenderRef.current = false;
const editor = editorRef.current;
const location = activeTableCellLocationRef.current;
if (!editor || !location) {
return;
}
const restoredCell = tableCellFromLocation(editor, location);
if (restoredCell) {
activeTableCellRef.current = restoredCell;
placeCaretInTableCell(restoredCell);
}
}, [document.html]);
useLayoutEffect(() => { useLayoutEffect(() => {
onStatusChange?.({ currentPage: pageView.current, pageCount: pageView.count, wordCount }); onStatusChange?.({ currentPage: pageView.current, pageCount: pageView.count, wordCount });
@@ -267,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) {
@@ -351,6 +622,71 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
runCommand("insertHTML", '<span data-qoffice-page-break="true" contenteditable="false"></span><p><br></p>'); runCommand("insertHTML", '<span data-qoffice-page-break="true" contenteditable="false"></span><p><br></p>');
} }
function setActiveTableCell(cell: HTMLTableCellElement) {
const editor = editorRef.current;
activeTableCellRef.current = cell;
if (editor) {
activeTableCellLocationRef.current = tableCellLocation(editor, cell) ?? activeTableCellLocationRef.current;
}
}
function queueActiveTableCellRestore(cell: HTMLTableCellElement) {
setActiveTableCell(cell);
restoreActiveTableCellAfterRenderRef.current = true;
}
function rememberActiveTableCell(): HTMLTableCellElement | undefined {
const editor = editorRef.current;
if (!editor) {
return undefined;
}
const cell = tableCellFromSelection(editor);
if (cell) {
setActiveTableCell(cell);
}
return cell;
}
function rememberActiveTableCellFromEvent(event: MouseEvent<HTMLElement>) {
const editor = editorRef.current;
const target = event.target;
const cell = target instanceof Element ? target.closest("td,th") : undefined;
if (editor && cell instanceof HTMLTableCellElement && editor.contains(cell)) {
setActiveTableCell(cell);
return;
}
rememberActiveTableCell();
}
function activeTableCell(): HTMLTableCellElement | undefined {
const editor = editorRef.current;
if (!editor) {
return undefined;
}
const selectedCell = rememberActiveTableCell();
if (selectedCell) {
return selectedCell;
}
const rememberedCell = activeTableCellRef.current;
if (rememberedCell && editor.contains(rememberedCell)) {
return rememberedCell;
}
const rememberedLocation = activeTableCellLocationRef.current;
if (!rememberedLocation) {
return undefined;
}
const restoredCell = tableCellFromLocation(editor, rememberedLocation);
if (restoredCell) {
activeTableCellRef.current = restoredCell;
}
return restoredCell;
}
function insertTable() { function insertTable() {
const editor = editorRef.current; const editor = editorRef.current;
if (!editor) { if (!editor) {
@@ -364,9 +700,13 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
if (range && (range.commonAncestorContainer === editor || editor.contains(range.commonAncestorContainer))) { if (range && (range.commonAncestorContainer === editor || editor.contains(range.commonAncestorContainer))) {
range.deleteContents(); range.deleteContents();
const fragment = globalThis.document.createRange().createContextualFragment(html); const fragment = globalThis.document.createRange().createContextualFragment(html);
const firstCell = fragment.querySelector("td,th") as HTMLTableCellElement | null;
const lastNode = fragment.lastChild; const lastNode = fragment.lastChild;
range.insertNode(fragment); range.insertNode(fragment);
if (lastNode) { if (firstCell) {
queueActiveTableCellRestore(firstCell);
placeCaretInTableCell(firstCell);
} else if (lastNode) {
range.setStartAfter(lastNode); range.setStartAfter(lastNode);
range.collapse(true); range.collapse(true);
selection?.removeAllRanges(); selection?.removeAllRanges();
@@ -374,11 +714,77 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
} }
} else { } else {
editor.insertAdjacentHTML("beforeend", html); editor.insertAdjacentHTML("beforeend", html);
const tables = editor.querySelectorAll("table");
const firstCell = tables[tables.length - 1]?.querySelector("td,th") as HTMLTableCellElement | null;
if (firstCell) {
queueActiveTableCellRestore(firstCell);
placeCaretInTableCell(firstCell);
}
} }
updateHtml(); updateHtml();
} }
function insertTableRowBelow() {
const editor = editorRef.current;
if (!editor) {
return;
}
const cell = activeTableCell();
const row = cell?.parentElement;
if (!cell || !(row instanceof HTMLTableRowElement)) {
window.alert("Поместите курсор в ячейку таблицы.");
return;
}
const newRow = globalThis.document.createElement("tr");
Array.from({ length: Math.max(1, row.cells.length) }, () => createBlankTableCell()).forEach((newCell) => newRow.append(newCell));
row.after(newRow);
const nextCell = newRow.cells[Math.min(cell.cellIndex, newRow.cells.length - 1)] ?? newRow.cells[0];
queueActiveTableCellRestore(nextCell);
placeCaretInTableCell(nextCell);
updateHtml();
}
function insertTableColumnRight() {
const editor = editorRef.current;
if (!editor) {
return;
}
const cell = activeTableCell();
const row = cell?.parentElement;
const table = cell?.closest("table");
if (!cell || !(row instanceof HTMLTableRowElement) || !(table instanceof HTMLTableElement)) {
window.alert("Поместите курсор в ячейку таблицы.");
return;
}
const columnIndex = cell.cellIndex;
let selectedNewCell: HTMLTableCellElement | undefined;
Array.from(table.rows).forEach((tableRow) => {
const referenceCell = tableRow.cells[Math.min(columnIndex, Math.max(0, tableRow.cells.length - 1))];
const tagName = referenceCell?.tagName.toLowerCase() === "th" ? "th" : "td";
const newCell = createBlankTableCell(tagName);
if (referenceCell?.nextSibling) {
tableRow.insertBefore(newCell, referenceCell.nextSibling);
} else {
tableRow.append(newCell);
}
if (tableRow === row) {
selectedNewCell = newCell;
}
});
if (selectedNewCell) {
queueActiveTableCellRestore(selectedNewCell);
placeCaretInTableCell(selectedNewCell);
}
updateHtml();
}
async function insertImage(file: File | undefined) { async function insertImage(file: File | undefined) {
if (!file) { if (!file) {
return; return;
@@ -406,8 +812,11 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
} }
function updateHtml() { function updateHtml() {
if (editorRef.current) { rememberActiveTableCell();
onChange({ ...document, html: editorRef.current.innerHTML }); const editor = editorRef.current;
if (editor) {
pendingEditorSelectionRestoreRef.current = editorSelectionBookmark(editor) ?? null;
onChange({ ...document, html: editor.innerHTML });
refreshPageView(); refreshPageView();
} }
} }
@@ -419,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 {
@@ -432,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;
@@ -471,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}
@@ -634,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="Списки">
@@ -717,10 +1175,18 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
))} ))}
</select> </select>
</label> </label>
<button className="compact-command" type="button" onClick={insertTable}> <button className="compact-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTable}>
<Table2 size={16} /> <Table2 size={16} />
Таблица Таблица
</button> </button>
<button className="compact-command word-table-structure-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTableRowBelow}>
<Rows3 size={16} />
Строка ниже
</button>
<button className="compact-command word-table-structure-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTableColumnRight}>
<Columns2 size={16} />
Столбец справа
</button>
</div> </div>
<span className="word-ribbon-title">Таблица</span> <span className="word-ribbon-title">Таблица</span>
</section> </section>
@@ -732,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}
@@ -763,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>
@@ -775,22 +1242,28 @@ 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"
contentEditable contentEditable
suppressContentEditableWarning suppressContentEditableWarning
onInput={updateHtml} onInput={updateHtml}
dangerouslySetInnerHTML={{ __html: document.html }} onFocus={rememberActiveTableCell}
onKeyUp={rememberActiveTableCell}
onMouseDown={rememberActiveTableCellFromEvent}
onMouseUp={rememberActiveTableCellFromEvent}
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}
+1 -1
View File
@@ -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;
+25 -2
View File
@@ -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 });
}
});
}); });
+6 -1
View File
@@ -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);
} }
} }
+108
View File
@@ -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
View File
@@ -1,4 +1,15 @@
import type { CellFormat, MergedCellRange, SheetChart, SheetChartType, SheetFrozenPane, Workbook } from "../types"; import type {
CellFormat,
MergedCellRange,
SheetChart,
SheetChartType,
SheetFrozenPane,
SheetPageMarginPreset,
SheetPageOrientation,
SheetPageSetup,
SheetPageSizeId,
Workbook
} from "../types";
import { evaluateCell, expandRange } from "../utils/spreadsheet"; import { 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"' : ""}`;
} }
+120
View File
@@ -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
View File
@@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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");
} }
+49 -1
View File
@@ -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("Строка с пробелами");
}); });
+32 -6
View File
@@ -1,13 +1,15 @@
import type { Slide, SlideImage, SlideListStyle, SlideTextAlign } from "../types"; import type { Slide, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign } from "../types";
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers"; 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")
+87
View File
@@ -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,82 @@ describe("wordOffice helpers", () => {
]); ]);
}); });
it("preserves HTML table colspan as DOCX gridSpan", async () => {
const html = '<table><tr><td colspan="2">Merged</td><td>Tail</td></tr></table>';
const blocks = htmlToWordBlocks(html);
const blob = await exportDocxBlob("Table colspan.docx", html);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
expect(blocks).toEqual([
{
type: "table",
rows: [[{ text: "Merged", runs: [{ text: "Merged" }], colSpan: 2 }, "Tail"]]
}
]);
expect(documentXml).toMatch(/<w:gridSpan\b[^>]*w:val="2"[^>]*\/>/);
});
it("сохраняет пустые ячейки и строки таблицы для DOCX", async () => {
const html = `
<table>
<tr><td><br></td><td></td><td>Итог</td></tr>
<tr><td></td><td>&nbsp;</td><td><br></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")) ?? "";
expect(blocks).toEqual([
{
type: "table",
rows: [
["", "", "Итог"],
["", "", ""]
]
}
]);
expect(documentXml).toContain("<w:tbl>");
expect(documentXml.match(/<w:tr>/g)).toHaveLength(2);
expect(documentXml.match(/<w:tc>/g)).toHaveLength(6);
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 -22
View File
@@ -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,14 +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) => .map((row) => Array.from(row.querySelectorAll("th,td")).map(tableCellValue))
Array.from(row.querySelectorAll("th,td"))
.map(elementText)
.filter((cell) => cell.length > 0)
)
.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);
@@ -922,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);
} }
@@ -1112,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("");
@@ -1123,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, {}))
@@ -1483,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) {
@@ -1697,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)) {
@@ -1979,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) {
+62
View File
@@ -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" });
});
}); });
+72 -32
View File
@@ -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;
@@ -675,7 +696,7 @@ button {
} }
.word-table-group { .word-table-group {
flex: 0 0 156px; flex: 0 0 236px;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
@@ -701,12 +722,17 @@ button {
width: 100%; width: 100%;
} }
.word-table-controls .compact-command { .word-table-controls .compact-command:not(.word-table-structure-command) {
grid-column: 1 / -1; grid-column: 1 / -1;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
.word-table-structure-command {
justify-content: flex-start;
width: 100%;
}
.word-edit-group { .word-edit-group {
flex: 0 0 118px; flex: 0 0 118px;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -1041,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;
} }
@@ -1074,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 {
@@ -1084,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;
@@ -1092,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;
} }
@@ -1099,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 {
@@ -2222,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;
+24
View File
@@ -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[];
} }
+139
View File
@@ -0,0 +1,139 @@
# Ollama/Gemma Task: QOffice Next Iteration
You are Gemma running through Ollama. Work on the local repository:
```text
G:\Repos\QOffice
```
Project objective: QOffice is a cross-platform Russian-language office suite intended to move toward Microsoft Office-like functionality. It contains separate applications: QWord, QExcell, QPowerPoint, and QOutlook. Microsoft Office file compatibility is a core requirement.
Current verified repository state:
- `git status --short --branch` returned `## main...origin/main`.
- Latest commit before this task: `5487d04 Add QPowerPoint slide size support`.
- QWord already has DOCX import/export, page setup controls, and a Microsoft Word-like ribbon.
- QWord does not yet implement DOCX header/footer support.
## Iteration Goal
Implement QWord support for Microsoft Word / DOCX headers and footers.
The feature must be real DOCX compatibility work. Do not implement this only as internal JSON state. Import and export must read and write actual DOCX header/footer parts or clearly explain why that is impossible after inspecting the local `docx` package API.
## Files To Inspect First
First check for local project instructions:
```powershell
rg --files -g AGENTS.md
```
Then inspect these files before editing:
- `src/types.ts`
- `src/data/sampleData.ts`
- `src/App.tsx`
- `src/io/wordOffice.ts`
- `src/io/wordOffice.test.ts`
- `src/io/wordOfficeImport.test.ts`
- `src/features/qword/QWord.tsx`
- `src/features/qword/QWord.test.tsx`
## Verified Starting Points
- `src/types.ts`: `WordDocument` currently has `id`, `title`, `updatedAt`, `html`, and optional `pageSetup`.
- `src/io/wordOffice.ts`: `importDocxFile` imports DOCX through `mammoth`, `styledDocxHtmlFromArrayBuffer`, and `docxPageSetupFromArrayBuffer`.
- `src/io/wordOffice.ts`: `exportDocxBlob` currently accepts only `pageSetup` in its options object.
- `src/io/wordOffice.ts`: `lastDocxSectionProperties` already finds the final `w:sectPr`.
- `src/features/qword/QWord.tsx`: `openDocx` applies imported `title`, `html`, and `pageSetup`.
- `src/features/qword/QWord.tsx`: `saveDocx` passes only `pageSetup` into `exportDocxBlob`.
- QWord's `layout` ribbon tab already contains page setup controls.
## Implementation Requirements
1. Data model:
Add optional QWord document fields. Preferred names:
```ts
headerHtml?: string;
footerHtml?: string;
```
Existing saved documents without these fields must remain valid.
2. DOCX import:
- Read `word/document.xml`.
- Find the final applicable `w:sectPr`.
- Read `w:headerReference` and `w:footerReference`.
- Resolve their relationship ids through `word/_rels/document.xml.rels`.
- Read the corresponding `word/header*.xml` and `word/footer*.xml` parts.
- For this first iteration, support the `default` reference type.
- Do not break documents that also contain `first` or `even` references; those can be ignored for now.
- Convert header/footer content into HTML using the existing `wordOffice.ts` helper patterns. Preserve at least paragraphs and basic inline text styling.
3. DOCX export:
- Extend `exportDocxBlob` options to accept `headerHtml` and `footerHtml`.
- Use the local `docx` package API to create real DOCX header/footer parts if available.
- Reuse the existing HTML-to-DOCX block pipeline where practical.
- Preserve existing `pageSetup` behavior.
- Generated DOCX must contain real header/footer parts and relationships.
4. QWord UI:
- Add Russian-language controls for header and footer.
- Preferred location: QWord `layout` ribbon tab near page setup.
- Do not add a right sidebar.
- Use clear Russian labels, for example:
- `Верхний колонтитул`
- `Нижний колонтитул`
- Editing these fields must call `onChange` with updated `document.headerHtml` and `document.footerHtml`.
- `openDocx` must apply imported header/footer fields.
- `saveDocx` must pass header/footer fields to `exportDocxBlob`.
5. Tests:
Add focused regression tests:
- Import DOCX with `headerReference` and `footerReference`; assert imported `headerHtml` and `footerHtml`.
- Export DOCX with header/footer; assert header/footer parts and relationships exist.
- Roundtrip export -> import; assert header/footer values survive.
- QWord UI test: changing header/footer controls calls `onChange` with `headerHtml` and `footerHtml`.
## Verification Commands
Run the narrow test set first:
```powershell
npx vitest run src\io\wordOffice.test.ts src\io\wordOfficeImport.test.ts src\features\qword\QWord.test.tsx
```
If the narrow tests pass, run:
```powershell
npm run build
npm test
npm audit --audit-level=high
```
## Constraints
- Do not touch unrelated files.
- Do not revert user changes.
- Do not run destructive git commands.
- Do not add dependencies unless necessary.
- Follow the existing project patterns.
- If you are only an LLM without filesystem access, return a unified diff patch and verification commands.
- If you are running inside an agent with filesystem access, edit the files directly but do not commit or push.
## Final Response Required
Report:
- changed files;
- tests and command results;
- exactly how DOCX import/export was implemented;
- any remaining risks or unverified behavior.