Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+18
View File
@@ -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
+55
View File
@@ -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
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+258
View File
@@ -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();
}
});
+20
View File
@@ -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
View File
@@ -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>
+6807
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -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
View File
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+176
View File
@@ -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
}
]
}
};
+822
View File
@@ -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)
};
}
+390
View File
@@ -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>
);
}
+455
View File
@@ -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>
);
}
+416
View File
@@ -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(/&nbsp;/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>
);
}
+33
View File
@@ -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>;
};
}
}
+18
View File
@@ -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;
}
+42
View File
@@ -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();
});
});
+25
View File
@@ -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);
}, []);
}
+18
View File
@@ -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)]);
});
});
+56
View File
@@ -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;
}
+377
View File
@@ -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
+21
View File
@@ -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("текст");
});
});
+52
View File
@@ -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);
});
}
+325
View 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>Строка&nbsp;1<br>Строка &amp; 2</p><ul><li>Пункт</li></ul></body></html>",
"--alt--"
].join("\r\n");
expect(htmlToPlainText("<p>Строка&nbsp;1<br>Строка &amp; 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&nbsp;текст</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);
});
});
+797
View File
@@ -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;
}
+13
View File
@@ -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");
});
});
+18
View File
@@ -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");
}
+217
View File
@@ -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>`;
}
+507
View File
@@ -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) : "";
}
+199
View File
@@ -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>");
});
});
+804
View File
@@ -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;
}
+10
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+111
View File
@@ -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[];
};
}
+15
View File
@@ -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);
}
+98
View File
@@ -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("Текст");
});
});
+373
View File
@@ -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");
}
+20
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -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"
}
});