Initial QOffice implementation
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# QOffice
|
||||||
|
|
||||||
|
QOffice — оригинальная основа кроссплатформенного офисного пакета с четырьмя приложениями:
|
||||||
|
|
||||||
|
- QWord: редактор документов
|
||||||
|
- QExcell: редактор таблиц
|
||||||
|
- QPowerPoint: редактор презентаций
|
||||||
|
- QOutlook: локальное рабочее пространство почты
|
||||||
|
|
||||||
|
Репозиторий не содержит код, ресурсы, брендинг, иконки или проприетарные UI-материалы Microsoft Office.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Desktop-оболочка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run electron:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поддержка форматов
|
||||||
|
|
||||||
|
- QWord открывает `.docx` и сохраняет `.docx`; при экспорте DOCX сохраняются заголовки, абзацы, выравнивание абзацев, списки, простые таблицы, внешние гиперссылки, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, цвет текста и подсветка.
|
||||||
|
- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, формулы за пределами стартового диапазона A-H, базовые merged cells, внешние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, заливку и числовые форматы XLSX для дат, процентов, денежных и дробных значений.
|
||||||
|
- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, читает заметки докладчика через relationships слайда и сохраняет базовые растровые изображения слайдов.
|
||||||
|
- QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable и MIME-вложения. Импорт MSG читает тему, отправителя, получателей, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы.
|
||||||
|
|
||||||
|
В desktop-режиме Electron используются системные диалоги открытия и сохранения файлов. В web-режиме остаются браузерный выбор файла и скачивание результата.
|
||||||
|
Экспорт PDF в desktop-режиме выполняется через Electron `webContents.printToPDF`; в web-режиме команда открывает браузерную печать, где пользователь может выбрать сохранение в PDF.
|
||||||
|
|
||||||
|
Desktop-меню поддерживает системные команды для активного модуля:
|
||||||
|
|
||||||
|
- `Ctrl/Cmd+O`: открыть файл текущего приложения.
|
||||||
|
- `Ctrl/Cmd+S`: сохранить файл текущего приложения.
|
||||||
|
- `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF.
|
||||||
|
- `Ctrl/Cmd+P`: печать текущей рабочей области.
|
||||||
|
|
||||||
|
Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, цвет/подсветку текста и выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, заметки докладчика, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены.
|
||||||
|
|
||||||
|
## Сборка пакета
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dist:desktop
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,258 @@
|
|||||||
|
const { app, BrowserWindow, dialog, ipcMain, Menu, shell } = require("electron");
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const isDev = !app.isPackaged;
|
||||||
|
const fileKinds = {
|
||||||
|
docx: {
|
||||||
|
title: "Открыть документ Word",
|
||||||
|
saveTitle: "Сохранить документ Word",
|
||||||
|
defaultName: "Документ QWord.docx",
|
||||||
|
filters: [{ name: "Документы Word (.docx)", extensions: ["docx"] }]
|
||||||
|
},
|
||||||
|
xlsx: {
|
||||||
|
title: "Открыть книгу Excel",
|
||||||
|
saveTitle: "Сохранить книгу Excel",
|
||||||
|
defaultName: "Книга QExcell.xlsx",
|
||||||
|
filters: [{ name: "Книги Excel (.xlsx)", extensions: ["xlsx"] }]
|
||||||
|
},
|
||||||
|
pptx: {
|
||||||
|
title: "Открыть презентацию PowerPoint",
|
||||||
|
saveTitle: "Сохранить презентацию PowerPoint",
|
||||||
|
defaultName: "Презентация QPowerPoint.pptx",
|
||||||
|
filters: [{ name: "Презентации PowerPoint (.pptx)", extensions: ["pptx"] }]
|
||||||
|
},
|
||||||
|
eml: {
|
||||||
|
title: "Открыть письмо EML",
|
||||||
|
saveTitle: "Сохранить письмо EML",
|
||||||
|
defaultName: "Письмо.eml",
|
||||||
|
filters: [{ name: "Письма EML (.eml)", extensions: ["eml"] }]
|
||||||
|
},
|
||||||
|
mail: {
|
||||||
|
title: "Открыть письмо Outlook",
|
||||||
|
saveTitle: "Сохранить письмо EML",
|
||||||
|
defaultName: "Письмо.eml",
|
||||||
|
filters: [{ name: "Письма Outlook (.eml, .msg)", extensions: ["eml", "msg"] }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function validatedKind(kind) {
|
||||||
|
if (typeof kind !== "string" || !Object.hasOwn(fileKinds, kind)) {
|
||||||
|
throw new Error("Неподдерживаемый тип файла QOffice.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileKinds[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDefaultName(kind, defaultFileName) {
|
||||||
|
const config = validatedKind(kind);
|
||||||
|
const extension = config.filters[0].extensions[0];
|
||||||
|
return safeFileNameWithExtension(defaultFileName, extension, config.defaultName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFileNameWithExtension(defaultFileName, extension, fallbackName) {
|
||||||
|
const candidate = typeof defaultFileName === "string" && defaultFileName.trim() ? path.basename(defaultFileName.trim()) : fallbackName;
|
||||||
|
return candidate.toLowerCase().endsWith(`.${extension}`) ? candidate : `${candidate}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerWindow(event) {
|
||||||
|
return BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFocusedCommand(command) {
|
||||||
|
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||||
|
if (focusedWindow && !focusedWindow.isDestroyed()) {
|
||||||
|
focusedWindow.webContents.send("qoffice:command", command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApplicationMenu() {
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
const template = [
|
||||||
|
...(isMac
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "QOffice",
|
||||||
|
submenu: [
|
||||||
|
{ role: "about", label: "О QOffice" },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "hide", label: "Скрыть QOffice" },
|
||||||
|
{ role: "hideOthers", label: "Скрыть остальные" },
|
||||||
|
{ role: "unhide", label: "Показать все" },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "quit", label: "Выйти из QOffice" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
label: "Файл",
|
||||||
|
submenu: [
|
||||||
|
{ label: "Открыть...", accelerator: "CmdOrCtrl+O", click: () => sendFocusedCommand("open") },
|
||||||
|
{ label: "Сохранить как...", accelerator: "CmdOrCtrl+S", click: () => sendFocusedCommand("save") },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ label: "Экспорт в PDF...", accelerator: "CmdOrCtrl+Shift+E", click: () => sendFocusedCommand("export-pdf") },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ label: "Печать...", accelerator: "CmdOrCtrl+P", click: () => sendFocusedCommand("print") },
|
||||||
|
{ type: "separator" },
|
||||||
|
isMac ? { role: "close", label: "Закрыть окно" } : { role: "quit", label: "Выход" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Правка",
|
||||||
|
submenu: [
|
||||||
|
{ role: "undo", label: "Отменить" },
|
||||||
|
{ role: "redo", label: "Повторить" },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "cut", label: "Вырезать" },
|
||||||
|
{ role: "copy", label: "Копировать" },
|
||||||
|
{ role: "paste", label: "Вставить" },
|
||||||
|
{ role: "selectAll", label: "Выделить все" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Вид",
|
||||||
|
submenu: [
|
||||||
|
{ role: "resetZoom", label: "Сбросить масштаб" },
|
||||||
|
{ role: "zoomIn", label: "Увеличить" },
|
||||||
|
{ role: "zoomOut", label: "Уменьшить" },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "togglefullscreen", label: "Во весь экран" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Окно",
|
||||||
|
submenu: [
|
||||||
|
{ role: "minimize", label: "Свернуть" },
|
||||||
|
{ role: "reload", label: "Перезагрузить" },
|
||||||
|
...(isDev ? [{ role: "toggleDevTools", label: "Инструменты разработчика" }] : [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle("qoffice:open-office-file", async (event, kind) => {
|
||||||
|
const config = validatedKind(kind);
|
||||||
|
const result = await dialog.showOpenDialog(ownerWindow(event), {
|
||||||
|
title: config.title,
|
||||||
|
properties: ["openFile"],
|
||||||
|
filters: config.filters
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = result.filePaths[0];
|
||||||
|
const bytes = await fs.readFile(filePath);
|
||||||
|
return {
|
||||||
|
canceled: false,
|
||||||
|
fileName: path.basename(filePath),
|
||||||
|
filePath,
|
||||||
|
base64Data: bytes.toString("base64")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("qoffice:save-office-file", async (event, payload) => {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
throw new Error("Некорректный запрос сохранения файла QOffice.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { kind, defaultFileName, base64Data } = payload;
|
||||||
|
const config = validatedKind(kind);
|
||||||
|
if (typeof base64Data !== "string") {
|
||||||
|
throw new Error("Некорректные данные файла QOffice.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dialog.showSaveDialog(ownerWindow(event), {
|
||||||
|
title: config.saveTitle,
|
||||||
|
defaultPath: safeDefaultName(kind, defaultFileName),
|
||||||
|
filters: config.filters
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.filePath) {
|
||||||
|
return { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(result.filePath, Buffer.from(base64Data, "base64"));
|
||||||
|
return {
|
||||||
|
canceled: false,
|
||||||
|
fileName: path.basename(result.filePath),
|
||||||
|
filePath: result.filePath
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("qoffice:export-pdf", async (event, defaultFileName) => {
|
||||||
|
const sourceWindow = ownerWindow(event);
|
||||||
|
if (!sourceWindow) {
|
||||||
|
throw new Error("Не удалось найти окно QOffice для экспорта PDF.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dialog.showSaveDialog(sourceWindow, {
|
||||||
|
title: "Экспорт PDF",
|
||||||
|
defaultPath: safeFileNameWithExtension(defaultFileName, "pdf", "QOffice.pdf"),
|
||||||
|
filters: [{ name: "PDF (.pdf)", extensions: ["pdf"] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.filePath) {
|
||||||
|
return { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdf = await event.sender.printToPDF({
|
||||||
|
printBackground: true,
|
||||||
|
preferCSSPageSize: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.writeFile(result.filePath, pdf);
|
||||||
|
return {
|
||||||
|
canceled: false,
|
||||||
|
fileName: path.basename(result.filePath),
|
||||||
|
filePath: result.filePath
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const mainWindow = new BrowserWindow({
|
||||||
|
width: 1440,
|
||||||
|
height: 920,
|
||||||
|
minWidth: 1080,
|
||||||
|
minHeight: 720,
|
||||||
|
title: "QOffice",
|
||||||
|
backgroundColor: "#f6f8fb",
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, "preload.cjs"),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
mainWindow.loadURL("http://127.0.0.1:5173");
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(path.join(__dirname, "../dist/index.html"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createApplicationMenu();
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
ipcRenderer.on("qoffice:command", (_event, command) => {
|
||||||
|
if (typeof command === "string") {
|
||||||
|
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("qoffice", {
|
||||||
|
isDesktop: true,
|
||||||
|
platform: process.platform,
|
||||||
|
openOfficeFile: (kind) => ipcRenderer.invoke("qoffice:open-office-file", kind),
|
||||||
|
saveOfficeFile: (kind, defaultFileName, base64Data) =>
|
||||||
|
ipcRenderer.invoke("qoffice:save-office-file", {
|
||||||
|
kind,
|
||||||
|
defaultFileName,
|
||||||
|
base64Data
|
||||||
|
}),
|
||||||
|
exportPdf: (defaultFileName) => ipcRenderer.invoke("qoffice:export-pdf", defaultFileName)
|
||||||
|
});
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>QOffice</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+6807
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "qoffice",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"author": "QOffice",
|
||||||
|
"description": "Кроссплатформенный офисный пакет QOffice с QWord, QExcell, QPowerPoint и QOutlook.",
|
||||||
|
"main": "electron/main.cjs",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"test": "vitest run",
|
||||||
|
"electron:dev": "concurrently -k \"npm:dev\" \"wait-on http://127.0.0.1:5173 && electron .\"",
|
||||||
|
"dist:desktop": "npm run build && electron-builder"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@kenjiuno/msgreader": "^1.28.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"docx": "^9.7.1",
|
||||||
|
"fast-xml-parser": "^5.8.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
|
"pptxgenjs": "^4.0.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^15.0.7",
|
||||||
|
"@types/react": "^18.3.27",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"concurrently": "^10.0.0",
|
||||||
|
"electron": "^42.3.0",
|
||||||
|
"electron-builder": "^26.8.1",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^8.0.14",
|
||||||
|
"vitest": "^4.1.7",
|
||||||
|
"wait-on": "^8.0.5"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "dev.qoffice.suite",
|
||||||
|
"productName": "QOffice",
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*"
|
||||||
|
],
|
||||||
|
"directories": {
|
||||||
|
"buildResources": "build",
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"icon": "build/icon",
|
||||||
|
"win": {
|
||||||
|
"target": "nsis"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { AppShell } from "./components/AppShell";
|
||||||
|
import { QOutlook } from "./features/qoutlook/QOutlook";
|
||||||
|
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
|
||||||
|
import { QWord } from "./features/qword/QWord";
|
||||||
|
import { QExcell } from "./features/qexcell/QExcell";
|
||||||
|
import { initialState } from "./data/sampleData";
|
||||||
|
import { usePersistentState } from "./hooks/usePersistentState";
|
||||||
|
import type { AppId, MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "qoffice-state-v1";
|
||||||
|
|
||||||
|
function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
||||||
|
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
||||||
|
const sampleSheet = initialState.workbook.sheets.find((sheet) => sheet.id === "sheet-1");
|
||||||
|
const sampleWordHtml = initialState.word.html;
|
||||||
|
const needsSampleWordColor =
|
||||||
|
state.word.id === initialState.word.id &&
|
||||||
|
sampleWordHtml.includes("Ключевой результат") &&
|
||||||
|
!state.word.html.includes("Ключевой результат");
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
if (needsSampleWordColor) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
const messages = state.mail.messages.map((message) => {
|
||||||
|
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
return { ...message, attachments: launchChecklistAttachments };
|
||||||
|
});
|
||||||
|
|
||||||
|
const sheets =
|
||||||
|
state.workbook.id === initialState.workbook.id && sampleSheet
|
||||||
|
? state.workbook.sheets.map((sheet) => {
|
||||||
|
if (sheet.id !== "sheet-1") {
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsMergedCells = (sheet.mergedCells?.length ?? 0) === 0;
|
||||||
|
const needsSampleCell = !sheet.cells.A8 && sampleSheet.cells.A8;
|
||||||
|
const missingSampleFormats = Object.entries(sampleSheet.cellFormats ?? {}).filter(([address]) => !sheet.cellFormats?.[address]);
|
||||||
|
const missingSampleCharts = (sampleSheet.charts ?? []).filter((chart) => !(sheet.charts ?? []).some((existingChart) => existingChart.id === chart.id));
|
||||||
|
if (!needsMergedCells && !needsSampleCell && missingSampleFormats.length === 0 && missingSampleCharts.length === 0) {
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...sheet,
|
||||||
|
cells: needsSampleCell ? { ...sheet.cells, A8: sampleSheet.cells.A8 } : sheet.cells,
|
||||||
|
mergedCells: needsMergedCells ? sampleSheet.mergedCells : sheet.mergedCells,
|
||||||
|
cellFormats:
|
||||||
|
missingSampleFormats.length > 0
|
||||||
|
? {
|
||||||
|
...sheet.cellFormats,
|
||||||
|
...Object.fromEntries(missingSampleFormats)
|
||||||
|
}
|
||||||
|
: sheet.cellFormats,
|
||||||
|
charts: missingSampleCharts.length > 0 ? [...(sheet.charts ?? []), ...missingSampleCharts] : sheet.charts
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: state.workbook.sheets;
|
||||||
|
|
||||||
|
return changed
|
||||||
|
? {
|
||||||
|
...state,
|
||||||
|
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
|
||||||
|
workbook: { ...state.workbook, sheets },
|
||||||
|
mail: { ...state.mail, messages }
|
||||||
|
}
|
||||||
|
: state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
|
||||||
|
|
||||||
|
const status = useMemo(() => {
|
||||||
|
const updatedAt =
|
||||||
|
state.activeApp === "word"
|
||||||
|
? state.word.updatedAt
|
||||||
|
: state.activeApp === "sheets"
|
||||||
|
? state.workbook.updatedAt
|
||||||
|
: state.activeApp === "slides"
|
||||||
|
? state.deck.updatedAt
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
savedLabel: "Сохранено локально",
|
||||||
|
updatedAt
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
function setActiveApp(activeApp: AppId) {
|
||||||
|
setState((current) => ({ ...current, activeApp }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWord(word: WordDocument) {
|
||||||
|
setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWorkbook(workbook: Workbook) {
|
||||||
|
setState((current) => ({ ...current, workbook: { ...workbook, updatedAt: new Date().toISOString() } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDeck(deck: SlideDeck) {
|
||||||
|
setState((current) => ({ ...current, deck: { ...deck, updatedAt: new Date().toISOString() } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMail(updater: (mail: QOfficeState["mail"]) => QOfficeState["mail"]) {
|
||||||
|
setState((current) => ({ ...current, mail: updater(current.mail) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMailFolder(folder: MailMessage["folder"]) {
|
||||||
|
updateMail((mail) => {
|
||||||
|
const firstInFolder = mail.messages.find((message) => message.folder === folder);
|
||||||
|
return {
|
||||||
|
...mail,
|
||||||
|
activeFolder: folder,
|
||||||
|
activeMessageId: firstInFolder?.id ?? mail.activeMessageId
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSurface =
|
||||||
|
state.activeApp === "word" ? (
|
||||||
|
<QWord document={state.word} onChange={updateWord} />
|
||||||
|
) : state.activeApp === "sheets" ? (
|
||||||
|
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
|
||||||
|
) : state.activeApp === "slides" ? (
|
||||||
|
<QPowerPoint deck={state.deck} onChange={updateDeck} />
|
||||||
|
) : (
|
||||||
|
<QOutlook mail={state.mail} onChange={updateMail} onFolderChange={setMailFolder} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell activeApp={state.activeApp} onAppChange={setActiveApp} status={status}>
|
||||||
|
{activeSurface}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
FileText,
|
||||||
|
HelpCircle,
|
||||||
|
Mail,
|
||||||
|
MonitorCog,
|
||||||
|
PanelRight,
|
||||||
|
Presentation,
|
||||||
|
Save,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Share2,
|
||||||
|
Table2
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { AppId } from "../types";
|
||||||
|
|
||||||
|
interface AppShellProps {
|
||||||
|
activeApp: AppId;
|
||||||
|
onAppChange: (appId: AppId) => void;
|
||||||
|
status: {
|
||||||
|
savedLabel: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps: Array<{ id: AppId; label: string; icon: typeof FileText; accent: string }> = [
|
||||||
|
{ id: "word", label: "QWord", icon: FileText, accent: "word" },
|
||||||
|
{ id: "sheets", label: "QExcell", icon: Table2, accent: "sheets" },
|
||||||
|
{ id: "slides", label: "QPowerPoint", icon: Presentation, accent: "slides" },
|
||||||
|
{ id: "mail", label: "QOutlook", icon: Mail, accent: "mail" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ribbonGroups = [
|
||||||
|
{
|
||||||
|
title: "Буфер",
|
||||||
|
commands: [
|
||||||
|
{ label: "Вставить", icon: FileText },
|
||||||
|
{ label: "Копировать", icon: Save }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Вставка",
|
||||||
|
commands: [
|
||||||
|
{ label: "Таблица", icon: Table2 },
|
||||||
|
{ label: "Диаграмма", icon: BarChart3 },
|
||||||
|
{ label: "Панель", icon: PanelRight }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Совместная работа",
|
||||||
|
commands: [
|
||||||
|
{ label: "Поделиться", icon: Share2 },
|
||||||
|
{ label: "Параметры", icon: MonitorCog }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppShell({ activeApp, onAppChange, status, children }: AppShellProps) {
|
||||||
|
const active = apps.find((app) => app.id === activeApp) ?? apps[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="qoffice-shell">
|
||||||
|
<aside className="app-rail" aria-label="Приложения QOffice">
|
||||||
|
<div className="brand">QOffice</div>
|
||||||
|
<nav className="app-nav">
|
||||||
|
{apps.map((app) => {
|
||||||
|
const Icon = app.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={app.id}
|
||||||
|
className={`app-nav-item accent-${app.accent} ${activeApp === app.id ? "is-active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAppChange(app.id)}
|
||||||
|
aria-pressed={activeApp === app.id}
|
||||||
|
title={`Открыть ${app.label}`}
|
||||||
|
>
|
||||||
|
<span className="app-icon">
|
||||||
|
<Icon size={22} strokeWidth={2.2} />
|
||||||
|
</span>
|
||||||
|
<span>{app.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="rail-footer">
|
||||||
|
<button className="icon-button" type="button" title="Параметры" aria-label="Параметры">
|
||||||
|
<Settings size={18} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" type="button" title="Справка" aria-label="Справка">
|
||||||
|
<HelpCircle size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="workspace">
|
||||||
|
<header className="titlebar">
|
||||||
|
<div>
|
||||||
|
<strong>{active.label}</strong>
|
||||||
|
<span>{status.savedLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="titlebar-actions">
|
||||||
|
<label className="command-search">
|
||||||
|
<Search size={16} />
|
||||||
|
<input placeholder="Найти команду" aria-label="Найти команду" />
|
||||||
|
</label>
|
||||||
|
<button className="primary-command" type="button">
|
||||||
|
<Share2 size={16} />
|
||||||
|
Поделиться
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="ribbon" aria-label="Лента команд">
|
||||||
|
<div className="ribbon-tabs">
|
||||||
|
{["Файл", "Главная", "Вставка", "Рецензирование", "Вид", "Экспорт"].map((tab, index) => (
|
||||||
|
<button key={tab} className={index === 1 ? "is-active" : ""} type="button">
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ribbon-groups">
|
||||||
|
{ribbonGroups.map((group) => (
|
||||||
|
<div className="ribbon-group" key={group.title}>
|
||||||
|
<div className="ribbon-command-row">
|
||||||
|
{group.commands.map((command) => {
|
||||||
|
const Icon = command.icon;
|
||||||
|
return (
|
||||||
|
<button type="button" key={command.label} className="tool-button" title={command.label}>
|
||||||
|
<Icon size={17} />
|
||||||
|
<span>{command.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="ribbon-group-title">{group.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="surface-host">{children}</div>
|
||||||
|
|
||||||
|
<footer className="statusbar">
|
||||||
|
<span>Готово</span>
|
||||||
|
<span>{status.updatedAt ? `Обновлено: ${new Date(status.updatedAt).toLocaleString("ru-RU")}` : "Локальная почта"}</span>
|
||||||
|
<span>Масштаб 100%</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { QOfficeState } from "../types";
|
||||||
|
|
||||||
|
export const initialState: QOfficeState = {
|
||||||
|
activeApp: "word",
|
||||||
|
word: {
|
||||||
|
id: "qword-project-proposal",
|
||||||
|
title: "Проектное предложение.qdocx",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
html: `
|
||||||
|
<h1>Проектное предложение</h1>
|
||||||
|
<h2>1. Обзор</h2>
|
||||||
|
<p>Документ описывает цели, область работ, сроки и ожидаемые результаты предлагаемого проекта.</p>
|
||||||
|
<h2>2. Цели</h2>
|
||||||
|
<p><span style="color: #0f5fae;">Ключевой результат</span>: <span style="background-color: #fff2cc;">подготовить проверяемую версию офисного пакета</span> с импортом и экспортом документов Microsoft Office.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Поставить масштабируемое и сопровождаемое решение.</li>
|
||||||
|
<li>Повысить операционную эффективность.</li>
|
||||||
|
<li>Обеспечить безопасность данных и соответствие требованиям.</li>
|
||||||
|
<li>Показать измеримую бизнес-ценность.</li>
|
||||||
|
</ul>
|
||||||
|
<h2>3. Область работ</h2>
|
||||||
|
<p>Проект включает анализ требований, проектирование системы, разработку, тестирование, внедрение и сопровождение после запуска.</p>
|
||||||
|
<p>Дополнительные материалы доступны по <a href="https://example.com/qoffice/project">ссылке проекта</a>.</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Этап</th><th>Описание</th><th>Ответственный</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Планирование</td><td>Требования и календарный план</td><td>Алиса К.</td></tr>
|
||||||
|
<tr><td>Проектирование</td><td>Архитектура и UX</td><td>Борис Л.</td></tr>
|
||||||
|
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
workbook: {
|
||||||
|
id: "qexcell-quarter-plan",
|
||||||
|
title: "Квартальный план.qxlsx",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
activeSheet: "sheet-1",
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
id: "sheet-1",
|
||||||
|
name: "Revenue",
|
||||||
|
cells: {
|
||||||
|
A1: "Продукт",
|
||||||
|
B1: "1 кв.",
|
||||||
|
C1: "2 кв.",
|
||||||
|
D1: "3 кв.",
|
||||||
|
E1: "Итого",
|
||||||
|
A2: "Базовый",
|
||||||
|
B2: "12500",
|
||||||
|
C2: "14300",
|
||||||
|
D2: "16800",
|
||||||
|
E2: "=SUM(B2:D2)",
|
||||||
|
A3: "Команды",
|
||||||
|
B3: "8200",
|
||||||
|
C3: "9100",
|
||||||
|
D3: "9900",
|
||||||
|
E3: "=SUM(B3:D3)",
|
||||||
|
A4: "Корпоративный",
|
||||||
|
B4: "19600",
|
||||||
|
C4: "21400",
|
||||||
|
D4: "23800",
|
||||||
|
E4: "=SUM(B4:D4)",
|
||||||
|
A6: "Общий итог",
|
||||||
|
E6: "=SUM(E2:E4)",
|
||||||
|
A8: "Объединённая строка примечания"
|
||||||
|
},
|
||||||
|
mergedCells: [{ start: "A8", end: "E8" }],
|
||||||
|
cellFormats: {
|
||||||
|
B2: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
C2: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
D2: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
E2: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
B3: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
C3: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
D3: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
E3: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
B4: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
C4: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
D4: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
E4: { numberFormat: "# ##0.00 ₽" },
|
||||||
|
E6: { numberFormat: "# ##0.00 ₽" }
|
||||||
|
},
|
||||||
|
charts: [
|
||||||
|
{
|
||||||
|
id: "chart-revenue-total",
|
||||||
|
type: "bar",
|
||||||
|
title: "Итого по продуктам",
|
||||||
|
labelRange: "A2:A4",
|
||||||
|
valueRange: "E2:E4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
deck: {
|
||||||
|
id: "qpowerpoint-roadmap",
|
||||||
|
title: "Дорожная карта.qpptx",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
activeSlideId: "slide-1",
|
||||||
|
slides: [
|
||||||
|
{
|
||||||
|
id: "slide-1",
|
||||||
|
title: "Дорожная карта QOffice",
|
||||||
|
subtitle: "Единая рабочая среда для документов, данных, презентаций и коммуникации.",
|
||||||
|
notes: "Начать с цели продукта и текущего этапа.",
|
||||||
|
theme: "classic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slide-2",
|
||||||
|
title: "Модули продукта",
|
||||||
|
subtitle: "QWord, QExcell, QPowerPoint и QOutlook используют общую оболочку и модель хранения.",
|
||||||
|
notes: "Описать общую архитектуру и навигацию между модулями.",
|
||||||
|
theme: "ocean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slide-3",
|
||||||
|
title: "Следующий этап",
|
||||||
|
subtitle: "Импорт и экспорт файлов, совместная работа, аудит доступности и подпись пакетов.",
|
||||||
|
notes: "Завершить последовательностью реализации.",
|
||||||
|
theme: "graphite"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
mail: {
|
||||||
|
activeFolder: "inbox",
|
||||||
|
activeMessageId: "mail-1",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "mail-1",
|
||||||
|
folder: "inbox",
|
||||||
|
from: "Мария Чен",
|
||||||
|
to: "product@qoffice.local",
|
||||||
|
subject: "Контрольный список запуска",
|
||||||
|
body: "Пожалуйста, проверьте этапы редакторов, цели упаковки и ответственных за качество до пятницы.",
|
||||||
|
time: "09:24",
|
||||||
|
read: false,
|
||||||
|
flagged: true,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "attachment-launch-checklist-1",
|
||||||
|
fileName: "launch-checklist.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
size: 37,
|
||||||
|
contentBase64: "0J/RgNC+0LLQtdGA0LjRgtGMIERPQ1gvWExTWC9QUFRYL0VNTA==",
|
||||||
|
disposition: "attachment"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mail-2",
|
||||||
|
folder: "inbox",
|
||||||
|
from: "Иван Белл",
|
||||||
|
to: "product@qoffice.local",
|
||||||
|
subject: "Проверка формул таблиц",
|
||||||
|
body: "Последний слой таблиц поддерживает диапазоны, основные агрегатные функции, числовые форматы и базовые диаграммы. Следующий шаг — расширение fidelity сложных книг.",
|
||||||
|
time: "Вчера",
|
||||||
|
read: true,
|
||||||
|
flagged: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mail-3",
|
||||||
|
folder: "sent",
|
||||||
|
from: "product@qoffice.local",
|
||||||
|
to: "team@qoffice.local",
|
||||||
|
subject: "Заметки дизайн-ревью",
|
||||||
|
body: "Я отправил наблюдения по оболочке, ленте команд, инспектору и строке состояния после последнего ревью.",
|
||||||
|
time: "Пн",
|
||||||
|
read: true,
|
||||||
|
flagged: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Bold,
|
||||||
|
Download,
|
||||||
|
Eraser,
|
||||||
|
FileText,
|
||||||
|
FileUp,
|
||||||
|
FunctionSquare,
|
||||||
|
Italic,
|
||||||
|
Link,
|
||||||
|
LineChart,
|
||||||
|
PaintBucket,
|
||||||
|
PieChart,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Sigma,
|
||||||
|
Trash2,
|
||||||
|
Underline
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types";
|
||||||
|
import { cellId, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
|
||||||
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
|
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
|
||||||
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
|
import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles";
|
||||||
|
import { exportPdfOrPrint } from "../../io/pdfExport";
|
||||||
|
import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
|
||||||
|
|
||||||
|
interface QExcellProps {
|
||||||
|
workbook: Workbook;
|
||||||
|
onChange: (workbook: Workbook) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraEditableRows = 1;
|
||||||
|
const extraEditableColumns = 1;
|
||||||
|
const emptyHyperlinks: Record<string, string> = {};
|
||||||
|
const emptyCellFormats: Record<string, CellFormat> = {};
|
||||||
|
const emptyCharts: SheetChart[] = [];
|
||||||
|
|
||||||
|
const fillSwatches = [
|
||||||
|
{ color: "", label: "Без заливки" },
|
||||||
|
{ color: "FFF2CC", label: "Светло-желтая заливка" },
|
||||||
|
{ color: "D9EAD3", label: "Светло-зеленая заливка" },
|
||||||
|
{ color: "DDEBF7", label: "Светло-синяя заливка" },
|
||||||
|
{ color: "FCE4D6", label: "Светло-коралловая заливка" },
|
||||||
|
{ color: "EADCF8", label: "Светло-фиолетовая заливка" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const numberFormatOptions = [
|
||||||
|
{ value: "", label: "Обычный" },
|
||||||
|
{ value: "0.00", label: "Число" },
|
||||||
|
{ value: "# ##0.00 ₽", label: "Деньги" },
|
||||||
|
{ value: "0%", label: "Процент" },
|
||||||
|
{ value: "dd.mm.yyyy", label: "Дата" },
|
||||||
|
{ value: "dd.mm.yyyy hh:mm", label: "Дата и время" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartTypeOptions: Array<{ value: SheetChartType; label: string }> = [
|
||||||
|
{ value: "bar", label: "Столбчатая" },
|
||||||
|
{ value: "line", label: "Линейная" },
|
||||||
|
{ value: "pie", label: "Круговая" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizedLinkInput(value: string | null) {
|
||||||
|
const raw = value?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
|
||||||
|
try {
|
||||||
|
const url = new URL(candidate);
|
||||||
|
return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(url.protocol.toLowerCase()) ? url.href : "";
|
||||||
|
} catch {
|
||||||
|
window.alert("Введите корректный адрес ссылки.");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedFillColor(value?: string) {
|
||||||
|
const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? "";
|
||||||
|
return /^[0-9A-F]{6}$/.test(raw) ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedCellFormat(format: CellFormat): CellFormat | undefined {
|
||||||
|
const fillColor = normalizedFillColor(format.fillColor);
|
||||||
|
const numberFormat = format.numberFormat?.trim();
|
||||||
|
const normalized: CellFormat = {
|
||||||
|
...(format.bold ? { bold: true } : {}),
|
||||||
|
...(format.italics ? { italics: true } : {}),
|
||||||
|
...(format.underline ? { underline: true } : {}),
|
||||||
|
...(fillColor ? { fillColor } : {}),
|
||||||
|
...(numberFormat ? { numberFormat } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellInputStyle(format?: CellFormat): CSSProperties {
|
||||||
|
return {
|
||||||
|
...(format?.bold ? { fontWeight: 700 } : {}),
|
||||||
|
...(format?.italics ? { fontStyle: "italic" } : {}),
|
||||||
|
...(format?.underline ? { textDecoration: "underline" } : {}),
|
||||||
|
...(format?.fillColor ? { backgroundColor: `#${format.fillColor}` } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedChartRange(value: string) {
|
||||||
|
const raw = value.trim().replace(/\$/g, "").toUpperCase();
|
||||||
|
const [start, end = start] = raw.split(":");
|
||||||
|
if (!/^[A-Z]+[1-9][0-9]*$/.test(start) || !/^[A-Z]+[1-9][0-9]*$/.test(end)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${start}:${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartTypeIcon(type: SheetChartType) {
|
||||||
|
if (type === "line") {
|
||||||
|
return LineChart;
|
||||||
|
}
|
||||||
|
if (type === "pie") {
|
||||||
|
return PieChart;
|
||||||
|
}
|
||||||
|
return BarChart3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartTypeLabel(type: SheetChartType) {
|
||||||
|
return chartTypeOptions.find((option) => option.value === type)?.label ?? "Столбчатая";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QExcell({ workbook, onChange }: QExcellProps) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [selectedCell, setSelectedCell] = useState("A1");
|
||||||
|
const [selectedChartId, setSelectedChartId] = useState("");
|
||||||
|
const [chartTitle, setChartTitle] = useState("Итого по продуктам");
|
||||||
|
const [chartType, setChartType] = useState<SheetChartType>("bar");
|
||||||
|
const [chartLabelRange, setChartLabelRange] = useState("A2:A4");
|
||||||
|
const [chartValueRange, setChartValueRange] = useState("E2:E4");
|
||||||
|
const activeSheet = workbook.sheets.find((sheet) => sheet.id === workbook.activeSheet) ?? workbook.sheets[0] ?? { id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [] };
|
||||||
|
const activeMergedCells = activeSheet.mergedCells ?? [];
|
||||||
|
const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks;
|
||||||
|
const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats;
|
||||||
|
const activeCharts = activeSheet.charts ?? emptyCharts;
|
||||||
|
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
|
||||||
|
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
|
||||||
|
const selectedCellFormat = activeCellFormats[selectedCell] ?? {};
|
||||||
|
const selectedFormattedValue = formatCellValueForDisplay(evaluateCell(selectedCell, activeSheet.cells), selectedCellFormat);
|
||||||
|
const boundsCells = useMemo(
|
||||||
|
() =>
|
||||||
|
[...Object.keys(activeHyperlinks), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>(
|
||||||
|
(cells, address) => ({ ...cells, [address]: cells[address] || activeHyperlinks[address] || "format" }),
|
||||||
|
{ ...activeSheet.cells }
|
||||||
|
),
|
||||||
|
[activeCellFormats, activeHyperlinks, activeSheet.cells]
|
||||||
|
);
|
||||||
|
const sheetBounds = useMemo(() => usedSheetBounds(boundsCells, activeMergedCells), [activeMergedCells, boundsCells]);
|
||||||
|
const rows = useMemo(() => Array.from({ length: sheetBounds.rowCount + extraEditableRows }, (_, index) => index), [sheetBounds.rowCount]);
|
||||||
|
const visibleColumnLabels = useMemo(
|
||||||
|
() => columnLabelsForCount(sheetBounds.columnCount + extraEditableColumns),
|
||||||
|
[sheetBounds.columnCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
return summarizeColumn(activeSheet.cells, 4);
|
||||||
|
}, [activeSheet.cells]);
|
||||||
|
|
||||||
|
function updateCell(id: string, value: string) {
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) =>
|
||||||
|
sheet.id === activeSheet.id ? { ...sheet, cells: { ...sheet.cells, [id]: value } } : sheet
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHyperlink(id: string, href: string) {
|
||||||
|
const normalizedId = id.toUpperCase();
|
||||||
|
const nextHyperlinks = { ...activeHyperlinks };
|
||||||
|
if (href) {
|
||||||
|
nextHyperlinks[normalizedId] = href;
|
||||||
|
} else {
|
||||||
|
delete nextHyperlinks[normalizedId];
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, hyperlinks: nextHyperlinks } : sheet))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCellFormat(id: string, formatPatch: CellFormat) {
|
||||||
|
const normalizedId = id.toUpperCase();
|
||||||
|
const nextFormats = { ...activeCellFormats };
|
||||||
|
const nextFormat = normalizedCellFormat({ ...(nextFormats[normalizedId] ?? {}), ...formatPatch });
|
||||||
|
if (nextFormat) {
|
||||||
|
nextFormats[normalizedId] = nextFormat;
|
||||||
|
} else {
|
||||||
|
delete nextFormats[normalizedId];
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, cellFormats: nextFormats } : sheet))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectedFormat(key: "bold" | "italics" | "underline") {
|
||||||
|
updateCellFormat(selectedCell, { [key]: !selectedCellFormat[key] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertLink() {
|
||||||
|
const href = normalizedLinkInput(window.prompt("Введите адрес ссылки", selectedHyperlink));
|
||||||
|
if (!href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCells = selectedRawValue ? activeSheet.cells : { ...activeSheet.cells, [selectedCell]: href };
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) =>
|
||||||
|
sheet.id === activeSheet.id
|
||||||
|
? {
|
||||||
|
...sheet,
|
||||||
|
cells: nextCells,
|
||||||
|
hyperlinks: { ...activeHyperlinks, [selectedCell]: href }
|
||||||
|
}
|
||||||
|
: sheet
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedCell() {
|
||||||
|
const nextCells = { ...activeSheet.cells };
|
||||||
|
const nextHyperlinks = { ...activeHyperlinks };
|
||||||
|
const nextFormats = { ...activeCellFormats };
|
||||||
|
delete nextCells[selectedCell];
|
||||||
|
delete nextHyperlinks[selectedCell];
|
||||||
|
delete nextFormats[selectedCell];
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) =>
|
||||||
|
sheet.id === activeSheet.id ? { ...sheet, cells: nextCells, hyperlinks: nextHyperlinks, cellFormats: nextFormats } : sheet
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveSheetName(name: string) {
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, name } : sheet))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChart(chart: SheetChart) {
|
||||||
|
setSelectedChartId(chart.id);
|
||||||
|
setChartTitle(chart.title);
|
||||||
|
setChartType(chart.type);
|
||||||
|
setChartLabelRange(chart.labelRange);
|
||||||
|
setChartValueRange(chart.valueRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetChartForm() {
|
||||||
|
setSelectedChartId("");
|
||||||
|
setChartTitle("Итого по продуктам");
|
||||||
|
setChartType("bar");
|
||||||
|
setChartLabelRange("A2:A4");
|
||||||
|
setChartValueRange("E2:E4");
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertChart() {
|
||||||
|
const labelRange = normalizedChartRange(chartLabelRange);
|
||||||
|
const valueRange = normalizedChartRange(chartValueRange);
|
||||||
|
if (!labelRange || !valueRange) {
|
||||||
|
window.alert("Введите диапазоны диаграммы в формате A2:A4.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextChart: SheetChart = {
|
||||||
|
id: selectedChartId || `chart-${Date.now()}`,
|
||||||
|
type: chartType,
|
||||||
|
title: chartTitle.trim() || chartTypeLabel(chartType),
|
||||||
|
labelRange,
|
||||||
|
valueRange
|
||||||
|
};
|
||||||
|
const nextCharts = selectedChartId
|
||||||
|
? activeCharts.map((chart) => (chart.id === selectedChartId ? nextChart : chart))
|
||||||
|
: [...activeCharts, nextChart];
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, charts: nextCharts } : sheet))
|
||||||
|
});
|
||||||
|
setSelectedChartId(nextChart.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteChart(id: string) {
|
||||||
|
const nextCharts = activeCharts.filter((chart) => chart.id !== id);
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, charts: nextCharts } : sheet))
|
||||||
|
});
|
||||||
|
if (selectedChartId === id) {
|
||||||
|
resetChartForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
const exportBounds = usedSheetBounds(activeSheet.cells, activeMergedCells);
|
||||||
|
const exportRows = Array.from({ length: exportBounds.rowCount }, (_, index) => index);
|
||||||
|
const exportColumnLabels = columnLabelsForCount(exportBounds.columnCount);
|
||||||
|
const csv = exportRows
|
||||||
|
.map((row) =>
|
||||||
|
exportColumnLabels
|
||||||
|
.map((_, column) => {
|
||||||
|
const value = activeSheet.cells[cellId(column, row)] ?? "";
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
})
|
||||||
|
.join(",")
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
downloadTextFile(workbook.title.replace(/\.qxlsx$/i, ".csv"), csv, "text/csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openXlsx(file: File | undefined) {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imported = await importXlsxFile(file);
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
title: imported.title,
|
||||||
|
activeSheet: imported.sheets[0]?.id ?? "sheet-1",
|
||||||
|
sheets: imported.sheets.length > 0 ? imported.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [] }]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось открыть XLSX.");
|
||||||
|
} finally {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveXlsx() {
|
||||||
|
try {
|
||||||
|
const fileName = replaceFileExtension(workbook.title, ".xlsx");
|
||||||
|
const blob = await exportXlsxBlob(workbook);
|
||||||
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
|
await saveDesktopOfficeFile("xlsx", fileName, blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadBlobFile(fileName, blob);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось сохранить XLSX.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseXlsx() {
|
||||||
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
|
const file = await openDesktopOfficeFile("xlsx");
|
||||||
|
await openXlsx(file ?? undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPdf() {
|
||||||
|
try {
|
||||||
|
await exportPdfOrPrint(workbook.title);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useQOfficeCommand({
|
||||||
|
open: chooseXlsx,
|
||||||
|
save: saveXlsx,
|
||||||
|
"export-pdf": exportPdf,
|
||||||
|
print: () => window.print()
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-layout sheet-layout">
|
||||||
|
<section className="spreadsheet-stage" aria-label="Редактор QExcell">
|
||||||
|
<div className="module-toolbar">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden-file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
onChange={(event) => void openXlsx(event.target.files?.[0])}
|
||||||
|
aria-label="Открыть XLSX"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="file-title-input"
|
||||||
|
value={workbook.title}
|
||||||
|
aria-label="Название книги"
|
||||||
|
onChange={(event) => onChange({ ...workbook, title: event.target.value })}
|
||||||
|
/>
|
||||||
|
<button className="compact-command" type="button" onClick={() => updateCell(selectedCell, "=SUM(B2:D2)")}>
|
||||||
|
<Sigma size={16} />
|
||||||
|
Сумма
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={() => updateCell(selectedCell, "=AVG(B2:D2)")}>
|
||||||
|
<FunctionSquare size={16} />
|
||||||
|
Среднее
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={clearSelectedCell}>
|
||||||
|
<Eraser size={16} />
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={insertLink}>
|
||||||
|
<Link size={16} />
|
||||||
|
Ссылка
|
||||||
|
</button>
|
||||||
|
<div className="cell-format-tools" aria-label="Формат ячейки">
|
||||||
|
<button
|
||||||
|
className={`icon-button format-toggle ${selectedCellFormat.bold ? "is-active" : ""}`.trim()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSelectedFormat("bold")}
|
||||||
|
title="Полужирный"
|
||||||
|
aria-label="Полужирный"
|
||||||
|
>
|
||||||
|
<Bold size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`icon-button format-toggle ${selectedCellFormat.italics ? "is-active" : ""}`.trim()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSelectedFormat("italics")}
|
||||||
|
title="Курсив"
|
||||||
|
aria-label="Курсив"
|
||||||
|
>
|
||||||
|
<Italic size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`icon-button format-toggle ${selectedCellFormat.underline ? "is-active" : ""}`.trim()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSelectedFormat("underline")}
|
||||||
|
title="Подчеркнуть"
|
||||||
|
aria-label="Подчеркнуть"
|
||||||
|
>
|
||||||
|
<Underline size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="fill-swatches" aria-label="Заливка">
|
||||||
|
<PaintBucket size={16} aria-hidden="true" />
|
||||||
|
{fillSwatches.map((swatch) => (
|
||||||
|
<button
|
||||||
|
key={swatch.label}
|
||||||
|
className={`fill-swatch ${swatch.color ? "" : "is-empty"} ${selectedCellFormat.fillColor === swatch.color || (!selectedCellFormat.fillColor && !swatch.color) ? "is-active" : ""}`.trim()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateCellFormat(selectedCell, { fillColor: swatch.color })}
|
||||||
|
title={swatch.label}
|
||||||
|
aria-label={swatch.label}
|
||||||
|
style={swatch.color ? { backgroundColor: `#${swatch.color}` } : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="compact-command" type="button" onClick={() => void chooseXlsx()}>
|
||||||
|
<FileUp size={16} />
|
||||||
|
Открыть XLSX
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={() => void saveXlsx()}>
|
||||||
|
<Save size={16} />
|
||||||
|
Сохранить XLSX
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={exportCsv}>
|
||||||
|
<Download size={16} />
|
||||||
|
Экспорт CSV
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={() => void exportPdf()}>
|
||||||
|
<FileText size={16} />
|
||||||
|
Экспорт PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="formula-bar">
|
||||||
|
<span>{selectedCell}</span>
|
||||||
|
<input
|
||||||
|
value={selectedRawValue}
|
||||||
|
aria-label="Строка формул"
|
||||||
|
onChange={(event) => updateCell(selectedCell, event.target.value)}
|
||||||
|
placeholder="Введите значение или формулу, например =SUM(B2:D2)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-wrap">
|
||||||
|
<table className="sheet-grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th aria-label="Номера строк" />
|
||||||
|
{visibleColumnLabels.map((column) => (
|
||||||
|
<th key={column}>{column}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row}>
|
||||||
|
<th>{row + 1}</th>
|
||||||
|
{visibleColumnLabels.map((_, column) => {
|
||||||
|
const id = cellId(column, row);
|
||||||
|
const merge = mergedCellInfo(id, activeMergedCells);
|
||||||
|
if (merge && !merge.isOrigin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = activeSheet.cells[id] ?? "";
|
||||||
|
const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw;
|
||||||
|
const hyperlink = activeHyperlinks[id] ?? "";
|
||||||
|
const cellFormat = activeCellFormats[id];
|
||||||
|
const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={id}
|
||||||
|
className={`${selectedCell === id ? "is-selected" : ""} ${merge ? "is-merged" : ""} ${hyperlink ? "is-linked" : ""} ${cellFormat ? "is-formatted" : ""}`.trim()}
|
||||||
|
colSpan={merge?.columnSpan}
|
||||||
|
rowSpan={merge?.rowSpan}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={selectedCell === id ? raw : display}
|
||||||
|
aria-label={`Ячейка ${id}`}
|
||||||
|
title={hyperlink || undefined}
|
||||||
|
style={cellInputStyle(cellFormat)}
|
||||||
|
onFocus={() => setSelectedCell(id)}
|
||||||
|
onChange={(event) => updateCell(id, event.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeCharts.length > 0 ? (
|
||||||
|
<div className="chart-board" aria-label="Диаграммы листа">
|
||||||
|
{activeCharts.map((chart) => (
|
||||||
|
<SheetChartPreview
|
||||||
|
key={chart.id}
|
||||||
|
chart={chart}
|
||||||
|
cells={activeSheet.cells}
|
||||||
|
selected={chart.id === selectedChartId}
|
||||||
|
onSelect={() => selectChart(chart)}
|
||||||
|
onDelete={() => deleteChart(chart.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="inspector" aria-label="Сводка книги">
|
||||||
|
<section>
|
||||||
|
<h3>Книга</h3>
|
||||||
|
<label>
|
||||||
|
Лист
|
||||||
|
<select value={workbook.activeSheet} onChange={(event) => onChange({ ...workbook, activeSheet: event.target.value })}>
|
||||||
|
{workbook.sheets.map((sheet) => (
|
||||||
|
<option key={sheet.id} value={sheet.id}>
|
||||||
|
{sheet.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="wide-command"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const id = `sheet-${workbook.sheets.length + 1}`;
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
activeSheet: id,
|
||||||
|
sheets: [...workbook.sheets, { id, name: `Лист ${workbook.sheets.length + 1}`, cells: {}, mergedCells: [] }]
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Новый лист
|
||||||
|
</button>
|
||||||
|
<label>
|
||||||
|
Имя листа
|
||||||
|
<input value={activeSheet.name} onChange={(event) => updateActiveSheetName(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Выделение</h3>
|
||||||
|
<p className="stat-line">{selectedCell}</p>
|
||||||
|
<p className="muted">Значение: {selectedRawValue || "пусто"}</p>
|
||||||
|
<p className="muted">Расчет: {selectedFormattedValue || "пусто"}</p>
|
||||||
|
<label>
|
||||||
|
Формат числа
|
||||||
|
<select value={selectedCellFormat.numberFormat ?? ""} onChange={(event) => updateCellFormat(selectedCell, { numberFormat: event.target.value })}>
|
||||||
|
{numberFormatOptions.map((option) => (
|
||||||
|
<option key={option.label} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ссылка
|
||||||
|
<input
|
||||||
|
value={selectedHyperlink}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
onChange={(event) => updateHyperlink(selectedCell, event.target.value)}
|
||||||
|
onBlur={(event) => updateHyperlink(selectedCell, normalizedLinkInput(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="wide-command" type="button" onClick={insertLink}>
|
||||||
|
<Link size={16} />
|
||||||
|
Вставить ссылку
|
||||||
|
</button>
|
||||||
|
<p className="muted">Объединённых диапазонов: {activeMergedCells.length}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Диаграммы</h3>
|
||||||
|
<label>
|
||||||
|
Заголовок
|
||||||
|
<input value={chartTitle} onChange={(event) => setChartTitle(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Тип
|
||||||
|
<select value={chartType} onChange={(event) => setChartType(event.target.value as SheetChartType)}>
|
||||||
|
{chartTypeOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Подписи
|
||||||
|
<input value={chartLabelRange} onChange={(event) => setChartLabelRange(event.target.value)} placeholder="A2:A4" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Значения
|
||||||
|
<input value={chartValueRange} onChange={(event) => setChartValueRange(event.target.value)} placeholder="E2:E4" />
|
||||||
|
</label>
|
||||||
|
<button className="wide-command" type="button" onClick={upsertChart}>
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
{selectedChartId ? "Сохранить диаграмму" : "Добавить диаграмму"}
|
||||||
|
</button>
|
||||||
|
{selectedChartId ? (
|
||||||
|
<button className="wide-command" type="button" onClick={resetChartForm}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Новая диаграмма
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<p className="muted">Диаграмм на листе: {activeCharts.length}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Сводка</h3>
|
||||||
|
<p className="big-number">{totals.toLocaleString("ru-RU")}</p>
|
||||||
|
<p className="muted">Сумма числовых значений в столбце E.</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetChartPreview({
|
||||||
|
chart,
|
||||||
|
cells,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onDelete
|
||||||
|
}: {
|
||||||
|
chart: SheetChart;
|
||||||
|
cells: Record<string, string>;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = chartTypeIcon(chart.type);
|
||||||
|
const points = chartDataPoints(chart, cells);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`sheet-chart ${selected ? "is-selected" : ""}`.trim()} onClick={onSelect}>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<Icon size={18} />
|
||||||
|
<strong>{chart.title}</strong>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
type="button"
|
||||||
|
title="Удалить диаграмму"
|
||||||
|
aria-label={`Удалить диаграмму ${chart.title}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<ChartSvg type={chart.type} points={points} />
|
||||||
|
<p className="muted">
|
||||||
|
{chartTypeLabel(chart.type)} · {chart.labelRange} / {chart.valueRange}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartSvg({ type, points }: { type: SheetChartType; points: Array<{ label: string; value: number }> }) {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return (
|
||||||
|
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Пустая диаграмма">
|
||||||
|
<text x="180" y="95" textAnchor="middle">
|
||||||
|
Нет данных
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "pie") {
|
||||||
|
return <PieChartSvg points={points} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "line") {
|
||||||
|
return <LineChartSvg points={points} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BarChartSvg points={points} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarChartSvg({ points }: { points: Array<{ label: string; value: number }> }) {
|
||||||
|
const max = Math.max(1, ...points.map((point) => Math.abs(point.value)));
|
||||||
|
const width = 320 / points.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Столбчатая диаграмма">
|
||||||
|
<line x1="28" y1="142" x2="346" y2="142" />
|
||||||
|
{points.map((point, index) => {
|
||||||
|
const barHeight = Math.round((Math.abs(point.value) / max) * 112);
|
||||||
|
const x = 32 + index * width;
|
||||||
|
return (
|
||||||
|
<g key={`${point.label}-${index}`}>
|
||||||
|
<rect x={x + 8} y={142 - barHeight} width={Math.max(16, width - 18)} height={barHeight} rx="4" />
|
||||||
|
<text x={x + width / 2} y="162" textAnchor="middle">
|
||||||
|
{point.label.slice(0, 10)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineChartSvg({ points }: { points: Array<{ label: string; value: number }> }) {
|
||||||
|
const max = Math.max(1, ...points.map((point) => Math.abs(point.value)));
|
||||||
|
const step = points.length > 1 ? 300 / (points.length - 1) : 0;
|
||||||
|
const coordinates = points.map((point, index) => {
|
||||||
|
const x = points.length > 1 ? 30 + index * step : 180;
|
||||||
|
const y = 142 - (Math.abs(point.value) / max) * 112;
|
||||||
|
return { x, y, label: point.label };
|
||||||
|
});
|
||||||
|
const path = coordinates.map((point) => `${point.x},${point.y}`).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Линейная диаграмма">
|
||||||
|
<line x1="28" y1="142" x2="346" y2="142" />
|
||||||
|
<polyline points={path} />
|
||||||
|
{coordinates.map((point, index) => (
|
||||||
|
<g key={`${point.label}-${index}`}>
|
||||||
|
<circle cx={point.x} cy={point.y} r="4" />
|
||||||
|
<text x={point.x} y="162" textAnchor="middle">
|
||||||
|
{point.label.slice(0, 10)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PieChartSvg({ points }: { points: Array<{ label: string; value: number }> }) {
|
||||||
|
const total = points.reduce((sum, point) => sum + Math.max(0, point.value), 0) || 1;
|
||||||
|
let startAngle = -90;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Круговая диаграмма">
|
||||||
|
{points.map((point, index) => {
|
||||||
|
const value = Math.max(0, point.value);
|
||||||
|
const angle = (value / total) * 360;
|
||||||
|
const path = pieSlicePath(95, 90, 58, startAngle, startAngle + angle);
|
||||||
|
startAngle += angle;
|
||||||
|
return <path key={`${point.label}-${index}`} d={path} className={`pie-slice slice-${index % 6}`} />;
|
||||||
|
})}
|
||||||
|
{points.slice(0, 5).map((point, index) => (
|
||||||
|
<g key={`${point.label}-${index}`} className="pie-legend">
|
||||||
|
<rect x="190" y={42 + index * 20} width="10" height="10" className={`pie-slice slice-${index % 6}`} />
|
||||||
|
<text x="208" y={52 + index * 20}>
|
||||||
|
{point.label.slice(0, 18)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pieSlicePath(cx: number, cy: number, radius: number, startAngle: number, endAngle: number) {
|
||||||
|
const start = polarToCartesian(cx, cy, radius, endAngle);
|
||||||
|
const end = polarToCartesian(cx, cy, radius, startAngle);
|
||||||
|
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
|
||||||
|
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polarToCartesian(cx: number, cy: number, radius: number, angle: number) {
|
||||||
|
const radians = (angle * Math.PI) / 180;
|
||||||
|
return {
|
||||||
|
x: cx + radius * Math.cos(radians),
|
||||||
|
y: cy + radius * Math.sin(radians)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { Archive, Download, FileText, FileUp, Flag, MailOpen, Paperclip, PenLine, Search, Send, X } from "lucide-react";
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import type { MailAttachment, MailMessage, QOfficeState } from "../../types";
|
||||||
|
import { downloadBlobFile } from "../../utils/download";
|
||||||
|
import { attachmentToBlob, createMailAttachmentFromFile, exportEmlBlob, importMailFile } from "../../io/outlookMail";
|
||||||
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
|
import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles";
|
||||||
|
import { exportPdfOrPrint } from "../../io/pdfExport";
|
||||||
|
import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
|
||||||
|
|
||||||
|
interface QOutlookProps {
|
||||||
|
mail: QOfficeState["mail"];
|
||||||
|
onChange: (updater: (mail: QOfficeState["mail"]) => QOfficeState["mail"]) => void;
|
||||||
|
onFolderChange: (folder: MailMessage["folder"]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders: Array<{ id: MailMessage["folder"]; label: string }> = [
|
||||||
|
{ id: "inbox", label: "Входящие" },
|
||||||
|
{ id: "sent", label: "Отправленные" },
|
||||||
|
{ id: "drafts", label: "Черновики" },
|
||||||
|
{ id: "archive", label: "Архив" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||||
|
return "0 Б";
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ["Б", "КБ", "МБ", "ГБ"];
|
||||||
|
let value = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const draftAttachmentInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [draft, setDraft] = useState<{ to: string; subject: string; body: string; attachments: MailAttachment[] }>({
|
||||||
|
to: "",
|
||||||
|
subject: "",
|
||||||
|
body: "",
|
||||||
|
attachments: []
|
||||||
|
});
|
||||||
|
const activeMessage = mail.messages.find((message) => message.id === mail.activeMessageId) ?? mail.messages[0];
|
||||||
|
|
||||||
|
const visibleMessages = useMemo(() => {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
return mail.messages.filter((message) => {
|
||||||
|
const inFolder = message.folder === mail.activeFolder;
|
||||||
|
const matchesQuery =
|
||||||
|
!normalizedQuery ||
|
||||||
|
[message.from, message.to, message.subject, message.body, ...(message.attachments ?? []).map((attachment) => attachment.fileName)].some((field) =>
|
||||||
|
field.toLowerCase().includes(normalizedQuery)
|
||||||
|
);
|
||||||
|
return inFolder && matchesQuery;
|
||||||
|
});
|
||||||
|
}, [mail.activeFolder, mail.messages, query]);
|
||||||
|
|
||||||
|
function updateMessage(id: string, patch: Partial<MailMessage>) {
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
messages: current.messages.map((message) => (message.id === id ? { ...message, ...patch } : message))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendDraft() {
|
||||||
|
if (!draft.to.trim() || !draft.subject.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `mail-${Date.now()}`;
|
||||||
|
const message: MailMessage = {
|
||||||
|
id,
|
||||||
|
folder: "sent",
|
||||||
|
from: "product@qoffice.local",
|
||||||
|
to: draft.to.trim(),
|
||||||
|
subject: draft.subject.trim(),
|
||||||
|
body: draft.body.trim(),
|
||||||
|
time: new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }),
|
||||||
|
read: true,
|
||||||
|
flagged: false,
|
||||||
|
attachments: draft.attachments
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
activeFolder: "sent",
|
||||||
|
activeMessageId: id,
|
||||||
|
messages: [message, ...current.messages]
|
||||||
|
}));
|
||||||
|
setDraft({ to: "", subject: "", body: "", attachments: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMailFile(file: File | undefined) {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imported = await importMailFile(file);
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
activeFolder: "inbox",
|
||||||
|
activeMessageId: imported.id,
|
||||||
|
messages: [imported, ...current.messages]
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось открыть письмо.");
|
||||||
|
} finally {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEml(message: MailMessage) {
|
||||||
|
try {
|
||||||
|
const blob = exportEmlBlob(message);
|
||||||
|
const fileName = replaceFileExtension(message.subject || "Письмо", ".eml");
|
||||||
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
|
await saveDesktopOfficeFile("eml", fileName, blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadBlobFile(fileName, blob);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось сохранить EML.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDraftAttachments(files: FileList | null) {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachments = await Promise.all(Array.from(files).map((file) => createMailAttachmentFromFile(file)));
|
||||||
|
setDraft((current) => ({ ...current, attachments: [...current.attachments, ...attachments] }));
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось добавить вложение.");
|
||||||
|
} finally {
|
||||||
|
if (draftAttachmentInputRef.current) {
|
||||||
|
draftAttachmentInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDraftAttachment(id: string) {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
attachments: current.attachments.filter((attachment) => attachment.id !== id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadAttachment(attachment: MailAttachment) {
|
||||||
|
downloadBlobFile(attachment.fileName, attachmentToBlob(attachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseMailFile() {
|
||||||
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
|
const file = await openDesktopOfficeFile("mail");
|
||||||
|
await openMailFile(file ?? undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPdf() {
|
||||||
|
try {
|
||||||
|
await exportPdfOrPrint(activeMessage?.subject || "Письмо QOutlook");
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useQOfficeCommand({
|
||||||
|
open: chooseMailFile,
|
||||||
|
save: () => {
|
||||||
|
if (activeMessage) {
|
||||||
|
void saveEml(activeMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"export-pdf": exportPdf,
|
||||||
|
print: () => window.print()
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeAttachments = activeMessage?.attachments ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mail-layout">
|
||||||
|
<aside className="mail-folders" aria-label="Папки почты">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden-file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".eml,.msg,message/rfc822,application/vnd.ms-outlook"
|
||||||
|
onChange={(event) => void openMailFile(event.target.files?.[0])}
|
||||||
|
aria-label="Открыть EML или MSG"
|
||||||
|
/>
|
||||||
|
<button className="wide-command compose-command" type="button">
|
||||||
|
<PenLine size={16} />
|
||||||
|
Написать
|
||||||
|
</button>
|
||||||
|
<button className="wide-command" type="button" onClick={() => void chooseMailFile()}>
|
||||||
|
<FileUp size={16} />
|
||||||
|
Открыть EML/MSG
|
||||||
|
</button>
|
||||||
|
{folders.map((folder) => {
|
||||||
|
const count = mail.messages.filter((message) => message.folder === folder.id).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={folder.id}
|
||||||
|
className={mail.activeFolder === folder.id ? "is-active" : ""}
|
||||||
|
onClick={() => onFolderChange(folder.id)}
|
||||||
|
>
|
||||||
|
<span>{folder.label}</span>
|
||||||
|
<span>{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="message-list-panel" aria-label="Список писем">
|
||||||
|
<label className="mail-search">
|
||||||
|
<Search size={16} />
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Поиск писем" />
|
||||||
|
</label>
|
||||||
|
<div className="message-list">
|
||||||
|
{visibleMessages.map((message) => {
|
||||||
|
const attachmentCount = message.attachments?.length ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={message.id}
|
||||||
|
className={`message-row ${message.id === activeMessage?.id ? "is-active" : ""} ${message.read ? "" : "is-unread"}`}
|
||||||
|
onClick={() => {
|
||||||
|
onChange((current) => ({ ...current, activeMessageId: message.id }));
|
||||||
|
updateMessage(message.id, { read: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{message.from}</strong>
|
||||||
|
<small>{message.time}</small>
|
||||||
|
</span>
|
||||||
|
<b>{message.subject}</b>
|
||||||
|
<em>{message.body}</em>
|
||||||
|
{attachmentCount > 0 ? (
|
||||||
|
<small className="message-attachment-count">
|
||||||
|
<Paperclip size={13} />
|
||||||
|
{attachmentCount}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visibleMessages.length === 0 ? <p className="empty-state">В этой папке нет писем.</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="message-reader" aria-label="Чтение письма">
|
||||||
|
{activeMessage ? (
|
||||||
|
<>
|
||||||
|
<div className="message-actions">
|
||||||
|
<button type="button" onClick={() => updateMessage(activeMessage.id, { read: !activeMessage.read })}>
|
||||||
|
<MailOpen size={16} />
|
||||||
|
{activeMessage.read ? "Отметить непрочитанным" : "Отметить прочитанным"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => updateMessage(activeMessage.id, { flagged: !activeMessage.flagged })}>
|
||||||
|
<Flag size={16} />
|
||||||
|
{activeMessage.flagged ? "Снять флаг" : "Поставить флаг"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => updateMessage(activeMessage.id, { folder: "archive" })}>
|
||||||
|
<Archive size={16} />
|
||||||
|
В архив
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void saveEml(activeMessage)}>
|
||||||
|
<Download size={16} />
|
||||||
|
Сохранить EML
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void exportPdf()}>
|
||||||
|
<FileText size={16} />
|
||||||
|
Экспорт PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<article className="message-content">
|
||||||
|
<h2>{activeMessage.subject}</h2>
|
||||||
|
<p className="muted">
|
||||||
|
От: {activeMessage.from} · Кому: {activeMessage.to} · {activeMessage.time}
|
||||||
|
</p>
|
||||||
|
<p>{activeMessage.body}</p>
|
||||||
|
{activeAttachments.length > 0 ? (
|
||||||
|
<section className="attachments-panel" aria-label="Вложения письма">
|
||||||
|
<h3>Вложения</h3>
|
||||||
|
<ul className="attachment-list">
|
||||||
|
{activeAttachments.map((attachment) => (
|
||||||
|
<li key={attachment.id} className="attachment-item">
|
||||||
|
<span className="attachment-main">
|
||||||
|
<Paperclip size={16} />
|
||||||
|
<span>
|
||||||
|
<strong>{attachment.fileName}</strong>
|
||||||
|
<small>
|
||||||
|
{attachment.contentType} · {formatBytes(attachment.size)}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => downloadAttachment(attachment)} aria-label={`Скачать ${attachment.fileName}`}>
|
||||||
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="empty-state">Выберите письмо.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="compose-panel"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
sendDraft();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>Новое письмо</h3>
|
||||||
|
<input value={draft.to} onChange={(event) => setDraft({ ...draft, to: event.target.value })} placeholder="Кому" aria-label="Кому" />
|
||||||
|
<input
|
||||||
|
value={draft.subject}
|
||||||
|
onChange={(event) => setDraft({ ...draft, subject: event.target.value })}
|
||||||
|
placeholder="Тема"
|
||||||
|
aria-label="Тема"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={draft.body}
|
||||||
|
onChange={(event) => setDraft({ ...draft, body: event.target.value })}
|
||||||
|
placeholder="Текст письма"
|
||||||
|
aria-label="Текст письма"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={draftAttachmentInputRef}
|
||||||
|
className="hidden-file-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => void addDraftAttachments(event.target.files)}
|
||||||
|
aria-label="Добавить вложения"
|
||||||
|
/>
|
||||||
|
<div className="compose-attachments">
|
||||||
|
<button type="button" onClick={() => draftAttachmentInputRef.current?.click()}>
|
||||||
|
<Paperclip size={16} />
|
||||||
|
Добавить файлы
|
||||||
|
</button>
|
||||||
|
{draft.attachments.length > 0 ? (
|
||||||
|
<ul className="attachment-list compact">
|
||||||
|
{draft.attachments.map((attachment) => (
|
||||||
|
<li key={attachment.id} className="attachment-item">
|
||||||
|
<span className="attachment-main">
|
||||||
|
<Paperclip size={16} />
|
||||||
|
<span>
|
||||||
|
<strong>{attachment.fileName}</strong>
|
||||||
|
<small>{formatBytes(attachment.size)}</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => removeDraftAttachment(attachment.id)} aria-label={`Удалить ${attachment.fileName}`}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button className="primary-command" type="submit" disabled={!draft.to.trim() || !draft.subject.trim()}>
|
||||||
|
<Send size={16} />
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Copy, Download, FileText, FileUp, Image as ImageIcon, Palette, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import type { Slide, SlideDeck, SlideImage } from "../../types";
|
||||||
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
|
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
|
||||||
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
|
import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles";
|
||||||
|
import { exportPdfOrPrint } from "../../io/pdfExport";
|
||||||
|
import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
|
||||||
|
|
||||||
|
interface QPowerPointProps {
|
||||||
|
deck: SlideDeck;
|
||||||
|
onChange: (deck: SlideDeck) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeLabels: Record<Slide["theme"], string> = {
|
||||||
|
classic: "Классическая",
|
||||||
|
ocean: "Океан",
|
||||||
|
graphite: "Графит"
|
||||||
|
};
|
||||||
|
|
||||||
|
const slideWidthInches = 13.333;
|
||||||
|
const slideHeightInches = 7.5;
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
|
||||||
|
reader.addEventListener("error", () => reject(new Error("Не удалось прочитать изображение.")));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImageSize(src: string) {
|
||||||
|
return new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||||||
|
const image = new window.Image();
|
||||||
|
image.addEventListener("load", () => resolve({ width: image.naturalWidth || 1280, height: image.naturalHeight || 720 }), { once: true });
|
||||||
|
image.addEventListener("error", () => reject(new Error("Не удалось загрузить изображение.")), { once: true });
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaledSlideImageSize(size: { width: number; height: number }) {
|
||||||
|
const aspectRatio = size.width > 0 && size.height > 0 ? size.width / size.height : 16 / 9;
|
||||||
|
let width = 4.1;
|
||||||
|
let height = width / aspectRatio;
|
||||||
|
|
||||||
|
if (height > 2.6) {
|
||||||
|
height = 2.6;
|
||||||
|
width = height * aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: roundSlideNumber(width),
|
||||||
|
height: roundSlideNumber(height)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundSlideNumber(value: number) {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideImageStyle(image: SlideImage) {
|
||||||
|
return {
|
||||||
|
left: `${(image.x / slideWidthInches) * 100}%`,
|
||||||
|
top: `${(image.y / slideHeightInches) * 100}%`,
|
||||||
|
width: `${(image.width / slideWidthInches) * 100}%`,
|
||||||
|
height: `${(image.height / slideHeightInches) * 100}%`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clonedSlideImages(images?: SlideImage[]) {
|
||||||
|
const suffix = Date.now();
|
||||||
|
return images?.map((image, index) => ({ ...image, id: `${image.id}-copy-${suffix}-${index}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [selectedImageId, setSelectedImageId] = useState("");
|
||||||
|
const activeSlide = deck.slides.find((slide) => slide.id === deck.activeSlideId) ?? deck.slides[0];
|
||||||
|
const activeImages = activeSlide.images ?? [];
|
||||||
|
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
|
||||||
|
|
||||||
|
function updateSlide(nextSlide: Slide) {
|
||||||
|
onChange({
|
||||||
|
...deck,
|
||||||
|
slides: deck.slides.map((slide) => (slide.id === nextSlide.id ? nextSlide : slide))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImages(images: SlideImage[]) {
|
||||||
|
updateSlide({ ...activeSlide, images });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedImage(patch: Partial<SlideImage>) {
|
||||||
|
if (!selectedImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImages(activeImages.map((image) => (image.id === selectedImage.id ? { ...image, ...patch } : image)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedImageNumber(key: "x" | "y" | "width" | "height", value: string, max: number) {
|
||||||
|
const number = Number.parseFloat(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectedImage({ [key]: roundSlideNumber(Math.min(Math.max(number, 0), max)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelectedImage() {
|
||||||
|
if (!selectedImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImages(activeImages.filter((image) => image.id !== selectedImage.id));
|
||||||
|
setSelectedImageId("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSlide() {
|
||||||
|
const id = `slide-${Date.now()}`;
|
||||||
|
onChange({
|
||||||
|
...deck,
|
||||||
|
activeSlideId: id,
|
||||||
|
slides: [
|
||||||
|
...deck.slides,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
title: "Новый слайд",
|
||||||
|
subtitle: "Добавьте основной тезис презентации.",
|
||||||
|
notes: "",
|
||||||
|
theme: "classic",
|
||||||
|
images: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateSlide() {
|
||||||
|
const id = `slide-${Date.now()}`;
|
||||||
|
onChange({
|
||||||
|
...deck,
|
||||||
|
activeSlideId: id,
|
||||||
|
slides: [...deck.slides, { ...activeSlide, id, title: `${activeSlide.title} — копия`, images: clonedSlideImages(activeSlide.images) }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSlide() {
|
||||||
|
if (deck.slides.length === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSlides = deck.slides.filter((slide) => slide.id !== activeSlide.id);
|
||||||
|
onChange({ ...deck, slides: nextSlides, activeSlideId: nextSlides[0].id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPptx(file: File | undefined) {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imported = await importPptxFile(file);
|
||||||
|
onChange({
|
||||||
|
...deck,
|
||||||
|
title: imported.title,
|
||||||
|
activeSlideId: imported.slides[0]?.id ?? "slide-1",
|
||||||
|
slides: imported.slides.length > 0 ? imported.slides : deck.slides
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось открыть PPTX.");
|
||||||
|
} finally {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertImage(file: File | undefined) {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const src = await readFileAsDataUrl(file);
|
||||||
|
const size = scaledSlideImageSize(await readImageSize(src));
|
||||||
|
const image: SlideImage = {
|
||||||
|
id: `slide-image-${Date.now()}`,
|
||||||
|
src,
|
||||||
|
alt: file.name,
|
||||||
|
x: roundSlideNumber(Math.max(0.4, slideWidthInches - size.width - 0.9)),
|
||||||
|
y: roundSlideNumber(Math.max(1.4, slideHeightInches - size.height - 0.8)),
|
||||||
|
...size
|
||||||
|
};
|
||||||
|
|
||||||
|
updateImages([...activeImages, image]);
|
||||||
|
setSelectedImageId(image.id);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось вставить изображение.");
|
||||||
|
} finally {
|
||||||
|
if (imageInputRef.current) {
|
||||||
|
imageInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePptx() {
|
||||||
|
try {
|
||||||
|
const fileName = replaceFileExtension(deck.title, ".pptx");
|
||||||
|
const blob = await exportPptxBlob(deck);
|
||||||
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
|
await saveDesktopOfficeFile("pptx", fileName, blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadBlobFile(fileName, blob);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось сохранить PPTX.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choosePptx() {
|
||||||
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
|
const file = await openDesktopOfficeFile("pptx");
|
||||||
|
await openPptx(file ?? undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPdf() {
|
||||||
|
try {
|
||||||
|
await exportPdfOrPrint(deck.title);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useQOfficeCommand({
|
||||||
|
open: choosePptx,
|
||||||
|
save: savePptx,
|
||||||
|
"export-pdf": exportPdf,
|
||||||
|
print: () => window.print()
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-layout slides-layout">
|
||||||
|
<aside className="slide-strip" aria-label="Слайды">
|
||||||
|
<button className="wide-command" type="button" onClick={addSlide}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Слайд
|
||||||
|
</button>
|
||||||
|
{deck.slides.map((slide, index) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={slide.id}
|
||||||
|
className={`slide-thumb theme-${slide.theme} ${slide.id === activeSlide.id ? "is-active" : ""}`}
|
||||||
|
onClick={() => onChange({ ...deck, activeSlideId: slide.id })}
|
||||||
|
>
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
<strong>{slide.title}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="slide-stage" aria-label="Редактор QPowerPoint">
|
||||||
|
<div className="module-toolbar">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden-file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
onChange={(event) => void openPptx(event.target.files?.[0])}
|
||||||
|
aria-label="Открыть PPTX"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
className="hidden-file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/bmp,image/svg+xml"
|
||||||
|
onChange={(event) => void insertImage(event.target.files?.[0])}
|
||||||
|
aria-label="Вставить изображение"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="file-title-input"
|
||||||
|
value={deck.title}
|
||||||
|
aria-label="Название презентации"
|
||||||
|
onChange={(event) => onChange({ ...deck, title: event.target.value })}
|
||||||
|
/>
|
||||||
|
<button className="compact-command" type="button" onClick={duplicateSlide}>
|
||||||
|
<Copy size={16} />
|
||||||
|
Дублировать
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={deleteSlide} disabled={deck.slides.length === 1}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={() => imageInputRef.current?.click()}>
|
||||||
|
<ImageIcon size={16} />
|
||||||
|
Изображение
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={() => void choosePptx()}>
|
||||||
|
<FileUp size={16} />
|
||||||
|
Открыть PPTX
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={() => void savePptx()}>
|
||||||
|
<Save size={16} />
|
||||||
|
Сохранить PPTX
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="compact-command"
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadTextFile(deck.title.replace(/\.qpptx$/i, ".json"), JSON.stringify(deck, null, 2), "application/json")}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
Экспорт JSON
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={() => void exportPdf()}>
|
||||||
|
<FileText size={16} />
|
||||||
|
Экспорт PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`slide-canvas theme-${activeSlide.theme}`}>
|
||||||
|
{activeImages.map((image) => (
|
||||||
|
<button
|
||||||
|
key={image.id}
|
||||||
|
type="button"
|
||||||
|
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
|
||||||
|
style={slideImageStyle(image)}
|
||||||
|
onClick={() => setSelectedImageId(image.id)}
|
||||||
|
title={image.alt || "Изображение"}
|
||||||
|
aria-label={image.alt || "Изображение слайда"}
|
||||||
|
>
|
||||||
|
<img src={image.src} alt={image.alt || ""} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
className="slide-title-input"
|
||||||
|
value={activeSlide.title}
|
||||||
|
aria-label="Заголовок слайда"
|
||||||
|
onChange={(event) => updateSlide({ ...activeSlide, title: event.target.value })}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="slide-subtitle-input"
|
||||||
|
value={activeSlide.subtitle}
|
||||||
|
aria-label="Подзаголовок слайда"
|
||||||
|
onChange={(event) => updateSlide({ ...activeSlide, subtitle: event.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="slide-accent-line" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="inspector" aria-label="Параметры слайда">
|
||||||
|
<section>
|
||||||
|
<h3>Оформление</h3>
|
||||||
|
<label>
|
||||||
|
<span>
|
||||||
|
<Palette size={16} />
|
||||||
|
Тема
|
||||||
|
</span>
|
||||||
|
<select value={activeSlide.theme} onChange={(event) => updateSlide({ ...activeSlide, theme: event.target.value as Slide["theme"] })}>
|
||||||
|
{Object.entries(themeLabels).map(([theme, label]) => (
|
||||||
|
<option key={theme} value={theme}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Изображения</h3>
|
||||||
|
<div className="slide-image-list">
|
||||||
|
{activeImages.map((image, index) => (
|
||||||
|
<button
|
||||||
|
key={image.id}
|
||||||
|
type="button"
|
||||||
|
className={image.id === selectedImageId ? "is-active" : ""}
|
||||||
|
onClick={() => setSelectedImageId(image.id)}
|
||||||
|
>
|
||||||
|
{image.alt || `Изображение ${index + 1}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedImage ? (
|
||||||
|
<>
|
||||||
|
<div className="image-geometry-grid">
|
||||||
|
<label>
|
||||||
|
X
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={slideWidthInches}
|
||||||
|
step="0.1"
|
||||||
|
value={selectedImage.x}
|
||||||
|
onChange={(event) => updateSelectedImageNumber("x", event.target.value, slideWidthInches)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Y
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={slideHeightInches}
|
||||||
|
step="0.1"
|
||||||
|
value={selectedImage.y}
|
||||||
|
onChange={(event) => updateSelectedImageNumber("y", event.target.value, slideHeightInches)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ширина
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max={slideWidthInches}
|
||||||
|
step="0.1"
|
||||||
|
value={selectedImage.width}
|
||||||
|
onChange={(event) => updateSelectedImageNumber("width", event.target.value, slideWidthInches)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Высота
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max={slideHeightInches}
|
||||||
|
step="0.1"
|
||||||
|
value={selectedImage.height}
|
||||||
|
onChange={(event) => updateSelectedImageNumber("height", event.target.value, slideHeightInches)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button className="compact-command danger-command" type="button" onClick={deleteSelectedImage}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Удалить изображение
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Заметки докладчика</h3>
|
||||||
|
<textarea
|
||||||
|
className="notes-field"
|
||||||
|
value={activeSlide.notes}
|
||||||
|
aria-label="Заметки докладчика"
|
||||||
|
onChange={(event) => updateSlide({ ...activeSlide, notes: event.target.value })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import {
|
||||||
|
AlignCenter,
|
||||||
|
AlignLeft,
|
||||||
|
AlignRight,
|
||||||
|
Bold,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
FileUp,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Italic,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
PaintBucket,
|
||||||
|
Pilcrow,
|
||||||
|
Save,
|
||||||
|
Type,
|
||||||
|
Underline
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { WordDocument } from "../../types";
|
||||||
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
|
import { exportDocxBlob, importDocxFile } from "../../io/wordOffice";
|
||||||
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
|
import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles";
|
||||||
|
import { exportPdfOrPrint } from "../../io/pdfExport";
|
||||||
|
import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
|
||||||
|
|
||||||
|
interface QWordProps {
|
||||||
|
document: WordDocument;
|
||||||
|
onChange: (document: WordDocument) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countWords(html: string) {
|
||||||
|
const text = html.replace(/<[^>]+>/g, " ").replace(/ /g, " ").trim();
|
||||||
|
return text ? text.split(/\s+/).length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedLinkInput(value: string | null) {
|
||||||
|
const raw = value?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
|
||||||
|
try {
|
||||||
|
const url = new URL(candidate);
|
||||||
|
return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(url.protocol.toLowerCase()) ? url.href : "";
|
||||||
|
} catch {
|
||||||
|
window.alert("Введите корректный адрес ссылки.");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
|
||||||
|
reader.addEventListener("error", () => reject(new Error("Не удалось прочитать изображение.")));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImageSize(src: string) {
|
||||||
|
return new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||||||
|
const image = new window.Image();
|
||||||
|
image.addEventListener("load", () => resolve({ width: image.naturalWidth || 520, height: image.naturalHeight || 260 }), { once: true });
|
||||||
|
image.addEventListener("error", () => reject(new Error("Не удалось загрузить изображение.")), { once: true });
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaledImageSize(size: { width: number; height: number }, maxWidth = 680) {
|
||||||
|
const scale = size.width > maxWidth ? maxWidth / size.width : 1;
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(size.width * scale)),
|
||||||
|
height: Math.max(1, Math.round(size.height * scale))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const textColorSwatches = [
|
||||||
|
{ color: "#202124", label: "Черный текст" },
|
||||||
|
{ color: "#0f5fae", label: "Синий текст" },
|
||||||
|
{ color: "#0f766e", label: "Зеленый текст" },
|
||||||
|
{ color: "#c2410c", label: "Красный текст" },
|
||||||
|
{ color: "#7c3aed", label: "Фиолетовый текст" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const highlightSwatches = [
|
||||||
|
{ color: "#fff2cc", label: "Желтая подсветка" },
|
||||||
|
{ color: "#d9ead3", label: "Зеленая подсветка" },
|
||||||
|
{ color: "#ddebf7", label: "Синяя подсветка" },
|
||||||
|
{ color: "#fce4d6", label: "Коралловая подсветка" },
|
||||||
|
{ color: "#ffffff", label: "Без подсветки" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function QWord({ document, onChange }: QWordProps) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
||||||
|
|
||||||
|
function runCommand(command: string, value?: string) {
|
||||||
|
editorRef.current?.focus();
|
||||||
|
globalThis.document.execCommand(command, false, value);
|
||||||
|
if (editorRef.current) {
|
||||||
|
onChange({ ...document, html: editorRef.current.innerHTML });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextColor(color: string) {
|
||||||
|
runCommand("foreColor", color);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHighlight(color: string) {
|
||||||
|
editorRef.current?.focus();
|
||||||
|
globalThis.document.execCommand("hiliteColor", false, color);
|
||||||
|
globalThis.document.execCommand("backColor", false, color);
|
||||||
|
updateHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertLink() {
|
||||||
|
const href = normalizedLinkInput(window.prompt("Введите адрес ссылки"));
|
||||||
|
if (!href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorRef.current?.focus();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||||
|
const anchor = globalThis.document.createElement("a");
|
||||||
|
anchor.href = href;
|
||||||
|
anchor.textContent = href;
|
||||||
|
const range = selection?.rangeCount ? selection.getRangeAt(0) : globalThis.document.createRange();
|
||||||
|
range.insertNode(anchor);
|
||||||
|
range.setStartAfter(anchor);
|
||||||
|
range.collapse(true);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
} else {
|
||||||
|
globalThis.document.execCommand("createLink", false, href);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertImage(file: File | undefined) {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataUrl = await readFileAsDataUrl(file);
|
||||||
|
const size = scaledImageSize(await readImageSize(dataUrl));
|
||||||
|
const paragraph = globalThis.document.createElement("p");
|
||||||
|
const image = globalThis.document.createElement("img");
|
||||||
|
image.src = dataUrl;
|
||||||
|
image.alt = file.name;
|
||||||
|
image.width = size.width;
|
||||||
|
image.height = size.height;
|
||||||
|
paragraph.append(image);
|
||||||
|
editorRef.current?.append(paragraph);
|
||||||
|
updateHtml();
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось вставить изображение.");
|
||||||
|
} finally {
|
||||||
|
if (imageInputRef.current) {
|
||||||
|
imageInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHtml() {
|
||||||
|
if (editorRef.current) {
|
||||||
|
onChange({ ...document, html: editorRef.current.innerHTML });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDocx(file: File | undefined) {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imported = await importDocxFile(file);
|
||||||
|
onChange({ ...document, title: imported.title, html: imported.html });
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось открыть DOCX.");
|
||||||
|
} finally {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDocx() {
|
||||||
|
try {
|
||||||
|
const fileName = replaceFileExtension(document.title, ".docx");
|
||||||
|
const blob = await exportDocxBlob(document.title, editorRef.current?.innerHTML ?? document.html);
|
||||||
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
|
await saveDesktopOfficeFile("docx", fileName, blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadBlobFile(fileName, blob);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось сохранить DOCX.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseDocx() {
|
||||||
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
|
const file = await openDesktopOfficeFile("docx");
|
||||||
|
await openDocx(file ?? undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPdf() {
|
||||||
|
try {
|
||||||
|
await exportPdfOrPrint(document.title);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useQOfficeCommand({
|
||||||
|
open: chooseDocx,
|
||||||
|
save: saveDocx,
|
||||||
|
"export-pdf": exportPdf,
|
||||||
|
print: () => window.print()
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-layout qword-layout">
|
||||||
|
<section className="document-stage" aria-label="Редактор QWord">
|
||||||
|
<div className="module-toolbar">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden-file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
onChange={(event) => void openDocx(event.target.files?.[0])}
|
||||||
|
aria-label="Открыть DOCX"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
className="hidden-file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/bmp"
|
||||||
|
onChange={(event) => void insertImage(event.target.files?.[0])}
|
||||||
|
aria-label="Вставить изображение"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="file-title-input"
|
||||||
|
value={document.title}
|
||||||
|
aria-label="Название документа"
|
||||||
|
onChange={(event) => onChange({ ...document, title: event.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="segmented-tools" aria-label="Форматирование текста">
|
||||||
|
<button type="button" onClick={() => runCommand("bold")} title="Полужирный">
|
||||||
|
<Bold size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => runCommand("italic")} title="Курсив">
|
||||||
|
<Italic size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => runCommand("underline")} title="Подчеркнутый">
|
||||||
|
<Underline size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => runCommand("insertUnorderedList")} title="Маркированный список">
|
||||||
|
<List size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={insertLink} title="Вставить ссылку">
|
||||||
|
<Link size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => imageInputRef.current?.click()} title="Вставить изображение">
|
||||||
|
<ImageIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => runCommand("justifyLeft")} title="По левому краю">
|
||||||
|
<AlignLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => runCommand("justifyCenter")} title="По центру">
|
||||||
|
<AlignCenter size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => runCommand("justifyRight")} title="По правому краю">
|
||||||
|
<AlignRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-color-tools" aria-label="Цвет текста">
|
||||||
|
<Type size={16} aria-hidden="true" />
|
||||||
|
{textColorSwatches.map((swatch) => (
|
||||||
|
<button
|
||||||
|
key={swatch.label}
|
||||||
|
className="color-swatch"
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyTextColor(swatch.color)}
|
||||||
|
title={swatch.label}
|
||||||
|
aria-label={swatch.label}
|
||||||
|
style={{ backgroundColor: swatch.color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-color-tools" aria-label="Подсветка текста">
|
||||||
|
<PaintBucket size={16} aria-hidden="true" />
|
||||||
|
{highlightSwatches.map((swatch) => (
|
||||||
|
<button
|
||||||
|
key={swatch.label}
|
||||||
|
className="color-swatch"
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyHighlight(swatch.color)}
|
||||||
|
title={swatch.label}
|
||||||
|
aria-label={swatch.label}
|
||||||
|
style={{ backgroundColor: swatch.color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<select aria-label="Стиль абзаца" onChange={(event) => runCommand("formatBlock", event.target.value)}>
|
||||||
|
<option value="p">Обычный текст</option>
|
||||||
|
<option value="h1">Заголовок 1</option>
|
||||||
|
<option value="h2">Заголовок 2</option>
|
||||||
|
<option value="blockquote">Цитата</option>
|
||||||
|
</select>
|
||||||
|
<button className="compact-command" type="button" onClick={() => void chooseDocx()}>
|
||||||
|
<FileUp size={16} />
|
||||||
|
Открыть DOCX
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={() => void saveDocx()}>
|
||||||
|
<Save size={16} />
|
||||||
|
Сохранить DOCX
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="compact-command"
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadTextFile(document.title.replace(/\.qdocx$/i, ".html"), document.html, "text/html")}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
Экспорт HTML
|
||||||
|
</button>
|
||||||
|
<button className="compact-command" type="button" onClick={() => void exportPdf()}>
|
||||||
|
<FileText size={16} />
|
||||||
|
Экспорт PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-ruler" aria-hidden="true">
|
||||||
|
{Array.from({ length: 9 }, (_, index) => (
|
||||||
|
<span key={index}>{index + 1}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article
|
||||||
|
ref={editorRef}
|
||||||
|
className="paper document-content"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onInput={updateHtml}
|
||||||
|
dangerouslySetInnerHTML={{ __html: document.html }}
|
||||||
|
aria-label="Содержимое документа"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="inspector" aria-label="Параметры документа">
|
||||||
|
<div className="inspector-tabs">
|
||||||
|
<button type="button">Стили</button>
|
||||||
|
<button className="is-active" type="button">
|
||||||
|
Свойства
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<section>
|
||||||
|
<h3>Документ</h3>
|
||||||
|
<label>
|
||||||
|
Размер страницы
|
||||||
|
<select defaultValue="a4">
|
||||||
|
<option value="a4">A4, 210 x 297 мм</option>
|
||||||
|
<option value="letter">Letter, 8.5 x 11 дюймов</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ориентация
|
||||||
|
<select defaultValue="portrait">
|
||||||
|
<option value="portrait">Книжная</option>
|
||||||
|
<option value="landscape">Альбомная</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="metric-grid">
|
||||||
|
<label>
|
||||||
|
Верх
|
||||||
|
<input defaultValue="20" inputMode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Низ
|
||||||
|
<input defaultValue="20" inputMode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Слева
|
||||||
|
<input defaultValue="20" inputMode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Справа
|
||||||
|
<input defaultValue="20" inputMode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Статистика</h3>
|
||||||
|
<p className="stat-line">
|
||||||
|
<Pilcrow size={16} />
|
||||||
|
{wordCount} слов
|
||||||
|
</p>
|
||||||
|
<p className="muted">Автосохранение включено. Данные хранятся в локальном хранилище браузера.</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+33
@@ -0,0 +1,33 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
type DesktopOfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail";
|
||||||
|
type QOfficeDesktopCommand = "open" | "save" | "export-pdf" | "print";
|
||||||
|
|
||||||
|
interface DesktopOpenOfficeFileResult {
|
||||||
|
canceled: boolean;
|
||||||
|
fileName?: string;
|
||||||
|
filePath?: string;
|
||||||
|
base64Data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopSaveOfficeFileResult {
|
||||||
|
canceled: boolean;
|
||||||
|
fileName?: string;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface WindowEventMap {
|
||||||
|
"qoffice-command": CustomEvent<{ command: QOfficeDesktopCommand }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
qoffice?: {
|
||||||
|
isDesktop?: boolean;
|
||||||
|
platform?: string;
|
||||||
|
openOfficeFile?: (kind: DesktopOfficeFileKind) => Promise<DesktopOpenOfficeFileResult>;
|
||||||
|
saveOfficeFile?: (kind: DesktopOfficeFileKind, defaultFileName: string, base64Data: string) => Promise<DesktopSaveOfficeFileResult>;
|
||||||
|
exportPdf?: (defaultFileName: string) => Promise<DesktopSaveOfficeFileResult>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function usePersistentState<T>(key: string, initialValue: T, migrate: (value: T) => T = (value) => value) {
|
||||||
|
const [value, setValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
return migrate(raw ? (JSON.parse(raw) as T) : initialValue);
|
||||||
|
} catch {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { render, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useQOfficeCommand } from "./useQOfficeCommand";
|
||||||
|
|
||||||
|
function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf?: () => void }) {
|
||||||
|
useQOfficeCommand({ save: onSave, "export-pdf": onExportPdf });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useQOfficeCommand", () => {
|
||||||
|
it("вызывает обработчик команды QOffice", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<CommandProbe onSave={onSave} />);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "save" } }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("использует актуальный обработчик после rerender", async () => {
|
||||||
|
const firstSave = vi.fn();
|
||||||
|
const secondSave = vi.fn();
|
||||||
|
const view = render(<CommandProbe onSave={firstSave} />);
|
||||||
|
|
||||||
|
view.rerender(<CommandProbe onSave={secondSave} />);
|
||||||
|
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "save" } }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(secondSave).toHaveBeenCalledTimes(1));
|
||||||
|
expect(firstSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("вызывает обработчик экспорта PDF", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const onExportPdf = vi.fn();
|
||||||
|
render(<CommandProbe onSave={onSave} onExportPdf={onExportPdf} />);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "export-pdf" } }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onExportPdf).toHaveBeenCalledTimes(1));
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export type QOfficeCommand = "open" | "save" | "export-pdf" | "print";
|
||||||
|
export type QOfficeCommandHandlers = Partial<Record<QOfficeCommand, () => void | Promise<void>>>;
|
||||||
|
|
||||||
|
export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
}, [handlers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onCommand(event: WindowEventMap["qoffice-command"]) {
|
||||||
|
const command = event.detail.command;
|
||||||
|
const handler = handlersRef.current[command];
|
||||||
|
if (handler) {
|
||||||
|
void handler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("qoffice-command", onCommand);
|
||||||
|
return () => window.removeEventListener("qoffice-command", onCommand);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { base64ToBytes, bytesToBase64 } from "./desktopOfficeFiles";
|
||||||
|
|
||||||
|
describe("desktopOfficeFiles helpers", () => {
|
||||||
|
it("кодирует и декодирует бинарные данные без потерь", () => {
|
||||||
|
const source = new Uint8Array([0, 1, 2, 127, 128, 255]);
|
||||||
|
expect([...base64ToBytes(bytesToBase64(source))]).toEqual([...source]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("поддерживает данные больше одного chunk", () => {
|
||||||
|
const source = new Uint8Array(70000);
|
||||||
|
source.forEach((_, index) => {
|
||||||
|
source[index] = index % 251;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...base64ToBytes(bytesToBase64(source)).slice(69990)]).toEqual([...source.slice(69990)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type OfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail";
|
||||||
|
|
||||||
|
const mimeTypes: Record<OfficeFileKind, string> = {
|
||||||
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
eml: "message/rfc822",
|
||||||
|
mail: "application/octet-stream"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isDesktopOfficeBridgeAvailable() {
|
||||||
|
return Boolean(window.qoffice?.isDesktop && window.qoffice.openOfficeFile && window.qoffice.saveOfficeFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDesktopOfficeFile(kind: OfficeFileKind): Promise<File | null> {
|
||||||
|
const result = await window.qoffice?.openOfficeFile?.(kind);
|
||||||
|
if (!result || result.canceled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.base64Data) {
|
||||||
|
throw new Error("Desktop-оболочка QOffice не вернула данные файла.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new File([base64ToBytes(result.base64Data)], result.fileName ?? `qoffice.${kind}`, {
|
||||||
|
type: mimeTypes[kind]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDesktopOfficeFile(kind: OfficeFileKind, defaultFileName: string, blob: Blob) {
|
||||||
|
const bridge = window.qoffice;
|
||||||
|
if (!bridge?.saveOfficeFile) {
|
||||||
|
throw new Error("Desktop-оболочка QOffice недоступна.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Data = bytesToBase64(new Uint8Array(await blob.arrayBuffer()));
|
||||||
|
return bridge.saveOfficeFile(kind, defaultFileName, base64Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToBase64(bytes: Uint8Array) {
|
||||||
|
let binary = "";
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.slice(index, index + chunkSize));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToBytes(base64Data: string) {
|
||||||
|
const binary = atob(base64Data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildChartXml,
|
||||||
|
buildCellXml,
|
||||||
|
buildHyperlinksXml,
|
||||||
|
buildMergeCellsXml,
|
||||||
|
createCellStyleRegistry,
|
||||||
|
buildWorksheetRelationshipsXml,
|
||||||
|
buildWorksheetXml,
|
||||||
|
exportXlsxBlob,
|
||||||
|
importXlsxFile,
|
||||||
|
chartDataPoints,
|
||||||
|
chartXmlToSheetChart,
|
||||||
|
workbookRelationshipTargets,
|
||||||
|
workbookSheetRefs,
|
||||||
|
worksheetXmlToCells,
|
||||||
|
worksheetXmlToCellFormats,
|
||||||
|
worksheetXmlToHyperlinks,
|
||||||
|
worksheetXmlToMergedCells,
|
||||||
|
xlsxStylesXmlToCellFormatTable,
|
||||||
|
xlsxCellToText
|
||||||
|
} from "./excellOffice";
|
||||||
|
|
||||||
|
describe("excellOffice OOXML helpers", () => {
|
||||||
|
it("сохраняет формулу при импорте как строку с '='", () => {
|
||||||
|
expect(xlsxCellToText({ r: "C1", f: "SUM(A1:B1)", v: "3" }, [])).toBe("=SUM(A1:B1)");
|
||||||
|
expect(xlsxCellToText({ r: "C2", f: { "#text": "A2*2" }, v: "8" }, [])).toBe("=A2*2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("читает shared string, inline string, число и boolean", () => {
|
||||||
|
expect(xlsxCellToText({ r: "A1", t: "s", v: "1" }, ["Первый", "Второй"])).toBe("Второй");
|
||||||
|
expect(xlsxCellToText({ r: "A2", t: "inlineStr", is: { t: "Текст" } }, [])).toBe("Текст");
|
||||||
|
expect(xlsxCellToText({ r: "A3", v: "12500" }, [])).toBe("12500");
|
||||||
|
expect(xlsxCellToText({ r: "A4", t: "b", v: "1" }, [])).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("записывает формулу в тег <f>", () => {
|
||||||
|
expect(buildCellXml("c1", "=SUM(A1:B1)")).toBe('<c r="C1"><f>SUM(A1:B1)</f></c>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("собирает XML листа с отсортированными ячейками", () => {
|
||||||
|
const xml = buildWorksheetXml({
|
||||||
|
B2: "200",
|
||||||
|
A1: "Статья",
|
||||||
|
C2: "=B2*2"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(xml).toContain('<row r="1"><c r="A1" t="inlineStr"><is><t>Статья</t></is></c></row>');
|
||||||
|
expect(xml).toContain('<row r="2"><c r="B2"><v>200</v></c><c r="C2"><f>B2*2</f></c></row>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет merged cells в XML листа", () => {
|
||||||
|
const xml = buildWorksheetXml({ A1: "Заголовок" }, [{ start: "A1", end: "C2" }]);
|
||||||
|
|
||||||
|
expect(buildMergeCellsXml([{ start: "C2", end: "A1" }, { start: "A1", end: "C2" }])).toBe(
|
||||||
|
'<mergeCells count="1"><mergeCell ref="A1:C2"/></mergeCells>'
|
||||||
|
);
|
||||||
|
expect(xml).toContain('<mergeCells count="1"><mergeCell ref="A1:C2"/></mergeCells>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет внешние гиперссылки XLSX в XML листа и relationships", () => {
|
||||||
|
const hyperlinks = {
|
||||||
|
A1: "https://example.com/report",
|
||||||
|
B2: "javascript:alert(1)"
|
||||||
|
};
|
||||||
|
const xml = buildWorksheetXml({ A1: "Отчет", B2: "Скрипт" }, [], hyperlinks);
|
||||||
|
const relationshipsXml = buildWorksheetRelationshipsXml(hyperlinks);
|
||||||
|
|
||||||
|
expect(buildHyperlinksXml(hyperlinks)).toBe('<hyperlinks><hyperlink ref="A1" r:id="rId1"/></hyperlinks>');
|
||||||
|
expect(xml).toContain('<hyperlinks><hyperlink ref="A1" r:id="rId1"/></hyperlinks>');
|
||||||
|
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"');
|
||||||
|
expect(relationshipsXml).toContain('Target="https://example.com/report"');
|
||||||
|
expect(relationshipsXml).toContain('TargetMode="External"');
|
||||||
|
expect(relationshipsXml).not.toContain("javascript:alert");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет базовое форматирование ячеек XLSX в XML листа", () => {
|
||||||
|
const cellFormats = {
|
||||||
|
A1: { bold: true, fillColor: "fff2cc" },
|
||||||
|
B2: { italics: true, underline: true },
|
||||||
|
C3: { fillColor: "bad-color" }
|
||||||
|
};
|
||||||
|
const registry = createCellStyleRegistry([cellFormats]);
|
||||||
|
const xml = buildWorksheetXml({ A1: "Итог", B2: "Комментарий", C3: "Без стиля" }, [], {}, cellFormats, registry);
|
||||||
|
const emptyCellXml = buildWorksheetXml({}, [], {}, { C3: { underline: true } });
|
||||||
|
|
||||||
|
expect(xml).toContain('<c r="A1" s="1" t="inlineStr"><is><t>Итог</t></is></c>');
|
||||||
|
expect(xml).toContain('<c r="B2" s="2" t="inlineStr"><is><t>Комментарий</t></is></c>');
|
||||||
|
expect(xml).toContain('<c r="C3" t="inlineStr"><is><t>Без стиля</t></is></c>');
|
||||||
|
expect(emptyCellXml).toContain('<c r="C3" s="1"/>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("читает базовое форматирование ячеек XLSX из styles.xml и XML листа", () => {
|
||||||
|
const styleTable = xlsxStylesXmlToCellFormatTable({
|
||||||
|
styleSheet: {
|
||||||
|
numFmts: {
|
||||||
|
numFmt: { numFmtId: "164", formatCode: "dd.mm.yyyy" }
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
font: [{}, { b: {}, i: {}, u: {} }]
|
||||||
|
},
|
||||||
|
fills: {
|
||||||
|
fill: [{}, {}, { patternFill: { fgColor: { rgb: "FFFFF2CC" } } }]
|
||||||
|
},
|
||||||
|
cellXfs: {
|
||||||
|
xf: [{ fontId: "0", fillId: "0" }, { fontId: "1", fillId: "2", numFmtId: "164" }, { fontId: "0", fillId: "0", numFmtId: "14" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(styleTable[1]).toEqual({ bold: true, italics: true, underline: true, fillColor: "FFF2CC", numberFormat: "dd.mm.yyyy" });
|
||||||
|
expect(styleTable[2]).toEqual({ numberFormat: "m/d/yy" });
|
||||||
|
expect(
|
||||||
|
worksheetXmlToCellFormats(
|
||||||
|
{
|
||||||
|
worksheet: {
|
||||||
|
sheetData: {
|
||||||
|
row: {
|
||||||
|
r: "1",
|
||||||
|
c: [{ r: "A1", s: "1", t: "inlineStr", is: { t: "Итог" } }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
styleTable
|
||||||
|
)
|
||||||
|
).toEqual({ A1: { bold: true, italics: true, underline: true, fillColor: "FFF2CC", numberFormat: "dd.mm.yyyy" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("преобразует XML листа в модель QExcell", () => {
|
||||||
|
const cells = worksheetXmlToCells(
|
||||||
|
{
|
||||||
|
worksheet: {
|
||||||
|
sheetData: {
|
||||||
|
row: {
|
||||||
|
r: "1",
|
||||||
|
c: [
|
||||||
|
{ r: "A1", t: "inlineStr", is: { t: "Статья" } },
|
||||||
|
{ r: "B1", v: "1000" },
|
||||||
|
{ r: "C1", f: "B1*2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cells).toEqual({
|
||||||
|
A1: "Статья",
|
||||||
|
B1: "1000",
|
||||||
|
C1: "=B1*2"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("читает merged cells из XML листа", () => {
|
||||||
|
expect(
|
||||||
|
worksheetXmlToMergedCells({
|
||||||
|
worksheet: {
|
||||||
|
mergeCells: {
|
||||||
|
mergeCell: [{ ref: "C2:A1" }, { ref: "D4:D4" }, { ref: "bad" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toEqual([{ start: "A1", end: "C2" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("читает внешние гиперссылки XLSX из XML листа и relationships", () => {
|
||||||
|
expect(
|
||||||
|
worksheetXmlToHyperlinks(
|
||||||
|
{
|
||||||
|
worksheet: {
|
||||||
|
hyperlinks: {
|
||||||
|
hyperlink: [
|
||||||
|
{ ref: "A1", "r:id": "rIdLink" },
|
||||||
|
{ ref: "B2", "r:id": "rIdUnsafe" },
|
||||||
|
{ ref: "bad", "r:id": "rIdLink" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Relationships: {
|
||||||
|
Relationship: [
|
||||||
|
{
|
||||||
|
Id: "rIdLink",
|
||||||
|
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
|
||||||
|
Target: "https://example.com/report",
|
||||||
|
TargetMode: "External"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "rIdUnsafe",
|
||||||
|
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
|
||||||
|
Target: "javascript:alert(1)",
|
||||||
|
TargetMode: "External"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).toEqual({ A1: "https://example.com/report" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("читает ссылки листов и relationships из workbook XML", () => {
|
||||||
|
expect(
|
||||||
|
workbookSheetRefs({
|
||||||
|
workbook: {
|
||||||
|
sheets: {
|
||||||
|
sheet: { name: "Бюджет", sheetId: "7", "r:id": "rId3" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toEqual([{ id: "sheet-7", name: "Бюджет", relationshipId: "rId3" }]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
workbookRelationshipTargets({
|
||||||
|
Relationships: {
|
||||||
|
Relationship: [{ Id: "rId3", Target: "worksheets/sheet7.xml" }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toEqual({ rId3: "worksheets/sheet7.xml" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("создает OOXML chart part для диаграммы XLSX", () => {
|
||||||
|
const xml = buildChartXml(
|
||||||
|
{
|
||||||
|
id: "chart-1",
|
||||||
|
type: "bar",
|
||||||
|
title: "Итого",
|
||||||
|
labelRange: "A2:A4",
|
||||||
|
valueRange: "E2:E4"
|
||||||
|
},
|
||||||
|
"Бюджет",
|
||||||
|
{
|
||||||
|
A2: "Базовый",
|
||||||
|
A3: "Команды",
|
||||||
|
A4: "Корпоративный",
|
||||||
|
E2: "43600",
|
||||||
|
E3: "27200",
|
||||||
|
E4: "64800"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(xml).toContain("<c:barChart>");
|
||||||
|
expect(xml).toContain("<c:f>'Бюджет'!$A$2:$A$4</c:f>");
|
||||||
|
expect(xml).toContain("<c:f>'Бюджет'!$E$2:$E$4</c:f>");
|
||||||
|
expect(xml).toContain("<c:v>Базовый</c:v>");
|
||||||
|
expect(xml).toContain("<c:v>43600</c:v>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("читает базовую диаграмму из chart XML", () => {
|
||||||
|
expect(
|
||||||
|
chartXmlToSheetChart(
|
||||||
|
{
|
||||||
|
"c:chartSpace": {
|
||||||
|
"c:chart": {
|
||||||
|
"c:title": {
|
||||||
|
"c:tx": {
|
||||||
|
"c:rich": {
|
||||||
|
"a:p": { "a:r": { "a:t": "Продажи" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"c:plotArea": {
|
||||||
|
"c:lineChart": {
|
||||||
|
"c:ser": {
|
||||||
|
"c:cat": { "c:strRef": { "c:f": "'Бюджет'!$A$2:$A$4" } },
|
||||||
|
"c:val": { "c:numRef": { "c:f": "'Бюджет'!$E$2:$E$4" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Бюджет",
|
||||||
|
0
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
id: "xlsx-chart-1",
|
||||||
|
type: "line",
|
||||||
|
title: "Продажи",
|
||||||
|
labelRange: "A2:A4",
|
||||||
|
valueRange: "E2:E4"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("готовит точки диаграммы из диапазонов листа", () => {
|
||||||
|
expect(
|
||||||
|
chartDataPoints(
|
||||||
|
{
|
||||||
|
id: "chart-1",
|
||||||
|
type: "bar",
|
||||||
|
title: "Итого",
|
||||||
|
labelRange: "A2:A3",
|
||||||
|
valueRange: "E2:E3"
|
||||||
|
},
|
||||||
|
{ A2: "Базовый", A3: "Команды", E2: "=SUM(B2:D2)", B2: "10", C2: "20", D2: "30", E3: "25" }
|
||||||
|
)
|
||||||
|
).toEqual([
|
||||||
|
{ label: "Базовый", value: 60 },
|
||||||
|
{ label: "Команды", value: 25 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет и импортирует несколько листов с дальними ячейками", async () => {
|
||||||
|
const blob = await exportXlsxBlob({
|
||||||
|
id: "workbook",
|
||||||
|
title: "Бюджет.xlsx",
|
||||||
|
updatedAt: "2026-05-31T00:00:00.000Z",
|
||||||
|
activeSheet: "sheet-1",
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
id: "sheet-1",
|
||||||
|
name: "Бюджет",
|
||||||
|
cells: {
|
||||||
|
A1: "Статья",
|
||||||
|
AA20: "7",
|
||||||
|
AB20: "8",
|
||||||
|
AC20: "=SUM(AA20:AB20)",
|
||||||
|
D20: "44927",
|
||||||
|
E20: "0.25"
|
||||||
|
},
|
||||||
|
mergedCells: [{ start: "A1", end: "C1" }],
|
||||||
|
hyperlinks: { A1: "https://example.com/budget" },
|
||||||
|
cellFormats: {
|
||||||
|
A1: { bold: true, fillColor: "FFF2CC" },
|
||||||
|
AC20: { underline: true },
|
||||||
|
D20: { numberFormat: "dd.mm.yyyy" },
|
||||||
|
E20: { numberFormat: "0%" }
|
||||||
|
},
|
||||||
|
charts: [
|
||||||
|
{
|
||||||
|
id: "chart-budget",
|
||||||
|
type: "bar",
|
||||||
|
title: "Итого",
|
||||||
|
labelRange: "A2:A3",
|
||||||
|
valueRange: "AA20:AB20"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sheet-2",
|
||||||
|
name: "Справочник",
|
||||||
|
cells: {
|
||||||
|
B2: "Проект"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const imported = await importXlsxFile(new File([blob], "Бюджет.xlsx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(imported.title).toBe("Бюджет.xlsx");
|
||||||
|
expect(imported.sheets).toHaveLength(2);
|
||||||
|
expect(imported.sheets[0].name).toBe("Бюджет");
|
||||||
|
expect(imported.sheets[0].cells.AC20).toBe("=SUM(AA20:AB20)");
|
||||||
|
expect(imported.sheets[0].mergedCells).toEqual([{ start: "A1", end: "C1" }]);
|
||||||
|
expect(imported.sheets[0].hyperlinks).toEqual({ A1: "https://example.com/budget" });
|
||||||
|
expect(imported.sheets[0].cellFormats).toEqual({
|
||||||
|
A1: { bold: true, fillColor: "FFF2CC" },
|
||||||
|
AC20: { underline: true },
|
||||||
|
D20: { numberFormat: "dd.mm.yyyy" },
|
||||||
|
E20: { numberFormat: "0%" }
|
||||||
|
});
|
||||||
|
expect(imported.sheets[0].charts).toEqual([
|
||||||
|
{
|
||||||
|
id: "xlsx-chart-1",
|
||||||
|
type: "bar",
|
||||||
|
title: "Итого",
|
||||||
|
labelRange: "A2:A3",
|
||||||
|
valueRange: "AA20:AB20"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(imported.sheets[1].name).toBe("Справочник");
|
||||||
|
expect(imported.sheets[1].cells.B2).toBe("Проект");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, replaceFileExtension } from "./fileHelpers";
|
||||||
|
|
||||||
|
describe("fileHelpers", () => {
|
||||||
|
it("получает название из имени файла", () => {
|
||||||
|
expect(officeTitleFromFileName("C:\\Документы\\Отчет.docx", "Без названия")).toBe("Отчет.docx");
|
||||||
|
expect(officeTitleFromFileName(" ", "Без названия")).toBe("Без названия");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("заменяет расширение файла", () => {
|
||||||
|
expect(replaceFileExtension("Отчет.docx", ".pdf")).toBe("Отчет.pdf");
|
||||||
|
expect(replaceFileExtension("Книга", "xlsx")).toBe("Книга.xlsx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("читает File как ArrayBuffer без выбора файла в браузере", async () => {
|
||||||
|
const file = new File(["текст"], "Проверка.txt", { type: "text/plain" });
|
||||||
|
const buffer = await readOfficeFileAsArrayBuffer(file);
|
||||||
|
|
||||||
|
expect(new TextDecoder().decode(buffer)).toBe("текст");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export const DOCX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
export const XLSX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
export const PPTX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
|
export const EML_MIME_TYPE = "message/rfc822;charset=utf-8";
|
||||||
|
|
||||||
|
const pathSeparatorPattern = /[/\\]/;
|
||||||
|
|
||||||
|
export function officeTitleFromFileName(fileName: string, fallbackTitle: string) {
|
||||||
|
const trimmed = fileName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return fallbackTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = trimmed.split(pathSeparatorPattern).filter(Boolean);
|
||||||
|
return parts[parts.length - 1] ?? fallbackTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceFileExtension(fileName: string, extension: string) {
|
||||||
|
const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`;
|
||||||
|
const baseName = fileName.trim() || "Документ";
|
||||||
|
const withoutExtension = baseName.replace(/\.[^.\\/]+$/, "");
|
||||||
|
|
||||||
|
return `${withoutExtension}${normalizedExtension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readOfficeFileAsArrayBuffer(file: File) {
|
||||||
|
if (typeof file.arrayBuffer !== "function") {
|
||||||
|
return readFileWithFileReader(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileWithFileReader(file: File): Promise<ArrayBuffer> {
|
||||||
|
if (typeof FileReader === "undefined") {
|
||||||
|
throw new Error("Не удалось прочитать файл: среда не поддерживает File.arrayBuffer() и FileReader.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () => reject(new Error("Не удалось прочитать файл."));
|
||||||
|
reader.onload = () => {
|
||||||
|
if (reader.result instanceof ArrayBuffer) {
|
||||||
|
resolve(reader.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error("Не удалось прочитать файл как ArrayBuffer."));
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
attachmentToBlob,
|
||||||
|
buildEmlSource,
|
||||||
|
createMailAttachmentFromFile,
|
||||||
|
decodeMimeWords,
|
||||||
|
decodeTransferBody,
|
||||||
|
extractMessageAttachments,
|
||||||
|
extractMessageBody,
|
||||||
|
htmlToPlainText,
|
||||||
|
importEmlFile,
|
||||||
|
isSupportedEmlFileName,
|
||||||
|
isSupportedMailFileName,
|
||||||
|
isSupportedMsgFileName,
|
||||||
|
msgDataToMailMessage,
|
||||||
|
parseContentType,
|
||||||
|
parseEmlHeaders,
|
||||||
|
parseEmlSource,
|
||||||
|
parseMultipartBody
|
||||||
|
} from "./outlookMail";
|
||||||
|
import type { MailMessage } from "../types";
|
||||||
|
|
||||||
|
describe("outlookMail helpers", () => {
|
||||||
|
it("разбирает folded EML headers", () => {
|
||||||
|
expect(parseEmlHeaders("Subject: Первая строка\r\n\tвторая строка\r\nFrom: user@example.com")).toEqual({
|
||||||
|
subject: "Первая строка вторая строка",
|
||||||
|
from: "user@example.com"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("разделяет заголовки и тело EML", () => {
|
||||||
|
const parsed = parseEmlSource("From: a@example.com\r\nTo: b@example.com\r\n\r\nТекст письма");
|
||||||
|
|
||||||
|
expect(parsed.headers.from).toBe("a@example.com");
|
||||||
|
expect(parsed.body).toBe("Текст письма");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("декодирует UTF-8 MIME words и transfer encodings", () => {
|
||||||
|
expect(decodeMimeWords("=?UTF-8?B?0J/RgNC40LLQtdGC?=")).toBe("Привет");
|
||||||
|
expect(decodeTransferBody("0J/RgNC40LLQtdGC", "base64")).toBe("Привет");
|
||||||
|
expect(decodeTransferBody("=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82", "quoted-printable")).toBe("Привет");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("разбирает Content-Type с quoted boundary и charset", () => {
|
||||||
|
expect(parseContentType('multipart/alternative; boundary="----=_Next;Part"; charset=utf-8')).toEqual({
|
||||||
|
mediaType: "multipart/alternative",
|
||||||
|
parameters: {
|
||||||
|
boundary: "----=_Next;Part",
|
||||||
|
charset: "utf-8"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("разбирает multipart body на MIME-части", () => {
|
||||||
|
const parts = parseMultipartBody(
|
||||||
|
[
|
||||||
|
"Преамбула",
|
||||||
|
"--qoffice-boundary",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"Текстовая версия",
|
||||||
|
"--qoffice-boundary",
|
||||||
|
"Content-Type: text/html; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"<p>HTML версия</p>",
|
||||||
|
"--qoffice-boundary--"
|
||||||
|
].join("\r\n"),
|
||||||
|
"qoffice-boundary"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parts).toEqual([
|
||||||
|
{ headers: { "content-type": "text/plain; charset=utf-8" }, body: "Текстовая версия" },
|
||||||
|
{ headers: { "content-type": "text/html; charset=utf-8" }, body: "<p>HTML версия</p>" }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("выбирает text/plain из multipart/alternative перед HTML", () => {
|
||||||
|
const body = [
|
||||||
|
"--alt",
|
||||||
|
"Content-Type: text/html; charset=utf-8",
|
||||||
|
"Content-Transfer-Encoding: quoted-printable",
|
||||||
|
"",
|
||||||
|
"<p>=D0=92=D0=B5=D1=80=D1=81=D0=B8=D1=8F HTML</p>",
|
||||||
|
"--alt",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"Текстовая версия",
|
||||||
|
"--alt--"
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
expect(extractMessageBody({ "content-type": 'multipart/alternative; boundary="alt"' }, body)).toBe("Текстовая версия");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("преобразует HTML body в читаемый текст, если text/plain отсутствует", () => {
|
||||||
|
const body = [
|
||||||
|
"--alt",
|
||||||
|
"Content-Type: text/html; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"<html><body><h1>Тема</h1><p>Строка 1<br>Строка & 2</p><ul><li>Пункт</li></ul></body></html>",
|
||||||
|
"--alt--"
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
expect(htmlToPlainText("<p>Строка 1<br>Строка & 2</p>")).toBe("Строка 1\nСтрока & 2");
|
||||||
|
expect(extractMessageBody({ "content-type": "multipart/alternative; boundary=alt" }, body)).toBe("Тема\nСтрока 1\nСтрока & 2\n• Пункт");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("извлекает MIME-вложения из multipart/mixed и не смешивает их с текстом письма", async () => {
|
||||||
|
const body = [
|
||||||
|
"--mixed",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"Текст письма",
|
||||||
|
"--mixed",
|
||||||
|
"Content-Type: text/plain; name*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82.txt",
|
||||||
|
"Content-Disposition: attachment; filename*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82.txt",
|
||||||
|
"Content-Transfer-Encoding: base64",
|
||||||
|
"",
|
||||||
|
"0J/RgNC40LLQtdGC",
|
||||||
|
"--mixed--"
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
const headers = { "content-type": 'multipart/mixed; boundary="mixed"' };
|
||||||
|
const attachments = extractMessageAttachments(headers, body);
|
||||||
|
|
||||||
|
expect(extractMessageBody(headers, body)).toBe("Текст письма");
|
||||||
|
expect(attachments).toHaveLength(1);
|
||||||
|
expect(attachments[0]).toMatchObject({
|
||||||
|
fileName: "отчет.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
size: new TextEncoder().encode("Привет").byteLength,
|
||||||
|
contentBase64: "0J/RgNC40LLQtdGC",
|
||||||
|
disposition: "attachment"
|
||||||
|
});
|
||||||
|
await expect(attachmentToBlob(attachments[0]).text()).resolves.toBe("Привет");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("импортирует multipart EML как письмо QOutlook", async () => {
|
||||||
|
const eml = [
|
||||||
|
"From: sender@example.com",
|
||||||
|
"To: reader@example.com",
|
||||||
|
"Subject: =?UTF-8?B?0JzRg9C70YzRgtC40L/QsNGA0YI=?=",
|
||||||
|
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
|
||||||
|
'Content-Type: multipart/alternative; boundary="alt"',
|
||||||
|
"",
|
||||||
|
"--alt",
|
||||||
|
"Content-Type: text/html; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"<p>HTML</p>",
|
||||||
|
"--alt",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"Content-Transfer-Encoding: base64",
|
||||||
|
"",
|
||||||
|
"0J/RgNC40LLQtdGCLCDQv9C40YHRjNC80L4=",
|
||||||
|
"--alt--"
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
const imported = await importEmlFile(new File([eml], "message.eml", { type: "message/rfc822" }));
|
||||||
|
|
||||||
|
expect(imported.subject).toBe("Мультипарт");
|
||||||
|
expect(imported.body).toBe("Привет, письмо");
|
||||||
|
expect(imported.from).toBe("sender@example.com");
|
||||||
|
expect(imported.to).toBe("reader@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("импортирует multipart/mixed EML с вложением", async () => {
|
||||||
|
const eml = [
|
||||||
|
"From: sender@example.com",
|
||||||
|
"To: reader@example.com",
|
||||||
|
"Subject: Отчет",
|
||||||
|
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
|
||||||
|
'Content-Type: multipart/mixed; boundary="mixed"',
|
||||||
|
"",
|
||||||
|
"--mixed",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"Письмо с файлом",
|
||||||
|
"--mixed",
|
||||||
|
"Content-Type: application/octet-stream; name=\"report.bin\"",
|
||||||
|
"Content-Disposition: attachment; filename=\"report.bin\"",
|
||||||
|
"Content-Transfer-Encoding: base64",
|
||||||
|
"",
|
||||||
|
"SGVsbG8=",
|
||||||
|
"--mixed--"
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
const imported = await importEmlFile(new File([eml], "message.eml", { type: "message/rfc822" }));
|
||||||
|
|
||||||
|
expect(imported.body).toBe("Письмо с файлом");
|
||||||
|
expect(imported.attachments).toEqual([
|
||||||
|
{
|
||||||
|
id: "attachment-report-bin-1",
|
||||||
|
fileName: "report.bin",
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
size: 5,
|
||||||
|
contentBase64: "SGVsbG8=",
|
||||||
|
disposition: "attachment"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
|
||||||
|
const message = msgDataToMailMessage(
|
||||||
|
{
|
||||||
|
getAttachment: () => ({
|
||||||
|
fileName: "report.txt",
|
||||||
|
content: new TextEncoder().encode("Hello")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
senderSmtpAddress: "sender@example.com",
|
||||||
|
subject: "MSG отчет",
|
||||||
|
bodyHtml: "<p>HTML текст</p>",
|
||||||
|
messageDeliveryTime: "Mon, 01 Jun 2026 10:00:00 GMT",
|
||||||
|
recipients: [
|
||||||
|
{ smtpAddress: "reader@example.com", recipType: "to" },
|
||||||
|
{ smtpAddress: "copy@example.com", recipType: "cc" }
|
||||||
|
],
|
||||||
|
attachments: [{ fileName: "report.txt", attachMimeTag: "text/plain", contentLength: 5 }]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(message).toMatchObject({
|
||||||
|
folder: "inbox",
|
||||||
|
from: "sender@example.com",
|
||||||
|
to: "reader@example.com",
|
||||||
|
subject: "MSG отчет",
|
||||||
|
body: "HTML текст",
|
||||||
|
time: "Mon, 01 Jun 2026 10:00:00 GMT",
|
||||||
|
read: false,
|
||||||
|
flagged: false
|
||||||
|
});
|
||||||
|
expect(message.id).toMatch(/^msg-/);
|
||||||
|
expect(message.attachments).toEqual([
|
||||||
|
{
|
||||||
|
id: "attachment-report-txt-1",
|
||||||
|
fileName: "report.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
size: 5,
|
||||||
|
contentBase64: "SGVsbG8=",
|
||||||
|
disposition: "attachment"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await expect(attachmentToBlob(message.attachments?.[0]!).text()).resolves.toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("собирает EML source с UTF-8 subject encoding", () => {
|
||||||
|
const message: MailMessage = {
|
||||||
|
id: "1",
|
||||||
|
folder: "sent",
|
||||||
|
from: "sender@example.com",
|
||||||
|
to: "reader@example.com",
|
||||||
|
subject: "Тема",
|
||||||
|
body: "Текст",
|
||||||
|
time: "Mon, 01 Jan 2024 10:00:00 +0000",
|
||||||
|
read: true,
|
||||||
|
flagged: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const eml = buildEmlSource(message);
|
||||||
|
|
||||||
|
expect(eml).toContain("From: sender@example.com");
|
||||||
|
expect(eml).toContain("Subject: =?UTF-8?B?");
|
||||||
|
expect(eml.endsWith("Текст")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
|
||||||
|
const message: MailMessage = {
|
||||||
|
id: "1",
|
||||||
|
folder: "sent",
|
||||||
|
from: "sender@example.com",
|
||||||
|
to: "reader@example.com",
|
||||||
|
subject: "Тема",
|
||||||
|
body: "Текст",
|
||||||
|
time: "Mon, 01 Jan 2024 10:00:00 +0000",
|
||||||
|
read: true,
|
||||||
|
flagged: false,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
fileName: "отчет.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
size: 5,
|
||||||
|
contentBase64: "SGVsbG8=",
|
||||||
|
disposition: "attachment"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const eml = buildEmlSource(message);
|
||||||
|
const parsed = parseEmlSource(eml);
|
||||||
|
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
|
||||||
|
|
||||||
|
expect(eml).toContain("Content-Type: multipart/mixed");
|
||||||
|
expect(eml).toContain("filename*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82%2E%74%78%74");
|
||||||
|
expect(extractMessageBody(parsed.headers, parsed.body)).toBe("Текст");
|
||||||
|
expect(attachments[0]).toMatchObject({
|
||||||
|
fileName: "отчет.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
contentBase64: "SGVsbG8=",
|
||||||
|
size: 5
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("создает вложение из File для новых писем", async () => {
|
||||||
|
const attachment = await createMailAttachmentFromFile(new File(["Привет"], "note.txt", { type: "text/plain" }));
|
||||||
|
|
||||||
|
expect(attachment).toMatchObject({
|
||||||
|
fileName: "note.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
size: new TextEncoder().encode("Привет").byteLength,
|
||||||
|
contentBase64: "0J/RgNC40LLQtdGC",
|
||||||
|
disposition: "attachment"
|
||||||
|
});
|
||||||
|
expect(attachment.id).toMatch(/^attachment-note-txt-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("принимает EML и MSG filenames для импорта почты", () => {
|
||||||
|
expect(isSupportedEmlFileName("message.eml")).toBe(true);
|
||||||
|
expect(isSupportedMsgFileName("message.msg")).toBe(true);
|
||||||
|
expect(isSupportedMailFileName("message.eml")).toBe(true);
|
||||||
|
expect(isSupportedMailFileName("message.msg")).toBe(true);
|
||||||
|
expect(isSupportedEmlFileName("archive.pst")).toBe(false);
|
||||||
|
expect(isSupportedMailFileName("archive.pst")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,797 @@
|
|||||||
|
import type { MailAttachment, MailMessage } from "../types";
|
||||||
|
import { EML_MIME_TYPE } from "./fileHelpers";
|
||||||
|
|
||||||
|
type MailHeaders = Record<string, string>;
|
||||||
|
|
||||||
|
interface ParsedContentType {
|
||||||
|
mediaType: string;
|
||||||
|
parameters: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedHeaderParameters {
|
||||||
|
value: string;
|
||||||
|
parameters: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailPart {
|
||||||
|
headers: MailHeaders;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MsgAttachmentSummary {
|
||||||
|
fileName?: string;
|
||||||
|
fileNameShort?: string;
|
||||||
|
name?: string;
|
||||||
|
attachMimeTag?: string;
|
||||||
|
contentLength?: number;
|
||||||
|
pidContentId?: string;
|
||||||
|
attachmentHidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MsgRecipient {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
smtpAddress?: string;
|
||||||
|
recipType?: "to" | "cc" | "bcc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MsgFileData {
|
||||||
|
senderEmail?: string;
|
||||||
|
senderSmtpAddress?: string;
|
||||||
|
senderName?: string;
|
||||||
|
subject?: string;
|
||||||
|
body?: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
html?: Uint8Array;
|
||||||
|
headers?: string;
|
||||||
|
creationTime?: string;
|
||||||
|
lastModificationTime?: string;
|
||||||
|
clientSubmitTime?: string;
|
||||||
|
messageDeliveryTime?: string;
|
||||||
|
recipients?: MsgRecipient[];
|
||||||
|
attachments?: MsgAttachmentSummary[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MsgReaderInstance {
|
||||||
|
getFileData: () => MsgFileData;
|
||||||
|
getAttachment: (attachment: MsgAttachmentSummary) => { fileName?: string; content: Uint8Array };
|
||||||
|
}
|
||||||
|
|
||||||
|
type MsgReaderConstructor = new (arrayBuffer: ArrayBuffer) => MsgReaderInstance;
|
||||||
|
|
||||||
|
export type ImportedMailMessage = MailMessage;
|
||||||
|
|
||||||
|
export async function importMailFile(file: File): Promise<ImportedMailMessage> {
|
||||||
|
if (isSupportedEmlFileName(file.name)) {
|
||||||
|
return importEmlFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSupportedMsgFileName(file.name)) {
|
||||||
|
return importMsgFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("QOutlook импортирует файлы .eml и одиночные сообщения Outlook .msg. Файлы PST сейчас не поддерживаются.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
||||||
|
if (!isSupportedEmlFileName(file.name)) {
|
||||||
|
throw new Error("QOutlook импортирует только файлы .eml. Используйте общий импорт QOutlook для файлов .msg.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await file.text();
|
||||||
|
const parsed = parseEmlSource(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `eml-${Date.now()}`,
|
||||||
|
folder: "inbox",
|
||||||
|
from: decodeMimeWords(parsed.headers.from || ""),
|
||||||
|
to: decodeMimeWords(parsed.headers.to || ""),
|
||||||
|
subject: decodeMimeWords(parsed.headers.subject || "Без темы"),
|
||||||
|
body: extractMessageBody(parsed.headers, parsed.body),
|
||||||
|
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
|
||||||
|
read: false,
|
||||||
|
flagged: false,
|
||||||
|
attachments: extractMessageAttachments(parsed.headers, parsed.body)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importMsgFile(file: File): Promise<ImportedMailMessage> {
|
||||||
|
if (!isSupportedMsgFileName(file.name)) {
|
||||||
|
throw new Error("QOutlook импортирует только одиночные сообщения Outlook .msg.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const MsgReader = await loadMsgReader();
|
||||||
|
const reader = new MsgReader(await file.arrayBuffer());
|
||||||
|
const data = reader.getFileData();
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`Не удалось прочитать MSG: ${data.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgDataToMailMessage(reader, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function msgDataToMailMessage(reader: Pick<MsgReaderInstance, "getAttachment">, data: MsgFileData): MailMessage {
|
||||||
|
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
|
||||||
|
return {
|
||||||
|
id: `msg-${Date.now()}`,
|
||||||
|
folder: "inbox",
|
||||||
|
from: firstText(data.senderSmtpAddress, data.senderEmail, data.senderName, decodeMimeWords(headerFields.from || "")),
|
||||||
|
to: msgRecipientList(data.recipients, "to") || decodeMimeWords(headerFields.to || ""),
|
||||||
|
subject: firstText(data.subject, decodeMimeWords(headerFields.subject || ""), "Без темы"),
|
||||||
|
body: msgBodyText(data),
|
||||||
|
time: firstText(data.messageDeliveryTime, data.clientSubmitTime, data.creationTime, data.lastModificationTime, headerFields.date, new Date().toLocaleString("ru-RU")),
|
||||||
|
read: false,
|
||||||
|
flagged: false,
|
||||||
|
attachments: msgAttachments(reader, data.attachments)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportEmlBlob(message: MailMessage): Blob {
|
||||||
|
return new Blob([buildEmlSource(message)], { type: EML_MIME_TYPE });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedEmlFileName(fileName: string): boolean {
|
||||||
|
return fileName.toLowerCase().endsWith(".eml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedMsgFileName(fileName: string): boolean {
|
||||||
|
return fileName.toLowerCase().endsWith(".msg");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedMailFileName(fileName: string): boolean {
|
||||||
|
return isSupportedEmlFileName(fileName) || isSupportedMsgFileName(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEmlSource(source: string): { headers: MailHeaders; body: string } {
|
||||||
|
const normalized = normalizeMailNewlines(source);
|
||||||
|
const separatorIndex = normalized.indexOf("\n\n");
|
||||||
|
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
throw new Error("Не удалось прочитать EML: не найден раздел заголовков письма.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: parseEmlHeaders(normalized.slice(0, separatorIndex)),
|
||||||
|
body: normalized.slice(separatorIndex + 2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEmlHeaders(source: string): MailHeaders {
|
||||||
|
const headers: MailHeaders = {};
|
||||||
|
let activeHeader = "";
|
||||||
|
|
||||||
|
for (const line of normalizeMailNewlines(source).split("\n")) {
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[ \t]/.test(line) && activeHeader) {
|
||||||
|
headers[activeHeader] = `${headers[activeHeader]} ${line.trim()}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonIndex = line.indexOf(":");
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeHeader = line.slice(0, colonIndex).trim().toLowerCase();
|
||||||
|
headers[activeHeader] = line.slice(colonIndex + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMessageBody(headers: MailHeaders, body: string): string {
|
||||||
|
const contentType = parseContentType(headers["content-type"]);
|
||||||
|
|
||||||
|
if (contentType.mediaType.startsWith("multipart/")) {
|
||||||
|
const boundary = contentType.parameters.boundary;
|
||||||
|
if (!boundary) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyParts = parseMultipartBody(body, boundary)
|
||||||
|
.filter((part) => !isAttachmentPart(part.headers))
|
||||||
|
.map((part) => {
|
||||||
|
const partType = parseContentType(part.headers["content-type"]);
|
||||||
|
return {
|
||||||
|
mediaType: partType.mediaType,
|
||||||
|
text: extractMessageBody(part.headers, part.body)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((part) => part.text.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
bodyParts.find((part) => part.mediaType === "text/plain")?.text ??
|
||||||
|
bodyParts.find((part) => part.mediaType === "text/html")?.text ??
|
||||||
|
bodyParts[0]?.text ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = decodeTransferBody(body, headers["content-transfer-encoding"], contentType.parameters.charset);
|
||||||
|
return contentType.mediaType === "text/html" ? htmlToPlainText(decoded) : normalizeMailNewlines(decoded).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMessageAttachments(headers: MailHeaders, body: string): MailAttachment[] {
|
||||||
|
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
||||||
|
return {
|
||||||
|
id: createAttachmentId(file.name),
|
||||||
|
fileName: file.name || "Вложение",
|
||||||
|
contentType: file.type || "application/octet-stream",
|
||||||
|
size: file.size,
|
||||||
|
contentBase64: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
|
||||||
|
disposition: "attachment"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachmentToBlob(attachment: MailAttachment): Blob {
|
||||||
|
const bytes = base64ToBytes(attachment.contentBase64);
|
||||||
|
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
|
||||||
|
new Uint8Array(arrayBuffer).set(bytes);
|
||||||
|
return new Blob([arrayBuffer], {
|
||||||
|
type: attachment.contentType || "application/octet-stream"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function msgBodyText(data: MsgFileData): string {
|
||||||
|
const plainBody = firstText(data.body);
|
||||||
|
if (plainBody) {
|
||||||
|
return normalizeMailNewlines(plainBody).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlBody = firstText(data.bodyHtml);
|
||||||
|
if (htmlBody) {
|
||||||
|
return htmlToPlainText(htmlBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.html && data.html.byteLength > 0) {
|
||||||
|
return htmlToPlainText(decodeBytes(data.html, msgCharset(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function msgCharset(data: MsgFileData) {
|
||||||
|
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
|
||||||
|
return parseContentType(headerFields["content-type"]).parameters.charset || "utf-8";
|
||||||
|
}
|
||||||
|
|
||||||
|
function msgRecipientList(recipients: MsgRecipient[] = [], type: MsgRecipient["recipType"] = "to") {
|
||||||
|
return recipients
|
||||||
|
.filter((recipient) => !recipient.recipType || recipient.recipType === type)
|
||||||
|
.map((recipient) => firstText(recipient.smtpAddress, recipient.email, recipient.name))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function msgAttachments(reader: Pick<MsgReaderInstance, "getAttachment">, attachments: MsgAttachmentSummary[] = []): MailAttachment[] {
|
||||||
|
return attachments
|
||||||
|
.filter((attachment) => !attachment.attachmentHidden)
|
||||||
|
.flatMap((attachment, index) => {
|
||||||
|
try {
|
||||||
|
const data = reader.getAttachment(attachment);
|
||||||
|
const fileName = firstText(data.fileName, attachment.fileName, attachment.fileNameShort, attachment.name, `Вложение ${index + 1}`);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: createAttachmentId(fileName, index),
|
||||||
|
fileName,
|
||||||
|
contentType: firstText(attachment.attachMimeTag, mimeTypeFromFileName(fileName)),
|
||||||
|
size: data.content.byteLength,
|
||||||
|
contentBase64: bytesToBase64(data.content),
|
||||||
|
disposition: "attachment" as const,
|
||||||
|
...(attachment.pidContentId ? { contentId: attachment.pidContentId } : {})
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeTypeFromFileName(fileName: string) {
|
||||||
|
const extension = fileName.toLowerCase().split(".").pop() ?? "";
|
||||||
|
const mimeTypesByExtension: Record<string, string> = {
|
||||||
|
csv: "text/csv",
|
||||||
|
doc: "application/msword",
|
||||||
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
gif: "image/gif",
|
||||||
|
html: "text/html",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
pdf: "application/pdf",
|
||||||
|
png: "image/png",
|
||||||
|
ppt: "application/vnd.ms-powerpoint",
|
||||||
|
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
txt: "text/plain",
|
||||||
|
xls: "application/vnd.ms-excel",
|
||||||
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
zip: "application/zip"
|
||||||
|
};
|
||||||
|
|
||||||
|
return mimeTypesByExtension[extension] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstText(...values: Array<string | undefined | null>) {
|
||||||
|
return values.map((value) => value?.trim() ?? "").find(Boolean) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMultipartBody(body: string, boundary: string): MailPart[] {
|
||||||
|
const marker = `--${boundary}`;
|
||||||
|
const closingMarker = `--${boundary}--`;
|
||||||
|
const parts: string[] = [];
|
||||||
|
let activePart: string[] = [];
|
||||||
|
let insidePart = false;
|
||||||
|
|
||||||
|
for (const line of normalizeMailNewlines(body).split("\n")) {
|
||||||
|
if (line === marker || line === closingMarker) {
|
||||||
|
if (insidePart && activePart.length > 0) {
|
||||||
|
parts.push(activePart.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
activePart = [];
|
||||||
|
insidePart = line === marker;
|
||||||
|
if (line === closingMarker) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insidePart) {
|
||||||
|
activePart.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.flatMap((part) => {
|
||||||
|
try {
|
||||||
|
return [parseEmlSource(part)];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAttachmentParts(headers: MailHeaders, body: string): MailPart[] {
|
||||||
|
const contentType = parseContentType(headers["content-type"]);
|
||||||
|
|
||||||
|
if (contentType.mediaType.startsWith("multipart/")) {
|
||||||
|
const boundary = contentType.parameters.boundary;
|
||||||
|
if (!boundary) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseMultipartBody(body, boundary).flatMap((part) => collectAttachmentParts(part.headers, part.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAttachmentPart(headers) ? [{ headers, body }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentFromPart(part: MailPart, index: number): MailAttachment {
|
||||||
|
const contentType = parseContentType(part.headers["content-type"]);
|
||||||
|
const disposition = parseContentDisposition(part.headers["content-disposition"]);
|
||||||
|
const bytes = decodeTransferBytes(part.body, part.headers["content-transfer-encoding"]);
|
||||||
|
const fileName =
|
||||||
|
decodeMimeParameter(disposition.parameters, "filename") ??
|
||||||
|
decodeMimeParameter(contentType.parameters, "name") ??
|
||||||
|
`Вложение ${index + 1}`;
|
||||||
|
const contentId = part.headers["content-id"]?.replace(/^<|>$/g, "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createAttachmentId(fileName, index),
|
||||||
|
fileName,
|
||||||
|
contentType: contentType.mediaType || "application/octet-stream",
|
||||||
|
size: bytes.byteLength,
|
||||||
|
contentBase64: bytesToBase64(bytes),
|
||||||
|
disposition: disposition.value === "inline" ? "inline" : "attachment",
|
||||||
|
...(contentId ? { contentId } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseContentType(value = "text/plain"): ParsedContentType {
|
||||||
|
const parsed = parseHeaderParameters(value, "text/plain");
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaType: parsed.value.toLowerCase(),
|
||||||
|
parameters: parsed.parameters
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEmlSource(message: MailMessage): string {
|
||||||
|
const attachments = message.attachments ?? [];
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
const boundary = createMultipartBoundary(message);
|
||||||
|
const lines = [
|
||||||
|
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||||
|
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||||
|
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
|
||||||
|
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||||
|
"",
|
||||||
|
`--${boundary}`,
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"Content-Transfer-Encoding: 8bit",
|
||||||
|
"",
|
||||||
|
normalizeMailNewlines(message.body || ""),
|
||||||
|
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
||||||
|
`--${boundary}--`,
|
||||||
|
""
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||||
|
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||||
|
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
|
||||||
|
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"Content-Transfer-Encoding: 8bit",
|
||||||
|
"",
|
||||||
|
normalizeMailNewlines(message.body || "")
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeMimeWords(value: string): string {
|
||||||
|
return value.replace(/=\?([^?]+)\?([bqBQ])\?([^?]+)\?=/g, (_match, charset: string, encoding: string, encoded: string) => {
|
||||||
|
const normalizedCharset = charset.toLowerCase();
|
||||||
|
if (normalizedCharset !== "utf-8" && normalizedCharset !== "utf8") {
|
||||||
|
return _match;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encoding.toLowerCase() === "b") {
|
||||||
|
return decodeUtf8Bytes(base64ToBytes(encoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeUtf8Bytes(quotedPrintableToBytes(encoded.replace(/_/g, " ")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTransferBody(body: string, encoding = "", charset = "utf-8"): string {
|
||||||
|
const normalizedEncoding = encoding.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedEncoding === "base64" || normalizedEncoding === "quoted-printable") {
|
||||||
|
return decodeBytes(decodeTransferBytes(body, normalizedEncoding), charset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeMailNewlines(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlToPlainText(html: string): string {
|
||||||
|
const withBreaks = html
|
||||||
|
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, " ")
|
||||||
|
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
||||||
|
.replace(/<\s*\/\s*(p|div|li|tr|h[1-6])\s*>/gi, "\n")
|
||||||
|
.replace(/<\s*li[^>]*>/gi, "• ")
|
||||||
|
.replace(/<[^>]+>/g, " ");
|
||||||
|
|
||||||
|
return decodeHtmlEntities(withBreaks)
|
||||||
|
.replace(/[ \t\f\v]+/g, " ")
|
||||||
|
.replace(/\n[ \t]+/g, "\n")
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMailNewlines(value: string): string {
|
||||||
|
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContentDisposition(value = ""): ParsedHeaderParameters {
|
||||||
|
return parseHeaderParameters(value, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeaderParameters(value: string, fallbackValue: string): ParsedHeaderParameters {
|
||||||
|
const [rawValue, ...rawParameters] = splitMimeHeaderParameters(value || fallbackValue);
|
||||||
|
const parameters: Record<string, string> = {};
|
||||||
|
|
||||||
|
rawParameters.forEach((parameter) => {
|
||||||
|
const equalsIndex = parameter.indexOf("=");
|
||||||
|
if (equalsIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = parameter.slice(0, equalsIndex).trim().toLowerCase();
|
||||||
|
const parameterValue = unquoteHeaderValue(parameter.slice(equalsIndex + 1).trim());
|
||||||
|
if (key) {
|
||||||
|
parameters[key] = parameterValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: (rawValue || fallbackValue).trim(),
|
||||||
|
parameters
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeMimeParameter(parameters: Record<string, string>, name: string): string | undefined {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
const encodedDirectValue = parameters[`${normalizedName}*`];
|
||||||
|
if (encodedDirectValue) {
|
||||||
|
return decodeRfc2231Value(encodedDirectValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directValue = parameters[normalizedName];
|
||||||
|
if (directValue) {
|
||||||
|
return decodeMimeWords(directValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const continuations: Array<{ value: string; encoded: boolean }> = [];
|
||||||
|
for (let index = 0; ; index += 1) {
|
||||||
|
const encodedKey = `${normalizedName}*${index}*`;
|
||||||
|
const plainKey = `${normalizedName}*${index}`;
|
||||||
|
if (parameters[encodedKey] !== undefined) {
|
||||||
|
continuations.push({ value: parameters[encodedKey], encoded: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameters[plainKey] !== undefined) {
|
||||||
|
continuations.push({ value: parameters[plainKey], encoded: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (continuations.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedValue = continuations.map((part) => part.value).join("");
|
||||||
|
return continuations.some((part) => part.encoded) ? decodeRfc2231Value(combinedValue) : decodeMimeWords(combinedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRfc2231Value(value: string): string {
|
||||||
|
const charsetMatch = /^([^']*)'[^']*'(.*)$/.exec(value);
|
||||||
|
const charset = charsetMatch?.[1] || "utf-8";
|
||||||
|
const encodedValue = charsetMatch?.[2] ?? value;
|
||||||
|
return decodeBytes(percentEncodedToBytes(encodedValue), charset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentEncodedToBytes(value: string): Uint8Array {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const char = value[index];
|
||||||
|
if (char === "%" && /^[\dA-Fa-f]{2}$/.test(value.slice(index + 1, index + 3))) {
|
||||||
|
bytes.push(Number.parseInt(value.slice(index + 1, index + 3), 16));
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const byte of new TextEncoder().encode(char)) {
|
||||||
|
bytes.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTransferBytes(body: string, encoding = ""): Uint8Array {
|
||||||
|
const normalizedEncoding = encoding.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedEncoding === "base64") {
|
||||||
|
return base64ToBytes(body.replace(/\s+/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEncoding === "quoted-printable") {
|
||||||
|
return quotedPrintableToBytes(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextEncoder().encode(normalizeMailNewlines(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMultipartBoundary(message: MailMessage): string {
|
||||||
|
const seed = `${message.id}-${message.subject}-${message.attachments?.length ?? 0}`;
|
||||||
|
const normalizedSeed = seed.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
return `qoffice-${normalizedSeed || "message"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
||||||
|
const safeContentType = sanitizeMimeType(attachment.contentType);
|
||||||
|
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
||||||
|
const contentId = sanitizeHeaderValue(attachment.contentId ?? "");
|
||||||
|
return [
|
||||||
|
`--${boundary}`,
|
||||||
|
`Content-Type: ${safeContentType}; ${formatHeaderParameter("name", attachment.fileName)}`,
|
||||||
|
"Content-Transfer-Encoding: base64",
|
||||||
|
`Content-Disposition: ${disposition}; ${formatHeaderParameter("filename", attachment.fileName)}`,
|
||||||
|
...(contentId ? [`Content-ID: <${contentId}>`] : []),
|
||||||
|
"",
|
||||||
|
wrapBase64(attachment.contentBase64)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeaderParameter(name: string, value: string): string {
|
||||||
|
const sanitized = sanitizeHeaderValue(value || "Вложение");
|
||||||
|
if (/^[\x20-\x7E]*$/.test(sanitized)) {
|
||||||
|
return `${name}="${sanitized.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${name}*=utf-8''${percentEncodeUtf8(sanitized)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentEncodeUtf8(value: string): string {
|
||||||
|
return Array.from(new TextEncoder().encode(value))
|
||||||
|
.map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMimeType(value = "application/octet-stream"): string {
|
||||||
|
const mediaType = parseContentType(value).mediaType;
|
||||||
|
return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mediaType) ? mediaType : "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapBase64(value: string): string {
|
||||||
|
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAttachmentId(fileName: string, index?: number): string {
|
||||||
|
const normalizedName = fileName.toLowerCase().replace(/[^a-z0-9а-яё]+/gi, "-").replace(/^-+|-+$/g, "");
|
||||||
|
const suffix = index === undefined ? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : String(index + 1);
|
||||||
|
return `attachment-${normalizedName || "file"}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeHeaderValue(value: string): string {
|
||||||
|
if (/^[\x20-\x7E]*$/.test(value)) {
|
||||||
|
return sanitizeHeaderValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `=?UTF-8?B?${btoa(binary)}?=`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeHeaderValue(value: string): string {
|
||||||
|
return value.replace(/[\r\n]+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMimeHeaderParameters(value: string): string[] {
|
||||||
|
const segments: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let quoted = false;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (const char of value) {
|
||||||
|
if (escaped) {
|
||||||
|
current += char;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\\") {
|
||||||
|
current += char;
|
||||||
|
escaped = quoted;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
quoted = !quoted;
|
||||||
|
current += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ";" && !quoted) {
|
||||||
|
segments.push(current.trim());
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push(current.trim());
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unquoteHeaderValue(value: string): string {
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAttachmentPart(headers: MailHeaders): boolean {
|
||||||
|
const disposition = parseContentDisposition(headers["content-disposition"]);
|
||||||
|
const contentType = parseContentType(headers["content-type"]);
|
||||||
|
return (
|
||||||
|
disposition.value.toLowerCase() === "attachment" ||
|
||||||
|
Boolean(decodeMimeParameter(disposition.parameters, "filename")) ||
|
||||||
|
Boolean(decodeMimeParameter(contentType.parameters, "name"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(value: string): string {
|
||||||
|
const namedEntities: Record<string, string> = {
|
||||||
|
amp: "&",
|
||||||
|
gt: ">",
|
||||||
|
lt: "<",
|
||||||
|
nbsp: " ",
|
||||||
|
quot: '"',
|
||||||
|
apos: "'"
|
||||||
|
};
|
||||||
|
|
||||||
|
return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
|
||||||
|
const normalized = entity.toLowerCase();
|
||||||
|
if (normalized.startsWith("#x")) {
|
||||||
|
return String.fromCodePoint(Number.parseInt(normalized.slice(2), 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("#")) {
|
||||||
|
return String.fromCodePoint(Number.parseInt(normalized.slice(1), 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
return namedEntities[normalized] ?? match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string): Uint8Array {
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.slice(index, index + chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotedPrintableToBytes(value: string): Uint8Array {
|
||||||
|
const unfolded = value.replace(/=\n/g, "");
|
||||||
|
const bytes: number[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < unfolded.length; index += 1) {
|
||||||
|
const char = unfolded[index];
|
||||||
|
if (char === "=" && /^[\dA-Fa-f]{2}$/.test(unfolded.slice(index + 1, index + 3))) {
|
||||||
|
bytes.push(Number.parseInt(unfolded.slice(index + 1, index + 3), 16));
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.push(char.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUtf8Bytes(bytes: Uint8Array): string {
|
||||||
|
return decodeBytes(bytes, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBytes(bytes: Uint8Array, charset = "utf-8"): string {
|
||||||
|
try {
|
||||||
|
return new TextDecoder(charset || "utf-8", { fatal: false }).decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMsgReader(): Promise<MsgReaderConstructor> {
|
||||||
|
const module = (await import("@kenjiuno/msgreader")) as unknown as { default?: MsgReaderConstructor } & MsgReaderConstructor;
|
||||||
|
return module.default ?? module;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pdfFileName } from "./pdfExport";
|
||||||
|
|
||||||
|
describe("pdfExport helpers", () => {
|
||||||
|
it("заменяет расширение на PDF", () => {
|
||||||
|
expect(pdfFileName("Документ QWord.docx")).toBe("Документ QWord.pdf");
|
||||||
|
expect(pdfFileName("Книга QExcell.xlsx")).toBe("Книга QExcell.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("добавляет расширение PDF к имени без расширения", () => {
|
||||||
|
expect(pdfFileName("Презентация")).toBe("Презентация.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { replaceFileExtension } from "./fileHelpers";
|
||||||
|
|
||||||
|
export function isDesktopPdfExportAvailable() {
|
||||||
|
return Boolean(window.qoffice?.isDesktop && window.qoffice.exportPdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportPdfOrPrint(defaultFileName: string) {
|
||||||
|
if (isDesktopPdfExportAvailable()) {
|
||||||
|
await window.qoffice?.exportPdf?.(pdfFileName(defaultFileName));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pdfFileName(fileName: string) {
|
||||||
|
return replaceFileExtension(fileName || "QOffice", ".pdf");
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
import {
|
||||||
|
exportPptxBlob,
|
||||||
|
extractTextBlocks,
|
||||||
|
importPptxFile,
|
||||||
|
listSlidePaths,
|
||||||
|
normalizeWhitespace,
|
||||||
|
presentationSlidePaths,
|
||||||
|
relationshipTargetByType,
|
||||||
|
resolvePptTarget
|
||||||
|
} from "./powerPointOffice";
|
||||||
|
|
||||||
|
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
describe("powerPointOffice helpers", () => {
|
||||||
|
it("извлекает текстовые runs из OOXML-подобных узлов", () => {
|
||||||
|
const parsedSlide = {
|
||||||
|
sld: {
|
||||||
|
cSld: {
|
||||||
|
spTree: {
|
||||||
|
sp: [
|
||||||
|
{ txBody: { p: { r: { t: "Заголовок" } } } },
|
||||||
|
{ txBody: { p: [{ r: { t: "Первый пункт" } }, { r: { t: "Второй пункт" } }] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(extractTextBlocks(parsedSlide)).toEqual(["Заголовок", "Первый пункт", "Второй пункт"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сортирует пути слайдов по числовому индексу", () => {
|
||||||
|
const zip = {
|
||||||
|
files: {
|
||||||
|
"ppt/slides/slide10.xml": {},
|
||||||
|
"ppt/slides/slide2.xml": {},
|
||||||
|
"ppt/slides/slide1.xml": {},
|
||||||
|
"ppt/notesSlides/notesSlide1.xml": {}
|
||||||
|
},
|
||||||
|
file: () => null
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(listSlidePaths(zip)).toEqual([
|
||||||
|
{ path: "ppt/slides/slide1.xml", index: 1 },
|
||||||
|
{ path: "ppt/slides/slide2.xml", index: 2 },
|
||||||
|
{ path: "ppt/slides/slide10.xml", index: 10 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("читает порядок слайдов из presentation relationships", () => {
|
||||||
|
const presentationXml = {
|
||||||
|
presentation: {
|
||||||
|
sldIdLst: {
|
||||||
|
sldId: [{ id: "300", "r:id": "rIdB" }, { id: "200", "r:id": "rIdA" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const relationshipsXml = {
|
||||||
|
Relationships: {
|
||||||
|
Relationship: [
|
||||||
|
{
|
||||||
|
Id: "rIdA",
|
||||||
|
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
|
||||||
|
Target: "slides/slide1.xml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "rIdB",
|
||||||
|
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
|
||||||
|
Target: "slides/slide10.xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(presentationSlidePaths(presentationXml, relationshipsXml)).toEqual(["ppt/slides/slide10.xml", "ppt/slides/slide1.xml"]);
|
||||||
|
expect(listSlidePaths({ files: {}, file: () => null }, presentationXml, relationshipsXml)).toEqual([
|
||||||
|
{ path: "ppt/slides/slide10.xml", index: 10 },
|
||||||
|
{ path: "ppt/slides/slide1.xml", index: 1 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("разрешает относительные targets заметок от части слайда", () => {
|
||||||
|
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
|
||||||
|
expect(
|
||||||
|
relationshipTargetByType(
|
||||||
|
{
|
||||||
|
Relationships: {
|
||||||
|
Relationship: {
|
||||||
|
Id: "rIdNotes",
|
||||||
|
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide",
|
||||||
|
Target: "../notesSlides/notesSlide7.xml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide",
|
||||||
|
"ppt/slides/slide3.xml"
|
||||||
|
)
|
||||||
|
).toBe("ppt/notesSlides/notesSlide7.xml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("импортирует PPTX в порядке presentation.xml и берет заметки через slide rels", async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file(
|
||||||
|
"ppt/presentation.xml",
|
||||||
|
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="255" r:id="rId1"/></p:sldIdLst></p:presentation>`
|
||||||
|
);
|
||||||
|
zip.file(
|
||||||
|
"ppt/_rels/presentation.xml.rels",
|
||||||
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/></Relationships>`
|
||||||
|
);
|
||||||
|
zip.file("ppt/slides/slide1.xml", slideXml("Первый файл", "Но второй по порядку"));
|
||||||
|
zip.file("ppt/slides/slide2.xml", slideXml("Первый по порядку", "Содержимое первого слайда"));
|
||||||
|
zip.file(
|
||||||
|
"ppt/slides/_rels/slide2.xml.rels",
|
||||||
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdNotes" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide9.xml"/></Relationships>`
|
||||||
|
);
|
||||||
|
zip.file("ppt/notesSlides/notesSlide9.xml", slideXml("Заметка докладчика"));
|
||||||
|
|
||||||
|
const blob = await zip.generateAsync({
|
||||||
|
type: "blob",
|
||||||
|
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
});
|
||||||
|
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
|
||||||
|
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
|
||||||
|
expect(imported.slides[0].notes).toBe("Заметка докладчика");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("импортирует изображения слайда из PPTX media relationships", async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file(
|
||||||
|
"ppt/presentation.xml",
|
||||||
|
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>`
|
||||||
|
);
|
||||||
|
zip.file(
|
||||||
|
"ppt/_rels/presentation.xml.rels",
|
||||||
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
|
||||||
|
);
|
||||||
|
zip.file("ppt/slides/slide1.xml", slideXmlWithPicture("Слайд с изображением"));
|
||||||
|
zip.file(
|
||||||
|
"ppt/slides/_rels/slide1.xml.rels",
|
||||||
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/logo.png"/></Relationships>`
|
||||||
|
);
|
||||||
|
zip.file("ppt/media/logo.png", tinyPngBase64, { base64: true });
|
||||||
|
|
||||||
|
const blob = await zip.generateAsync({
|
||||||
|
type: "blob",
|
||||||
|
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
});
|
||||||
|
const imported = await importPptxFile(new File([blob], "Изображения.pptx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(imported.slides[0].title).toBe("Слайд с изображением");
|
||||||
|
expect(imported.slides[0].images).toEqual([
|
||||||
|
{
|
||||||
|
id: "pptx-slide-1-image-1",
|
||||||
|
src: `data:image/png;base64,${tinyPngBase64}`,
|
||||||
|
alt: "Логотип",
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
width: 3,
|
||||||
|
height: 1.5
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("экспортирует изображения слайда в PPTX media relationships", async () => {
|
||||||
|
const blob = await exportPptxBlob({
|
||||||
|
title: "Слайды с изображением.pptx",
|
||||||
|
slides: [
|
||||||
|
{
|
||||||
|
id: "slide-1",
|
||||||
|
title: "Слайд с изображением",
|
||||||
|
subtitle: "Проверка экспорта",
|
||||||
|
notes: "",
|
||||||
|
theme: "classic",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
id: "image-1",
|
||||||
|
src: `data:image/png;base64,${tinyPngBase64}`,
|
||||||
|
alt: "Логотип",
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
width: 3,
|
||||||
|
height: 1.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const slideXml = await zip.file("ppt/slides/slide1.xml")?.async("string");
|
||||||
|
const relationshipsXml = await zip.file("ppt/slides/_rels/slide1.xml.rels")?.async("string");
|
||||||
|
const mediaFiles = Object.keys(zip.files).filter((path) => path.startsWith("ppt/media/") && path.endsWith(".png"));
|
||||||
|
|
||||||
|
expect(slideXml).toContain("<p:pic>");
|
||||||
|
expect(slideXml).toContain('descr="Логотип"');
|
||||||
|
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"');
|
||||||
|
expect(mediaFiles.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("нормализует пробелы в импортированном тексте", () => {
|
||||||
|
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function slideXml(...texts: string[]) {
|
||||||
|
return `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree>${texts
|
||||||
|
.map((text) => `<p:sp><p:txBody><a:p><a:r><a:t>${text}</a:t></a:r></a:p></p:txBody></p:sp>`)
|
||||||
|
.join("")}</p:spTree></p:cSld></p:sld>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideXmlWithPicture(title: string) {
|
||||||
|
return `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:pic><p:nvPicPr><p:cNvPr id="4" name="Picture 1" descr="Логотип"/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdImage"/></p:blipFill><p:spPr><a:xfrm><a:off x="914400" y="1828800"/><a:ext cx="2743200" cy="1371600"/></a:xfrm></p:spPr></p:pic></p:spTree></p:cSld></p:sld>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import type { Slide, SlideImage } from "../types";
|
||||||
|
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
||||||
|
|
||||||
|
export interface ImportedPowerPointDeck {
|
||||||
|
title: string;
|
||||||
|
slides: Slide[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportablePowerPointDeck {
|
||||||
|
title: string;
|
||||||
|
slides: Slide[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type XmlParserConstructor = new (options?: Record<string, unknown>) => { parse: (xml: string) => unknown };
|
||||||
|
type ZipEntry = {
|
||||||
|
async(type: "string"): Promise<string>;
|
||||||
|
async(type: "base64"): Promise<string>;
|
||||||
|
};
|
||||||
|
type ZipArchive = { files?: Record<string, unknown>; file: (path: string) => ZipEntry | null };
|
||||||
|
|
||||||
|
const slidePathPattern = /^ppt\/slides\/slide(\d+)\.xml$/;
|
||||||
|
const slideRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
|
||||||
|
const notesRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
|
||||||
|
const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
||||||
|
const emuPerInch = 914400;
|
||||||
|
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
|
||||||
|
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
|
||||||
|
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
|
||||||
|
graphite: { background: "1F2937", title: "F9FAFB", subtitle: "CBD5E1" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck> {
|
||||||
|
if (!file.name.toLowerCase().endsWith(".pptx")) {
|
||||||
|
throw new Error("QPowerPoint импортирует только файлы Microsoft PowerPoint .pptx.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const zip = await loadZip(file);
|
||||||
|
const parser = await createXmlParser();
|
||||||
|
const title = officeTitleFromFileName(file.name, "Презентация QPowerPoint.pptx");
|
||||||
|
const presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
|
||||||
|
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
|
||||||
|
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
|
||||||
|
|
||||||
|
if (slidePaths.length === 0) {
|
||||||
|
throw new Error("В файле PowerPoint не найдены слайды.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const slides = await Promise.all(
|
||||||
|
slidePaths.map(async ({ path, index }) => {
|
||||||
|
const slideXml = await readRequiredZipText(zip, path, "Не удалось прочитать слайд PowerPoint.");
|
||||||
|
const parsedSlide = parser.parse(slideXml);
|
||||||
|
const slideTextBlocks = extractTextBlocks(parsedSlide);
|
||||||
|
const images = await extractSlideImages(zip, parser, parsedSlide, path, index);
|
||||||
|
const notesPath = await slideNotesPath(zip, parser, path);
|
||||||
|
const notesXml = notesPath ? await readOptionalZipText(zip, notesPath) : await readOptionalZipText(zip, `ppt/notesSlides/notesSlide${index}.xml`);
|
||||||
|
const notes = notesXml ? extractTextBlocks(parser.parse(notesXml)).join("\n") : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `pptx-slide-${index}`,
|
||||||
|
title: slideTextBlocks[0] || `Слайд ${index}`,
|
||||||
|
subtitle: slideTextBlocks.slice(1).join("\n"),
|
||||||
|
notes,
|
||||||
|
theme: "classic" as const,
|
||||||
|
images
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { title, slides };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
|
||||||
|
const moduleName = "pptxgenjs";
|
||||||
|
const pptxModule = (await import(moduleName)) as { default?: new () => Record<string, unknown> };
|
||||||
|
const PptxGenJS = pptxModule.default ?? (pptxModule as unknown as new () => Record<string, unknown>);
|
||||||
|
const pptx = new PptxGenJS() as Record<string, unknown> & {
|
||||||
|
layout?: string;
|
||||||
|
author?: string;
|
||||||
|
subject?: string;
|
||||||
|
title?: string;
|
||||||
|
company?: string;
|
||||||
|
addSlide: () => {
|
||||||
|
background?: { color: string };
|
||||||
|
addText: (text: string, options: Record<string, unknown>) => void;
|
||||||
|
addImage?: (options: Record<string, unknown>) => void;
|
||||||
|
addNotes?: (notes: string) => void;
|
||||||
|
};
|
||||||
|
write: (options: { outputType: "blob" | "arraybuffer" }) => Promise<Blob | ArrayBuffer>;
|
||||||
|
};
|
||||||
|
|
||||||
|
pptx.layout = "LAYOUT_WIDE";
|
||||||
|
pptx.author = "QOffice";
|
||||||
|
pptx.company = "QOffice";
|
||||||
|
pptx.subject = "Экспорт QPowerPoint";
|
||||||
|
pptx.title = deck.title;
|
||||||
|
|
||||||
|
for (const sourceSlide of deck.slides) {
|
||||||
|
const colors = themeColors[sourceSlide.theme] ?? themeColors.classic;
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: colors.background };
|
||||||
|
sourceSlide.images?.forEach((image, index) => {
|
||||||
|
const data = normalizedImageData(image.src);
|
||||||
|
if (!data || !slide.addImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
slide.addImage({
|
||||||
|
data,
|
||||||
|
x: image.x,
|
||||||
|
y: image.y,
|
||||||
|
w: image.width,
|
||||||
|
h: image.height,
|
||||||
|
altText: image.alt || `Изображение ${index + 1}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
slide.addText(sourceSlide.title || "Без заголовка", {
|
||||||
|
x: 0.7,
|
||||||
|
y: 0.65,
|
||||||
|
w: 11.9,
|
||||||
|
h: 0.75,
|
||||||
|
fontFace: "Arial",
|
||||||
|
fontSize: 30,
|
||||||
|
bold: true,
|
||||||
|
color: colors.title,
|
||||||
|
margin: 0.04,
|
||||||
|
breakLine: false
|
||||||
|
});
|
||||||
|
slide.addText(sourceSlide.subtitle || "", {
|
||||||
|
x: 0.75,
|
||||||
|
y: 1.65,
|
||||||
|
w: 11.5,
|
||||||
|
h: 4.35,
|
||||||
|
fontFace: "Arial",
|
||||||
|
fontSize: 18,
|
||||||
|
color: colors.subtitle,
|
||||||
|
valign: "top",
|
||||||
|
fit: "shrink",
|
||||||
|
breakLine: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sourceSlide.notes.trim() && slide.addNotes) {
|
||||||
|
slide.addNotes(sourceSlide.notes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pptx.write({ outputType: "blob" });
|
||||||
|
return result instanceof Blob ? result : new Blob([result], { type: PPTX_MIME_TYPE });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTextBlocks(xmlNode: unknown): string[] {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
collectTextBlocks(xmlNode, blocks);
|
||||||
|
return blocks.map(normalizeWhitespace).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSlidePaths(
|
||||||
|
zip: ZipArchive,
|
||||||
|
presentationXml?: unknown,
|
||||||
|
presentationRelationshipsXml?: unknown
|
||||||
|
): Array<{ path: string; index: number }> {
|
||||||
|
const orderedSlidePaths = presentationSlidePaths(presentationXml, presentationRelationshipsXml);
|
||||||
|
if (orderedSlidePaths.length > 0) {
|
||||||
|
return orderedSlidePaths.map((path, index) => ({ path, index: slideIndexFromPath(path) ?? index + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(zip.files ?? {})
|
||||||
|
.map((path) => {
|
||||||
|
const match = slidePathPattern.exec(path);
|
||||||
|
return match ? { path, index: Number(match[1]) } : null;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is { path: string; index: number } => entry !== null)
|
||||||
|
.sort((a, b) => a.index - b.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presentationSlidePaths(presentationXml: unknown, presentationRelationshipsXml: unknown): string[] {
|
||||||
|
const relationshipTargets = relationshipTargetsById(presentationRelationshipsXml, slideRelationshipType);
|
||||||
|
const slideIds = toArray(childRecord(childRecord(presentationXml, "presentation"), "sldIdLst").sldId)
|
||||||
|
.map((entry) => relationshipId(asRecord(entry)))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return slideIds.map((id) => relationshipTargets[id]).filter((target): target is string => Boolean(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
|
||||||
|
const relationships = childRecord(relationshipsXml, "Relationships");
|
||||||
|
const targets: Record<string, string> = {};
|
||||||
|
const baseDirectory = partDirectory(sourcePartPath);
|
||||||
|
|
||||||
|
toArray(relationships.Relationship).forEach((relationship) => {
|
||||||
|
const record = asRecord(relationship);
|
||||||
|
const id = stringValue(record.Id ?? record.id ?? record["@_Id"] ?? record["@_id"]);
|
||||||
|
const target = stringValue(record.Target ?? record.target ?? record["@_Target"] ?? record["@_target"]);
|
||||||
|
const relationshipType = stringValue(record.Type ?? record.type ?? record["@_Type"] ?? record["@_type"]);
|
||||||
|
if (id && target && (!type || relationshipType === type)) {
|
||||||
|
targets[id] = resolvePptTarget(baseDirectory, target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationshipTargetByType(relationshipsXml: unknown, type: string, sourcePartPath: string): string {
|
||||||
|
const relationships = childRecord(relationshipsXml, "Relationships");
|
||||||
|
const relationship = toArray(relationships.Relationship)
|
||||||
|
.map(asRecord)
|
||||||
|
.find((record) => stringValue(record.Type ?? record.type ?? record["@_Type"] ?? record["@_type"]) === type);
|
||||||
|
|
||||||
|
return relationship ? resolvePptTarget(partDirectory(sourcePartPath), stringValue(relationship.Target ?? relationship.target ?? relationship["@_Target"] ?? relationship["@_target"])) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePptTarget(baseDirectory: string, target: string): string {
|
||||||
|
const normalizedBase = baseDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
const normalizedTarget = target.replace(/\\/g, "/");
|
||||||
|
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${normalizedBase}/${normalizedTarget}`;
|
||||||
|
const segments: string[] = [];
|
||||||
|
|
||||||
|
rawPath.split("/").forEach((segment) => {
|
||||||
|
if (!segment || segment === ".") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment === "..") {
|
||||||
|
segments.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push(segment);
|
||||||
|
});
|
||||||
|
|
||||||
|
return segments.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWhitespace(value: string): string {
|
||||||
|
return value.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractSlideImages(
|
||||||
|
zip: ZipArchive,
|
||||||
|
parser: { parse: (xml: string) => unknown },
|
||||||
|
slideXml: unknown,
|
||||||
|
slidePath: string,
|
||||||
|
slideIndex: number
|
||||||
|
): Promise<SlideImage[]> {
|
||||||
|
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
|
||||||
|
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
|
||||||
|
const imageTargets = relationshipTargetsById(relationshipsXml, imageRelationshipType, slidePath);
|
||||||
|
const pictures = recordsByKey(slideXml, "pic");
|
||||||
|
const images = await Promise.all(
|
||||||
|
pictures.map(async (picture, imageIndex) => slideImageFromPicture(zip, picture, imageTargets, slideIndex, imageIndex))
|
||||||
|
);
|
||||||
|
|
||||||
|
return images.filter((image): image is SlideImage => Boolean(image));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function slideImageFromPicture(
|
||||||
|
zip: ZipArchive,
|
||||||
|
picture: Record<string, unknown>,
|
||||||
|
imageTargets: Record<string, string>,
|
||||||
|
slideIndex: number,
|
||||||
|
imageIndex: number
|
||||||
|
): Promise<SlideImage | null> {
|
||||||
|
const relationshipId = pictureRelationshipId(picture);
|
||||||
|
const target = relationshipId ? imageTargets[relationshipId] : "";
|
||||||
|
const src = target ? await zipImageDataUri(zip, target) : "";
|
||||||
|
if (!src) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `pptx-slide-${slideIndex}-image-${imageIndex + 1}`,
|
||||||
|
src,
|
||||||
|
alt: pictureAltText(picture) || `Изображение ${imageIndex + 1}`,
|
||||||
|
...pictureBounds(picture)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pictureRelationshipId(picture: Record<string, unknown>) {
|
||||||
|
const blip = firstRecordByKey(picture, "blip");
|
||||||
|
return stringValue(
|
||||||
|
blip["r:embed"] ??
|
||||||
|
blip.embed ??
|
||||||
|
blip["@_r:embed"] ??
|
||||||
|
blip["@_embed"] ??
|
||||||
|
blip["r:link"] ??
|
||||||
|
blip.link ??
|
||||||
|
blip["@_r:link"] ??
|
||||||
|
blip["@_link"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pictureAltText(picture: Record<string, unknown>) {
|
||||||
|
const cNvPr = firstRecordByKey(picture, "cNvPr");
|
||||||
|
return stringValue(cNvPr.descr ?? cNvPr["@_descr"] ?? cNvPr.name ?? cNvPr["@_name"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pictureBounds(picture: Record<string, unknown>) {
|
||||||
|
const xfrm = firstRecordByKey(picture, "xfrm");
|
||||||
|
const off = childRecord(xfrm, "off");
|
||||||
|
const ext = childRecord(xfrm, "ext");
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: emuToInches(numberAttribute(off, "x") ?? 914400),
|
||||||
|
y: emuToInches(numberAttribute(off, "y") ?? 3429000),
|
||||||
|
width: emuToInches(numberAttribute(ext, "cx") ?? 3657600),
|
||||||
|
height: emuToInches(numberAttribute(ext, "cy") ?? 2057400)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zipImageDataUri(zip: ZipArchive, path: string) {
|
||||||
|
const mimeType = imageMimeTypeFromPath(path);
|
||||||
|
if (!mimeType) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await zip.file(path)?.async("base64")) ?? "";
|
||||||
|
return data ? `data:${mimeType};base64,${data}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageMimeTypeFromPath(path: string) {
|
||||||
|
const extension = path.toLowerCase().split(/[?#]/)[0].split(".").pop() ?? "";
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
svg: "image/svg+xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
return mimeTypes[extension] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedImageData(src: string) {
|
||||||
|
const value = src.trim();
|
||||||
|
return /^data:image\/(?:png|jpe?g|gif|bmp|svg\+xml);base64,/i.test(value) ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberAttribute(record: Record<string, unknown>, name: string) {
|
||||||
|
const value = Number.parseFloat(stringValue(record[name] ?? record[`@_${name}`]));
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emuToInches(value: number) {
|
||||||
|
return Math.max(0, Math.round((value / emuPerInch) * 1000) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadZip(file: File): Promise<ZipArchive> {
|
||||||
|
const moduleName = "jszip";
|
||||||
|
const jsZipModule = (await import(moduleName)) as { default?: { loadAsync: (data: ArrayBuffer) => Promise<ZipArchive> } };
|
||||||
|
const jsZip = jsZipModule.default ?? (jsZipModule as unknown as { loadAsync: (data: ArrayBuffer) => Promise<ZipArchive> });
|
||||||
|
return jsZip.loadAsync(await readOfficeFileAsArrayBuffer(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createXmlParser(): Promise<{ parse: (xml: string) => unknown }> {
|
||||||
|
const moduleName = "fast-xml-parser";
|
||||||
|
const parserModule = (await import(moduleName)) as { XMLParser: XmlParserConstructor };
|
||||||
|
return new parserModule.XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
removeNSPrefix: true,
|
||||||
|
textNodeName: "#text",
|
||||||
|
trimValues: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOptionalParsedXml(zip: ZipArchive, parser: { parse: (xml: string) => unknown }, path: string): Promise<unknown> {
|
||||||
|
const xml = await readOptionalZipText(zip, path);
|
||||||
|
return xml ? parser.parse(xml) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRequiredZipText(zip: ZipArchive, path: string, errorMessage: string): Promise<string> {
|
||||||
|
const text = await readOptionalZipText(zip, path);
|
||||||
|
if (!text) {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOptionalZipText(zip: ZipArchive, path: string): Promise<string> {
|
||||||
|
return (await zip.file(path)?.async("string")) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function slideNotesPath(zip: ZipArchive, parser: { parse: (xml: string) => unknown }, slidePath: string): Promise<string> {
|
||||||
|
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
|
||||||
|
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
|
||||||
|
return relationshipTargetByType(relationshipsXml, notesRelationshipType, slidePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTextBlocks(node: unknown, blocks: string[]): void {
|
||||||
|
if (node === null || node === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node === "string" || typeof node === "number") {
|
||||||
|
blocks.push(String(node));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) {
|
||||||
|
collectTextBlocks(item, blocks);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = node as Record<string, unknown>;
|
||||||
|
if (typeof record.t === "string" || typeof record.t === "number") {
|
||||||
|
blocks.push(String(record.t));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof record["#text"] === "string" || typeof record["#text"] === "number") {
|
||||||
|
blocks.push(String(record["#text"]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
if (isXmlAttributeKey(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
collectTextBlocks(value, blocks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isXmlAttributeKey(key: string) {
|
||||||
|
return key.startsWith("@_") || ["id", "name", "descr", "x", "y", "cx", "cy", "embed", "link"].includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideIndexFromPath(path: string): number | undefined {
|
||||||
|
const match = slidePathPattern.exec(path);
|
||||||
|
return match ? Number(match[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function partDirectory(path: string): string {
|
||||||
|
return path.replace(/\\/g, "/").replace(/\/[^/]*$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function partFileName(path: string): string {
|
||||||
|
return path.replace(/\\/g, "/").split("/").pop() ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipId(record: Record<string, unknown>): string {
|
||||||
|
return stringValue(record["r:id"] ?? record.id ?? record.rId ?? record["@_r:id"] ?? record["@_id"] ?? record["@_rId"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function childRecord(parent: unknown, key: string): Record<string, unknown> {
|
||||||
|
const record = asRecord(parent);
|
||||||
|
return asRecord(record[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(value: unknown): unknown[] {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(value) ? value : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordsByKey(node: unknown, key: string): Array<Record<string, unknown>> {
|
||||||
|
const records: Array<Record<string, unknown>> = [];
|
||||||
|
collectRecordsByKey(node, key, records);
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstRecordByKey(node: unknown, key: string): Record<string, unknown> {
|
||||||
|
return recordsByKey(node, key)[0] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRecordsByKey(node: unknown, key: string, records: Array<Record<string, unknown>>): void {
|
||||||
|
if (node === null || node === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
node.forEach((item) => collectRecordsByKey(item, key, records));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(node as Record<string, unknown>).forEach(([entryKey, value]) => {
|
||||||
|
if (entryKey === key) {
|
||||||
|
toArray(value).forEach((item) => {
|
||||||
|
const record = asRecord(item);
|
||||||
|
if (Object.keys(record).length > 0) {
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
collectRecordsByKey(value, key, records);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
import { exportDocxBlob, htmlToWordBlocks, normalizeImportedDocxHtml, wordInlineRunsToTextRunOptions } from "./wordOffice";
|
||||||
|
|
||||||
|
const tinyPngDataUri =
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
describe("wordOffice helpers", () => {
|
||||||
|
it("разбирает заголовки, абзацы и списки", () => {
|
||||||
|
const blocks = htmlToWordBlocks(`
|
||||||
|
<h1>План проекта</h1>
|
||||||
|
<h2>Задачи</h2>
|
||||||
|
<p>Первый абзац.</p>
|
||||||
|
<ul><li>Подготовить документ</li><li>Проверить таблицу</li></ul>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{ type: "heading", level: 1, text: "План проекта", runs: [{ text: "План проекта" }] },
|
||||||
|
{ type: "heading", level: 2, text: "Задачи", runs: [{ text: "Задачи" }] },
|
||||||
|
{ type: "paragraph", text: "Первый абзац.", runs: [{ text: "Первый абзац." }] },
|
||||||
|
{
|
||||||
|
type: "list",
|
||||||
|
ordered: false,
|
||||||
|
items: [
|
||||||
|
{ text: "Подготовить документ", runs: [{ text: "Подготовить документ" }] },
|
||||||
|
{ text: "Проверить таблицу", runs: [{ text: "Проверить таблицу" }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет базовое inline-форматирование текста для DOCX", () => {
|
||||||
|
const blocks = htmlToWordBlocks(`
|
||||||
|
<p>Обычный <strong>полужирный</strong> <em>курсив</em> <u>подчеркнутый</u> <span style="font-weight: 700; font-style: italic; text-decoration-line: underline; color: #0f5fae; background-color: rgb(255, 242, 204);">все стили</span></p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "Обычный полужирный курсив подчеркнутый все стили",
|
||||||
|
runs: [
|
||||||
|
{ text: "Обычный " },
|
||||||
|
{ text: "полужирный", bold: true },
|
||||||
|
{ text: " ", },
|
||||||
|
{ text: "курсив", italics: true },
|
||||||
|
{ text: " " },
|
||||||
|
{ text: "подчеркнутый", underline: true },
|
||||||
|
{ text: " " },
|
||||||
|
{ text: "все стили", bold: true, italics: true, underline: true, color: "0F5FAE", highlightColor: "FFF2CC" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет выравнивание абзацев и заголовков для DOCX", () => {
|
||||||
|
const blocks = htmlToWordBlocks(`
|
||||||
|
<h1 align="right">Итоги</h1>
|
||||||
|
<p style="text-align: center;">Центрированный абзац</p>
|
||||||
|
<p style="text-align: justify;">Текст по ширине</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{ type: "heading", level: 1, text: "Итоги", runs: [{ text: "Итоги" }], alignment: "right" },
|
||||||
|
{ type: "paragraph", text: "Центрированный абзац", runs: [{ text: "Центрированный абзац" }], alignment: "center" },
|
||||||
|
{ type: "paragraph", text: "Текст по ширине", runs: [{ text: "Текст по ширине" }], alignment: "both" }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет HTML-гиперссылки как inline runs", () => {
|
||||||
|
const blocks = htmlToWordBlocks(`
|
||||||
|
<p>Откройте <a href="https://example.com/report"><strong>отчет</strong></a> и <a href="javascript:alert(1)">небезопасную ссылку</a>.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "Откройте отчет и небезопасную ссылку.",
|
||||||
|
runs: [
|
||||||
|
{ text: "Откройте " },
|
||||||
|
{ text: "отчет", bold: true, href: "https://example.com/report" },
|
||||||
|
{ text: " и небезопасную ссылку." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("разбирает изображения из HTML как отдельные блоки", () => {
|
||||||
|
const blocks = htmlToWordBlocks(`<p>Перед <img src="${tinyPngDataUri}" alt="Логотип" width="120" height="40"> После</p>`);
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{ type: "paragraph", text: "Перед", runs: [{ text: "Перед" }] },
|
||||||
|
{ type: "image", src: tinyPngDataUri, alt: "Логотип", width: 120, height: 40 },
|
||||||
|
{ type: "paragraph", text: "После", runs: [{ text: "После" }] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("преобразует inline runs в параметры TextRun библиотеки docx", () => {
|
||||||
|
expect(
|
||||||
|
wordInlineRunsToTextRunOptions([
|
||||||
|
{ text: "Жирный", bold: true },
|
||||||
|
{ text: "Курсив", italics: true },
|
||||||
|
{ text: "Подчеркнутый", underline: true },
|
||||||
|
{ text: "Цвет", color: "0F5FAE", highlightColor: "FFF2CC" }
|
||||||
|
])
|
||||||
|
).toEqual([
|
||||||
|
{ text: "Жирный", bold: true },
|
||||||
|
{ text: "Курсив", italics: true },
|
||||||
|
{ text: "Подчеркнутый", underline: {} },
|
||||||
|
{ text: "Цвет", color: "0F5FAE", shading: { fill: "FFF2CC" } }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("записывает bold, italic и underline в XML экспортированного DOCX", async () => {
|
||||||
|
const blob = await exportDocxBlob(
|
||||||
|
"Форматирование.docx",
|
||||||
|
"<p><strong>Жирный</strong> <em>Курсив</em> <u>Подчеркнутый</u></p>"
|
||||||
|
);
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||||||
|
|
||||||
|
expect(documentXml).toContain("<w:b/>");
|
||||||
|
expect(documentXml).toContain("<w:i/>");
|
||||||
|
expect(documentXml).toContain("<w:u");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("записывает цвет текста и подсветку в XML экспортированного DOCX", async () => {
|
||||||
|
const blob = await exportDocxBlob(
|
||||||
|
"Цвет.docx",
|
||||||
|
'<p><span style="color: #0f5fae; background-color: #fff2cc;">Цветной фрагмент</span></p>'
|
||||||
|
);
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||||||
|
|
||||||
|
expect(documentXml).toContain('w:color w:val="0F5FAE"');
|
||||||
|
expect(documentXml).toContain('w:fill="FFF2CC"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("записывает выравнивание абзацев в XML экспортированного DOCX", async () => {
|
||||||
|
const blob = await exportDocxBlob("Выравнивание.docx", '<p style="text-align: center;">Центр</p><p align="right">Право</p>');
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||||||
|
|
||||||
|
expect(documentXml).toContain('<w:jc w:val="center"/>');
|
||||||
|
expect(documentXml).toContain('<w:jc w:val="right"/>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("записывает внешние гиперссылки в DOCX relationships", async () => {
|
||||||
|
const blob = await exportDocxBlob(
|
||||||
|
"Ссылки.docx",
|
||||||
|
'<p>См. <a href="https://example.com/report"><strong>отчет</strong></a> и <a href="javascript:alert(1)">скрипт</a>.</p>'
|
||||||
|
);
|
||||||
|
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(documentXml).toContain("<w:hyperlink");
|
||||||
|
expect(documentXml).toContain('w:rStyle w:val="Hyperlink"');
|
||||||
|
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"');
|
||||||
|
expect(relationshipsXml).toContain('Target="https://example.com/report"');
|
||||||
|
expect(relationshipsXml).toContain('TargetMode="External"');
|
||||||
|
expect(relationshipsXml).not.toContain("javascript:alert");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("записывает data URI изображения в DOCX media relationships", async () => {
|
||||||
|
const blob = await exportDocxBlob("Изображение.docx", `<p>Логотип</p><img src="${tinyPngDataUri}" alt="Логотип" width="32" height="32">`);
|
||||||
|
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");
|
||||||
|
const mediaFiles = Object.keys(zip.files).filter((path) => path.startsWith("word/media/") && path.endsWith(".png"));
|
||||||
|
|
||||||
|
expect(documentXml).toContain("<w:drawing>");
|
||||||
|
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"');
|
||||||
|
expect(mediaFiles).toHaveLength(1);
|
||||||
|
expect(await zip.file(mediaFiles[0])?.async("uint8array")).toHaveLength(68);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("разбирает простую таблицу", () => {
|
||||||
|
const blocks = htmlToWordBlocks(`
|
||||||
|
<table>
|
||||||
|
<tr><th>Этап</th><th>Ответственный</th></tr>
|
||||||
|
<tr><td>Планирование</td><td>Анна</td></tr>
|
||||||
|
</table>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
rows: [
|
||||||
|
["Этап", "Ответственный"],
|
||||||
|
["Планирование", "Анна"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("нормализует HTML после импорта DOCX", () => {
|
||||||
|
expect(normalizeImportedDocxHtml(" <p>Готово</p>\n")).toBe("<p>Готово</p>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,804 @@
|
|||||||
|
import { DOCX_MIME_TYPE, officeTitleFromFileName, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
||||||
|
|
||||||
|
export interface ImportedWordDocument {
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordInlineRun {
|
||||||
|
text: string;
|
||||||
|
bold?: boolean;
|
||||||
|
italics?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
href?: string;
|
||||||
|
color?: string;
|
||||||
|
highlightColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordListItem {
|
||||||
|
text: string;
|
||||||
|
runs: WordInlineRun[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordImageBlock {
|
||||||
|
type: "image";
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WordParagraphAlignment = "left" | "center" | "right" | "both";
|
||||||
|
|
||||||
|
export type WordBlock =
|
||||||
|
| { type: "heading"; level: 1 | 2; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment }
|
||||||
|
| { type: "paragraph"; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment }
|
||||||
|
| { type: "list"; ordered: boolean; items: WordListItem[] }
|
||||||
|
| { type: "table"; rows: string[][] }
|
||||||
|
| WordImageBlock;
|
||||||
|
|
||||||
|
type MammothModule = {
|
||||||
|
convertToHtml: (
|
||||||
|
input: { arrayBuffer: ArrayBuffer },
|
||||||
|
options?: { styleMap?: string[]; convertImage?: unknown }
|
||||||
|
) => Promise<{ value: string; messages?: unknown[] }>;
|
||||||
|
images?: {
|
||||||
|
dataUri?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocxModule = {
|
||||||
|
AlignmentType?: Record<string, string>;
|
||||||
|
Document: new (options: Record<string, unknown>) => unknown;
|
||||||
|
HeadingLevel: Record<string, string>;
|
||||||
|
Packer: {
|
||||||
|
toBlob?: (document: unknown) => Promise<Blob>;
|
||||||
|
toBuffer?: (document: unknown) => Promise<ArrayBuffer | Uint8Array>;
|
||||||
|
};
|
||||||
|
Paragraph: new (options: Record<string, unknown>) => unknown;
|
||||||
|
Table: new (options: Record<string, unknown>) => unknown;
|
||||||
|
TableCell: new (options: Record<string, unknown>) => unknown;
|
||||||
|
TableRow: new (options: Record<string, unknown>) => unknown;
|
||||||
|
TextRun: new (options: string | Record<string, unknown>) => unknown;
|
||||||
|
WidthType?: Record<string, string>;
|
||||||
|
ExternalHyperlink?: new (options: { children: unknown[]; link: string }) => unknown;
|
||||||
|
ImageRun?: new (options: Record<string, unknown>) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InlineFormat = Omit<WordInlineRun, "text">;
|
||||||
|
type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock };
|
||||||
|
|
||||||
|
const mammothStyleMap = [
|
||||||
|
"p[style-name='Title'] => h1:fresh",
|
||||||
|
"p[style-name='Heading 1'] => h1:fresh",
|
||||||
|
"p[style-name='Heading 2'] => h2:fresh"
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
|
||||||
|
if (!file.name.toLowerCase().endsWith(".docx")) {
|
||||||
|
throw new Error("QWord импортирует только файлы Microsoft Word .docx.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mammoth = await loadMammoth();
|
||||||
|
const arrayBuffer = await readOfficeFileAsArrayBuffer(file);
|
||||||
|
const result = await mammoth.convertToHtml(
|
||||||
|
{ arrayBuffer },
|
||||||
|
{
|
||||||
|
styleMap: mammothStyleMap,
|
||||||
|
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: officeTitleFromFileName(file.name, "Документ QWord.docx"),
|
||||||
|
html: normalizeImportedDocxHtml(result.value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDocxBlob(title: string, html: string): Promise<Blob> {
|
||||||
|
const docx = await loadDocx();
|
||||||
|
const blocks = htmlToWordBlocks(html);
|
||||||
|
const children = blocks.flatMap((block) => wordBlockToDocxChildren(block, docx));
|
||||||
|
const documentTitle = title.trim() || "Документ QWord";
|
||||||
|
|
||||||
|
const document = new docx.Document({
|
||||||
|
creator: "QOffice",
|
||||||
|
title: documentTitle,
|
||||||
|
numbering: {
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
reference: "qoffice-numbered-list",
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
level: 0,
|
||||||
|
format: "decimal",
|
||||||
|
text: "%1.",
|
||||||
|
alignment: docx.AlignmentType?.LEFT ?? "left",
|
||||||
|
style: {
|
||||||
|
paragraph: {
|
||||||
|
indent: { left: 720, hanging: 260 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
properties: {},
|
||||||
|
children:
|
||||||
|
children.length > 0
|
||||||
|
? children
|
||||||
|
: [
|
||||||
|
new docx.Paragraph({
|
||||||
|
children: [new docx.TextRun("")]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (docx.Packer.toBlob) {
|
||||||
|
return docx.Packer.toBlob(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docx.Packer.toBuffer) {
|
||||||
|
const buffer = await docx.Packer.toBuffer(document);
|
||||||
|
return new Blob([buffer as BlobPart], { type: DOCX_MIME_TYPE });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Не удалось создать DOCX: библиотека docx не вернула поддерживаемый упаковщик.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeImportedDocxHtml(html: string) {
|
||||||
|
return html.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlToWordBlocks(html: string): WordBlock[] {
|
||||||
|
const container = parseHtmlFragment(html);
|
||||||
|
const blocks: WordBlock[] = [];
|
||||||
|
|
||||||
|
Array.from(container.childNodes).forEach((node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = normalizeWhitespace(node.textContent ?? "");
|
||||||
|
if (text) {
|
||||||
|
blocks.push({ type: "paragraph", text, runs: [{ text }] });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(node instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagName = node.tagName.toLowerCase();
|
||||||
|
if (tagName === "img") {
|
||||||
|
blocks.push(imageBlockFromElement(node));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName === "h1" || tagName === "h2") {
|
||||||
|
const runs = elementInlineRuns(node);
|
||||||
|
const text = inlineRunsText(runs);
|
||||||
|
const alignment = paragraphAlignmentFromElement(node);
|
||||||
|
if (text) {
|
||||||
|
blocks.push({ type: "heading", level: tagName === "h1" ? 1 : 2, text, runs, ...(alignment ? { alignment } : {}) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName === "p" || tagName === "div") {
|
||||||
|
blocks.push(...paragraphElementToBlocks(node));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName === "ul" || tagName === "ol") {
|
||||||
|
const items = Array.from(node.children)
|
||||||
|
.filter((child) => child.tagName.toLowerCase() === "li")
|
||||||
|
.map((child) => {
|
||||||
|
const runs = elementInlineRuns(child);
|
||||||
|
return { text: inlineRunsText(runs), runs };
|
||||||
|
})
|
||||||
|
.filter((item) => item.text);
|
||||||
|
if (items.length > 0) {
|
||||||
|
blocks.push({ type: "list", ordered: tagName === "ol", items });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName === "table") {
|
||||||
|
const rows = tableRows(node);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
blocks.push({ type: "table", rows });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = elementText(node);
|
||||||
|
if (text) {
|
||||||
|
blocks.push({ type: "paragraph", text, runs: [{ text }] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) {
|
||||||
|
if (block.type === "heading") {
|
||||||
|
return [
|
||||||
|
new docx.Paragraph({
|
||||||
|
heading: block.level === 1 ? docx.HeadingLevel.HEADING_1 : docx.HeadingLevel.HEADING_2,
|
||||||
|
spacing: { after: 160 },
|
||||||
|
...docxAlignmentOption(block.alignment, docx),
|
||||||
|
children: wordInlineRunsToTextRuns(block.runs, docx)
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "paragraph") {
|
||||||
|
return [
|
||||||
|
new docx.Paragraph({
|
||||||
|
spacing: { after: 160 },
|
||||||
|
...docxAlignmentOption(block.alignment, docx),
|
||||||
|
children: wordInlineRunsToTextRuns(block.runs, docx)
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "list") {
|
||||||
|
return block.items.map(
|
||||||
|
(item) =>
|
||||||
|
new docx.Paragraph({
|
||||||
|
spacing: { after: 80 },
|
||||||
|
...(block.ordered ? { numbering: { reference: "qoffice-numbered-list", level: 0 } } : { bullet: { level: 0 } }),
|
||||||
|
children: wordInlineRunsToTextRuns(item.runs, docx)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "image") {
|
||||||
|
const imageRun = wordImageBlockToImageRun(block, docx);
|
||||||
|
if (!imageRun) {
|
||||||
|
return block.alt
|
||||||
|
? [
|
||||||
|
new docx.Paragraph({
|
||||||
|
spacing: { after: 160 },
|
||||||
|
children: [new docx.TextRun(block.alt)]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
new docx.Paragraph({
|
||||||
|
spacing: { after: 160 },
|
||||||
|
children: [imageRun]
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
new docx.Table({
|
||||||
|
width: {
|
||||||
|
size: 100,
|
||||||
|
type: docx.WidthType?.PERCENTAGE ?? "pct"
|
||||||
|
},
|
||||||
|
rows: block.rows.map(
|
||||||
|
(row) =>
|
||||||
|
new docx.TableRow({
|
||||||
|
children: row.map(
|
||||||
|
(cell) =>
|
||||||
|
new docx.TableCell({
|
||||||
|
children: [
|
||||||
|
new docx.Paragraph({
|
||||||
|
children: [new docx.TextRun(cell)]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordInlineRunsToTextRunOptions(runs: WordInlineRun[]) {
|
||||||
|
return runs.map(wordInlineRunToTextRunOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordInlineRunToTextRunOption(run: WordInlineRun) {
|
||||||
|
const options: Record<string, unknown> = { text: run.text };
|
||||||
|
if (run.bold) {
|
||||||
|
options.bold = true;
|
||||||
|
}
|
||||||
|
if (run.italics) {
|
||||||
|
options.italics = true;
|
||||||
|
}
|
||||||
|
if (run.underline) {
|
||||||
|
options.underline = {};
|
||||||
|
}
|
||||||
|
if (run.color) {
|
||||||
|
options.color = run.color;
|
||||||
|
}
|
||||||
|
if (run.highlightColor) {
|
||||||
|
options.shading = { fill: run.highlightColor };
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordInlineRunsToTextRuns(runs: WordInlineRun[], docx: DocxModule) {
|
||||||
|
const normalizedRuns = runs.length > 0 ? runs : [{ text: "" }];
|
||||||
|
return normalizedRuns.map((run) => {
|
||||||
|
const option = wordInlineRunToTextRunOption(run);
|
||||||
|
const href = normalizedHyperlinkTarget(run.href);
|
||||||
|
if (href && docx.ExternalHyperlink) {
|
||||||
|
return new docx.ExternalHyperlink({
|
||||||
|
link: href,
|
||||||
|
children: [new docx.TextRun({ ...option, style: "Hyperlink" })]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new docx.TextRun(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxAlignmentOption(alignment: WordParagraphAlignment | undefined, docx: DocxModule) {
|
||||||
|
const value = docxAlignmentValue(alignment, docx);
|
||||||
|
return value ? { alignment: value } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxAlignmentValue(alignment: WordParagraphAlignment | undefined, docx: DocxModule) {
|
||||||
|
if (!alignment) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignmentType = docx.AlignmentType ?? {};
|
||||||
|
if (alignment === "center") {
|
||||||
|
return alignmentType.CENTER ?? "center";
|
||||||
|
}
|
||||||
|
if (alignment === "right") {
|
||||||
|
return alignmentType.RIGHT ?? "right";
|
||||||
|
}
|
||||||
|
if (alignment === "both") {
|
||||||
|
return alignmentType.BOTH ?? alignmentType.JUSTIFIED ?? "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
return alignmentType.LEFT ?? "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordImageBlockToImageRun(block: WordImageBlock, docx: DocxModule) {
|
||||||
|
if (!docx.ImageRun) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = parseDataUriImage(block.src);
|
||||||
|
if (!image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimensions = imageDimensions(block, image.bytes);
|
||||||
|
return new docx.ImageRun({
|
||||||
|
type: image.type,
|
||||||
|
data: image.bytes,
|
||||||
|
transformation: dimensions,
|
||||||
|
...(block.alt ? { altText: { title: block.alt, description: block.alt } } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHtmlFragment(html: string) {
|
||||||
|
if (typeof DOMParser !== "undefined") {
|
||||||
|
return new DOMParser().parseFromString(`<main>${html}</main>`, "text/html").body.firstElementChild as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const container = document.createElement("main");
|
||||||
|
container.innerHTML = html;
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Не удалось обработать HTML: DOMParser недоступен в этой среде.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableRows(table: Element) {
|
||||||
|
return Array.from(table.querySelectorAll("tr"))
|
||||||
|
.map((row) =>
|
||||||
|
Array.from(row.querySelectorAll("th,td"))
|
||||||
|
.map(elementText)
|
||||||
|
.filter((cell) => cell.length > 0)
|
||||||
|
)
|
||||||
|
.filter((row) => row.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphElementToBlocks(element: Element): WordBlock[] {
|
||||||
|
const segments = collectInlineSegments(Array.from(element.childNodes), {});
|
||||||
|
const alignment = paragraphAlignmentFromElement(element);
|
||||||
|
const blocks: WordBlock[] = [];
|
||||||
|
let pendingRuns: WordInlineRun[] = [];
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
const runs = normalizeInlineRuns(pendingRuns);
|
||||||
|
const text = inlineRunsText(runs);
|
||||||
|
if (text) {
|
||||||
|
blocks.push({ type: "paragraph", text, runs, ...(alignment ? { alignment } : {}) });
|
||||||
|
}
|
||||||
|
pendingRuns = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
segments.forEach((segment) => {
|
||||||
|
if (segment.type === "runs") {
|
||||||
|
pendingRuns.push(...segment.runs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushParagraph();
|
||||||
|
blocks.push(segment.image);
|
||||||
|
});
|
||||||
|
|
||||||
|
flushParagraph();
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementInlineRuns(element: Element) {
|
||||||
|
return normalizeInlineRuns(collectInlineRuns(Array.from(element.childNodes), {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphAlignmentFromElement(element: Element): WordParagraphAlignment | undefined {
|
||||||
|
const rawAlignment = element.getAttribute("align") || cssTextAlignValue(element.getAttribute("style") ?? "");
|
||||||
|
const normalized = rawAlignment.trim().toLowerCase();
|
||||||
|
if (normalized === "center" || normalized === "right" || normalized === "left") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized === "justify" ? "both" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssTextAlignValue(style: string) {
|
||||||
|
return /(?:^|;)\s*text-align\s*:\s*([^;]+)/i.exec(style)?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssColorValue(style: string, property: "color" | "background" | "background-color") {
|
||||||
|
const value = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1] ?? "";
|
||||||
|
return normalizedCssColor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedCssColor(value: string) {
|
||||||
|
const raw = value.trim().toLowerCase();
|
||||||
|
if (!raw || ["transparent", "inherit", "initial", "currentcolor", "auto"].includes(raw)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw)?.[1];
|
||||||
|
if (hex) {
|
||||||
|
return hex.length === 3
|
||||||
|
? hex
|
||||||
|
.split("")
|
||||||
|
.map((char) => `${char}${char}`)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
: hex.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgb = /^rgba?\(([^)]+)\)$/i.exec(raw)?.[1];
|
||||||
|
if (rgb) {
|
||||||
|
const channels = rgb
|
||||||
|
.split(",")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((channel) => Number.parseFloat(channel.trim()));
|
||||||
|
if (channels.length === 3 && channels.every((channel) => Number.isFinite(channel) && channel >= 0 && channel <= 255)) {
|
||||||
|
return channels.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("").toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedColors: Record<string, string> = {
|
||||||
|
black: "000000",
|
||||||
|
blue: "0000FF",
|
||||||
|
cyan: "00FFFF",
|
||||||
|
gray: "808080",
|
||||||
|
green: "008000",
|
||||||
|
magenta: "FF00FF",
|
||||||
|
orange: "FFA500",
|
||||||
|
purple: "800080",
|
||||||
|
red: "FF0000",
|
||||||
|
white: "FFFFFF",
|
||||||
|
yellow: "FFFF00"
|
||||||
|
};
|
||||||
|
|
||||||
|
return namedColors[raw] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectInlineRuns(nodes: ChildNode[], format: InlineFormat): WordInlineRun[] {
|
||||||
|
return collectInlineSegments(nodes, format).flatMap((segment) => (segment.type === "runs" ? segment.runs : []));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectInlineSegments(nodes: ChildNode[], format: InlineFormat): InlineSegment[] {
|
||||||
|
return nodes.flatMap((node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = normalizeInlineWhitespace(node.textContent ?? "");
|
||||||
|
return text ? [{ type: "runs", runs: [{ text, ...format }] }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(node instanceof Element)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.tagName.toLowerCase() === "br") {
|
||||||
|
return [{ type: "runs", runs: [{ text: " ", ...format }] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.tagName.toLowerCase() === "img") {
|
||||||
|
return [{ type: "image", image: imageBlockFromElement(node) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectInlineSegments(Array.from(node.childNodes), formatForElement(node, format));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatForElement(element: Element, inherited: InlineFormat): InlineFormat {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const style = element.getAttribute("style") ?? "";
|
||||||
|
const href = tagName === "a" ? normalizedHyperlinkTarget(element.getAttribute("href")) : inherited.href;
|
||||||
|
const color = cssColorValue(style, "color") || inherited.color;
|
||||||
|
const highlightColor =
|
||||||
|
cssColorValue(style, "background-color") || cssColorValue(style, "background") || inherited.highlightColor;
|
||||||
|
|
||||||
|
return cleanInlineFormat({
|
||||||
|
bold: inherited.bold || tagName === "b" || tagName === "strong" || /font-weight\s*:\s*(bold|[6-9]00)\b/i.test(style),
|
||||||
|
italics: inherited.italics || tagName === "i" || tagName === "em" || /font-style\s*:\s*italic\b/i.test(style),
|
||||||
|
underline: inherited.underline || tagName === "u" || /text-decoration(?:-line)?\s*:[^;]*underline/i.test(style),
|
||||||
|
...(href ? { href } : {}),
|
||||||
|
...(color ? { color } : {}),
|
||||||
|
...(highlightColor ? { highlightColor } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInlineRuns(runs: WordInlineRun[]) {
|
||||||
|
const normalized = runs
|
||||||
|
.map((run) => ({ ...run, text: normalizeInlineWhitespace(run.text) }))
|
||||||
|
.filter((run) => run.text.length > 0);
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized[0] = { ...normalized[0], text: normalized[0].text.trimStart() };
|
||||||
|
const lastIndex = normalized.length - 1;
|
||||||
|
normalized[lastIndex] = { ...normalized[lastIndex], text: normalized[lastIndex].text.trimEnd() };
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
.filter((run) => run.text.length > 0)
|
||||||
|
.reduce<WordInlineRun[]>((merged, run) => {
|
||||||
|
const previous = merged[merged.length - 1];
|
||||||
|
if (previous && sameInlineFormat(previous, run)) {
|
||||||
|
previous.text += run.text;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.push(run);
|
||||||
|
return merged;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameInlineFormat(first: InlineFormat, second: InlineFormat) {
|
||||||
|
return (
|
||||||
|
Boolean(first.bold) === Boolean(second.bold) &&
|
||||||
|
Boolean(first.italics) === Boolean(second.italics) &&
|
||||||
|
Boolean(first.underline) === Boolean(second.underline) &&
|
||||||
|
(first.href ?? "") === (second.href ?? "") &&
|
||||||
|
(first.color ?? "") === (second.color ?? "") &&
|
||||||
|
(first.highlightColor ?? "") === (second.highlightColor ?? "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanInlineFormat(format: InlineFormat): InlineFormat {
|
||||||
|
return {
|
||||||
|
...(format.bold ? { bold: true } : {}),
|
||||||
|
...(format.italics ? { italics: true } : {}),
|
||||||
|
...(format.underline ? { underline: true } : {}),
|
||||||
|
...(format.href ? { href: format.href } : {}),
|
||||||
|
...(format.color ? { color: format.color } : {}),
|
||||||
|
...(format.highlightColor ? { highlightColor: format.highlightColor } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedHyperlinkTarget(value?: string | null) {
|
||||||
|
const raw = value?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
const protocol = url.protocol.toLowerCase();
|
||||||
|
return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(protocol) ? url.href : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageBlockFromElement(element: Element): WordImageBlock {
|
||||||
|
return {
|
||||||
|
type: "image",
|
||||||
|
src: element.getAttribute("src") ?? "",
|
||||||
|
...(element.getAttribute("alt") ? { alt: element.getAttribute("alt") ?? "" } : {}),
|
||||||
|
...imageElementDimensions(element)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageElementDimensions(element: Element) {
|
||||||
|
const width = numericAttribute(element, "width") ?? cssPixelValue(element.getAttribute("style") ?? "", "width");
|
||||||
|
const height = numericAttribute(element, "height") ?? cssPixelValue(element.getAttribute("style") ?? "", "height");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(width ? { width } : {}),
|
||||||
|
...(height ? { height } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericAttribute(element: Element, name: string) {
|
||||||
|
const value = Number.parseFloat(element.getAttribute(name) ?? "");
|
||||||
|
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssPixelValue(style: string, property: "width" | "height") {
|
||||||
|
const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number.parseFloat(match[1]);
|
||||||
|
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDataUriImage(src: string) {
|
||||||
|
const match = /^data:(image\/(?:png|jpe?g|gif|bmp));base64,([a-z0-9+/=\s]+)$/i.exec(src.trim());
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = imageTypeFromMime(match[1]);
|
||||||
|
if (!type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
bytes: base64ToBytes(match[2].replace(/\s+/g, ""))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageTypeFromMime(mimeType: string) {
|
||||||
|
const normalized = mimeType.toLowerCase();
|
||||||
|
if (normalized === "image/jpeg" || normalized === "image/jpg") {
|
||||||
|
return "jpg";
|
||||||
|
}
|
||||||
|
if (normalized === "image/png") {
|
||||||
|
return "png";
|
||||||
|
}
|
||||||
|
if (normalized === "image/gif") {
|
||||||
|
return "gif";
|
||||||
|
}
|
||||||
|
if (normalized === "image/bmp") {
|
||||||
|
return "bmp";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageDimensions(block: WordImageBlock, bytes: Uint8Array) {
|
||||||
|
const detected = detectImageDimensions(bytes);
|
||||||
|
const width = Math.min(block.width ?? detected?.width ?? 520, 620);
|
||||||
|
const height =
|
||||||
|
block.height ??
|
||||||
|
(detected ? Math.max(1, Math.round((width / detected.width) * detected.height)) : undefined) ??
|
||||||
|
260;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectImageDimensions(bytes: Uint8Array) {
|
||||||
|
const png = pngDimensions(bytes);
|
||||||
|
if (png) {
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jpegDimensions(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngDimensions(bytes: Uint8Array) {
|
||||||
|
const isPng =
|
||||||
|
bytes.length >= 24 &&
|
||||||
|
bytes[0] === 0x89 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x4e &&
|
||||||
|
bytes[3] === 0x47 &&
|
||||||
|
bytes[12] === 0x49 &&
|
||||||
|
bytes[13] === 0x48 &&
|
||||||
|
bytes[14] === 0x44 &&
|
||||||
|
bytes[15] === 0x52;
|
||||||
|
if (!isPng) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: readUInt32BE(bytes, 16),
|
||||||
|
height: readUInt32BE(bytes, 20)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jpegDimensions(bytes: Uint8Array) {
|
||||||
|
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 2;
|
||||||
|
while (offset + 9 < bytes.length) {
|
||||||
|
if (bytes[offset] !== 0xff) {
|
||||||
|
offset += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = bytes[offset + 1];
|
||||||
|
const length = (bytes[offset + 2] << 8) + bytes[offset + 3];
|
||||||
|
if (length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStartOfFrame = marker >= 0xc0 && marker <= 0xc3;
|
||||||
|
if (isStartOfFrame) {
|
||||||
|
return {
|
||||||
|
height: (bytes[offset + 5] << 8) + bytes[offset + 6],
|
||||||
|
width: (bytes[offset + 7] << 8) + bytes[offset + 8]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += 2 + length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUInt32BE(bytes: Uint8Array, offset: number) {
|
||||||
|
return ((bytes[offset] << 24) >>> 0) + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string) {
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineRunsText(runs: WordInlineRun[]) {
|
||||||
|
return normalizeWhitespace(runs.map((run) => run.text).join(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementText(element: Element) {
|
||||||
|
return normalizeWhitespace(element.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWhitespace(value: string) {
|
||||||
|
return value.replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInlineWhitespace(value: string) {
|
||||||
|
return value.replace(/\u00a0/g, " ").replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMammoth() {
|
||||||
|
const module = (await import("mammoth")) as { default?: MammothModule } & MammothModule;
|
||||||
|
return module.default ?? module;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocx() {
|
||||||
|
const module = (await import("docx")) as unknown as { default?: DocxModule } & DocxModule;
|
||||||
|
return module.default ?? module;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
+1416
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
export type AppId = "word" | "sheets" | "slides" | "mail";
|
||||||
|
|
||||||
|
export interface WordDocument {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Workbook {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
activeSheet: string;
|
||||||
|
sheets: Sheet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sheet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cells: Record<string, string>;
|
||||||
|
mergedCells?: MergedCellRange[];
|
||||||
|
hyperlinks?: Record<string, string>;
|
||||||
|
cellFormats?: Record<string, CellFormat>;
|
||||||
|
charts?: SheetChart[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CellFormat {
|
||||||
|
bold?: boolean;
|
||||||
|
italics?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
fillColor?: string;
|
||||||
|
numberFormat?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergedCellRange {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SheetChartType = "bar" | "line" | "pie";
|
||||||
|
|
||||||
|
export interface SheetChart {
|
||||||
|
id: string;
|
||||||
|
type: SheetChartType;
|
||||||
|
title: string;
|
||||||
|
labelRange: string;
|
||||||
|
valueRange: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlideDeck {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
activeSlideId: string;
|
||||||
|
slides: Slide[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Slide {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
notes: string;
|
||||||
|
theme: "classic" | "ocean" | "graphite";
|
||||||
|
images?: SlideImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlideImage {
|
||||||
|
id: string;
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailAttachment {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
contentBase64: string;
|
||||||
|
disposition: "attachment" | "inline";
|
||||||
|
contentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailMessage {
|
||||||
|
id: string;
|
||||||
|
folder: "inbox" | "sent" | "drafts" | "archive";
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
time: string;
|
||||||
|
read: boolean;
|
||||||
|
flagged: boolean;
|
||||||
|
attachments?: MailAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QOfficeState {
|
||||||
|
activeApp: AppId;
|
||||||
|
word: WordDocument;
|
||||||
|
workbook: Workbook;
|
||||||
|
deck: SlideDeck;
|
||||||
|
mail: {
|
||||||
|
activeFolder: MailMessage["folder"];
|
||||||
|
activeMessageId: string;
|
||||||
|
messages: MailMessage[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function downloadTextFile(fileName: string, contents: string, mimeType: string) {
|
||||||
|
const blob = new Blob([contents], { type: mimeType });
|
||||||
|
downloadBlobFile(fileName, blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlobFile(fileName: string, blob: Blob) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
cellId,
|
||||||
|
columnLabel,
|
||||||
|
columnLabelsForCount,
|
||||||
|
evaluateCell,
|
||||||
|
expandRange,
|
||||||
|
formatCellValueForDisplay,
|
||||||
|
mergedCellInfo,
|
||||||
|
normalizeCellRange,
|
||||||
|
parseCellId,
|
||||||
|
summarizeColumn,
|
||||||
|
usedSheetBounds
|
||||||
|
} from "./spreadsheet";
|
||||||
|
|
||||||
|
describe("spreadsheet utilities", () => {
|
||||||
|
it("expands rectangular ranges", () => {
|
||||||
|
expect(expandRange("A1:B2")).toEqual(["A1", "B1", "A2", "B2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports Excel-style columns beyond H", () => {
|
||||||
|
expect(columnLabel(0)).toBe("A");
|
||||||
|
expect(columnLabel(25)).toBe("Z");
|
||||||
|
expect(columnLabel(26)).toBe("AA");
|
||||||
|
expect(columnLabelsForCount(28).slice(-2)).toEqual(["AA", "AB"]);
|
||||||
|
expect(cellId(27, 19)).toBe("AB20");
|
||||||
|
expect(parseCellId("ab20")).toEqual({ column: 27, row: 19 });
|
||||||
|
expect(expandRange("Z1:AA2")).toEqual(["Z1", "AA1", "Z2", "AA2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates aggregate formulas", () => {
|
||||||
|
const cells = { A1: "2", A2: "3", A3: "=SUM(A1:A2)", A4: "=AVG(A1:A2)" };
|
||||||
|
expect(evaluateCell("A3", cells)).toBe("5");
|
||||||
|
expect(evaluateCell("A4", cells)).toBe("2.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates arithmetic formulas with cell references", () => {
|
||||||
|
const cells = { A1: "10", B1: "5", C1: "=A1*B1+2" };
|
||||||
|
expect(evaluateCell("C1", cells)).toBe("52");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates formulas outside the default visible grid", () => {
|
||||||
|
const cells = { AA20: "7", AB20: "8", AC20: "=SUM(AA20:AB20)", AD20: "=AC20*2" };
|
||||||
|
expect(evaluateCell("AC20", cells)).toBe("15");
|
||||||
|
expect(evaluateCell("AD20", cells)).toBe("30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports cycles instead of silently treating them as zero", () => {
|
||||||
|
const cells = { A1: "=SUM(A1:A2)", A2: "2", B1: "=B1+1" };
|
||||||
|
expect(evaluateCell("A1", cells)).toBe("#CYCLE");
|
||||||
|
expect(evaluateCell("B1", cells)).toBe("#CYCLE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates used sheet bounds from populated cells", () => {
|
||||||
|
expect(usedSheetBounds({ C3: "x", AA20: "y" })).toEqual({ columnCount: 27, rowCount: 20 });
|
||||||
|
expect(usedSheetBounds({}, [{ start: "A1", end: "J24" }])).toEqual({ columnCount: 10, rowCount: 24 });
|
||||||
|
expect(usedSheetBounds({})).toEqual({ columnCount: 8, rowCount: 16 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes and locates merged cell ranges", () => {
|
||||||
|
expect(normalizeCellRange({ start: "c2", end: "A1" })).toEqual({ start: "A1", end: "C2" });
|
||||||
|
expect(mergedCellInfo("B1", [{ start: "A1", end: "C2" }])).toEqual({
|
||||||
|
range: { start: "A1", end: "C2" },
|
||||||
|
isOrigin: false,
|
||||||
|
columnSpan: 3,
|
||||||
|
rowSpan: 2
|
||||||
|
});
|
||||||
|
expect(mergedCellInfo("A1", [{ start: "A1", end: "C2" }])?.isOrigin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the final aggregate formula as the column summary when present", () => {
|
||||||
|
const cells = {
|
||||||
|
E2: "=SUM(B2:D2)",
|
||||||
|
E3: "=SUM(B3:D3)",
|
||||||
|
E4: "=SUM(B4:D4)",
|
||||||
|
E6: "=SUM(E2:E4)",
|
||||||
|
B2: "12500",
|
||||||
|
C2: "14300",
|
||||||
|
D2: "16800",
|
||||||
|
B3: "8200",
|
||||||
|
C3: "9100",
|
||||||
|
D3: "9900",
|
||||||
|
B4: "19600",
|
||||||
|
C4: "21400",
|
||||||
|
D4: "23800"
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(summarizeColumn(cells, 4)).toBe(135600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats numeric cell display values from XLSX number formats", () => {
|
||||||
|
expect(formatCellValueForDisplay("44927", { numberFormat: "dd.mm.yyyy" })).toBe("01.01.2023");
|
||||||
|
expect(formatCellValueForDisplay("44927.5", { numberFormat: "dd.mm.yyyy hh:mm" })).toBe("01.01.2023 12:00");
|
||||||
|
expect(formatCellValueForDisplay("0.25", { numberFormat: "0%" })).toBe("25%");
|
||||||
|
expect(formatCellValueForDisplay("1250.5", { numberFormat: "# ##0.00 ₽" })).toBe("1 250,50 ₽");
|
||||||
|
expect(formatCellValueForDisplay("Текст", { numberFormat: "0.00" })).toBe("Текст");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import type { CellFormat } from "../types";
|
||||||
|
|
||||||
|
export const DEFAULT_COLUMN_COUNT = 8;
|
||||||
|
export const DEFAULT_ROW_COUNT = 16;
|
||||||
|
export const columnLabels = columnLabelsForCount(DEFAULT_COLUMN_COUNT);
|
||||||
|
|
||||||
|
export interface SpreadsheetRange {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellId(columnIndex: number, rowIndex: number) {
|
||||||
|
return `${columnLabel(columnIndex)}${rowIndex + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCellId(id: string) {
|
||||||
|
const match = /^([A-Z]+)([1-9][0-9]*)$/i.exec(id.trim());
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = columnIndex(match[1]);
|
||||||
|
if (column < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = Number(match[2]) - 1;
|
||||||
|
return { column, row };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function columnLabel(columnIndex: number) {
|
||||||
|
let value = Math.max(0, Math.floor(columnIndex)) + 1;
|
||||||
|
let label = "";
|
||||||
|
|
||||||
|
while (value > 0) {
|
||||||
|
const remainder = (value - 1) % 26;
|
||||||
|
label = String.fromCharCode(65 + remainder) + label;
|
||||||
|
value = Math.floor((value - 1) / 26);
|
||||||
|
}
|
||||||
|
|
||||||
|
return label || "A";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function columnLabelsForCount(count: number) {
|
||||||
|
const normalizedCount = Math.max(0, Math.floor(count));
|
||||||
|
return Array.from({ length: normalizedCount }, (_, index) => columnLabel(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usedSheetBounds(
|
||||||
|
cells: Record<string, string>,
|
||||||
|
ranges: SpreadsheetRange[] = [],
|
||||||
|
minimumColumns = DEFAULT_COLUMN_COUNT,
|
||||||
|
minimumRows = DEFAULT_ROW_COUNT
|
||||||
|
) {
|
||||||
|
const cellBounds = Object.entries(cells).reduce(
|
||||||
|
(bounds, [address, value]) => {
|
||||||
|
if (value === "") {
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseCellId(address);
|
||||||
|
if (!parsed) {
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnCount: Math.max(bounds.columnCount, parsed.column + 1),
|
||||||
|
rowCount: Math.max(bounds.rowCount, parsed.row + 1)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnCount: minimumColumns,
|
||||||
|
rowCount: minimumRows
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return ranges.reduce((bounds, range) => {
|
||||||
|
const rangeBounds = cellRangeBounds(range);
|
||||||
|
if (!rangeBounds) {
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnCount: Math.max(bounds.columnCount, rangeBounds.endColumn + 1),
|
||||||
|
rowCount: Math.max(bounds.rowCount, rangeBounds.endRow + 1)
|
||||||
|
};
|
||||||
|
}, cellBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeColumn(cells: Record<string, string>, columnIndex: number) {
|
||||||
|
const bounds = usedSheetBounds(cells);
|
||||||
|
const entries = Array.from({ length: bounds.rowCount }, (_, rowIndex) => {
|
||||||
|
const id = cellId(columnIndex, rowIndex);
|
||||||
|
const raw = cells[id] ?? "";
|
||||||
|
const evaluated = evaluateCell(id, cells);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
value: Number(evaluated)
|
||||||
|
};
|
||||||
|
}).filter((entry) => Number.isFinite(entry.value));
|
||||||
|
|
||||||
|
const aggregateEntry = [...entries].reverse().find((entry) => /^=(SUM|AVG|MIN|MAX)\(/i.test(entry.raw.trim()));
|
||||||
|
if (aggregateEntry) {
|
||||||
|
return aggregateEntry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.reduce((sum, entry) => sum + entry.value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandRange(range: string) {
|
||||||
|
const [start, end] = range.split(":");
|
||||||
|
const parsedStart = parseCellId(start);
|
||||||
|
const parsedEnd = parseCellId(end);
|
||||||
|
if (!parsedStart || !parsedEnd) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const minColumn = Math.min(parsedStart.column, parsedEnd.column);
|
||||||
|
const maxColumn = Math.max(parsedStart.column, parsedEnd.column);
|
||||||
|
const minRow = Math.min(parsedStart.row, parsedEnd.row);
|
||||||
|
const maxRow = Math.max(parsedStart.row, parsedEnd.row);
|
||||||
|
const ids: string[] = [];
|
||||||
|
|
||||||
|
for (let row = minRow; row <= maxRow; row += 1) {
|
||||||
|
for (let column = minColumn; column <= maxColumn; column += 1) {
|
||||||
|
ids.push(cellId(column, row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCellRange(range: SpreadsheetRange): SpreadsheetRange | null {
|
||||||
|
const bounds = cellRangeBounds(range);
|
||||||
|
if (!bounds) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: cellId(bounds.startColumn, bounds.startRow),
|
||||||
|
end: cellId(bounds.endColumn, bounds.endRow)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellRangeBounds(range: SpreadsheetRange) {
|
||||||
|
const parsedStart = parseCellId(range.start);
|
||||||
|
const parsedEnd = parseCellId(range.end);
|
||||||
|
if (!parsedStart || !parsedEnd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startColumn: Math.min(parsedStart.column, parsedEnd.column),
|
||||||
|
startRow: Math.min(parsedStart.row, parsedEnd.row),
|
||||||
|
endColumn: Math.max(parsedStart.column, parsedEnd.column),
|
||||||
|
endRow: Math.max(parsedStart.row, parsedEnd.row)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergedCellInfo(cell: string, ranges: SpreadsheetRange[]) {
|
||||||
|
const parsedCell = parseCellId(cell);
|
||||||
|
if (!parsedCell) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
const bounds = cellRangeBounds(range);
|
||||||
|
if (!bounds) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inside =
|
||||||
|
parsedCell.column >= bounds.startColumn &&
|
||||||
|
parsedCell.column <= bounds.endColumn &&
|
||||||
|
parsedCell.row >= bounds.startRow &&
|
||||||
|
parsedCell.row <= bounds.endRow;
|
||||||
|
if (!inside) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: normalizeCellRange(range),
|
||||||
|
isOrigin: parsedCell.column === bounds.startColumn && parsedCell.row === bounds.startRow,
|
||||||
|
columnSpan: bounds.endColumn - bounds.startColumn + 1,
|
||||||
|
rowSpan: bounds.endRow - bounds.startRow + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: string) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valuesForArgument(argument: string, cells: Record<string, string>, seen: Set<string>) {
|
||||||
|
if (argument.includes(":")) {
|
||||||
|
return expandRange(argument).map((id) => evaluateCell(id, cells, seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [evaluateCell(argument, cells, seen)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateCell(id: string, cells: Record<string, string>, seen = new Set<string>()): string {
|
||||||
|
const normalizedId = id.trim().toUpperCase();
|
||||||
|
const raw = cells[normalizedId] ?? cells[id] ?? "";
|
||||||
|
if (!raw.startsWith("=")) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(normalizedId)) {
|
||||||
|
return "#CYCLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(normalizedId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expression = raw.slice(1).trim();
|
||||||
|
const aggregate = /^(SUM|AVG|MIN|MAX)\(([^)]+)\)$/i.exec(expression);
|
||||||
|
if (aggregate) {
|
||||||
|
const fn = aggregate[1].toUpperCase();
|
||||||
|
const evaluatedValues = aggregate[2].split(",").flatMap((argument) => valuesForArgument(argument.trim(), cells, seen));
|
||||||
|
const errorValue = evaluatedValues.find((value) => value.startsWith("#"));
|
||||||
|
if (errorValue) {
|
||||||
|
return errorValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = evaluatedValues.map(toNumber);
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
fn === "AVG"
|
||||||
|
? values.reduce((sum, value) => sum + value, 0) / values.length
|
||||||
|
: fn === "MIN"
|
||||||
|
? Math.min(...values)
|
||||||
|
: fn === "MAX"
|
||||||
|
? Math.max(...values)
|
||||||
|
: values.reduce((sum, value) => sum + value, 0);
|
||||||
|
|
||||||
|
return formatNumber(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
let referenceError = "";
|
||||||
|
const safeExpression = expression.replace(/\b[A-Z]+[1-9][0-9]*\b/gi, (reference) => {
|
||||||
|
const evaluated = evaluateCell(reference.toUpperCase(), cells, seen);
|
||||||
|
if (evaluated.startsWith("#")) {
|
||||||
|
referenceError = evaluated;
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(toNumber(evaluated));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (referenceError) {
|
||||||
|
return referenceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[0-9+\-*/().\s]+$/.test(safeExpression)) {
|
||||||
|
return "#VALUE";
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Function(`"use strict"; return (${safeExpression});`)();
|
||||||
|
return Number.isFinite(result) ? formatNumber(result) : "#VALUE";
|
||||||
|
} catch {
|
||||||
|
return "#VALUE";
|
||||||
|
} finally {
|
||||||
|
seen.delete(normalizedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnIndex(label: string) {
|
||||||
|
const normalized = label.trim().toUpperCase();
|
||||||
|
if (!/^[A-Z]+$/.test(normalized)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.split("").reduce((value, letter) => value * 26 + letter.charCodeAt(0) - 64, 0) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(value: number) {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCellValueForDisplay(value: string, format?: CellFormat) {
|
||||||
|
const numberFormat = format?.numberFormat?.trim();
|
||||||
|
if (!numberFormat || value === "" || value.startsWith("#")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = normalizedNumberFormatPattern(numberFormat);
|
||||||
|
if (isDateLikeNumberFormat(pattern)) {
|
||||||
|
return formatExcelSerialDate(number, pattern) ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pattern.includes("%")) {
|
||||||
|
return `${formatFixedNumber(number * 100, decimalPlacesForFormat(pattern))}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = currencySymbolForFormat(numberFormat);
|
||||||
|
if (currency) {
|
||||||
|
return `${formatFixedNumber(number, decimalPlacesForFormat(pattern))} ${currency}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[#0]\.[#0]+/.test(pattern)) {
|
||||||
|
return formatFixedNumber(number, decimalPlacesForFormat(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedNumberFormatPattern(format: string) {
|
||||||
|
return format
|
||||||
|
.replace(/"[^"]*"/g, "")
|
||||||
|
.replace(/\\./g, "")
|
||||||
|
.replace(/\[[^\]]+]/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateLikeNumberFormat(pattern: string) {
|
||||||
|
return /[dy]/.test(pattern) || (/[hs]/.test(pattern) && /m/.test(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimalPlacesForFormat(pattern: string) {
|
||||||
|
const decimalPart = /[.,]([#0]+)/.exec(pattern)?.[1] ?? "";
|
||||||
|
return decimalPart.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currencySymbolForFormat(format: string) {
|
||||||
|
return /[₽$€£¥]/.exec(format)?.[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFixedNumber(value: number, decimals: number) {
|
||||||
|
const safeDecimals = Math.max(0, Math.min(decimals, 10));
|
||||||
|
const [integerPart, fractionPart = ""] = Math.abs(value).toFixed(safeDecimals).split(".");
|
||||||
|
const sign = value < 0 ? "-" : "";
|
||||||
|
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||||
|
return fractionPart ? `${sign}${groupedInteger},${fractionPart}` : `${sign}${groupedInteger}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExcelSerialDate(serial: number, pattern: string) {
|
||||||
|
if (serial <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const milliseconds = Math.round((serial - 25569) * 86400000);
|
||||||
|
const date = new Date(milliseconds);
|
||||||
|
if (!Number.isFinite(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDate = /[dy]/.test(pattern);
|
||||||
|
const hasTime = /[hs]/.test(pattern);
|
||||||
|
const hasSeconds = /s/.test(pattern);
|
||||||
|
const datePart = hasDate ? `${pad2(date.getUTCDate())}.${pad2(date.getUTCMonth() + 1)}.${date.getUTCFullYear()}` : "";
|
||||||
|
const timePart = hasTime
|
||||||
|
? `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}${hasSeconds ? `:${pad2(date.getUTCSeconds())}` : ""}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return [datePart, timePart].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(value: number) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "./",
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
buffer: "buffer/",
|
||||||
|
string_decoder: "string_decoder/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
sourcemap: true
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: "./src/test/setup.ts"
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user