Compare commits

...

48 Commits

Author SHA1 Message Date
Курнат Андрей 74b81341e6 Add Ollama Gemma QWord task prompt 2026-06-10 19:57:58 +03:00
Курнат Андрей 5487d04cca Add QPowerPoint slide size support 2026-06-04 22:24:33 +03:00
Курнат Андрей 5dfe97013d Add QExcell XLSX page setup support 2026-06-04 22:08:58 +03:00
Курнат Андрей 96aff5b76e Import DOCX page setup into QWord 2026-06-04 21:58:24 +03:00
Курнат Андрей 217dafb760 Preserve QWord caret during input 2026-06-04 21:53:31 +03:00
Курнат Андрей 3719cb7ec8 Export QWord page setup to DOCX 2026-06-04 21:40:17 +03:00
Курнат Андрей e5cd922f7d Persist QWord page setup in documents 2026-06-04 21:35:37 +03:00
Курнат Андрей c1f8162afd Add QWord page setup controls 2026-06-04 21:30:19 +03:00
Курнат Андрей 0b993b7709 Respect QWord page breaks in page layout 2026-06-04 21:24:43 +03:00
Курнат Андрей 500d368af4 Sync QWord statusbar view modes 2026-06-04 21:20:29 +03:00
Курнат Андрей 5def87e738 Preserve DOCX table column spans 2026-06-04 21:14:53 +03:00
Курнат Андрей 74db17e348 Sync app window title with active file 2026-06-04 21:06:02 +03:00
Курнат Андрей 626576e156 Preserve QWord caret while typing 2026-06-04 21:00:40 +03:00
Курнат Андрей c16df37c95 Wire quick access commands 2026-06-02 20:39:30 +03:00
Курнат Андрей 231bb6c076 Support XLSX 1904 date imports 2026-06-02 19:42:51 +03:00
Курнат Андрей 6251c70ddc Normalize QExcell sheet names for XLSX export 2026-06-02 19:13:50 +03:00
Курнат Андрей e857beabd0 Respect DOCX underline none on import 2026-06-02 12:53:58 +03:00
Курнат Андрей 81a0de2ba9 Export QOutlook inline images as related CID parts 2026-06-02 12:26:58 +03:00
Курнат Андрей d86e0470c2 Embed QOutlook inline CID images 2026-06-02 12:22:22 +03:00
Курнат Андрей be7a0e5c98 Render QWord print pages as separate sheets 2026-06-02 09:27:13 +03:00
Курнат Андрей c72e78ac82 Keep QWord ribbon above document viewport 2026-06-02 09:19:05 +03:00
Курнат Андрей ddb04c32a4 Preserve QWord table cell inline formatting 2026-06-02 09:12:40 +03:00
Курнат Андрей e5537b726e Preserve empty QWord tables in DOCX export 2026-06-02 09:06:22 +03:00
Курнат Андрей a9f9aa6cca Add QWord table structure commands 2026-06-02 09:01:20 +03:00
Курнат Андрей 26bf4b5873 Add QWord table insertion command 2026-06-02 08:32:09 +03:00
Курнат Андрей 06c6a99e6d Add functional QWord ribbon tabs 2026-06-02 08:25:50 +03:00
Курнат Андрей 24fbae8a01 Tighten QWord ribbon layout 2026-06-02 08:15:45 +03:00
Курнат Андрей c9b09565b3 Preserve QWord line spacing in DOCX 2026-06-02 08:11:00 +03:00
Курнат Андрей f15467b8a1 Preserve QWord paragraph spacing in DOCX 2026-06-02 08:03:03 +03:00
Курнат Андрей f5ab0448c7 Preserve QPowerPoint text alignment in PPTX 2026-06-02 07:54:06 +03:00
Курнат Андрей b117e11431 Preserve QPowerPoint strikethrough in PPTX 2026-06-02 07:46:39 +03:00
Курнат Андрей c4532902c8 Preserve QPowerPoint text run styles in PPTX 2026-06-02 07:41:11 +03:00
Курнат Андрей b8c347142a Preserve QWord subscript superscript in DOCX 2026-06-02 07:24:42 +03:00
Курнат Андрей 7ace47dd2a Track QWord current page in status bar 2026-06-02 07:16:15 +03:00
Курнат Андрей 07f7335c74 Add QWord Word-style status bar 2026-06-02 07:08:43 +03:00
Курнат Андрей ffa377d931 Align QWord shell with Office ribbon 2026-06-02 07:00:42 +03:00
Курнат Андрей 68e0678ca6 Preserve QWord quote style in DOCX 2026-06-02 06:49:30 +03:00
Курнат Андрей c75efaa766 Support full QWord heading hierarchy in DOCX 2026-06-02 06:43:36 +03:00
Курнат Андрей 1c03ec168c Support QWord Heading 3 DOCX roundtrip 2026-06-02 06:37:49 +03:00
Курнат Андрей c4fd683ef3 Preserve QOutlook HTML EML bodies 2026-06-02 06:30:26 +03:00
Курнат Андрей e5727b4484 Preserve QPowerPoint image hyperlinks in PPTX 2026-06-02 06:17:58 +03:00
Курнат Андрей 0a675236b3 Preserve QPowerPoint text hyperlinks in PPTX 2026-06-02 06:08:47 +03:00
Курнат Андрей 2f1c70852d Import QWord DOCX lists from numbering 2026-06-02 05:57:52 +03:00
Курнат Андрей 22d2903f10 Preserve QExcell cell notes in XLSX 2026-06-02 05:50:30 +03:00
Курнат Андрей 57cce9ea96 Preserve QPowerPoint subtitle lists in PPTX 2026-06-02 05:34:40 +03:00
Курнат Андрей 464484bb19 Improve QWord ribbon fidelity 2026-06-01 23:29:28 +03:00
Курнат Андрей c8c1be7a30 Add standalone desktop launch modes 2026-06-01 23:15:20 +03:00
Курнат Андрей 91e8a6e9fd Make QOffice apps standalone Office-style surfaces 2026-06-01 23:08:56 +03:00
31 changed files with 7065 additions and 1025 deletions
+20 -4
View File
@@ -16,12 +16,25 @@ npm install
npm run dev
```
Прямые web-пути отдельных приложений: `/qword`, `/qexcell`, `/qpowerpoint`, `/qoutlook`.
## Desktop-оболочка
```bash
npm run electron:dev
```
Отдельные desktop-запуски приложений:
```bash
npm run electron:qword
npm run electron:qexcell
npm run electron:qpowerpoint
npm run electron:qoutlook
```
Electron также принимает аргумент `--qoffice-app=qword|qexcell|qpowerpoint|qoutlook`.
## Проверка
```bash
@@ -31,10 +44,11 @@ npm run build
## Поддержка форматов
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из HTML `margin-left`/`text-indent`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта.
- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width` и `col hidden`, высоту и скрытие строк через `row ht/customHeight` и `row hidden`, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений.
- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, переносы абзацев и ручные переносы строк `a:br` внутри текстовых блоков, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, переходы слайдов, заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка.
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает заголовки Word `Heading 1``Heading 6`, стиль Word `Quote` как HTML `blockquote`, выравнивание, отступы `w:ind`, интервалы абзацев `w:spacing` `before`/`after` и межстрочный интервал `w:spacing` `line`/`lineRule`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, маркированные и нумерованные списки через `w:numPr`/`numbering.xml`, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, нижний и верхний индекс через `w:vertAlign`, а при экспорте DOCX сохраняются заголовки HTML `h1``h6`, цитаты HTML `blockquote` как стиль Word `Quote`, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание, отступы абзацев из HTML `margin-left`/`text-indent`, интервалы из HTML `margin-top`/`margin-bottom` и межстрочный интервал из HTML `line-height`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, нижний и верхний индекс, цвет текста, подсветка, размер и семейство шрифта.
- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width` и `col hidden`, высоту и скрытие строк через `row ht/customHeight` и `row hidden`, управление скрытием выбранной строки и столбца в интерфейсе, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, legacy-заметки ячеек через `comments.xml` и VML `legacyDrawing`, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений.
- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, переносы абзацев и ручные переносы строк `a:br` внутри текстовых блоков, маркированные и нумерованные списки подзаголовка через `a:buChar`/`a:buAutoNum`, внешние ссылки текста заголовка/подзаголовка и изображений через `a:hlinkClick` и hyperlink relationships, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, переходы слайдов, заметки докладчика, внешние ссылки текста заголовка/подзаголовка и изображений, базовые растровые изображения слайдов, пользовательский цвет фона слайда, списки подзаголовка, а также цвет, размер, семейство шрифта, начертание `b`/`i`/`u`/`strike` и выравнивание `a:pPr algn` текста заголовка/подзаголовка.
- QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `multipart/related`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения и inline-вложения с `Content-ID`, `To`, `Cc`, `Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority`. Импорт MSG читает тему, отправителя, получателей `To/Cc/Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority` из транспортных заголовков, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно адресовать через `To/Cc/Bcc`, задавать `Reply-To` и сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы.
- QOutlook сохраняет HTML-тело импортированных EML/MSG писем в модели письма, показывает его в панели чтения через ограниченный HTML-preview без скриптов и экспортирует письма с HTML-телом в EML как `multipart/alternative`; при наличии вложений HTML и текстовая версии вкладываются внутрь `multipart/mixed`.
В desktop-режиме Electron используются системные диалоги открытия и сохранения файлов. В web-режиме остаются браузерный выбор файла и скачивание результата.
Экспорт PDF в desktop-режиме выполняется через Electron `webContents.printToPDF`; в web-режиме команда открывает браузерную печать, где пользователь может выбрать сохранение в PDF.
@@ -46,7 +60,9 @@ Desktop-меню поддерживает системные команды дл
- `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF.
- `Ctrl/Cmd+P`: печать текущей рабочей области.
Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:br`, `w:br w:type="page"`, `w:tab`, `w:color`, `w:shd`, `w:highlight`, `w:sz`, `w:rFonts` и `w:strike` для переносов строк/разрывов страниц/табуляции/цвета/подсветки/размера/семейства/зачеркивания текста DOCX, выравнивание и отступы абзацев DOCX через `w:jc`/`w:ind`, закладки DOCX через `w:bookmarkStart`/`w:bookmarkEnd`, внешние `w:hyperlink r:id` и внутренние `w:hyperlink w:anchor` гиперссылки DOCX, внешние и внутренние гиперссылки XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов, скрытые/veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати XLSX через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width`/`col hidden`, высоту и скрытие строк через `row ht/customHeight`/`row hidden` и активный лист XLSX через `workbookView activeTab`, ячейки, формулы, shared formulas XLSX с относительными ссылками, базовые merged cells, базовые стили, RGB/theme/indexed цвета XLSX с учетом `xl/theme/theme1.xml`, размеры и семейства шрифта, зачеркивание ячеек, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX с переносами абзацев и ручными переносами строк `a:br` при импорте, скрытые слайды PPTX через атрибут `show`, переходы PPTX через `p:transition` для `fade`/`push`/`wipe`, пользовательский фон слайдов PPTX, цвет, размер и семейство шрифта текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc/Reply-To`, заголовки цепочки писем `Message-ID`/`In-Reply-To`/`References`, заголовки приоритета `Importance`/`X-Priority`/`Priority`, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения EML, inline-вложения EML с `Content-ID` и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены.
Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:br`, `w:br w:type="page"`, `w:tab`, `w:color`, `w:shd`, `w:highlight`, `w:sz`, `w:rFonts`, `w:strike` и `w:vertAlign` для переносов строк/разрывов страниц/табуляции/цвета/подсветки/размера/семейства/зачеркивания/нижнего и верхнего индекса текста DOCX, выравнивание, отступы и интервалы абзацев DOCX через `w:jc`/`w:ind`/`w:spacing`, включая `line`/`lineRule`, закладки DOCX через `w:bookmarkStart`/`w:bookmarkEnd`, внешние `w:hyperlink r:id` и внутренние `w:hyperlink w:anchor` гиперссылки DOCX, маркированные и нумерованные списки DOCX через `w:numPr` и `numbering.xml`, внешние и внутренние гиперссылки XLSX, legacy-заметки ячеек XLSX через `comments.xml`, relationship `comments`, VML `vmlDrawing` и `legacyDrawing`, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов, скрытые/veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати XLSX через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width`/`col hidden`, высоту и скрытие строк через `row ht/customHeight`/`row hidden` и активный лист XLSX через `workbookView activeTab`, ячейки, формулы, shared formulas XLSX с относительными ссылками, базовые merged cells, базовые стили, RGB/theme/indexed цвета XLSX с учетом `xl/theme/theme1.xml`, размеры и семейства шрифта, зачеркивание ячеек, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX с переносами абзацев и ручными переносами строк `a:br` при импорте, внешние ссылки текста заголовка/подзаголовка и изображений PPTX через `a:hlinkClick` и hyperlink relationships, скрытые слайды PPTX через атрибут `show`, переходы PPTX через `p:transition` для `fade`/`push`/`wipe`, пользовательский фон слайдов PPTX, цвет, размер, семейство шрифта, начертание `b`/`i`/`u`/`strike` и выравнивание `a:pPr algn` текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc/Reply-To`, заголовки цепочки писем `Message-ID`/`In-Reply-To`/`References`, заголовки приоритета `Importance`/`X-Priority`/`Priority`, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения EML, inline-вложения EML с `Content-ID` и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены.
Для QOutlook EML round-trip дополнительно покрывает HTML body через `multipart/alternative` без вложений и с вложениями внутри `multipart/mixed`.
## Сборка пакета
+66 -13
View File
@@ -3,6 +3,49 @@ const fs = require("node:fs/promises");
const path = require("node:path");
const isDev = !app.isPackaged;
const applicationModes = {
word: {
title: "QWord",
windowTitle: "Документ1 - QWord",
route: "/qword"
},
sheets: {
title: "QExcell",
windowTitle: "Книга1 - QExcell",
route: "/qexcell"
},
slides: {
title: "QPowerPoint",
windowTitle: "Презентация1 - QPowerPoint",
route: "/qpowerpoint"
},
mail: {
title: "QOutlook",
windowTitle: "Почта - QOutlook",
route: "/qoutlook"
}
};
const applicationAliases = {
word: "word",
qword: "word",
docx: "word",
sheets: "sheets",
sheet: "sheets",
excel: "sheets",
excell: "sheets",
qexcell: "sheets",
xlsx: "sheets",
slides: "slides",
slide: "slides",
powerpoint: "slides",
qpowerpoint: "slides",
pptx: "slides",
mail: "mail",
outlook: "mail",
qoutlook: "mail",
eml: "mail",
msg: "mail"
};
const fileKinds = {
docx: {
title: "Открыть документ Word",
@@ -66,21 +109,28 @@ function sendFocusedCommand(command) {
}
}
function createApplicationMenu() {
function applicationModeFromArgs(argv = process.argv, env = process.env) {
const explicitArg = argv.find((argument) => argument.startsWith("--qoffice-app=") || argument.startsWith("--app="));
const rawMode = explicitArg?.split("=")[1] ?? env.QOFFICE_APP ?? "";
const mode = applicationAliases[String(rawMode).trim().toLowerCase()] ?? "word";
return applicationModes[mode];
}
function createApplicationMenu(applicationMode) {
const isMac = process.platform === "darwin";
const template = [
...(isMac
? [
{
label: "QOffice",
label: applicationMode.title,
submenu: [
{ role: "about", label: "О QOffice" },
{ role: "about", label: `О ${applicationMode.title}` },
{ type: "separator" },
{ role: "hide", label: "Скрыть QOffice" },
{ role: "hide", label: `Скрыть ${applicationMode.title}` },
{ role: "hideOthers", label: "Скрыть остальные" },
{ role: "unhide", label: "Показать все" },
{ type: "separator" },
{ role: "quit", label: "Выйти из QOffice" }
{ role: "quit", label: `Выйти из ${applicationMode.title}` }
]
}
]
@@ -95,7 +145,7 @@ function createApplicationMenu() {
{ type: "separator" },
{ label: "Печать...", accelerator: "CmdOrCtrl+P", click: () => sendFocusedCommand("print") },
{ type: "separator" },
isMac ? { role: "close", label: "Закрыть окно" } : { role: "quit", label: "Выход" }
isMac ? { role: "close", label: "Закрыть окно" } : { role: "quit", label: `Выход из ${applicationMode.title}` }
]
},
{
@@ -213,13 +263,13 @@ ipcMain.handle("qoffice:export-pdf", async (event, defaultFileName) => {
};
});
function createWindow() {
function createWindow(applicationMode) {
const mainWindow = new BrowserWindow({
width: 1440,
height: 920,
minWidth: 1080,
minHeight: 720,
title: "QOffice",
title: applicationMode.windowTitle,
backgroundColor: "#f6f8fb",
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
@@ -234,19 +284,22 @@ function createWindow() {
});
if (isDev) {
mainWindow.loadURL("http://127.0.0.1:5173");
mainWindow.loadURL(new URL(applicationMode.route, "http://127.0.0.1:5173").toString());
} else {
mainWindow.loadFile(path.join(__dirname, "../dist/index.html"));
mainWindow.loadFile(path.join(__dirname, "../dist/index.html"), { hash: applicationMode.route });
}
}
const launchApplicationMode = applicationModeFromArgs();
app.setName(launchApplicationMode.title);
app.whenReady().then(() => {
createApplicationMenu();
createWindow();
createApplicationMenu(launchApplicationMode);
createWindow(launchApplicationMode);
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
createWindow(launchApplicationMode);
}
});
});
+4
View File
@@ -12,6 +12,10 @@
"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 .\"",
"electron:qword": "concurrently -k \"npm:dev\" \"wait-on http://127.0.0.1:5173 && electron . --qoffice-app=qword\"",
"electron:qexcell": "concurrently -k \"npm:dev\" \"wait-on http://127.0.0.1:5173 && electron . --qoffice-app=qexcell\"",
"electron:qpowerpoint": "concurrently -k \"npm:dev\" \"wait-on http://127.0.0.1:5173 && electron . --qoffice-app=qpowerpoint\"",
"electron:qoutlook": "concurrently -k \"npm:dev\" \"wait-on http://127.0.0.1:5173 && electron . --qoffice-app=qoutlook\"",
"dist:desktop": "npm run build && electron-builder"
},
"dependencies": {
+108
View File
@@ -0,0 +1,108 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { initialState } from "./data/sampleData";
import type { QOfficeState } from "./types";
import { App, qofficeWindowTitle } from "./App";
afterEach(() => {
cleanup();
window.localStorage.clear();
window.history.pushState({}, "", "/");
window.document.title = "";
});
describe("qofficeWindowTitle", () => {
it("uses the current artifact name for standalone application title bars", () => {
expect(qofficeWindowTitle(initialState, "word")).toBe(`${initialState.word.title} - QWord`);
expect(qofficeWindowTitle(initialState, "sheets")).toBe(`${initialState.workbook.title} - QExcell`);
expect(qofficeWindowTitle(initialState, "slides")).toBe(`${initialState.deck.title} - QPowerPoint`);
const activeMessage = initialState.mail.messages.find((message) => message.id === initialState.mail.activeMessageId);
expect(qofficeWindowTitle(initialState, "mail")).toBe(`${activeMessage?.subject} - QOutlook`);
});
it("falls back to default standalone titles when names are blank", () => {
const state: QOfficeState = {
...initialState,
word: { ...initialState.word, title: " " },
workbook: { ...initialState.workbook, title: "" },
deck: { ...initialState.deck, title: "\n\t" },
mail: {
...initialState.mail,
messages: initialState.mail.messages.map((message) =>
message.id === initialState.mail.activeMessageId ? { ...message, subject: "" } : message
)
}
};
expect(qofficeWindowTitle(state, "word")).toBe("Документ1 - QWord");
expect(qofficeWindowTitle(state, "sheets")).toBe("Книга1 - QExcell");
expect(qofficeWindowTitle(state, "slides")).toBe("Презентация1 - QPowerPoint");
expect(qofficeWindowTitle(state, "mail")).toBe("Почта - QOutlook");
});
it("syncs the browser document title with the active standalone application", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
window.history.pushState({}, "", "/qword");
try {
render(<App />);
await waitFor(() => expect(window.document.title).toBe(qofficeWindowTitle(initialState, "word")));
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("syncs QWord statusbar view buttons with the active document view mode", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
window.history.pushState({}, "", "/qword");
try {
render(<App />);
const stage = screen.getByLabelText("Редактор QWord");
const printButton = screen.getByRole("button", { name: "Разметка страницы" });
const webButton = screen.getByRole("button", { name: "Веб-документ" });
expect(stage).toHaveClass("qword-view-print");
expect(printButton).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByRole("button", { name: "Черновик" })).not.toBeInTheDocument();
fireEvent.click(webButton);
expect(stage).toHaveClass("qword-view-web");
expect(webButton).toHaveAttribute("aria-pressed", "true");
expect(printButton).toHaveAttribute("aria-pressed", "false");
fireEvent.click(screen.getByRole("tab", { name: "Вид" }));
fireEvent.click(screen.getByRole("button", { name: "Чтение" }));
expect(stage).toHaveClass("qword-view-read");
expect(screen.getByRole("button", { name: "Режим чтения" })).toHaveAttribute("aria-pressed", "true");
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
});
+143 -17
View File
@@ -1,14 +1,48 @@
import { useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { BookOpen, Columns2, FileText, Minus, Plus } from "lucide-react";
import { AppShell } from "./components/AppShell";
import { QOutlook } from "./features/qoutlook/QOutlook";
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
import { QWord } from "./features/qword/QWord";
import { QWord, type QWordStatus, type WordViewMode } from "./features/qword/QWord";
import { QExcell } from "./features/qexcell/QExcell";
import { initialState } from "./data/sampleData";
import { usePersistentState } from "./hooks/usePersistentState";
import type { AppId, MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types";
import { activeAppFromLocation } from "./appRouting";
import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types";
const STORAGE_KEY = "qoffice-state-v1";
const defaultQWordStatus: QWordStatus = { currentPage: 1, pageCount: 1, wordCount: 0 };
const wordStatusbarViewModes = [
{ id: "print", label: "Разметка страницы", Icon: FileText },
{ id: "read", label: "Режим чтения", Icon: BookOpen },
{ id: "web", label: "Веб-документ", Icon: Columns2 }
] as const;
function clampedWordZoom(value: number) {
return Math.min(200, Math.max(50, value));
}
function normalizedWindowTitlePart(value: string | undefined, fallback: string) {
const normalized = value?.replace(/\s+/g, " ").trim();
return normalized || fallback;
}
export function qofficeWindowTitle(state: QOfficeState, activeApp: QOfficeState["activeApp"]) {
if (activeApp === "word") {
return `${normalizedWindowTitlePart(state.word.title, "Документ1")} - QWord`;
}
if (activeApp === "sheets") {
return `${normalizedWindowTitlePart(state.workbook.title, "Книга1")} - QExcell`;
}
if (activeApp === "slides") {
return `${normalizedWindowTitlePart(state.deck.title, "Презентация1")} - QPowerPoint`;
}
const activeMessage = state.mail.messages.find((message) => message.id === state.mail.activeMessageId);
return `${normalizedWindowTitlePart(activeMessage?.subject, "Почта")} - QOutlook`;
}
function migrateQOfficeState(state: QOfficeState): QOfficeState {
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
@@ -19,11 +53,15 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
state.word.id === initialState.word.id &&
sampleWordHtml.includes("Ключевой результат") &&
!state.word.html.includes("Ключевой результат");
const needsDeckSlideSize = !state.deck.slideSize;
let changed = false;
if (needsSampleWordColor) {
changed = true;
}
if (needsDeckSlideSize) {
changed = true;
}
const messages = state.mail.messages.map((message) => {
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
return message;
@@ -83,7 +121,7 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
...state,
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
workbook: { ...state.workbook, sheets },
deck: { ...state.deck, slides },
deck: { ...state.deck, slideSize: state.deck.slideSize ?? initialState.deck.slideSize ?? "wide", slides },
mail: { ...state.mail, messages }
}
: state;
@@ -91,26 +129,107 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
export function App() {
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
const [wordZoom, setWordZoom] = useState(100);
const [wordViewMode, setWordViewMode] = useState<WordViewMode>("print");
const activeApp = activeAppFromLocation(window.location);
const windowTitle = qofficeWindowTitle(state, activeApp);
useEffect(() => {
window.document.title = windowTitle;
}, [windowTitle]);
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
setWordStatus((current) =>
current.currentPage === nextStatus.currentPage && current.pageCount === nextStatus.pageCount && current.wordCount === nextStatus.wordCount ? current : nextStatus
);
}, []);
const adjustWordZoom = useCallback((delta: number) => {
setWordZoom((current) => clampedWordZoom(current + delta));
}, []);
const status = useMemo(() => {
const updatedAt =
state.activeApp === "word"
activeApp === "word"
? state.word.updatedAt
: state.activeApp === "sheets"
: activeApp === "sheets"
? state.workbook.updatedAt
: state.activeApp === "slides"
: activeApp === "slides"
? state.deck.updatedAt
: undefined;
if (activeApp === "word") {
return {
savedLabel: "Сохранено локально",
updatedAt,
className: "word-statusbar",
content: (
<>
<span>Страница {wordStatus.currentPage} из {wordStatus.pageCount}</span>
<span>Число слов: {wordStatus.wordCount}</span>
<span>русский</span>
</>
),
trailing: (
<div className="word-statusbar-tools" aria-label="Режимы просмотра и масштаб QWord">
<div className="word-view-buttons" aria-label="Режимы просмотра">
{wordStatusbarViewModes.map((mode) => {
const Icon = mode.Icon;
const isActive = wordViewMode === mode.id;
return (
<button
key={mode.id}
className={isActive ? "is-active" : undefined}
type="button"
title={mode.label}
aria-label={mode.label}
aria-pressed={isActive}
onClick={() => setWordViewMode(mode.id)}
>
<Icon size={15} />
</button>
);
})}
</div>
<button type="button" title="Уменьшить масштаб" aria-label="Уменьшить масштаб" onClick={() => adjustWordZoom(-10)}>
<Minus size={14} />
</button>
<input
className="word-zoom-slider"
type="range"
min="50"
max="200"
step="10"
value={wordZoom}
onChange={(event) => setWordZoom(clampedWordZoom(Number(event.target.value)))}
aria-label="Масштаб QWord"
/>
<button type="button" title="Увеличить масштаб" aria-label="Увеличить масштаб" onClick={() => adjustWordZoom(10)}>
<Plus size={14} />
</button>
<span>{wordZoom}%</span>
</div>
)
};
}
return {
savedLabel: "Сохранено локально",
updatedAt
};
}, [state]);
function setActiveApp(activeApp: AppId) {
setState((current) => ({ ...current, activeApp }));
}
}, [
activeApp,
adjustWordZoom,
state.deck.updatedAt,
state.word.updatedAt,
state.workbook.updatedAt,
wordStatus.currentPage,
wordStatus.pageCount,
wordStatus.wordCount,
wordViewMode,
wordZoom
]);
function updateWord(word: WordDocument) {
setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } }));
@@ -140,18 +259,25 @@ export function App() {
}
const activeSurface =
state.activeApp === "word" ? (
<QWord document={state.word} onChange={updateWord} />
) : state.activeApp === "sheets" ? (
activeApp === "word" ? (
<QWord
document={state.word}
zoom={wordZoom}
viewMode={wordViewMode}
onChange={updateWord}
onStatusChange={updateWordStatus}
onViewModeChange={setWordViewMode}
/>
) : activeApp === "sheets" ? (
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
) : state.activeApp === "slides" ? (
) : 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}>
<AppShell activeApp={activeApp} windowTitle={windowTitle} status={status}>
{activeSurface}
</AppShell>
);
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { activeAppFromLocation } from "./appRouting";
describe("activeAppFromLocation", () => {
it.each([
["/", "", "", "word"],
["/qword", "", "", "word"],
["/qexcell", "", "", "sheets"],
["/qpowerpoint", "", "", "slides"],
["/qoutlook", "", "", "mail"],
["/index.html", "#/qexcell", "", "sheets"],
["/index.html", "#qpowerpoint", "", "slides"],
["/index.html", "", "?app=qoutlook", "mail"],
["/index.html", "", "?module=pptx", "slides"],
["/unknown", "", "", "word"]
])("определяет приложение для pathname=%s hash=%s search=%s", (pathname, hash, search, expected) => {
expect(activeAppFromLocation({ pathname, hash, search })).toBe(expected);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { AppId } from "./types";
const routeAppIds = {
"/": "word",
"/qword": "word",
"/qexcell": "sheets",
"/qpowerpoint": "slides",
"/qoutlook": "mail"
} as const satisfies Record<string, AppId>;
const appAliases = {
word: "word",
qword: "word",
docx: "word",
sheets: "sheets",
sheet: "sheets",
excel: "sheets",
excell: "sheets",
qexcell: "sheets",
xlsx: "sheets",
slides: "slides",
slide: "slides",
powerpoint: "slides",
qpowerpoint: "slides",
pptx: "slides",
mail: "mail",
outlook: "mail",
qoutlook: "mail",
eml: "mail",
msg: "mail"
} as const satisfies Record<string, AppId>;
export interface AppLocationParts {
pathname: string;
hash?: string;
search?: string;
}
export function activeAppFromLocation(location: AppLocationParts): AppId {
const queryApp = appFromSearch(location.search);
if (queryApp) {
return queryApp;
}
const hashPath = normalizedPath(location.hash?.replace(/^#/, "") ?? "");
if (hashPath && hashPath !== "/") {
return routeAppIds[hashPath as keyof typeof routeAppIds] ?? appFromAlias(hashPath);
}
const path = normalizedPath(location.pathname);
return routeAppIds[path as keyof typeof routeAppIds] ?? appFromAlias(path) ?? "word";
}
function appFromSearch(search?: string) {
const params = new URLSearchParams(search ?? "");
return appFromAlias(params.get("app") ?? params.get("module") ?? "");
}
function appFromAlias(value: string) {
const normalized = value.trim().toLowerCase().replace(/^\/+|\/+$/g, "");
return appAliases[normalized as keyof typeof appAliases];
}
function normalizedPath(pathname: string) {
const normalized = pathname.trim().toLowerCase().replace(/\/+$/g, "") || "/";
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
+79
View File
@@ -0,0 +1,79 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { AppShell } from "./AppShell";
describe("AppShell", () => {
afterEach(() => {
cleanup();
});
it("не показывает общую ленту навигации между приложениями", () => {
const view = render(
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
<div>Рабочая область QWord</div>
</AppShell>
);
expect(screen.getByText("Рабочая область QWord")).toBeInTheDocument();
expect(view.container.querySelector(".ribbon")).not.toBeInTheDocument();
});
it("показывает пользовательскую строку состояния активного приложения", () => {
render(
<AppShell
activeApp="word"
status={{
savedLabel: "Сохранено локально",
content: (
<>
<span>Страница 1 из 2</span>
<span>Число слов: 42</span>
</>
),
trailing: <span>125%</span>
}}
>
<div>Рабочая область QWord</div>
</AppShell>
);
expect(screen.getByText("Страница 1 из 2")).toBeInTheDocument();
expect(screen.getByText("Число слов: 42")).toBeInTheDocument();
expect(screen.getByText("125%")).toBeInTheDocument();
});
it("показывает заголовок текущего файла активного приложения", () => {
render(
<AppShell activeApp="word" windowTitle="Отчет.docx - QWord" status={{ savedLabel: "Сохранено локально" }}>
<div>Рабочая область QWord</div>
</AppShell>
);
expect(screen.getByText("Отчет.docx - QWord")).toBeInTheDocument();
});
it("отправляет команды панели быстрого доступа активному приложению", () => {
const commands: string[] = [];
function onCommand(event: WindowEventMap["qoffice-command"]) {
commands.push(event.detail.command);
}
window.addEventListener("qoffice-command", onCommand);
try {
const view = render(
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
<div>Рабочая область QWord</div>
</AppShell>
);
const quickAccessButtons = view.container.querySelectorAll(".quick-access button");
fireEvent.click(quickAccessButtons[0]);
fireEvent.click(quickAccessButtons[1]);
fireEvent.click(quickAccessButtons[2]);
expect(commands).toEqual(["save", "undo", "redo"]);
} finally {
window.removeEventListener("qoffice-command", onCommand);
}
});
});
+47 -108
View File
@@ -1,109 +1,70 @@
import {
BarChart3,
FileText,
HelpCircle,
Mail,
MonitorCog,
PanelRight,
Presentation,
ChevronDown,
Redo2,
Save,
Search,
Settings,
Share2,
Table2
Undo2
} from "lucide-react";
import type { ReactNode } from "react";
import type { MouseEvent, ReactNode } from "react";
import type { QOfficeCommand } from "../hooks/useQOfficeCommand";
import type { AppId } from "../types";
interface AppShellProps {
activeApp: AppId;
onAppChange: (appId: AppId) => void;
windowTitle?: string;
status: {
savedLabel: string;
updatedAt?: string;
content?: ReactNode;
trailing?: ReactNode;
className?: 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 appTitles: Record<AppId, string> = {
word: "Документ1 - QWord",
sheets: "Книга1 - QExcell",
slides: "Презентация1 - QPowerPoint",
mail: "Почта - QOutlook"
};
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 }
]
function dispatchQOfficeCommand(command: QOfficeCommand) {
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command } }));
}
];
export function AppShell({ activeApp, onAppChange, status, children }: AppShellProps) {
const active = apps.find((app) => app.id === activeApp) ?? apps[0];
function keepActiveEditorFocus(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
}
export function AppShell({ activeApp, windowTitle, status, children }: AppShellProps) {
const statusClassName = ["statusbar", status.className].filter(Boolean).join(" ");
const displayWindowTitle = windowTitle?.trim() || appTitles[activeApp];
return (
<div className="qoffice-shell">
<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 className="quick-access" aria-label="Панель быстрого доступа">
<button type="button" title="Сохранить" aria-label="Сохранить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("save")}>
<Save size={16} />
</button>
<button type="button" title="Отменить" aria-label="Отменить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("undo")}>
<Undo2 size={16} />
</button>
<button type="button" title="Повторить" aria-label="Повторить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("redo")}>
<Redo2 size={16} />
</button>
<button type="button" title="Настроить панель" aria-label="Настроить панель">
<ChevronDown size={14} />
</button>
</div>
<strong className="window-title">{displayWindowTitle}</strong>
<div className="titlebar-actions">
<label className="command-search">
<Search size={16} />
<input placeholder="Найти команду" aria-label="Найти команду" />
<input placeholder="Поиск" aria-label="Поиск" />
</label>
<button className="primary-command" type="button">
<Share2 size={16} />
@@ -112,40 +73,18 @@ export function AppShell({ activeApp, onAppChange, status, children }: AppShellP
</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">
<footer className={statusClassName}>
<div className="statusbar-main">
{status.content ?? (
<>
<span>Готово</span>
<span>{status.updatedAt ? `Обновлено: ${new Date(status.updatedAt).toLocaleString("ru-RU")}` : "Локальная почта"}</span>
<span>Масштаб 100%</span>
</>
)}
</div>
<div className="statusbar-trailing">{status.trailing ?? <span>Масштаб 100%</span>}</div>
</footer>
</main>
</div>
+9 -1
View File
@@ -31,7 +31,8 @@ export const initialState: QOfficeState = {
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
</tbody>
</table>
`
`,
pageSetup: { size: "a4", orientation: "portrait", marginPreset: "normal" }
},
workbook: {
id: "qexcell-quarter-plan",
@@ -68,6 +69,9 @@ export const initialState: QOfficeState = {
A8: "Объединённая строка примечания"
},
mergedCells: [{ start: "A8", end: "E8" }],
comments: {
E2: "Проверить сумму перед отправкой квартального отчета."
},
cellFormats: {
A1: { bold: true, fontSize: 12, textColor: "FFFFFF", fillColor: "0F5FAE", horizontalAlign: "center" },
B1: { bold: true, fontSize: 12, textColor: "FFFFFF", fillColor: "0F5FAE", horizontalAlign: "center" },
@@ -105,10 +109,12 @@ export const initialState: QOfficeState = {
title: "Дорожная карта.qpptx",
updatedAt: new Date().toISOString(),
activeSlideId: "slide-1",
slideSize: "wide",
slides: [
{
id: "slide-1",
title: "Дорожная карта QOffice",
titleHyperlink: "https://git.kusoft.xyz/sevenhill/QOffice",
subtitle: "Единая рабочая среда для документов, данных, презентаций и коммуникации.",
notes: "Начать с цели продукта и текущего этапа.",
theme: "classic",
@@ -142,6 +148,8 @@ export const initialState: QOfficeState = {
cc: "qa@qoffice.local",
subject: "Контрольный список запуска",
body: "Пожалуйста, проверьте этапы редакторов, цели упаковки и ответственных за качество до пятницы.",
bodyHtml:
"<h3>Контрольный список запуска</h3><p>Пожалуйста, проверьте <strong>этапы редакторов</strong>, цели упаковки и ответственных за качество до пятницы.</p><ul><li>QWord: страницы и DOCX</li><li>QExcell: XLSX round-trip</li><li>QOutlook: EML/MSG импорт</li></ul>",
time: "09:24",
read: false,
flagged: true,
+316 -130
View File
@@ -11,6 +11,8 @@ import {
Bold,
Download,
Eraser,
Eye,
EyeOff,
FileText,
FileUp,
FunctionSquare,
@@ -23,14 +25,15 @@ import {
Save,
Sigma,
SquareDashed,
StickyNote,
Strikethrough,
Trash2,
Type,
Underline,
WrapText
} from "lucide-react";
import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types";
import { cellId, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
import type { CellFormat, SheetChart, SheetChartType, SheetPageMarginPreset, SheetPageOrientation, SheetPageSetup, SheetPageSizeId, Workbook } from "../../types";
import { cellId, columnLabel, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, parseCellId, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
import { replaceFileExtension } from "../../io/fileHelpers";
@@ -46,7 +49,9 @@ interface QExcellProps {
const extraEditableRows = 1;
const extraEditableColumns = 1;
const emptyHyperlinks: Record<string, string> = {};
const emptyComments: Record<string, string> = {};
const emptyCellFormats: Record<string, CellFormat> = {};
const emptyHiddenItems: Record<string, boolean> = {};
const emptyCharts: SheetChart[] = [];
const fillSwatches = [
@@ -90,6 +95,20 @@ const chartTypeOptions: Array<{ value: SheetChartType; label: string }> = [
{ value: "line", label: "Линейная" },
{ value: "pie", label: "Круговая" }
];
const sheetPageSizeOptions: Array<{ id: SheetPageSizeId; label: string }> = [
{ id: "a4", label: "A4" },
{ id: "letter", label: "Letter" }
];
const sheetPageOrientationOptions: Array<{ id: SheetPageOrientation; label: string }> = [
{ id: "portrait", label: "Книжная" },
{ id: "landscape", label: "Альбомная" }
];
const sheetPageMarginOptions: Array<{ id: SheetPageMarginPreset; label: string }> = [
{ id: "normal", label: "Обычные" },
{ id: "narrow", label: "Узкие" },
{ id: "wide", label: "Широкие" }
];
const defaultSheetPageSetup: SheetPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
const horizontalAlignOptions = [
{ value: "left", label: "По левому краю", icon: AlignLeft },
@@ -154,6 +173,18 @@ function normalizedCellFormat(format: CellFormat): CellFormat | undefined {
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizedSheetPageSetup(setup?: SheetPageSetup): SheetPageSetup {
const candidateSize = setup?.size;
const candidateOrientation = setup?.orientation;
const candidateMarginPreset = setup?.marginPreset;
const size = candidateSize && sheetPageSizeOptions.some((option) => option.id === candidateSize) ? candidateSize : defaultSheetPageSetup.size;
const orientation =
candidateOrientation && sheetPageOrientationOptions.some((option) => option.id === candidateOrientation) ? candidateOrientation : defaultSheetPageSetup.orientation;
const marginPreset =
candidateMarginPreset && sheetPageMarginOptions.some((option) => option.id === candidateMarginPreset) ? candidateMarginPreset : defaultSheetPageSetup.marginPreset;
return { size, orientation, marginPreset };
}
function cellInputStyle(format?: CellFormat): CSSProperties {
const textDecoration = [format?.underline ? "underline" : "", format?.strike ? "line-through" : ""].filter(Boolean).join(" ");
return {
@@ -224,6 +255,23 @@ function normalizedPrintAreaInput(value: string) {
return [...new Set(ranges)].join(",");
}
function nextHiddenItems(items: Record<string, boolean>, key: string, hidden: boolean) {
const nextItems = Object.entries(items).reduce<Record<string, boolean>>((result, [itemKey, isHidden]) => {
if (isHidden) {
result[itemKey] = true;
}
return result;
}, {});
if (hidden) {
nextItems[key] = true;
} else {
delete nextItems[key];
}
return Object.keys(nextItems).length > 0 ? nextItems : undefined;
}
function chartTypeIcon(type: SheetChartType) {
if (type === "line") {
return LineChart;
@@ -249,19 +297,30 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
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 activeComments = activeSheet.comments ?? emptyComments;
const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats;
const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems;
const activeHiddenRows = activeSheet.hiddenRows ?? emptyHiddenItems;
const activeCharts = activeSheet.charts ?? emptyCharts;
const activeSheetPageSetup = normalizedSheetPageSetup(activeSheet.pageSetup);
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
const selectedComment = activeComments[selectedCell] ?? "";
const selectedCellFormat = activeCellFormats[selectedCell] ?? {};
const selectedFormattedValue = formatCellValueForDisplay(evaluateCell(selectedCell, activeSheet.cells), selectedCellFormat);
const selectedCellPosition = parseCellId(selectedCell) ?? { column: 0, row: 0 };
const selectedColumnLabel = columnLabel(selectedCellPosition.column);
const selectedRowLabel = String(selectedCellPosition.row + 1);
const selectedColumnHidden = Boolean(activeHiddenColumns[selectedColumnLabel]);
const selectedRowHidden = Boolean(activeHiddenRows[selectedRowLabel]);
const hiddenColumnCount = Object.values(activeHiddenColumns).filter(Boolean).length;
const hiddenRowCount = Object.values(activeHiddenRows).filter(Boolean).length;
const boundsCells = useMemo(
() =>
[...Object.keys(activeHyperlinks), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>(
(cells, address) => ({ ...cells, [address]: cells[address] || activeHyperlinks[address] || "format" }),
[...Object.keys(activeHyperlinks), ...Object.keys(activeComments), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>(
(cells, address) => ({ ...cells, [address]: cells[address] || activeHyperlinks[address] || activeComments[address] || "format" }),
{ ...activeSheet.cells }
),
[activeCellFormats, activeHyperlinks, activeSheet.cells]
[activeCellFormats, activeComments, activeHyperlinks, activeSheet.cells]
);
const sheetBounds = useMemo(() => usedSheetBounds(boundsCells, activeMergedCells), [activeMergedCells, boundsCells]);
const rows = useMemo(() => Array.from({ length: sheetBounds.rowCount + extraEditableRows }, (_, index) => index), [sheetBounds.rowCount]);
@@ -298,6 +357,22 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
});
}
function updateComment(id: string, comment: string) {
const normalizedId = id.toUpperCase();
const nextComments = { ...activeComments };
const nextComment = comment.trim();
if (nextComment) {
nextComments[normalizedId] = nextComment;
} else {
delete nextComments[normalizedId];
}
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, comments: nextComments } : sheet))
});
}
function updateCellFormat(id: string, formatPatch: CellFormat) {
const normalizedId = id.toUpperCase();
const nextFormats = { ...activeCellFormats };
@@ -342,14 +417,16 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
function clearSelectedCell() {
const nextCells = { ...activeSheet.cells };
const nextHyperlinks = { ...activeHyperlinks };
const nextComments = { ...activeComments };
const nextFormats = { ...activeCellFormats };
delete nextCells[selectedCell];
delete nextHyperlinks[selectedCell];
delete nextComments[selectedCell];
delete nextFormats[selectedCell];
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) =>
sheet.id === activeSheet.id ? { ...sheet, cells: nextCells, hyperlinks: nextHyperlinks, cellFormats: nextFormats } : sheet
sheet.id === activeSheet.id ? { ...sheet, cells: nextCells, hyperlinks: nextHyperlinks, comments: nextComments, cellFormats: nextFormats } : sheet
)
});
}
@@ -368,6 +445,49 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
});
}
function updateActiveSheetPageSetup(pageSetupPatch: Partial<SheetPageSetup>) {
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) =>
sheet.id === activeSheet.id ? { ...sheet, pageSetup: normalizedSheetPageSetup({ ...activeSheetPageSetup, ...pageSetupPatch }) } : sheet
)
});
}
function addSheet() {
const id = `sheet-${workbook.sheets.length + 1}`;
onChange({
...workbook,
activeSheet: id,
sheets: [...workbook.sheets, { id, name: `Лист ${workbook.sheets.length + 1}`, cells: {}, mergedCells: [] }]
});
}
function updateSelectedColumnHidden(hidden: boolean) {
const hiddenColumns = nextHiddenItems(activeHiddenColumns, selectedColumnLabel, hidden);
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, hiddenColumns } : sheet))
});
}
function updateSelectedRowHidden(hidden: boolean) {
const hiddenRows = nextHiddenItems(activeHiddenRows, selectedRowLabel, hidden);
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, hiddenRows } : sheet))
});
}
function showAllHiddenSheetItems() {
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) =>
sheet.id === activeSheet.id ? { ...sheet, hiddenColumns: undefined, hiddenRows: undefined } : sheet
)
});
}
function selectChart(chart: SheetChart) {
setSelectedChartId(chart.id);
setChartTitle(chart.title);
@@ -534,6 +654,26 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
<Link size={16} />
Ссылка
</button>
<button
className={`compact-command ${selectedColumnHidden ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateSelectedColumnHidden(!selectedColumnHidden)}
title={`${selectedColumnHidden ? "Показать" : "Скрыть"} столбец ${selectedColumnLabel}`}
aria-label={`${selectedColumnHidden ? "Показать" : "Скрыть"} столбец ${selectedColumnLabel}`}
>
{selectedColumnHidden ? <Eye size={16} /> : <EyeOff size={16} />}
{selectedColumnHidden ? "Показать столбец" : "Скрыть столбец"}
</button>
<button
className={`compact-command ${selectedRowHidden ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateSelectedRowHidden(!selectedRowHidden)}
title={`${selectedRowHidden ? "Показать" : "Скрыть"} строку ${selectedRowLabel}`}
aria-label={`${selectedRowHidden ? "Показать" : "Скрыть"} строку ${selectedRowLabel}`}
>
{selectedRowHidden ? <Eye size={16} /> : <EyeOff size={16} />}
{selectedRowHidden ? "Показать строку" : "Скрыть строку"}
</button>
<div className="cell-format-tools" aria-label="Формат ячейки">
<button
className={`icon-button format-toggle ${selectedCellFormat.bold ? "is-active" : ""}`.trim()}
@@ -720,21 +860,178 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
/>
</div>
<div className="office-workbar sheet-workbar" aria-label="Параметры QExcell">
<div className="office-tool-section">
<span className="office-tool-title">Лист</span>
<label className="office-field">
<span>Активный</span>
<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="compact-command" type="button" onClick={addSheet}>
<Plus size={16} />
Новый лист
</button>
<label className="office-field">
<span>Имя</span>
<input value={activeSheet.name} onChange={(event) => updateActiveSheetName(event.target.value)} />
</label>
<label className="office-field">
<span>Печать</span>
<input
value={activeSheet.printArea ?? ""}
placeholder="A1:E20"
onChange={(event) => updateActiveSheetPrintArea(event.target.value)}
onBlur={(event) => updateActiveSheetPrintArea(normalizedPrintAreaInput(event.target.value))}
/>
</label>
<label className="office-field">
<span>Размер</span>
<select value={activeSheetPageSetup.size} onChange={(event) => updateActiveSheetPageSetup({ size: event.target.value as SheetPageSizeId })}>
{sheetPageSizeOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<label className="office-field">
<span>Ориентация</span>
<select
value={activeSheetPageSetup.orientation}
onChange={(event) => updateActiveSheetPageSetup({ orientation: event.target.value as SheetPageOrientation })}
>
{sheetPageOrientationOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<label className="office-field">
<span>Поля</span>
<select value={activeSheetPageSetup.marginPreset} onChange={(event) => updateActiveSheetPageSetup({ marginPreset: event.target.value as SheetPageMarginPreset })}>
{sheetPageMarginOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="office-tool-section">
<span className="office-tool-title">Ячейка</span>
<span className="office-chip">{selectedCell}</span>
<label className="office-field">
<span>Формат</span>
<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 className="office-field office-field-wide">
<span>Ссылка</span>
<input
value={selectedHyperlink}
placeholder="https://example.com"
onChange={(event) => updateHyperlink(selectedCell, event.target.value)}
onBlur={(event) => updateHyperlink(selectedCell, normalizedLinkInput(event.target.value))}
/>
</label>
<label className="office-field office-field-wide">
<span>
<StickyNote size={14} />
Заметка
</span>
<textarea
className="cell-comment-field"
value={selectedComment}
aria-label="Заметка ячейки"
placeholder="Комментарий Excel"
onChange={(event) => updateComment(selectedCell, event.target.value)}
/>
</label>
{hiddenColumnCount + hiddenRowCount > 0 ? (
<button className="compact-command" type="button" onClick={showAllHiddenSheetItems}>
<Eye size={16} />
Показать всё
</button>
) : null}
</div>
<div className="office-tool-section">
<span className="office-tool-title">Диаграмма</span>
<label className="office-field">
<span>Заголовок</span>
<input value={chartTitle} onChange={(event) => setChartTitle(event.target.value)} />
</label>
<label className="office-field">
<span>Тип</span>
<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 className="office-field">
<span>Подписи</span>
<input value={chartLabelRange} onChange={(event) => setChartLabelRange(event.target.value)} placeholder="A2:A4" />
</label>
<label className="office-field">
<span>Значения</span>
<input value={chartValueRange} onChange={(event) => setChartValueRange(event.target.value)} placeholder="E2:E4" />
</label>
<button className="compact-command" type="button" onClick={upsertChart}>
<BarChart3 size={16} />
{selectedChartId ? "Сохранить" : "Добавить"}
</button>
{selectedChartId ? (
<button className="compact-command" type="button" onClick={resetChartForm}>
<Plus size={16} />
Новая
</button>
) : null}
<span className="office-chip">E: {totals.toLocaleString("ru-RU")}</span>
</div>
</div>
<div className="grid-wrap">
<table className="sheet-grid">
<thead>
<tr>
<th aria-label="Номера строк" />
{visibleColumnLabels.map((column) => (
<th key={column}>{column}</th>
<th
key={column}
className={activeHiddenColumns[column] ? "is-hidden-column" : undefined}
title={activeHiddenColumns[column] ? `Скрытый столбец ${column}` : undefined}
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row}>
<th>{row + 1}</th>
{visibleColumnLabels.map((_, column) => {
{rows.map((row) => {
const rowLabel = String(row + 1);
const isHiddenRow = Boolean(activeHiddenRows[rowLabel]);
return (
<tr key={row} className={isHiddenRow ? "is-hidden-row" : undefined}>
<th className={isHiddenRow ? "is-hidden-row" : undefined} title={isHiddenRow ? `Скрытая строка ${rowLabel}` : undefined}>
{rowLabel}
</th>
{visibleColumnLabels.map((columnName, column) => {
const id = cellId(column, row);
const merge = mergedCellInfo(id, activeMergedCells);
if (merge && !merge.isOrigin) {
@@ -744,12 +1041,14 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
const raw = activeSheet.cells[id] ?? "";
const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw;
const hyperlink = activeHyperlinks[id] ?? "";
const comment = activeComments[id] ?? "";
const cellFormat = activeCellFormats[id];
const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat);
const isHiddenColumn = Boolean(activeHiddenColumns[columnName]);
return (
<td
key={id}
className={`${selectedCell === id ? "is-selected" : ""} ${merge ? "is-merged" : ""} ${hyperlink ? "is-linked" : ""} ${cellFormat ? "is-formatted" : ""} ${cellFormat?.wrapText ? "is-wrapped" : ""} ${cellFormat?.border ? "is-bordered" : ""} ${cellFormat?.verticalAlign ? `is-valign-${cellFormat.verticalAlign}` : ""}`.trim()}
className={`${selectedCell === id ? "is-selected" : ""} ${merge ? "is-merged" : ""} ${hyperlink ? "is-linked" : ""} ${comment ? "is-commented" : ""} ${cellFormat ? "is-formatted" : ""} ${cellFormat?.wrapText ? "is-wrapped" : ""} ${cellFormat?.border ? "is-bordered" : ""} ${cellFormat?.verticalAlign ? `is-valign-${cellFormat.verticalAlign}` : ""} ${isHiddenColumn ? "is-hidden-column" : ""} ${isHiddenRow ? "is-hidden-row" : ""}`.trim()}
style={cellBoxStyle(cellFormat)}
colSpan={merge?.columnSpan}
rowSpan={merge?.rowSpan}
@@ -758,7 +1057,7 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
<textarea
value={selectedCell === id ? raw : display}
aria-label={`Ячейка ${id}`}
title={hyperlink || undefined}
title={[hyperlink, comment].filter(Boolean).join("\n") || undefined}
style={cellInputStyle(cellFormat)}
onFocus={() => setSelectedCell(id)}
onChange={(event) => updateCell(id, event.target.value)}
@@ -767,7 +1066,7 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
<input
value={selectedCell === id ? raw : display}
aria-label={`Ячейка ${id}`}
title={hyperlink || undefined}
title={[hyperlink, comment].filter(Boolean).join("\n") || undefined}
style={cellInputStyle(cellFormat)}
onFocus={() => setSelectedCell(id)}
onChange={(event) => updateCell(id, event.target.value)}
@@ -777,7 +1076,8 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
);
})}
</tr>
))}
);
})}
</tbody>
</table>
</div>
@@ -798,120 +1098,6 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
) : 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>
<label>
Область печати
<input
value={activeSheet.printArea ?? ""}
placeholder="A1:E20"
onChange={(event) => updateActiveSheetPrintArea(event.target.value)}
onBlur={(event) => updateActiveSheetPrintArea(normalizedPrintAreaInput(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>
);
}
+96
View File
@@ -38,6 +38,93 @@ function formatBytes(bytes: number): string {
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[unitIndex]}`;
}
const allowedMailHtmlTags = new Set([
"a",
"b",
"blockquote",
"br",
"code",
"div",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"img",
"li",
"ol",
"p",
"pre",
"span",
"strong",
"sub",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"u",
"ul"
]);
const allowedMailHtmlAttributes = new Set(["align", "alt", "colspan", "height", "href", "rowspan", "src", "title", "width"]);
const allowedMailHtmlProtocols = new Set(["http:", "https:", "mailto:", "tel:"]);
const unsafeMailHtmlSelector = "script, style, iframe, object, embed, meta, link, base, form, input, button, textarea, select";
function sanitizeMailHtml(html: string): string {
const parser = new DOMParser();
const document = parser.parseFromString(html, "text/html");
document.querySelectorAll(unsafeMailHtmlSelector).forEach((element) => element.remove());
document.body.querySelectorAll("*").forEach((element) => {
const tagName = element.tagName.toLowerCase();
if (!allowedMailHtmlTags.has(tagName)) {
element.replaceWith(...Array.from(element.childNodes));
return;
}
Array.from(element.attributes).forEach((attribute) => {
const name = attribute.name.toLowerCase();
if (!allowedMailHtmlAttributes.has(name) || name.startsWith("on") || !isSafeMailHtmlAttribute(name, attribute.value)) {
element.removeAttribute(attribute.name);
}
});
if (tagName === "a" && element.hasAttribute("href")) {
element.setAttribute("rel", "noopener noreferrer");
element.setAttribute("target", "_blank");
}
});
return document.body.innerHTML;
}
function isSafeMailHtmlAttribute(name: string, value: string): boolean {
if (name !== "href" && name !== "src") {
return true;
}
const trimmed = value.trim();
if (name === "src" && /^data:image\/(?:png|jpe?g|gif|bmp|webp);base64,/i.test(trimmed)) {
return true;
}
try {
const parsed = new URL(trimmed, window.location.origin);
return allowedMailHtmlProtocols.has(parsed.protocol);
} catch {
return false;
}
}
export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const draftAttachmentInputRef = useRef<HTMLInputElement>(null);
@@ -73,6 +160,7 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
message.priority ?? "",
message.subject,
message.body,
message.bodyHtml ?? "",
...(message.attachments ?? []).map((attachment) => attachment.fileName)
].some((field) => field.toLowerCase().includes(normalizedQuery));
return inFolder && matchesQuery;
@@ -341,7 +429,15 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
{activeMessage.priority ? `Priority: ${activeMessage.priority}` : ""}
</p>
) : null}
{activeMessage.bodyHtml ? (
<div
className="message-html-body"
aria-label="HTML содержимое письма"
dangerouslySetInnerHTML={{ __html: sanitizeMailHtml(activeMessage.bodyHtml) }}
/>
) : (
<p>{activeMessage.body}</p>
)}
{activeAttachments.length > 0 ? (
<section className="attachments-panel" aria-label="Вложения письма">
<h3>Вложения</h3>
@@ -0,0 +1,100 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { SlideDeck } from "../../types";
import { QPowerPoint } from "./QPowerPoint";
const tinyPngDataUri =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
function deckWithLinkedImage(): SlideDeck {
return {
id: "deck-linked-image",
title: "Кликабельное изображение.qpptx",
updatedAt: "2026-06-02T00:00:00.000Z",
activeSlideId: "slide-1",
slides: [
{
id: "slide-1",
title: "Слайд с изображением",
subtitle: "Проверка ссылки",
notes: "",
theme: "classic",
images: [
{
id: "image-1",
src: tinyPngDataUri,
alt: "Логотип",
hyperlink: "https://example.com/logo",
x: 7,
y: 4,
width: 2,
height: 1
}
]
}
]
};
}
describe("QPowerPoint", () => {
it("показывает и обновляет ссылку выбранного изображения", () => {
const onChange = vi.fn();
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
const imageObject = view.container.querySelector(".slide-image-object");
expect(imageObject).toBeTruthy();
fireEvent.click(imageObject as Element);
const hyperlinkField = screen.getByLabelText("Ссылка изображения слайда") as HTMLInputElement;
expect(hyperlinkField).toHaveValue("https://example.com/logo");
fireEvent.change(hyperlinkField, { target: { value: "https://example.com/updated-logo" } });
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
const nextDeck = lastCall?.[0] as SlideDeck;
expect(nextDeck.slides[0].images?.[0].hyperlink).toBe("https://example.com/updated-logo");
});
it("переключает размер слайда презентации", () => {
const onChange = vi.fn();
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
const slideSizeSelect = view.container.querySelector('select[aria-label="Размер слайда"]') as HTMLSelectElement;
const canvas = view.container.querySelector(".slide-canvas") as HTMLElement;
expect(slideSizeSelect).toHaveValue("wide");
expect(canvas.style.aspectRatio).toBe("13.333 / 7.5");
fireEvent.change(slideSizeSelect, { target: { value: "standard" } });
const nextDeck = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as SlideDeck;
expect(nextDeck.slideSize).toBe("standard");
});
it("переключает начертание текста слайда", () => {
const onChange = vi.fn();
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
const boldTitleButton = view.container.querySelector('button[aria-label="Полужирный заголовок"]');
const strikeTitleButton = view.container.querySelector('button[aria-label="Зачеркнутый заголовок"]');
expect(boldTitleButton).toBeTruthy();
expect(strikeTitleButton).toBeTruthy();
fireEvent.click(boldTitleButton as Element);
const nextDeck = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as SlideDeck;
expect(onChange).toHaveBeenCalledTimes(1);
expect(nextDeck.slides[0].titleBold).toBe(false);
});
it("переключает выравнивание текста слайда", () => {
const onChange = vi.fn();
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
const centerTitleButton = view.container.querySelector('button[aria-label="Заголовок по центру"]');
expect(centerTitleButton).toBeTruthy();
fireEvent.click(centerTitleButton as Element);
const nextDeck = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as SlideDeck;
expect(onChange).toHaveBeenCalledTimes(1);
expect(nextDeck.slides[0].titleAlign).toBe("center");
});
});
+366 -82
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import { Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Palette, Play, Plus, Save, Trash2 } from "lucide-react";
import type { Slide, SlideDeck, SlideImage, SlideTransition } from "../../types";
import { AlignCenter, AlignLeft, AlignRight, Bold, Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Italic, Link, List, ListOrdered, ListX, Palette, Plus, Save, Strikethrough, Trash2, Underline } from "lucide-react";
import type { Slide, SlideDeck, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign, SlideTransition } from "../../types";
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
import { replaceFileExtension } from "../../io/fileHelpers";
@@ -14,6 +14,17 @@ interface QPowerPointProps {
onChange: (deck: SlideDeck) => void;
}
type SlideTextFormatField =
| "titleBold"
| "titleItalics"
| "titleUnderline"
| "titleStrike"
| "subtitleBold"
| "subtitleItalics"
| "subtitleUnderline"
| "subtitleStrike";
type SlideTextAlignField = "titleAlign" | "subtitleAlign";
const themeLabels: Record<Slide["theme"], string> = {
classic: "Классическая",
ocean: "Океан",
@@ -47,8 +58,20 @@ const slideTransitionOptions: Array<{ value: SlideTransition | ""; label: string
{ value: "wipe", label: "Шторка" }
];
const slideWidthInches = 13.333;
const slideHeightInches = 7.5;
const slideSizeOptions: Array<{ id: SlideSizeId; label: string; width: number; height: number }> = [
{ id: "wide", label: "Широкий 16:9", width: 13.333, height: 7.5 },
{ id: "standard", label: "Стандартный 4:3", width: 10, height: 7.5 }
];
type SlideDimensions = { width: number; height: number };
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
return value === "standard" ? "standard" : "wide";
}
function slideDimensions(value?: SlideSizeId): SlideDimensions {
return slideSizeOptions.find((option) => option.id === normalizedSlideSizeId(value)) ?? slideSizeOptions[0];
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
@@ -88,28 +111,42 @@ function roundSlideNumber(value: number) {
return Math.round(value * 10) / 10;
}
function slideImageStyle(image: SlideImage) {
function slideImageStyle(image: SlideImage, dimensions: SlideDimensions) {
return {
left: `${(image.x / slideWidthInches) * 100}%`,
top: `${(image.y / slideHeightInches) * 100}%`,
width: `${(image.width / slideWidthInches) * 100}%`,
height: `${(image.height / slideHeightInches) * 100}%`
left: `${(image.x / dimensions.width) * 100}%`,
top: `${(image.y / dimensions.height) * 100}%`,
width: `${(image.width / dimensions.width) * 100}%`,
height: `${(image.height / dimensions.height) * 100}%`
};
}
function slideCanvasStyle(slide: Slide) {
function slideCanvasStyle(slide: Slide, dimensions: SlideDimensions): CSSProperties {
const backgroundColor = normalizedHexColor(slide.backgroundColor);
return backgroundColor ? { background: `#${backgroundColor}` } : undefined;
return {
aspectRatio: `${dimensions.width} / ${dimensions.height}`,
...(backgroundColor ? { background: `#${backgroundColor}` } : {})
};
}
function slideTextStyle(color?: string, fontSize?: number, fontFamily?: string): CSSProperties | undefined {
function slideTextStyle(
color?: string,
fontSize?: number,
fontFamily?: string,
textFormat: { bold?: boolean; italics?: boolean; underline?: boolean; strike?: boolean; align?: SlideTextAlign; defaultBold?: boolean } = {}
): CSSProperties | undefined {
const textColor = normalizedHexColor(color);
const normalizedFontSize = normalizeFontSize(fontSize);
const normalizedFamily = normalizedFontFamily(fontFamily);
const effectiveBold = textFormat.bold ?? textFormat.defaultBold;
const textDecoration = [textFormat.underline ? "underline" : "", textFormat.strike ? "line-through" : ""].filter(Boolean).join(" ");
const style: CSSProperties = {
...(textColor ? { color: `#${textColor}` } : {}),
...(normalizedFontSize ? { fontSize: `${normalizedFontSize}pt` } : {}),
...(normalizedFamily ? { fontFamily: normalizedFamily } : {})
...(normalizedFamily ? { fontFamily: normalizedFamily } : {}),
...(effectiveBold !== undefined ? { fontWeight: effectiveBold ? 780 : 400 } : {}),
...(textFormat.italics ? { fontStyle: "italic" } : {}),
...(textDecoration ? { textDecoration } : {}),
...(textFormat.align ? { textAlign: textFormat.align } : {})
};
return Object.keys(style).length > 0 ? style : undefined;
@@ -145,6 +182,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
const activeSlide = deck.slides.find((slide) => slide.id === deck.activeSlideId) ?? deck.slides[0];
const activeImages = activeSlide.images ?? [];
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
const activeSlideSize = slideDimensions(deck.slideSize);
function updateSlide(nextSlide: Slide) {
onChange({
@@ -153,6 +191,15 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
});
}
function toggleSlideTextFormat(field: SlideTextFormatField, defaultValue = false) {
const enabled = Boolean(activeSlide[field] ?? defaultValue);
updateSlide({ ...activeSlide, [field]: !enabled });
}
function setSlideTextAlign(field: SlideTextAlignField, align: SlideTextAlign) {
updateSlide({ ...activeSlide, [field]: align });
}
function updateImages(images: SlideImage[]) {
updateSlide({ ...activeSlide, images });
}
@@ -183,6 +230,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
setSelectedImageId("");
}
function updateSubtitleListStyle(listStyle: SlideListStyle | "") {
updateSlide({ ...activeSlide, subtitleListStyle: listStyle || undefined });
}
function addSlide() {
const id = `slide-${Date.now()}`;
onChange({
@@ -231,6 +282,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
...deck,
title: imported.title,
activeSlideId: imported.slides[0]?.id ?? "slide-1",
slideSize: imported.slideSize ?? deck.slideSize ?? "wide",
slides: imported.slides.length > 0 ? imported.slides : deck.slides
});
} catch (error) {
@@ -254,8 +306,8 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
id: `slide-image-${Date.now()}`,
src,
alt: file.name,
x: roundSlideNumber(Math.max(0.4, slideWidthInches - size.width - 0.9)),
y: roundSlideNumber(Math.max(1.4, slideHeightInches - size.height - 0.8)),
x: roundSlideNumber(Math.max(0.4, activeSlideSize.width - size.width - 0.9)),
y: roundSlideNumber(Math.max(1.4, activeSlideSize.height - size.height - 0.8)),
...size
};
@@ -394,58 +446,19 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</button>
</div>
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide)}>
{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="Заголовок слайда"
style={slideTextStyle(activeSlide.titleColor, activeSlide.titleFontSize, activeSlide.titleFontFamily)}
onChange={(event) => updateSlide({ ...activeSlide, title: event.target.value })}
/>
<textarea
className="slide-subtitle-input"
value={activeSlide.subtitle}
aria-label="Подзаголовок слайда"
style={slideTextStyle(activeSlide.subtitleColor, activeSlide.subtitleFontSize, activeSlide.subtitleFontFamily)}
onChange={(event) => updateSlide({ ...activeSlide, subtitle: event.target.value })}
/>
<div className="slide-accent-line" />
</div>
</section>
<aside className="inspector" aria-label="Параметры слайда">
<section>
<h3>Оформление</h3>
<label className="slide-hidden-toggle">
<span>
<EyeOff size={16} />
Показ слайда
</span>
<div className="office-workbar slide-workbar" aria-label="Параметры QPowerPoint">
<div className="office-tool-section">
<span className="office-tool-title">Оформление</span>
<label className="office-check">
<input
type="checkbox"
checked={Boolean(activeSlide.hidden)}
onChange={(event) => updateSlide({ ...activeSlide, hidden: event.target.checked || undefined })}
/>
<span className="slide-hidden-toggle-text">Скрыть при показе слайд-шоу</span>
Скрыть слайд
</label>
<label>
<span>
<Palette size={16} />
Тема
</span>
<label className="office-field">
<span>Тема</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}>
@@ -454,11 +467,8 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
))}
</select>
</label>
<label>
<span>
<Play size={16} />
Переход
</span>
<label className="office-field">
<span>Переход</span>
<select
value={activeSlide.transition ?? ""}
onChange={(event) => {
@@ -473,7 +483,22 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
))}
</select>
</label>
<label className="office-field">
<span>Размер</span>
<select
value={normalizedSlideSizeId(deck.slideSize)}
onChange={(event) => onChange({ ...deck, slideSize: event.target.value as SlideSizeId })}
aria-label="Размер слайда"
>
{slideSizeOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<div className="slide-background-swatches" aria-label="Фон слайда">
<Palette size={16} aria-hidden="true" />
{backgroundColorSwatches.map((swatch) => (
<button
key={swatch.label}
@@ -486,7 +511,11 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
/>
))}
</div>
<div className="slide-text-controls">
</div>
<div className="office-tool-section">
<span className="office-tool-title">Текст</span>
<label className="office-field">
<span>Заголовок</span>
<select
value={activeSlide.titleFontSize ?? ""}
@@ -500,6 +529,9 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</option>
))}
</select>
</label>
<label className="office-field">
<span>Шрифт</span>
<select
value={activeSlide.titleFontFamily ?? ""}
onChange={(event) => updateSlide({ ...activeSlide, titleFontFamily: event.target.value || undefined })}
@@ -513,6 +545,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</option>
))}
</select>
</label>
<div className="slide-background-swatches" aria-label="Цвет заголовка">
{textColorSwatches.map((swatch) => (
<button
@@ -526,6 +559,86 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
/>
))}
</div>
<div className="cell-format-tools slide-format-tools" aria-label="Начертание заголовка">
<button
className={`icon-button format-toggle ${(activeSlide.titleBold ?? true) ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("titleBold", true)}
title="Полужирный заголовок"
aria-label="Полужирный заголовок"
>
<Bold size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.titleItalics ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("titleItalics")}
title="Курсив заголовка"
aria-label="Курсив заголовка"
>
<Italic size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.titleUnderline ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("titleUnderline")}
title="Подчеркнутый заголовок"
aria-label="Подчеркнутый заголовок"
>
<Underline size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.titleStrike ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("titleStrike")}
title="Зачеркнутый заголовок"
aria-label="Зачеркнутый заголовок"
>
<Strikethrough size={16} />
</button>
</div>
<div className="cell-format-tools slide-format-tools" aria-label="Выравнивание заголовка">
<button
className={`icon-button format-toggle ${(activeSlide.titleAlign ?? "left") === "left" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("titleAlign", "left")}
title="Заголовок по левому краю"
aria-label="Заголовок по левому краю"
>
<AlignLeft size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.titleAlign === "center" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("titleAlign", "center")}
title="Заголовок по центру"
aria-label="Заголовок по центру"
>
<AlignCenter size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.titleAlign === "right" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("titleAlign", "right")}
title="Заголовок по правому краю"
aria-label="Заголовок по правому краю"
>
<AlignRight size={16} />
</button>
</div>
<label className="office-field office-field-wide">
<span>
<Link size={14} />
Ссылка заголовка
</span>
<input
value={activeSlide.titleHyperlink ?? ""}
aria-label="Ссылка заголовка слайда"
placeholder="https://example.com"
onChange={(event) => updateSlide({ ...activeSlide, titleHyperlink: event.target.value || undefined })}
/>
</label>
<label className="office-field">
<span>Подзаголовок</span>
<select
value={activeSlide.subtitleFontSize ?? ""}
@@ -539,6 +652,9 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</option>
))}
</select>
</label>
<label className="office-field">
<span>Шрифт</span>
<select
value={activeSlide.subtitleFontFamily ?? ""}
onChange={(event) => updateSlide({ ...activeSlide, subtitleFontFamily: event.target.value || undefined })}
@@ -552,6 +668,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</option>
))}
</select>
</label>
<div className="slide-background-swatches" aria-label="Цвет подзаголовка">
{textColorSwatches.map((swatch) => (
<button
@@ -565,10 +682,118 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
/>
))}
</div>
<div className="cell-format-tools slide-format-tools" aria-label="Начертание подзаголовка">
<button
className={`icon-button format-toggle ${activeSlide.subtitleBold ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("subtitleBold")}
title="Полужирный подзаголовок"
aria-label="Полужирный подзаголовок"
>
<Bold size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.subtitleItalics ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("subtitleItalics")}
title="Курсив подзаголовка"
aria-label="Курсив подзаголовка"
>
<Italic size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.subtitleUnderline ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("subtitleUnderline")}
title="Подчеркнутый подзаголовок"
aria-label="Подчеркнутый подзаголовок"
>
<Underline size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.subtitleStrike ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("subtitleStrike")}
title="Зачеркнутый подзаголовок"
aria-label="Зачеркнутый подзаголовок"
>
<Strikethrough size={16} />
</button>
</div>
</section>
<section>
<h3>Изображения</h3>
<div className="cell-format-tools slide-format-tools" aria-label="Выравнивание подзаголовка">
<button
className={`icon-button format-toggle ${(activeSlide.subtitleAlign ?? "left") === "left" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("subtitleAlign", "left")}
title="Подзаголовок по левому краю"
aria-label="Подзаголовок по левому краю"
>
<AlignLeft size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.subtitleAlign === "center" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("subtitleAlign", "center")}
title="Подзаголовок по центру"
aria-label="Подзаголовок по центру"
>
<AlignCenter size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.subtitleAlign === "right" ? "is-active" : ""}`.trim()}
type="button"
onClick={() => setSlideTextAlign("subtitleAlign", "right")}
title="Подзаголовок по правому краю"
aria-label="Подзаголовок по правому краю"
>
<AlignRight size={16} />
</button>
</div>
<label className="office-field office-field-wide">
<span>
<Link size={14} />
Ссылка подзаголовка
</span>
<input
value={activeSlide.subtitleHyperlink ?? ""}
aria-label="Ссылка подзаголовка слайда"
placeholder="https://example.com"
onChange={(event) => updateSlide({ ...activeSlide, subtitleHyperlink: event.target.value || undefined })}
/>
</label>
<div className="segmented-tools slide-list-tools" aria-label="Список подзаголовка">
<button
type="button"
className={!activeSlide.subtitleListStyle ? "is-active" : ""}
onClick={() => updateSubtitleListStyle("")}
title="Без списка"
aria-label="Без списка"
>
<ListX size={16} />
</button>
<button
type="button"
className={activeSlide.subtitleListStyle === "bullet" ? "is-active" : ""}
onClick={() => updateSubtitleListStyle("bullet")}
title="Маркированный список"
aria-label="Маркированный список"
>
<List size={16} />
</button>
<button
type="button"
className={activeSlide.subtitleListStyle === "number" ? "is-active" : ""}
onClick={() => updateSubtitleListStyle("number")}
title="Нумерованный список"
aria-label="Нумерованный список"
>
<ListOrdered size={16} />
</button>
</div>
</div>
<div className="office-tool-section">
<span className="office-tool-title">Объекты</span>
<div className="slide-image-list">
{activeImages.map((image, index) => (
<button
@@ -589,10 +814,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<input
type="number"
min="0"
max={slideWidthInches}
max={activeSlideSize.width}
step="0.1"
value={selectedImage.x}
onChange={(event) => updateSelectedImageNumber("x", event.target.value, slideWidthInches)}
onChange={(event) => updateSelectedImageNumber("x", event.target.value, activeSlideSize.width)}
/>
</label>
<label>
@@ -600,10 +825,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<input
type="number"
min="0"
max={slideHeightInches}
max={activeSlideSize.height}
step="0.1"
value={selectedImage.y}
onChange={(event) => updateSelectedImageNumber("y", event.target.value, slideHeightInches)}
onChange={(event) => updateSelectedImageNumber("y", event.target.value, activeSlideSize.height)}
/>
</label>
<label>
@@ -611,10 +836,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<input
type="number"
min="0.1"
max={slideWidthInches}
max={activeSlideSize.width}
step="0.1"
value={selectedImage.width}
onChange={(event) => updateSelectedImageNumber("width", event.target.value, slideWidthInches)}
onChange={(event) => updateSelectedImageNumber("width", event.target.value, activeSlideSize.width)}
/>
</label>
<label>
@@ -622,30 +847,89 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
<input
type="number"
min="0.1"
max={slideHeightInches}
max={activeSlideSize.height}
step="0.1"
value={selectedImage.height}
onChange={(event) => updateSelectedImageNumber("height", event.target.value, slideHeightInches)}
onChange={(event) => updateSelectedImageNumber("height", event.target.value, activeSlideSize.height)}
/>
</label>
</div>
<label className="office-field office-field-wide">
<span>
<Link size={14} />
Ссылка изображения
</span>
<input
value={selectedImage.hyperlink ?? ""}
aria-label="Ссылка изображения слайда"
placeholder="https://example.com"
onChange={(event) => updateSelectedImage({ hyperlink: event.target.value || undefined })}
/>
</label>
<button className="compact-command danger-command" type="button" onClick={deleteSelectedImage}>
<Trash2 size={16} />
Удалить изображение
</button>
</>
) : null}
</section>
<section>
<h3>Заметки докладчика</h3>
</div>
<label className="office-tool-section notes-inline">
<span className="office-tool-title">Заметки</span>
<textarea
className="notes-field"
value={activeSlide.notes}
aria-label="Заметки докладчика"
onChange={(event) => updateSlide({ ...activeSlide, notes: event.target.value })}
/>
</label>
</div>
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide, activeSlideSize)}>
{activeImages.map((image) => (
<button
key={image.id}
type="button"
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
style={slideImageStyle(image, activeSlideSize)}
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="Заголовок слайда"
style={slideTextStyle(activeSlide.titleColor, activeSlide.titleFontSize, activeSlide.titleFontFamily, {
bold: activeSlide.titleBold,
italics: activeSlide.titleItalics,
underline: activeSlide.titleUnderline,
strike: activeSlide.titleStrike,
align: activeSlide.titleAlign,
defaultBold: true
})}
onChange={(event) => updateSlide({ ...activeSlide, title: event.target.value })}
/>
<textarea
className={`slide-subtitle-input ${activeSlide.subtitleListStyle ? `is-${activeSlide.subtitleListStyle}-list` : ""}`.trim()}
value={activeSlide.subtitle}
aria-label="Подзаголовок слайда"
style={slideTextStyle(activeSlide.subtitleColor, activeSlide.subtitleFontSize, activeSlide.subtitleFontFamily, {
bold: activeSlide.subtitleBold,
italics: activeSlide.subtitleItalics,
underline: activeSlide.subtitleUnderline,
strike: activeSlide.subtitleStrike,
align: activeSlide.subtitleAlign
})}
onChange={(event) => updateSlide({ ...activeSlide, subtitle: event.target.value })}
/>
<div className="slide-accent-line" />
</div>
</section>
</aside>
</div>
);
}
+664
View File
@@ -0,0 +1,664 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { WordDocument } from "../../types";
import { QWord, qwordCurrentPageFromViewport, qwordPageCountFromContent } from "./QWord";
afterEach(() => {
cleanup();
});
describe("qwordCurrentPageFromViewport", () => {
it("вычисляет текущую страницу по положению листа в области просмотра", () => {
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: 227, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(1);
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1090, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(2);
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -6000, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(3);
});
it("учитывает масштаб при расчете текущей страницы", () => {
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(2);
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1.2 })).toBe(1);
});
it("does not advance the current page while the viewport is inside the inter-page gap", () => {
expect(qwordCurrentPageFromViewport({ stageTop: 0, paperTop: -110, pageHeight: 100, pageGap: 20, pageCount: 3, zoom: 1 })).toBe(1);
expect(qwordCurrentPageFromViewport({ stageTop: 0, paperTop: -125, pageHeight: 100, pageGap: 20, pageCount: 3, zoom: 1 })).toBe(2);
});
});
describe("qwordPageCountFromContent", () => {
it("keeps manual page breaks as a minimum page count even when content height is short", () => {
expect(qwordPageCountFromContent({ scrollHeight: 300, pageHeight: 1123, manualPageBreakCount: 2 })).toBe(3);
});
it("keeps content height page count when it exceeds manual page breaks", () => {
expect(qwordPageCountFromContent({ scrollHeight: 2400, pageHeight: 1123, manualPageBreakCount: 1 })).toBe(3);
});
});
describe("QWord", () => {
it("держит ленту QWord вне прокручиваемой области документа", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-ribbon-layout-test",
title: "Лента.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<p>Текст</p>"
};
try {
const view = render(<QWord document={document} onChange={vi.fn()} />);
const toolbar = view.container.querySelector(".word-module-toolbar") as HTMLElement;
const stage = view.container.querySelector(".document-stage") as HTMLElement;
expect(toolbar).toBeInTheDocument();
expect(stage).toBeInTheDocument();
expect(stage).not.toContainElement(toolbar);
expect(toolbar.querySelector(".word-ribbon-tabs")).toBeInTheDocument();
expect(stage.querySelector(".page-ruler")).toBeInTheDocument();
expect(stage.querySelector(".paper-frame")).toBeInTheDocument();
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("показывает отдельные листы печатной разметки по расчетному числу страниц", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-print-pages-test",
title: "Страницы.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<p>Первая страница</p><p>Вторая страница</p>"
};
try {
const view = render(<QWord document={document} onChange={vi.fn()} />);
const stage = view.getByLabelText("Редактор QWord");
const paper = view.getByLabelText("Содержимое документа");
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
paper.style.setProperty("--qword-page-height", "1123px");
Object.defineProperty(paper, "scrollHeight", { configurable: true, value: 2400 });
fireEvent.scroll(stage);
await waitFor(() => expect(view.container.querySelectorAll(".page-sheet")).toHaveLength(3));
expect(frame.style.getPropertyValue("--qword-page-count")).toBe("3");
expect(frame.style.minHeight).toBe("3425px");
expect(frame.style.paddingBottom).toBe("56px");
expect(view.container.querySelectorAll(".page-break-guides span")).toHaveLength(2);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("показывает отдельный лист для ручного разрыва страницы даже при коротком содержимом", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-manual-break-pages-test",
title: "Разрыв.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: '<p>Первая страница</p><span data-qoffice-page-break="true" contenteditable="false"></span><p>Вторая страница</p>'
};
try {
const view = render(<QWord document={document} onChange={vi.fn()} />);
const stage = view.getByLabelText("Редактор QWord");
const paper = view.getByLabelText("Содержимое документа");
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
paper.style.setProperty("--qword-page-height", "1123px");
Object.defineProperty(paper, "scrollHeight", { configurable: true, value: 300 });
fireEvent.scroll(stage);
await waitFor(() => expect(view.container.querySelectorAll(".page-sheet")).toHaveLength(2));
expect(frame.style.getPropertyValue("--qword-page-count")).toBe("2");
expect(frame.style.minHeight).toBe("2274px");
expect(view.container.querySelectorAll(".page-break-guides span")).toHaveLength(1);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("меняет поля, размер и ориентацию страницы на вкладке Макет", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onChange = vi.fn();
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-page-setup-test",
title: "Макет.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: "<p>Текст страницы</p>"
};
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
onChange(nextDocument);
setCurrentDocument(nextDocument);
}}
/>
);
}
try {
const view = render(<StatefulQWord />);
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("794px");
expect(frame.style.getPropertyValue("--qword-page-height")).toBe("1123px");
expect(frame.style.getPropertyValue("--qword-page-margin-left")).toBe("86px");
fireEvent.click(screen.getByRole("tab", { name: "Макет" }));
fireEvent.change(screen.getByLabelText("Поля страницы"), { target: { value: "narrow" } });
fireEvent.change(screen.getByLabelText("Размер страницы"), { target: { value: "letter" } });
fireEvent.change(screen.getByLabelText("Ориентация страницы"), { target: { value: "landscape" } });
await waitFor(() => expect(frame.style.getPropertyValue("--qword-page-height")).toBe("816px"));
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("1056px");
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("48px");
expect(frame.style.getPropertyValue("--qword-page-margin-right")).toBe("48px");
expect(frame.style.minHeight).toBe("816px");
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" } }));
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("применяет сохраненные в документе параметры страницы при открытии QWord", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-saved-page-setup-test",
title: "Сохраненный макет.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: "<p>Текст страницы</p>",
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "wide" }
};
try {
const view = render(<QWord document={document} onChange={vi.fn()} />);
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("1056px");
expect(frame.style.getPropertyValue("--qword-page-height")).toBe("816px");
expect(frame.style.getPropertyValue("--qword-page-margin-left")).toBe("116px");
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("92px");
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("переключает вкладки ленты и показывает команды выбранной вкладки", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-ribbon-tabs-test",
title: "Лента.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<p>Текст</p>"
};
try {
render(<QWord document={document} onChange={vi.fn()} />);
expect(screen.getByRole("tab", { name: "Главная" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("region", { name: "Шрифт" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Вставка" }));
expect(screen.getByRole("tab", { name: "Вставка" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("region", { name: "Вставка" })).toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Шрифт" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Файл" }));
expect(screen.getByRole("tab", { name: "Файл" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("region", { name: "Файл" })).toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Вставка" })).not.toBeInTheDocument();
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("переключает режим просмотра документа на вкладке Вид", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-view-mode-test",
title: "Вид.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<p>Текст</p>"
};
try {
render(<QWord document={document} onChange={vi.fn()} />);
const stage = screen.getByLabelText("Редактор QWord");
expect(stage).toHaveClass("qword-view-print");
fireEvent.click(screen.getByRole("tab", { name: "Вид" }));
fireEvent.click(screen.getByRole("button", { name: "Веб-документ" }));
expect(stage).toHaveClass("qword-view-web");
expect(screen.getByRole("button", { name: "Веб-документ" })).toHaveAttribute("aria-pressed", "true");
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("сохраняет позицию курсора при наборе текста в документе", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onChange = vi.fn();
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-caret-input-test",
title: "Курсор.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: "<p>Начало</p>"
};
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
onChange(nextDocument);
setCurrentDocument(nextDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
const paper = screen.getByLabelText("Содержимое документа");
const textNode = paper.querySelector("p")?.firstChild;
expect(textNode).toBeInstanceOf(Text);
const typedText = "Начало текст";
textNode!.textContent = typedText;
const range = window.document.createRange();
range.setStart(textNode!, typedText.length);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
fireEvent.input(paper);
await waitFor(() => expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ html: `<p>${typedText}</p>` })));
const selection = window.getSelection();
expect(selection?.anchorNode).toBe(textNode);
expect(selection?.anchorOffset).toBe(typedText.length);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("сохраняет позицию курсора после нормализации HTML родительским состоянием", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onChange = vi.fn();
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-caret-normalized-input-test",
title: "Курсор.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: "<p>Начало</p>"
};
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
const normalizedDocument = {
...nextDocument,
html: nextDocument.html.replace("<p>", '<p data-normalized="true">')
};
onChange(normalizedDocument);
setCurrentDocument(normalizedDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
const paper = screen.getByLabelText("Содержимое документа");
const textNode = paper.querySelector("p")?.firstChild;
expect(textNode).toBeInstanceOf(Text);
const typedText = "Начало текст";
textNode!.textContent = typedText;
const range = window.document.createRange();
range.setStart(textNode!, typedText.length);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
fireEvent.input(paper);
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ html: `<p data-normalized="true">${typedText}</p>` }))
);
await waitFor(() => {
const normalizedTextNode = paper.querySelector("p")?.firstChild;
const selection = window.getSelection();
expect(selection?.anchorNode).toBe(normalizedTextNode);
expect(selection?.anchorOffset).toBe(typedText.length);
});
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("вставляет таблицу выбранного размера через вкладку Вставка", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onChange = vi.fn();
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-insert-table-test",
title: "Таблица.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<p>Перед таблицей</p>"
};
try {
render(<QWord document={document} onChange={onChange} />);
fireEvent.click(screen.getByRole("tab", { name: "Вставка" }));
fireEvent.change(screen.getByLabelText("Строки таблицы"), { target: { value: "2" } });
fireEvent.change(screen.getByLabelText("Столбцы таблицы"), { target: { value: "3" } });
fireEvent.click(screen.getByRole("button", { name: "Таблица" }));
const updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
expect(updatedDocument?.html).toContain("<table>");
expect(updatedDocument?.html.match(/<tr/g)).toHaveLength(2);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(6);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("сохраняет активную ячейку после вставки таблицы и перерендера", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onChange = vi.fn();
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-insert-table-restore-test",
title: "Активная таблица.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<p>Перед таблицей</p>"
};
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
onChange(nextDocument);
setCurrentDocument(nextDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
fireEvent.click(screen.getByRole("tab", { name: "Вставка" }));
const tableButton = screen.getByRole("button", { name: "Таблица" });
expect(fireEvent.mouseDown(tableButton)).toBe(false);
fireEvent.click(tableButton);
const rowButton = screen.getByRole("button", { name: "Строка ниже" });
expect(fireEvent.mouseDown(rowButton)).toBe(false);
fireEvent.click(rowButton);
const updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
expect(updatedDocument?.html.match(/<tr/g)).toHaveLength(4);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(12);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("добавляет строку и столбец к выбранной таблице", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onChange = vi.fn();
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-table-structure-test",
title: "Структура таблицы.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<table><tbody><tr><td>A</td><td>B</td></tr></tbody></table>"
};
function selectCell(text: string) {
const cell = screen.getByText(text).closest("td");
expect(cell).toBeInTheDocument();
const range = window.document.createRange();
range.selectNodeContents(cell as HTMLTableCellElement);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
}
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
onChange(nextDocument);
setCurrentDocument(nextDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
fireEvent.click(screen.getByRole("tab", { name: "Вставка" }));
selectCell("A");
const rowButton = screen.getByRole("button", { name: "Строка ниже" });
expect(fireEvent.mouseDown(rowButton)).toBe(false);
fireEvent.click(rowButton);
let updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
expect(updatedDocument?.html.match(/<tr/g)).toHaveLength(2);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(4);
selectCell("A");
const columnButton = screen.getByRole("button", { name: "Столбец справа" });
expect(fireEvent.mouseDown(columnButton)).toBe(false);
fireEvent.click(columnButton);
updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
expect(updatedDocument?.html.match(/<tr/g)).toHaveLength(2);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(6);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("обновляет текущую страницу в статусе при прокрутке документа", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onStatusChange = vi.fn();
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
const document: WordDocument = {
id: "word-scroll-test",
title: "Прокрутка.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<p>Первая страница</p>"
};
try {
const view = render(<QWord document={document} onChange={vi.fn()} onStatusChange={onStatusChange} />);
const stage = view.getByLabelText("Редактор QWord");
const paper = view.getByLabelText("Содержимое документа");
Object.defineProperty(paper, "scrollHeight", { configurable: true, value: 2400 });
stage.getBoundingClientRect = () => ({ bottom: 884, height: 850, left: 0, right: 1008, top: 34, width: 1008, x: 0, y: 34, toJSON: () => ({}) });
paper.getBoundingClientRect = () => ({ bottom: -77, height: 1123, left: 107, right: 901, top: -1200, width: 794, x: 107, y: -1200, toJSON: () => ({}) });
fireEvent.scroll(stage);
await waitFor(() => expect(onStatusChange).toHaveBeenLastCalledWith({ currentPage: 2, pageCount: 3, wordCount: 2 }));
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
export {};
type DesktopOfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail";
type QOfficeDesktopCommand = "open" | "save" | "export-pdf" | "print";
type QOfficeDesktopCommand = "open" | "save" | "export-pdf" | "print" | "undo" | "redo";
interface DesktopOpenOfficeFileResult {
canceled: boolean;
+25 -2
View File
@@ -1,5 +1,5 @@
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useQOfficeCommand } from "./useQOfficeCommand";
function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf?: () => void }) {
@@ -8,6 +8,10 @@ function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf
}
describe("useQOfficeCommand", () => {
afterEach(() => {
cleanup();
});
it("вызывает обработчик команды QOffice", async () => {
const onSave = vi.fn();
render(<CommandProbe onSave={onSave} />);
@@ -39,4 +43,23 @@ describe("useQOfficeCommand", () => {
await waitFor(() => expect(onExportPdf).toHaveBeenCalledTimes(1));
expect(onSave).not.toHaveBeenCalled();
});
it("использует команду документа для undo и redo без обработчика приложения", async () => {
const originalExecCommand = document.execCommand;
const execCommand = vi.fn();
Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
try {
render(<CommandProbe onSave={vi.fn()} />);
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "undo" } }));
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "redo" } }));
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("redo"));
expect(execCommand).toHaveBeenNthCalledWith(1, "undo");
expect(execCommand).toHaveBeenNthCalledWith(2, "redo");
} finally {
Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
}
});
});
+6 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
export type QOfficeCommand = "open" | "save" | "export-pdf" | "print";
export type QOfficeCommand = "open" | "save" | "export-pdf" | "print" | "undo" | "redo";
export type QOfficeCommandHandlers = Partial<Record<QOfficeCommand, () => void | Promise<void>>>;
export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
@@ -16,6 +16,11 @@ export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
const handler = handlersRef.current[command];
if (handler) {
void handler();
return;
}
if (command === "undo" || command === "redo") {
globalThis.document.execCommand?.(command);
}
}
+162
View File
@@ -4,9 +4,12 @@ import {
buildChartXml,
buildCellXml,
buildAutoFilterXml,
buildCommentsXml,
buildColumnWidthsXml,
buildHyperlinksXml,
buildMergeCellsXml,
buildPageMarginsXml,
buildPageSetupXml,
buildSheetPrXml,
buildSheetViewsXml,
createCellStyleRegistry,
@@ -20,6 +23,7 @@ import {
workbookPrintAreas,
workbookRelationshipTargets,
workbookSheetRefs,
workbookUsesDate1904,
worksheetXmlToAutoFilter,
worksheetXmlToCells,
worksheetXmlToCellFormats,
@@ -28,13 +32,16 @@ import {
worksheetXmlToHiddenColumns,
worksheetXmlToHiddenRows,
worksheetXmlToHyperlinks,
worksheetCommentsXmlToComments,
worksheetXmlToMergedCells,
worksheetXmlToPageSetup,
worksheetXmlToRowHeights,
worksheetXmlToShowGridLines,
worksheetXmlToTabColor,
worksheetXmlToZoomScale,
xlsxStylesXmlToCellFormatTable,
xlsxThemeXmlToColors,
xlsxSafeSheetNames,
xlsxCellToText
} from "./excellOffice";
@@ -145,6 +152,58 @@ describe("excellOffice OOXML helpers", () => {
expect(relationshipsXml).not.toContain('TargetMode="External"');
});
it("собирает и читает legacy comments XLSX для заметок ячеек", () => {
const commentsXml = buildCommentsXml({ B2: "Проверить сумму", A1: "Комментарий\nв две строки" });
const parsedComments = worksheetCommentsXmlToComments({
comments: {
commentList: {
comment: [
{ ref: "B2", text: { t: "Проверить сумму" } },
{ ref: "A1", text: { r: [{ t: "Комментарий" }, { t: "\nв две строки" }] } }
]
}
}
});
expect(commentsXml).toContain('<comment ref="A1" authorId="0"><text><t>Комментарий\nв две строки</t></text></comment>');
expect(commentsXml).toContain('<comment ref="B2" authorId="0"><text><t>Проверить сумму</t></text></comment>');
expect(parsedComments).toEqual({ A1: "Комментарий\nв две строки", B2: "Проверить сумму" });
});
it("сохраняет заметки ячеек XLSX в comments, VML и relationships", async () => {
const blob = await exportXlsxBlob({
id: "workbook",
title: "Comments.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Sheet 1",
cells: { A1: "Итого" },
comments: { A1: "Проверено финансовым отделом" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const contentTypesXml = await zip.file("[Content_Types].xml")?.async("string");
const worksheetXml = await zip.file("xl/worksheets/sheet1.xml")?.async("string");
const relationshipsXml = await zip.file("xl/worksheets/_rels/sheet1.xml.rels")?.async("string");
const commentsXml = await zip.file("xl/comments1.xml")?.async("string");
const vmlXml = await zip.file("xl/drawings/vmlDrawing1.vml")?.async("string");
const imported = await importXlsxFile(new File([blob], "Comments.xlsx", { type: blob.type }));
expect(contentTypesXml).toContain("/xl/comments1.xml");
expect(contentTypesXml).toContain('Extension="vml"');
expect(worksheetXml).toContain('<legacyDrawing r:id="rId2"/>');
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"');
expect(relationshipsXml).toContain('Target="../comments1.xml"');
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing"');
expect(commentsXml).toContain('<comment ref="A1" authorId="0">');
expect(vmlXml).toContain("<x:Row>0</x:Row><x:Column>0</x:Column>");
expect(imported.sheets[0].comments).toEqual({ A1: "Проверено финансовым отделом" });
});
it("сохраняет базовое форматирование ячеек XLSX в XML листа", () => {
const cellFormats = {
A1: { bold: true, fontSize: 14, fontFamily: "Arial", textColor: "0f5fae", fillColor: "fff2cc", horizontalAlign: "center" as const },
@@ -560,6 +619,38 @@ describe("excellOffice OOXML helpers", () => {
).toBe("sheet-7");
});
it("detects the XLSX 1904 date system flag from workbook properties", () => {
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "1" } } })).toBe(true);
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "true" } } })).toBe(true);
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "0" } } })).toBe(false);
});
it("converts imported XLSX 1904 date serials to the QExcell 1900 serial system", async () => {
const zip = new JSZip();
zip.file(
"xl/workbook.xml",
`<workbook><workbookPr date1904="1"/><sheets><sheet name="Dates" sheetId="1" r:id="rId1"/></sheets></workbook>`
);
zip.file(
"xl/_rels/workbook.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>`
);
zip.file("xl/styles.xml", `<styleSheet><cellXfs count="3"><xf numFmtId="0"/><xf numFmtId="14"/><xf numFmtId="20"/></cellXfs></styleSheet>`);
zip.file(
"xl/worksheets/sheet1.xml",
`<worksheet><sheetData><row r="1"><c r="A1" s="1"><v>43465</v></c><c r="B1"><v>5</v></c><c r="C1" s="2"><v>0.5</v></c></row></sheetData></worksheet>`
);
const blob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
});
const imported = await importXlsxFile(new File([blob], "Date1904.xlsx", { type: blob.type }));
expect(imported.sheets[0].cells).toEqual({ A1: "44927", B1: "5", C1: "0.5" });
expect(imported.sheets[0].cellFormats).toEqual({ A1: { numberFormat: "m/d/yy" }, C1: { numberFormat: "h:mm" } });
});
it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => {
const blob = await exportXlsxBlob({
id: "book-1",
@@ -579,6 +670,38 @@ describe("excellOffice OOXML helpers", () => {
expect(imported.activeSheet).toBe("sheet-2");
});
it("normalizes XLSX sheet names to Excel-safe unique names on export", async () => {
expect(
xlsxSafeSheetNames([
{ name: "Sales/2026:Q1*?" },
{ name: "'Very long worksheet name that exceeds thirty one characters'" },
{ name: "Sales 2026 Q1" },
{ name: "'[]:*?/\\'" },
{ name: "History" }
])
).toEqual(["Sales 2026 Q1", "Very long worksheet name that e", "Sales 2026 Q1 (2)", "Лист 4", "History 1"]);
const blob = await exportXlsxBlob({
id: "book-1",
title: "Safe sheet names.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{ id: "sheet-1", name: "Sales/2026:Q1*?", printArea: "A1:B2", cells: { A1: "Sales" } },
{ id: "sheet-2", name: "Sales 2026 Q1", cells: { A1: "Duplicate" } }
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const workbookXml = await zip.file("xl/workbook.xml")?.async("string");
const imported = await importXlsxFile(new File([blob], "Safe sheet names.xlsx", { type: blob.type }));
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1" sheetId="1" r:id="rId1"/>');
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1 (2)" sheetId="2" r:id="rId2"/>');
expect(workbookXml).toContain('<definedName name="_xlnm.Print_Area" localSheetId="0">\'Sales 2026 Q1\'!$A$1:$B$2</definedName>');
expect(imported.sheets.map((sheet) => sheet.name)).toEqual(["Sales 2026 Q1", "Sales 2026 Q1 (2)"]);
expect(imported.sheets[0].printArea).toBe("A1:B2");
});
it("сохраняет и импортирует область печати XLSX через definedName _xlnm.Print_Area", async () => {
expect(
workbookPrintAreas(
@@ -619,6 +742,45 @@ describe("excellOffice OOXML helpers", () => {
expect(imported.sheets[1].printArea).toBe("B2:C4,E1:E3");
});
it("сохраняет и импортирует параметры страницы XLSX через pageMargins и pageSetup", async () => {
expect(
worksheetXmlToPageSetup({
worksheet: {
pageMargins: { left: "0.25", right: "0.25", top: "0.75", bottom: "0.75", header: "0.3", footer: "0.3" },
pageSetup: { paperSize: "1", orientation: "landscape" }
}
})
).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" });
expect(buildPageMarginsXml({ size: "a4", orientation: "portrait", marginPreset: "wide" })).toBe(
'<pageMargins left="1" right="1" top="1" bottom="1" header="0.5" footer="0.5"/>'
);
expect(buildPageSetupXml({ size: "letter", orientation: "landscape", marginPreset: "normal" })).toBe(
'<pageSetup paperSize="1" orientation="landscape"/>'
);
const blob = await exportXlsxBlob({
id: "book-1",
title: "Page setup.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Layout",
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" },
cells: { A1: "Layout" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
const imported = await importXlsxFile(new File([blob], "Page setup.xlsx", { type: blob.type }));
expect(sheetXml).toContain('<pageMargins left="0.25" right="0.25" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>');
expect(sheetXml).toContain('<pageSetup paperSize="1" orientation="landscape"/>');
expect(imported.sheets[0].pageSetup).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" });
});
it("сохраняет и импортирует скрытые листы XLSX через sheet state", async () => {
const blob = await exportXlsxBlob({
id: "book-1",
+371 -26
View File
@@ -1,4 +1,15 @@
import type { CellFormat, MergedCellRange, SheetChart, SheetChartType, SheetFrozenPane, Workbook } from "../types";
import type {
CellFormat,
MergedCellRange,
SheetChart,
SheetChartType,
SheetFrozenPane,
SheetPageMarginPreset,
SheetPageOrientation,
SheetPageSetup,
SheetPageSizeId,
Workbook
} from "../types";
import { evaluateCell, expandRange } from "../utils/spreadsheet";
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers";
@@ -19,9 +30,11 @@ export interface ImportedExcellWorkbook {
showGridLines?: boolean;
zoomScale?: number;
printArea?: string;
pageSetup?: SheetPageSetup;
cells: Record<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
comments?: Record<string, string>;
cellFormats?: Record<string, CellFormat>;
charts?: SheetChart[];
}>;
@@ -76,9 +89,11 @@ interface ParsedSheet {
showGridLines?: boolean;
zoomScale?: number;
printArea?: string;
pageSetup?: SheetPageSetup;
cells: Record<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
comments?: Record<string, string>;
cellFormats?: Record<string, CellFormat>;
charts?: SheetChart[];
}
@@ -124,6 +139,12 @@ interface ChartPartRef {
sheetName: string;
}
interface CommentPartRef {
sheetIndex: number;
commentIndex: number;
comments: Record<string, string>;
}
const xmlParserOptions = {
ignoreAttributes: false,
attributeNamePrefix: "",
@@ -135,6 +156,8 @@ const xmlParserOptions = {
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
const drawingRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing";
const chartRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
const commentsRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
const vmlDrawingRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing";
const customNumberFormatStartId = 164;
const defaultXlsxThemeColors = [
"000000",
@@ -200,6 +223,16 @@ const builtinNumberFormats: Record<number, string> = {
48: "##0.0E+0",
49: "@"
};
const defaultSheetPageSetup: SheetPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
const xlsxPageSizePaperIds: Record<SheetPageSizeId, number> = {
letter: 1,
a4: 9
};
const xlsxPageMargins: Record<SheetPageMarginPreset, { left: number; right: number; top: number; bottom: number; header: number; footer: number }> = {
normal: { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 },
narrow: { left: 0.25, right: 0.25, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 },
wide: { left: 1, right: 1, top: 1, bottom: 1, header: 0.5, footer: 0.5 }
};
export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook> {
if (!file.name.toLowerCase().endsWith(".xlsx")) {
@@ -216,6 +249,7 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
const themeColors = await readOptionalWorkbookThemeColors(zip, parser);
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
const sheetRefs = workbookSheetRefs(workbookXml);
const date1904 = workbookUsesDate1904(workbookXml);
const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
const sheets = await Promise.all(
@@ -237,7 +271,9 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
const sheetPath = resolveWorkbookTarget(target);
const sheetXml = parser.parse(await readZipText(zip, sheetPath));
const sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath);
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors);
const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath);
const commentsXml = commentsPath ? await readOptionalParsedXml(zip, parser, commentsPath) : undefined;
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors, commentsXml, date1904);
const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name);
return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts };
})
@@ -255,20 +291,23 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
const JSZip = await loadJsZip();
const zip = new JSZip();
const sheets = workbook.sheets.length > 0 ? workbook.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [], hyperlinks: {} }];
const safeSheetNames = xlsxSafeSheetNames(sheets);
const safeSheets = sheets.map((sheet, index) => ({ ...sheet, name: safeSheetNames[index] }));
const styleRegistry = createCellStyleRegistry(sheets.map((sheet) => sheet.cellFormats));
const chartParts = chartPartRefsForSheets(sheets);
const activeSheetIndex = Math.max(0, sheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
const styleRegistry = createCellStyleRegistry(safeSheets.map((sheet) => sheet.cellFormats));
const chartParts = chartPartRefsForSheets(safeSheets);
const commentParts = commentPartRefsForSheets(safeSheets);
const activeSheetIndex = Math.max(0, safeSheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts));
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts, commentParts));
zip.file("_rels/.rels", buildRootRelationshipsXml());
zip.file("docProps/app.xml", buildAppPropertiesXml());
zip.file("docProps/core.xml", buildCorePropertiesXml());
zip.file(
"xl/workbook.xml",
buildWorkbookXml(
sheets.map((sheet, index) => ({
name: sheet.name.trim() || `Лист ${index + 1}`,
safeSheets.map((sheet) => ({
name: sheet.name,
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
...(sheet.printArea ? { printArea: sheet.printArea } : {})
})),
@@ -278,9 +317,14 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
zip.file("xl/_rels/workbook.xml.rels", buildWorkbookRelationshipsXml(sheets.length));
zip.file("xl/styles.xml", buildStylesXml(styleRegistry));
sheets.forEach((sheet, index) => {
safeSheets.forEach((sheet, index) => {
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
const drawingRelationshipId = sheetChartParts.length > 0 ? `rId${sortedExternalHyperlinkEntries(sheet.hyperlinks).length + 1}` : "";
const sheetCommentPart = commentParts.find((part) => part.sheetIndex === index);
const externalHyperlinkCount = sortedExternalHyperlinkEntries(sheet.hyperlinks).length;
const drawingRelationshipId = sheetChartParts.length > 0 ? `rId${externalHyperlinkCount + 1}` : "";
const legacyDrawingRelationshipId = sheetCommentPart
? `rId${externalHyperlinkCount + (sheetChartParts.length > 0 ? 1 : 0) + 2}`
: "";
zip.file(
`xl/worksheets/sheet${index + 1}.xml`,
buildWorksheetXml(
@@ -298,12 +342,16 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
sheet.hiddenColumns,
sheet.hiddenRows,
sheet.showGridLines,
sheet.zoomScale
sheet.zoomScale,
legacyDrawingRelationshipId,
sheet.pageSetup
)
);
const relationshipsXml = buildWorksheetRelationshipsXml(
sheet.hyperlinks,
sheetChartParts.length > 0 ? `../drawings/drawing${sheetChartParts[0].drawingIndex}.xml` : ""
sheetChartParts.length > 0 ? `../drawings/drawing${sheetChartParts[0].drawingIndex}.xml` : "",
sheetCommentPart ? `../comments${sheetCommentPart.commentIndex}.xml` : "",
sheetCommentPart ? `../drawings/vmlDrawing${sheetCommentPart.commentIndex}.vml` : ""
);
if (relationshipsXml) {
zip.file(`xl/worksheets/_rels/sheet${index + 1}.xml.rels`, relationshipsXml);
@@ -317,6 +365,11 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
zip.file(`xl/charts/chart${part.chartIndex}.xml`, buildChartXml(part.chart, part.sheetName, part.cells));
});
}
if (sheetCommentPart) {
zip.file(`xl/comments${sheetCommentPart.commentIndex}.xml`, buildCommentsXml(sheetCommentPart.comments));
zip.file(`xl/drawings/vmlDrawing${sheetCommentPart.commentIndex}.vml`, buildCommentsVmlDrawingXml(sheetCommentPart.comments));
}
});
return zip.generateAsync({ type: "blob", mimeType: XLSX_MIME_TYPE });
@@ -329,7 +382,9 @@ export function parsedWorksheetToSheet(
sharedStrings: string[],
relationshipsXml?: unknown,
styleTable: Array<CellFormat | undefined> = [],
themeColors: string[] = defaultXlsxThemeColors
themeColors: string[] = defaultXlsxThemeColors,
commentsXml?: unknown,
date1904 = false
): ParsedSheet {
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
@@ -340,6 +395,7 @@ export function parsedWorksheetToSheet(
const hiddenRows = worksheetXmlToHiddenRows(worksheetXml);
const showGridLines = worksheetXmlToShowGridLines(worksheetXml);
const zoomScale = worksheetXmlToZoomScale(worksheetXml);
const pageSetup = worksheetXmlToPageSetup(worksheetXml);
return {
id: sheet.id || `sheet-${index + 1}`,
@@ -354,15 +410,17 @@ export function parsedWorksheetToSheet(
...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}),
...(showGridLines === false ? { showGridLines } : {}),
...(zoomScale ? { zoomScale } : {}),
cells: worksheetXmlToCells(worksheetXml, sharedStrings),
...(pageSetup ? { pageSetup } : {}),
cells: worksheetXmlToCells(worksheetXml, sharedStrings, styleTable, date1904),
mergedCells: worksheetXmlToMergedCells(worksheetXml),
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
comments: worksheetCommentsXmlToComments(commentsXml),
cellFormats: worksheetXmlToCellFormats(worksheetXml, styleTable),
charts: []
};
}
export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[]) {
export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[], styleTable: Array<CellFormat | undefined> = [], date1904 = false) {
const cells: Record<string, string> = {};
const sharedFormulas: SharedFormulaMap = {};
const parsedCells = worksheetCellRecords(worksheetXml);
@@ -378,9 +436,10 @@ export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string
parsedCells.forEach(({ address, cell }) => {
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
const importedText = date1904 ? date1904AdjustedCellText(cell, text, styleTable[numberAttribute(cell.s)]) : text;
if (text !== "") {
cells[address] = text;
if (importedText !== "") {
cells[address] = importedText;
}
});
@@ -445,6 +504,22 @@ export function worksheetXmlToHyperlinks(worksheetXml: unknown, relationshipsXml
}, {});
}
export function worksheetCommentsXmlToComments(commentsXml?: unknown) {
const comments = childRecord(commentsXml, "comments");
const commentList = childRecord(comments, "commentList");
return toArray(commentList.comment).reduce<Record<string, string>>((result, comment) => {
const record = asRecord(comment);
const address = normalizeCellAddress(String(record.ref || ""));
const text = normalizeCommentText(xmlNodeText(childRecord(record, "text")));
if (address && text) {
result[address] = text;
}
return result;
}, {});
}
export function worksheetXmlToFrozenPane(worksheetXml: unknown): SheetFrozenPane | undefined {
const worksheet = childRecord(worksheetXml, "worksheet");
const sheetViews = childRecord(worksheet, "sheetViews");
@@ -491,6 +566,18 @@ export function worksheetXmlToZoomScale(worksheetXml: unknown) {
return normalizedSheetZoomScale(sheetView.zoomScale);
}
export function worksheetXmlToPageSetup(worksheetXml: unknown): SheetPageSetup | undefined {
const worksheet = childRecord(worksheetXml, "worksheet");
const pageSetup = childRecord(worksheet, "pageSetup");
const pageMargins = childRecord(worksheet, "pageMargins");
const size = sheetPageSizeFromPaperId(pageSetup.paperSize) ?? defaultSheetPageSetup.size;
const orientation = normalizedSheetPageOrientation(pageSetup.orientation) ?? defaultSheetPageSetup.orientation;
const marginPreset = sheetPageMarginPresetFromRecord(pageMargins) ?? defaultSheetPageSetup.marginPreset;
const hasPageSetup = Object.keys(pageSetup).length > 0;
const hasPageMargins = Object.keys(pageMargins).length > 0;
return hasPageSetup || hasPageMargins ? { size, orientation, marginPreset } : undefined;
}
export function worksheetXmlToAutoFilter(worksheetXml: unknown) {
const worksheet = childRecord(worksheetXml, "worksheet");
const autoFilter = childRecord(worksheet, "autoFilter");
@@ -754,7 +841,9 @@ export function buildWorksheetXml(
hiddenColumns: Record<string, boolean> = {},
hiddenRows: Record<string, boolean> = {},
showGridLines?: boolean,
zoomScale?: number
zoomScale?: number,
legacyDrawingRelationshipId = "",
pageSetup?: SheetPageSetup
) {
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows);
@@ -791,12 +880,15 @@ export function buildWorksheetXml(
const autoFilterXml = buildAutoFilterXml(autoFilter);
const hyperlinksXml = buildHyperlinksXml(hyperlinks);
const drawingXml = drawingRelationshipId ? `<drawing r:id="${escapeXmlAttribute(drawingRelationshipId)}"/>` : "";
const legacyDrawingXml = legacyDrawingRelationshipId ? `<legacyDrawing r:id="${escapeXmlAttribute(legacyDrawingRelationshipId)}"/>` : "";
const sheetPrXml = buildSheetPrXml(tabColor);
const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale);
const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns);
const pageMarginsXml = buildPageMarginsXml(pageSetup);
const pageSetupXml = buildPageSetupXml(pageSetup);
return xmlDeclaration(
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}</worksheet>`
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${pageMarginsXml}${pageSetupXml}${drawingXml}${legacyDrawingXml}</worksheet>`
);
}
@@ -889,6 +981,24 @@ export function buildSheetPrXml(tabColor?: string) {
return color ? `<sheetPr><tabColor rgb="FF${color}"/></sheetPr>` : "";
}
export function buildPageMarginsXml(pageSetup?: SheetPageSetup) {
if (!pageSetup) {
return "";
}
const margins = xlsxPageMargins[normalizedSheetPageSetup(pageSetup).marginPreset];
return `<pageMargins left="${formatSheetDimension(margins.left)}" right="${formatSheetDimension(margins.right)}" top="${formatSheetDimension(margins.top)}" bottom="${formatSheetDimension(margins.bottom)}" header="${formatSheetDimension(margins.header)}" footer="${formatSheetDimension(margins.footer)}"/>`;
}
export function buildPageSetupXml(pageSetup?: SheetPageSetup) {
if (!pageSetup) {
return "";
}
const normalized = normalizedSheetPageSetup(pageSetup);
return `<pageSetup paperSize="${xlsxPageSizePaperIds[normalized.size]}" orientation="${normalized.orientation}"/>`;
}
export function buildColumnWidthsXml(columnWidths: Record<string, number> = {}, hiddenColumns: Record<string, boolean> = {}) {
const columnsByNumber = new Map<number, { width?: number; hidden?: boolean }>();
normalizedSheetColumnWidths(columnWidths).forEach(({ column, width }) => {
@@ -917,22 +1027,31 @@ export function buildAutoFilterXml(autoFilter?: string) {
return ref ? `<autoFilter ref="${escapeXmlAttribute(ref)}"/>` : "";
}
export function buildWorksheetRelationshipsXml(hyperlinks: Record<string, string> = {}, drawingTarget = "") {
export function buildWorksheetRelationshipsXml(hyperlinks: Record<string, string> = {}, drawingTarget = "", commentsTarget = "", vmlDrawingTarget = "") {
const refs = sortedExternalHyperlinkEntries(hyperlinks);
if (refs.length === 0 && !drawingTarget) {
if (refs.length === 0 && !drawingTarget && !commentsTarget && !vmlDrawingTarget) {
return "";
}
const relationships = refs
const hyperlinkRelationships = refs
.map(
(entry, index) =>
`<Relationship Id="rId${index + 1}" Type="${hyperlinkRelationshipType}" Target="${escapeXmlAttribute(entry.target)}" TargetMode="External"/>`
)
.join("");
let nextRelationshipId = refs.length + 1;
const drawingRelationship = drawingTarget
? `<Relationship Id="rId${refs.length + 1}" Type="${drawingRelationshipType}" Target="${escapeXmlAttribute(drawingTarget)}"/>`
? `<Relationship Id="rId${nextRelationshipId++}" Type="${drawingRelationshipType}" Target="${escapeXmlAttribute(drawingTarget)}"/>`
: "";
return xmlDeclaration(`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${relationships}${drawingRelationship}</Relationships>`);
const commentsRelationship = commentsTarget
? `<Relationship Id="rId${nextRelationshipId++}" Type="${commentsRelationshipType}" Target="${escapeXmlAttribute(commentsTarget)}"/>`
: "";
const vmlDrawingRelationship = vmlDrawingTarget
? `<Relationship Id="rId${nextRelationshipId}" Type="${vmlDrawingRelationshipType}" Target="${escapeXmlAttribute(vmlDrawingTarget)}"/>`
: "";
return xmlDeclaration(
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${hyperlinkRelationships}${drawingRelationship}${commentsRelationship}${vmlDrawingRelationship}</Relationships>`
);
}
export function workbookSheetRefs(workbookXml: unknown): WorkbookSheetRef[] {
@@ -975,6 +1094,12 @@ export function workbookActiveSheetId(workbookXml: unknown, sheets: Array<{ id:
return sheets[activeTab]?.id ?? "";
}
export function workbookUsesDate1904(workbookXml: unknown) {
const workbook = childRecord(workbookXml, "workbook");
const workbookPr = childRecord(workbook, "workbookPr");
return booleanAttribute(workbookPr.date1904 ?? workbookPr["@_date1904"]);
}
export function workbookPrintAreas(workbookXml: unknown, sheets: Array<{ id: string; name: string }>): Record<string, string> {
const workbook = childRecord(workbookXml, "workbook");
const definedNames = childRecord(workbook, "definedNames");
@@ -1028,7 +1153,7 @@ export function worksheetRelationshipTargets(relationshipsXml: unknown) {
return targets;
}
function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = []) {
function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = [], commentParts: CommentPartRef[] = []) {
const sheetOverrides = Array.from({ length: sheetCount }, (_, index) => {
return `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
}).join("");
@@ -1038,9 +1163,13 @@ function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = [
const chartOverrides = chartParts
.map((part) => `<Override PartName="/xl/charts/chart${part.chartIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>`)
.join("");
const commentsOverrides = commentParts
.map((part) => `<Override PartName="/xl/comments${part.commentIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/>`)
.join("");
const vmlDefault = commentParts.length > 0 ? '<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>' : "";
return xmlDeclaration(
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheetOverrides}${drawingOverrides}${chartOverrides}</Types>`
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${vmlDefault}<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheetOverrides}${drawingOverrides}${chartOverrides}${commentsOverrides}</Types>`
);
}
@@ -1050,6 +1179,45 @@ function buildRootRelationshipsXml() {
);
}
export function xlsxSafeSheetNames(sheets: Array<{ name: string }>) {
const usedNames = new Set<string>();
return sheets.map((sheet, index) => uniqueXlsxSheetName(sheet.name, index, usedNames));
}
function uniqueXlsxSheetName(value: string, index: number, usedNames: Set<string>) {
const fallback = `Лист ${index + 1}`;
const normalizedName = normalizedXlsxSheetName(value);
const baseName = xlsxReservedSheetName(normalizedName) ? `${normalizedName} 1` : normalizedName || fallback;
let suffixNumber = 1;
let candidate = truncateXlsxSheetName(baseName);
while (usedNames.has(candidate.toLowerCase())) {
suffixNumber += 1;
const suffix = ` (${suffixNumber})`;
candidate = `${truncateXlsxSheetName(baseName, 31 - suffix.length)}${suffix}`;
}
usedNames.add(candidate.toLowerCase());
return candidate;
}
function normalizedXlsxSheetName(value: string) {
return value
.replace(/[\u0000-\u001f\\/?*:[\]]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/^'+|'+$/g, "")
.trim();
}
function xlsxReservedSheetName(value: string) {
return value.trim().toLowerCase() === "history";
}
function truncateXlsxSheetName(value: string, maxLength = 31) {
return Array.from(value).slice(0, Math.max(1, maxLength)).join("");
}
function buildWorkbookXml(sheets: Array<{ name: string; visibility?: WorkbookSheetRef["visibility"]; printArea?: string }>, activeSheetIndex = 0) {
const activeTab = Math.min(Math.max(0, activeSheetIndex), Math.max(0, sheets.length - 1));
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;
@@ -1122,6 +1290,63 @@ function chartPartRefsForSheets(sheets: Workbook["sheets"]): ChartPartRef[] {
return refs;
}
function commentPartRefsForSheets(sheets: Workbook["sheets"]): CommentPartRef[] {
const refs: CommentPartRef[] = [];
let commentIndex = 1;
sheets.forEach((sheet, sheetIndex) => {
const comments = normalizedSheetComments(sheet.comments);
if (Object.keys(comments).length === 0) {
return;
}
refs.push({ sheetIndex, commentIndex, comments });
commentIndex += 1;
});
return refs;
}
function normalizedSheetComments(comments: Record<string, string> = {}) {
const normalized: Record<string, string> = {};
sortedCellTextEntries(comments).forEach(([address, comment]) => {
const text = normalizeCommentText(comment);
if (text) {
normalized[address] = text;
}
});
return normalized;
}
export function buildCommentsXml(comments: Record<string, string> = {}) {
const refs = sortedCellTextEntries(normalizedSheetComments(comments));
if (refs.length === 0) {
return "";
}
const commentXml = refs
.map(([address, text]) => `<comment ref="${escapeXmlAttribute(address)}" authorId="0"><text><t>${escapeXmlText(text)}</t></text></comment>`)
.join("");
return xmlDeclaration(
`<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>QOffice</author></authors><commentList>${commentXml}</commentList></comments>`
);
}
function buildCommentsVmlDrawingXml(comments: Record<string, string> = {}) {
const refs = sortedCellTextEntries(normalizedSheetComments(comments));
const shapes = refs
.map(([address], index) => {
const column = Math.max(0, cellColumnNumber(address) - 1);
const row = Math.max(0, cellRowNumber(address) - 1);
return `<v:shape id="_x0000_s${1025 + index}" type="#_x0000_t202" style="position:absolute;margin-left:80pt;margin-top:5pt;width:144pt;height:79pt;z-index:${index + 1};visibility:hidden" fillcolor="#ffffe1" o:insetmode="auto"><v:fill color2="#ffffe1"/><v:shadow on="t" color="black" obscured="t"/><v:path o:connecttype="none"/><v:textbox style="mso-direction-alt:auto"><div style="text-align:left"/></v:textbox><x:ClientData ObjectType="Note"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>1, 15, 0, 2, 3, 15, 4, 16</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>${row}</x:Row><x:Column>${column}</x:Column></x:ClientData></v:shape>`;
})
.join("");
return xmlDeclaration(
`<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shapetype id="_x0000_t202" coordsize="21600,21600" o:spt="202" path="m,l,21600r21600,l21600,xe"><v:stroke joinstyle="miter"/><v:path gradientshapeok="t" o:connecttype="rect"/></v:shapetype>${shapes}</xml>`
);
}
function normalizedSheetCharts(charts: SheetChart[] = []) {
return charts
.map(normalizedChart)
@@ -1632,6 +1857,37 @@ function normalizedNumberFormat(value?: string) {
return raw.slice(0, 255);
}
function date1904AdjustedCellText(cell: XmlRecord, text: string, format?: CellFormat) {
if (!isNumericXlsxCell(cell) || !isDateSerialXlsxNumberFormat(format?.numberFormat)) {
return text;
}
const serial = Number(text);
if (!Number.isFinite(serial) || serial < 0) {
return text;
}
return normalizedXlsxNumberText(serial + 1462);
}
function isNumericXlsxCell(cell: XmlRecord) {
const type = typeof cell.t === "string" ? cell.t.trim() : "";
return type === "" || type === "n";
}
function isDateSerialXlsxNumberFormat(value?: string) {
const pattern = normalizedNumberFormat(value)
.replace(/"[^"]*"/g, "")
.replace(/\\./g, "")
.replace(/\[[^\]]+]/g, "")
.toLowerCase();
return /[dy]/.test(pattern) || (pattern.includes("m") && !/[hs]/.test(pattern));
}
function normalizedXlsxNumberText(value: number) {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(10)));
}
function xlsxNumberFormats(styleSheet: XmlRecord) {
const formats = { ...builtinNumberFormats };
toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {
@@ -1717,6 +1973,58 @@ function normalizedSheetZoomScale(value: unknown) {
return zoomScale >= 10 && zoomScale <= 400 && zoomScale !== 100 ? zoomScale : 0;
}
function normalizedSheetPageSetup(pageSetup?: SheetPageSetup): SheetPageSetup {
const size = isSheetPageSizeId(pageSetup?.size) ? pageSetup.size : defaultSheetPageSetup.size;
const orientation = normalizedSheetPageOrientation(pageSetup?.orientation) ?? defaultSheetPageSetup.orientation;
const marginPreset = isSheetPageMarginPreset(pageSetup?.marginPreset) ? pageSetup.marginPreset : defaultSheetPageSetup.marginPreset;
return { size, orientation, marginPreset };
}
function normalizedSheetPageOrientation(value: unknown): SheetPageOrientation | undefined {
return value === "portrait" || value === "landscape" ? value : undefined;
}
function isSheetPageSizeId(value: unknown): value is SheetPageSizeId {
return value === "a4" || value === "letter";
}
function isSheetPageMarginPreset(value: unknown): value is SheetPageMarginPreset {
return value === "normal" || value === "narrow" || value === "wide";
}
function sheetPageSizeFromPaperId(value: unknown): SheetPageSizeId | undefined {
const paperSize = positiveIntegerAttribute(value);
return (Object.keys(xlsxPageSizePaperIds) as SheetPageSizeId[]).find((sizeId) => xlsxPageSizePaperIds[sizeId] === paperSize);
}
function sheetPageMarginPresetFromRecord(record: XmlRecord): SheetPageMarginPreset | undefined {
const left = positiveNumberAttribute(record.left);
const right = positiveNumberAttribute(record.right);
const top = positiveNumberAttribute(record.top);
const bottom = positiveNumberAttribute(record.bottom);
const header = positiveNumberAttribute(record.header);
const footer = positiveNumberAttribute(record.footer);
if (!left || !right || !top || !bottom || !header || !footer) {
return undefined;
}
return (Object.keys(xlsxPageMargins) as SheetPageMarginPreset[]).find((preset) => {
const margins = xlsxPageMargins[preset];
return (
isCloseDimension(left, margins.left) &&
isCloseDimension(right, margins.right) &&
isCloseDimension(top, margins.top) &&
isCloseDimension(bottom, margins.bottom) &&
isCloseDimension(header, margins.header) &&
isCloseDimension(footer, margins.footer)
);
});
}
function isCloseDimension(actual: number, expected: number) {
return Math.abs(actual - expected) <= 0.01;
}
function buildRowAttributes(height?: number, hidden?: boolean) {
return `${height ? ` ht="${formatSheetDimension(height)}" customHeight="1"` : ""}${hidden ? ' hidden="1"' : ""}`;
}
@@ -1820,6 +2128,11 @@ async function readOptionalRelationships(zip: ZipInstance, parser: InstanceType<
return file ? parser.parse(await file.async("string")) : undefined;
}
async function readOptionalParsedXml(zip: ZipInstance, parser: InstanceType<XmlParserConstructor>, path: string) {
const file = zip.file(path);
return file ? parser.parse(await file.async("string")) : undefined;
}
async function readZipText(zip: ZipInstance, path: string) {
const file = zip.file(path);
if (!file) {
@@ -1867,6 +2180,11 @@ function relationshipTargetsByType(relationshipsXml: unknown, type: string, sour
return targets;
}
function worksheetCommentsPath(relationshipsXml: unknown, sheetPath: string) {
const commentTargets = relationshipTargetsByType(relationshipsXml, commentsRelationshipType, sheetPath);
return Object.values(commentTargets)[0] ?? "";
}
function resolvePartTarget(baseDirectory: string, target: string) {
const normalizedTarget = target.replace(/\\/g, "/");
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${baseDirectory}/${normalizedTarget}`;
@@ -2046,6 +2364,17 @@ function normalizeWhitespace(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function normalizeCommentText(value: string) {
return value
.replace(/\r\n?/g, "\n")
.split("\n")
.map((line) => line.trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
.slice(0, 32767);
}
function sortedCellEntries(cells: Record<string, string>) {
return Object.entries(cells)
.filter(([address, value]) => isCellAddress(address) && value !== "")
@@ -2060,6 +2389,22 @@ function sortedCellEntries(cells: Record<string, string>) {
});
}
function sortedCellTextEntries(values: Record<string, string>) {
return Object.entries(values)
.map(([address, value]) => [normalizeCellAddress(address), normalizeCommentText(value)] as const)
.filter(([address, value]) => Boolean(address && value))
.filter(([address], index, entries) => entries.findIndex(([candidate]) => candidate === address) === index)
.sort(([firstAddress], [secondAddress]) => {
const firstRow = cellRowNumber(firstAddress);
const secondRow = cellRowNumber(secondAddress);
if (firstRow !== secondRow) {
return firstRow - secondRow;
}
return cellColumnNumber(firstAddress) - cellColumnNumber(secondAddress);
});
}
function sortedWorksheetEntries(cells: Record<string, string>, cellFormats: Record<string, CellFormat> = {}) {
const entries = new Map<string, string>();
+149
View File
@@ -7,6 +7,7 @@ import {
decodeTransferBody,
extractMessageAttachments,
extractMessageBody,
extractMessageHtmlBody,
htmlToPlainText,
importEmlFile,
isSupportedEmlFileName,
@@ -203,6 +204,7 @@ describe("outlookMail helpers", () => {
expect(imported.subject).toBe("Мультипарт");
expect(imported.body).toBe("Привет, письмо");
expect(imported.bodyHtml).toBe("<p>HTML</p>");
expect(imported.from).toBe("sender@example.com");
expect(imported.to).toBe("reader@example.com");
expect(imported.cc).toBe("copy@example.com");
@@ -252,6 +254,82 @@ describe("outlookMail helpers", () => {
]);
});
it("embeds inline CID images in imported EML HTML body", async () => {
const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
const eml = [
"From: sender@example.com",
"To: reader@example.com",
"Subject: Inline logo",
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
'Content-Type: multipart/related; boundary="related"',
"",
"--related",
"Content-Type: text/html; charset=utf-8",
"",
'<p>Logo</p><img alt="logo" src="cid:Logo%40Example.com">',
"--related",
'Content-Type: image/png; name="logo.png"',
'Content-Disposition: inline; filename="logo.png"',
"Content-ID: <logo@example.com>",
"Content-Transfer-Encoding: base64",
"",
imageBase64,
"--related--"
].join("\r\n");
const imported = await importEmlFile(new File([eml], "inline.eml", { type: "message/rfc822" }));
expect(imported.body).toBe("Logo");
expect(imported.bodyHtml).toContain(`src="data:image/png;base64,${imageBase64}"`);
expect(imported.bodyHtml).not.toContain("cid:");
expect(imported.attachments).toEqual([
{
id: "attachment-logo-png-1",
fileName: "logo.png",
contentType: "image/png",
size: 68,
contentBase64: imageBase64,
disposition: "inline",
contentId: "logo@example.com"
}
]);
});
it("keeps non-image CID references in imported EML HTML body", async () => {
const eml = [
"From: sender@example.com",
"To: reader@example.com",
"Subject: Inline document",
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
'Content-Type: multipart/related; boundary="related"',
"",
"--related",
"Content-Type: text/html; charset=utf-8",
"",
'<p>Document</p><img src="cid:document">',
"--related",
'Content-Type: text/plain; name="document.txt"',
'Content-Disposition: inline; filename="document.txt"',
"Content-ID: <document>",
"Content-Transfer-Encoding: base64",
"",
"SGVsbG8=",
"--related--"
].join("\r\n");
const imported = await importEmlFile(new File([eml], "inline.eml", { type: "message/rfc822" }));
expect(imported.bodyHtml).toContain('src="cid:document"');
expect(imported.bodyHtml).not.toContain("data:text/plain");
expect(imported.attachments?.[0]).toMatchObject({
fileName: "document.txt",
contentType: "text/plain",
contentBase64: "SGVsbG8=",
disposition: "inline",
contentId: "document"
});
});
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
const message = msgDataToMailMessage(
{
@@ -298,11 +376,13 @@ describe("outlookMail helpers", () => {
xPriority: "5",
priority: "non-urgent",
subject: "MSG отчет",
bodyHtml: "<p>HTML&nbsp;текст</p>",
body: "HTML текст",
time: "Mon, 01 Jun 2026 10:00:00 GMT",
read: false,
flagged: false
});
expect(message.bodyHtml).toBe("<p>HTML&nbsp;текст</p>");
expect(message.id).toMatch(/^msg-/);
expect(message.attachments).toEqual([
{
@@ -355,6 +435,72 @@ describe("outlookMail helpers", () => {
expect(eml.endsWith("Текст")).toBe(true);
});
it("exports HTML body as multipart/alternative EML", () => {
const message: MailMessage = {
id: "html-1",
folder: "sent",
from: "sender@example.com",
to: "reader@example.com",
subject: "HTML message",
body: "Plain text",
bodyHtml: "<p><strong>HTML text</strong></p>",
time: "Mon, 01 Jan 2024 10:00:00 +0000",
read: true,
flagged: false
};
const eml = buildEmlSource(message);
const parsed = parseEmlSource(eml);
expect(eml).toContain("Content-Type: multipart/alternative");
expect(extractMessageBody(parsed.headers, parsed.body)).toBe("Plain text");
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>HTML text</strong></p>");
});
it("exports inline data images as CID references in related EML", () => {
const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
const message: MailMessage = {
id: "inline-image",
folder: "sent",
from: "sender@example.com",
to: "reader@example.com",
subject: "Inline image",
body: "Logo",
bodyHtml: `<p>Logo</p><img alt="logo" src="data:image/png;base64,${imageBase64}">`,
time: "Mon, 01 Jan 2024 10:00:00 +0000",
read: true,
flagged: false,
attachments: [
{
id: "logo",
fileName: "logo.png",
contentType: "image/png",
size: 68,
contentBase64: imageBase64,
disposition: "inline",
contentId: "logo@example.com"
}
]
};
const eml = buildEmlSource(message);
const parsed = parseEmlSource(eml);
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
expect(eml).toContain("Content-Type: multipart/related");
expect(eml).toContain("Content-ID: <logo@example.com>");
expect(eml).toContain('Content-Disposition: inline; filename="logo.png"');
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe('<p>Logo</p><img alt="logo" src="cid:logo@example.com">');
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).not.toContain("data:image/png;base64");
expect(attachments[0]).toMatchObject({
fileName: "logo.png",
contentType: "image/png",
contentBase64: imageBase64,
disposition: "inline",
contentId: "logo@example.com"
});
});
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
const message: MailMessage = {
id: "1",
@@ -372,6 +518,7 @@ describe("outlookMail helpers", () => {
priority: "urgent",
subject: "Тема",
body: "Текст",
bodyHtml: "<p><strong>Текст</strong></p>",
time: "Mon, 01 Jan 2024 10:00:00 +0000",
read: true,
flagged: false,
@@ -392,6 +539,7 @@ describe("outlookMail helpers", () => {
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
expect(eml).toContain("Content-Type: multipart/mixed");
expect(eml).toContain("Content-Type: multipart/alternative");
expect(eml).toContain("Cc: copy@example.com");
expect(eml).toContain("Bcc: hidden@example.com");
expect(eml).toContain("Reply-To: support@example.com");
@@ -403,6 +551,7 @@ describe("outlookMail helpers", () => {
expect(eml).toContain("Priority: urgent");
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(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>Текст</strong></p>");
expect(attachments[0]).toMatchObject({
fileName: "отчет.txt",
contentType: "text/plain",
+255 -5
View File
@@ -81,6 +81,8 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
const raw = await file.text();
const parsed = parseEmlSource(raw);
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
const bodyHtml = htmlWithEmbeddedCidAttachments(extractMessageHtmlBody(parsed.headers, parsed.body), attachments);
return {
id: `eml-${Date.now()}`,
@@ -94,10 +96,11 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
...mailPriorityHeaders(parsed.headers),
subject: decodeMimeWords(parsed.headers.subject || "Без темы"),
body: extractMessageBody(parsed.headers, parsed.body),
...(bodyHtml ? { bodyHtml } : {}),
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
read: false,
flagged: false,
attachments: extractMessageAttachments(parsed.headers, parsed.body)
attachments
};
}
@@ -121,6 +124,7 @@ export function msgDataToMailMessage(reader: Pick<MsgReaderInstance, "getAttachm
const cc = msgRecipientList(data.recipients, "cc") || decodeMimeWords(headerFields.cc || "");
const bcc = msgRecipientList(data.recipients, "bcc") || decodeMimeWords(headerFields.bcc || "");
const replyTo = decodeMimeWords(headerFields["reply-to"] || "");
const bodyHtml = msgBodyHtml(data);
return {
id: `msg-${Date.now()}`,
folder: "inbox",
@@ -133,6 +137,7 @@ export function msgDataToMailMessage(reader: Pick<MsgReaderInstance, "getAttachm
...mailPriorityHeaders(headerFields),
subject: firstText(data.subject, decodeMimeWords(headerFields.subject || ""), "Без темы"),
body: msgBodyText(data),
...(bodyHtml ? { bodyHtml } : {}),
time: firstText(data.messageDeliveryTime, data.clientSubmitTime, data.creationTime, data.lastModificationTime, headerFields.date, new Date().toLocaleString("ru-RU")),
read: false,
flagged: false,
@@ -229,10 +234,110 @@ export function extractMessageBody(headers: MailHeaders, body: string): string {
return contentType.mediaType === "text/html" ? htmlToPlainText(decoded) : normalizeMailNewlines(decoded).trim();
}
export function extractMessageHtmlBody(headers: MailHeaders, body: string): string {
const contentType = parseContentType(headers["content-type"]);
if (contentType.mediaType.startsWith("multipart/")) {
const boundary = contentType.parameters.boundary;
if (!boundary) {
return "";
}
return (
parseMultipartBody(body, boundary)
.filter((part) => !isAttachmentPart(part.headers))
.filter((part) => isMessageBodyPart(part.headers))
.map((part) => extractMessageHtmlBody(part.headers, part.body))
.find((html) => html.trim()) ?? ""
);
}
if (contentType.mediaType !== "text/html") {
return "";
}
return decodeTransferBody(body, headers["content-transfer-encoding"], contentType.parameters.charset).trim();
}
export function extractMessageAttachments(headers: MailHeaders, body: string): MailAttachment[] {
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
}
export function htmlWithEmbeddedCidAttachments(html: string, attachments: MailAttachment[] = []): string {
if (!html.trim() || attachments.length === 0) {
return html;
}
const inlineImagesByContentId = new Map<string, { contentBase64: string; contentType: string }>();
attachments.forEach((attachment) => {
const contentId = normalizedContentId(attachment.contentId);
const contentType = sanitizeMimeType(attachment.contentType);
const contentBase64 = attachment.contentBase64.trim();
if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) {
return;
}
if (!inlineImagesByContentId.has(contentId)) {
inlineImagesByContentId.set(contentId, { contentBase64, contentType });
}
});
if (inlineImagesByContentId.size === 0) {
return html;
}
return html.replace(/\bsrc\s*=\s*(?:(["'])\s*cid:([^"'>\s]+)\1|cid:([^\s"'<>]+))/gi, (match, quote: string | undefined, quotedCid: string | undefined, unquotedCid: string | undefined) => {
const contentId = normalizedContentId(decodeUriComponentSafe(quotedCid ?? unquotedCid ?? ""));
const attachment = inlineImagesByContentId.get(contentId);
if (!attachment) {
return match;
}
const dataUri = `data:${attachment.contentType};base64,${attachment.contentBase64}`;
return quote ? `src=${quote}${dataUri}${quote}` : `src="${dataUri}"`;
});
}
function htmlWithCidReferencesForInlineAttachments(html: string, attachments: MailAttachment[] = []): string {
if (!html.trim() || attachments.length === 0) {
return html;
}
const contentIdsByDataUri = new Map<string, string>();
attachments.forEach((attachment) => {
const contentId = cleanContentId(attachment.contentId);
const contentType = sanitizeMimeType(attachment.contentType);
const contentBase64 = attachment.contentBase64.trim();
if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) {
return;
}
const key = cidImageDataUriKey(contentType, contentBase64);
if (!contentIdsByDataUri.has(key)) {
contentIdsByDataUri.set(key, contentId);
}
});
if (contentIdsByDataUri.size === 0) {
return html;
}
return html.replace(
/\bsrc\s*=\s*(?:(["'])\s*data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^"'>\s]+)\1|data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^\s"'<>]+))/gi,
(match, quote: string | undefined, quotedType: string | undefined, quotedBase64: string | undefined, unquotedType: string | undefined, unquotedBase64: string | undefined) => {
const contentType = quotedType ?? unquotedType ?? "";
const contentBase64 = (quotedBase64 ?? unquotedBase64 ?? "").trim();
const contentId = contentIdsByDataUri.get(cidImageDataUriKey(contentType, contentBase64));
if (!contentId) {
return match;
}
const cidReference = escapeHtmlAttribute(`cid:${contentId}`);
return quote ? `src=${quote}${cidReference}${quote}` : `src="${cidReference}"`;
}
);
}
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
return {
id: createAttachmentId(file.name),
@@ -271,6 +376,19 @@ function msgBodyText(data: MsgFileData): string {
return "";
}
function msgBodyHtml(data: MsgFileData): string {
const htmlBody = firstText(data.bodyHtml);
if (htmlBody) {
return htmlBody;
}
if (data.html && data.html.byteLength > 0) {
return decodeBytes(data.html, msgCharset(data)).trim();
}
return "";
}
function msgCharset(data: MsgFileData) {
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
return parseContentType(headerFields["content-type"]).parameters.charset || "utf-8";
@@ -417,8 +535,32 @@ export function parseContentType(value = "text/plain"): ParsedContentType {
export function buildEmlSource(message: MailMessage): string {
const attachments = message.attachments ?? [];
const hasHtmlBody = Boolean(message.bodyHtml?.trim());
if (attachments.length > 0) {
const boundary = createMultipartBoundary(message);
const alternativeBoundary = createAlternativeBoundary(message);
const inlineRelatedAttachments = hasHtmlBody ? attachments.filter(isInlineRelatedAttachment) : [];
const mixedAttachments = inlineRelatedAttachments.length > 0 ? attachments.filter((attachment) => !isInlineRelatedAttachment(attachment)) : attachments;
if (hasHtmlBody && inlineRelatedAttachments.length > 0 && mixedAttachments.length === 0) {
const relatedBoundary = createRelatedBoundary(message);
const lines = [
`From: ${sanitizeHeaderValue(message.from)}`,
`To: ${sanitizeHeaderValue(message.to)}`,
...optionalAddressHeaders(message),
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
...optionalThreadHeaders(message),
...optionalPriorityHeaders(message),
"MIME-Version: 1.0",
`Content-Type: multipart/related; boundary="${relatedBoundary}"`,
"",
...buildRelatedBodyLines(relatedBoundary, alternativeBoundary, message, inlineRelatedAttachments),
""
];
return lines.join("\r\n");
}
const lines = [
`From: ${sanitizeHeaderValue(message.from)}`,
`To: ${sanitizeHeaderValue(message.to)}`,
@@ -431,11 +573,36 @@ export function buildEmlSource(message: MailMessage): string {
`Content-Type: multipart/mixed; boundary="${boundary}"`,
"",
`--${boundary}`,
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: 8bit",
...(inlineRelatedAttachments.length > 0
? buildRelatedPartLines(createRelatedBoundary(message), alternativeBoundary, message, inlineRelatedAttachments)
: hasHtmlBody
? buildAlternativePartLines(alternativeBoundary, message)
: buildPlainTextPartLines(message)),
...mixedAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
`--${boundary}--`,
""
];
return lines.join("\r\n");
}
if (hasHtmlBody) {
const boundary = createAlternativeBoundary(message);
const lines = [
`From: ${sanitizeHeaderValue(message.from)}`,
`To: ${sanitizeHeaderValue(message.to)}`,
...optionalAddressHeaders(message),
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
...optionalThreadHeaders(message),
...optionalPriorityHeaders(message),
"MIME-Version: 1.0",
`Content-Type: multipart/alternative; boundary="${boundary}"`,
"",
normalizeMailNewlines(message.body || ""),
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
`--${boundary}`,
...buildPlainTextPartLines(message),
`--${boundary}`,
...buildHtmlPartLines(message),
`--${boundary}--`,
""
];
@@ -461,6 +628,43 @@ export function buildEmlSource(message: MailMessage): string {
return lines.join("\r\n");
}
function buildPlainTextPartLines(message: MailMessage): string[] {
return ["Content-Type: text/plain; charset=utf-8", "Content-Transfer-Encoding: 8bit", "", normalizeMailNewlines(message.body || "")];
}
function buildHtmlPartLines(message: MailMessage): string[] {
return [
"Content-Type: text/html; charset=utf-8",
"Content-Transfer-Encoding: 8bit",
"",
normalizeMailNewlines(htmlWithCidReferencesForInlineAttachments(message.bodyHtml || "", message.attachments ?? []))
];
}
function buildAlternativePartLines(boundary: string, message: MailMessage): string[] {
return [
`Content-Type: multipart/alternative; boundary="${boundary}"`,
"",
`--${boundary}`,
...buildPlainTextPartLines(message),
`--${boundary}`,
...buildHtmlPartLines(message),
`--${boundary}--`
];
}
function buildRelatedPartLines(boundary: string, alternativeBoundary: string, message: MailMessage, inlineAttachments: MailAttachment[]): string[] {
return [`Content-Type: multipart/related; boundary="${boundary}"`, "", ...buildRelatedBodyLines(boundary, alternativeBoundary, message, inlineAttachments)];
}
function buildRelatedBodyLines(boundary: string, alternativeBoundary: string, message: MailMessage, inlineAttachments: MailAttachment[]): string[] {
return [`--${boundary}`, ...buildAlternativePartLines(alternativeBoundary, message), ...inlineAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)), `--${boundary}--`];
}
function isInlineRelatedAttachment(attachment: MailAttachment): boolean {
return attachment.disposition === "inline" && Boolean(cleanContentId(attachment.contentId));
}
function optionalAddressHeaders(message: MailMessage): string[] {
return [
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
@@ -659,6 +863,14 @@ function createMultipartBoundary(message: MailMessage): string {
return `qoffice-${normalizedSeed || "message"}`;
}
function createAlternativeBoundary(message: MailMessage): string {
return `${createMultipartBoundary(message)}-alternative`;
}
function createRelatedBoundary(message: MailMessage): string {
return `${createMultipartBoundary(message)}-related`;
}
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
const safeContentType = sanitizeMimeType(attachment.contentType);
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
@@ -694,6 +906,44 @@ function sanitizeMimeType(value = "application/octet-stream"): string {
return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mediaType) ? mediaType : "application/octet-stream";
}
function isEmbeddableCidImageContentType(value: string): boolean {
return ["image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp"].includes(normalizedCidImageContentType(value));
}
function normalizedCidImageContentType(value: string): string {
const contentType = value.toLowerCase();
return contentType === "image/jpg" ? "image/jpeg" : contentType;
}
function cidImageDataUriKey(contentType: string, contentBase64: string): string {
return `${normalizedCidImageContentType(contentType)}:${contentBase64.trim()}`;
}
function cleanContentId(value = ""): string {
return value.trim().replace(/^<|>$/g, "");
}
function normalizedContentId(value = ""): string {
return cleanContentId(value).toLowerCase();
}
function decodeUriComponentSafe(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function wrapBase64(value: string): string {
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
}
+398 -1
View File
@@ -7,6 +7,7 @@ import {
listSlidePaths,
normalizeWhitespace,
presentationSlidePaths,
presentationSlideSize,
relationshipTargetByType,
resolvePptTarget
} from "./powerPointOffice";
@@ -81,6 +82,30 @@ describe("powerPointOffice helpers", () => {
]);
});
it("читает размер слайда из presentation.xml", () => {
expect(
presentationSlideSize({
presentation: {
sldSz: { cx: "12192000", cy: "6858000" }
}
})
).toBe("wide");
expect(
presentationSlideSize({
presentation: {
sldSz: { "@_cx": "9144000", "@_cy": "6858000" }
}
})
).toBe("standard");
expect(
presentationSlideSize({
presentation: {
sldSz: { cx: "9144000", cy: "5143500" }
}
})
).toBeUndefined();
});
it("разрешает относительные targets заметок от части слайда", () => {
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
expect(
@@ -104,7 +129,7 @@ describe("powerPointOffice helpers", () => {
const zip = new JSZip();
zip.file(
"ppt/presentation.xml",
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="255" r:id="rId1"/></p:sldIdLst></p:presentation>`
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldSz cx="9144000" cy="6858000"/><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="255" r:id="rId1"/></p:sldIdLst></p:presentation>`
);
zip.file(
"ppt/_rels/presentation.xml.rels",
@@ -125,6 +150,7 @@ describe("powerPointOffice helpers", () => {
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
expect(imported.slideSize).toBe("standard");
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
expect(imported.slides[0].notes).toBe("Заметка докладчика");
});
@@ -173,6 +199,86 @@ describe("powerPointOffice helpers", () => {
expect(imported.slides[0].subtitle).toBe("Plan\nBuild\nShip");
});
it("импортирует маркированные и нумерованные списки подзаголовка PPTX", async () => {
const bulletZip = new JSZip();
bulletZip.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>`
);
bulletZip.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>`
);
bulletZip.file("ppt/slides/slide1.xml", slideXmlWithListSubtitle("План", ["Собрать требования", "Проверить импорт"], "bullet"));
const bulletBlob = await bulletZip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
const importedBullet = await importPptxFile(new File([bulletBlob], "Маркированный.pptx", { type: bulletBlob.type }));
expect(importedBullet.slides[0].subtitle).toBe("Собрать требования\nПроверить импорт");
expect(importedBullet.slides[0].subtitleListStyle).toBe("bullet");
const numberZip = new JSZip();
numberZip.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>`
);
numberZip.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>`
);
numberZip.file("ppt/slides/slide1.xml", slideXmlWithListSubtitle("План", ["Шаг один", "Шаг два"], "number"));
const numberBlob = await numberZip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
const importedNumber = await importPptxFile(new File([numberBlob], "Нумерованный.pptx", { type: numberBlob.type }));
expect(importedNumber.slides[0].subtitle).toBe("Шаг один\nШаг два");
expect(importedNumber.slides[0].subtitleListStyle).toBe("number");
});
it("экспортирует списки подзаголовка PPTX как OOXML bullets", async () => {
const bulletBlob = await exportPptxBlob({
title: "Маркированный.pptx",
slides: [
{
id: "slide-1",
title: "План",
subtitle: "Собрать требования\nПроверить импорт",
subtitleListStyle: "bullet",
notes: "",
theme: "classic"
}
]
});
const bulletZip = await JSZip.loadAsync(await bulletBlob.arrayBuffer());
const bulletSlideXml = (await bulletZip.file("ppt/slides/slide1.xml")?.async("string")) ?? "";
expect(bulletSlideXml).toContain("<a:buChar");
const numberBlob = await exportPptxBlob({
title: "Нумерованный.pptx",
slides: [
{
id: "slide-1",
title: "План",
subtitle: "Шаг один\nШаг два",
subtitleListStyle: "number",
notes: "",
theme: "classic"
}
]
});
const numberZip = await JSZip.loadAsync(await numberBlob.arrayBuffer());
const numberSlideXml = (await numberZip.file("ppt/slides/slide1.xml")?.async("string")) ?? "";
expect(numberSlideXml).toContain("<a:buAutoNum");
});
it("импортирует изображения слайда из PPTX media relationships", async () => {
const zip = new JSZip();
zip.file(
@@ -210,6 +316,35 @@ describe("powerPointOffice helpers", () => {
]);
});
it("импортирует внешние ссылки изображений PPTX через hlinkClick 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", slideXmlWithLinkedPicture("Слайд с кликабельным изображением"));
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"/><Relationship Id="rIdImageLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/logo" TargetMode="External"/></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].images?.[0]).toMatchObject({
alt: "Логотип",
hyperlink: "https://example.com/logo"
});
});
it("экспортирует изображения слайда в PPTX media relationships", async () => {
const blob = await exportPptxBlob({
title: "Слайды с изображением.pptx",
@@ -245,6 +380,42 @@ describe("powerPointOffice helpers", () => {
expect(mediaFiles.length).toBeGreaterThan(0);
});
it("экспортирует внешние ссылки изображений PPTX", 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: "Логотип",
hyperlink: "https://example.com/logo",
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 reimported = await importPptxFile(new File([blob], "Кликабельное изображение.pptx", { type: blob.type }));
expect(slideXml).toContain("hlinkClick");
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"');
expect(relationshipsXml).toContain('Target="https://example.com/logo"');
expect(reimported.slides[0].images?.[0]).toMatchObject({ hyperlink: "https://example.com/logo" });
});
it("экспортирует заметки докладчика в PPTX notesSlide relationships", async () => {
const blob = await exportPptxBlob({
title: "Заметки.pptx",
@@ -447,6 +618,208 @@ describe("powerPointOffice helpers", () => {
expect(exportedSlideXml).toContain('typeface="Times New Roman"');
});
it("импортирует и экспортирует начертание текста слайда PPTX", 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", slideXmlWithTextRunStyles("Заголовок", "Подзаголовок"));
const importedBlob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
const imported = await importPptxFile(new File([importedBlob], "Начертание.pptx", { type: importedBlob.type }));
expect(imported.slides[0]).toMatchObject({
titleBold: true,
titleItalics: true,
titleUnderline: true,
titleStrike: true,
subtitleBold: true,
subtitleItalics: true,
subtitleUnderline: true,
subtitleStrike: true
});
const exportedBlob = await exportPptxBlob({
title: "Начертание.pptx",
slides: [
{
id: "slide-1",
title: "Заголовок",
subtitle: "Подзаголовок",
notes: "",
theme: "classic",
titleBold: false,
titleItalics: true,
titleUnderline: true,
titleStrike: true,
subtitleBold: true,
subtitleItalics: true,
subtitleUnderline: true,
subtitleStrike: true
}
]
});
const exportedZip = await JSZip.loadAsync(await exportedBlob.arrayBuffer());
const exportedSlideXml = (await exportedZip.file("ppt/slides/slide1.xml")?.async("string")) ?? "";
expect(exportedSlideXml).toContain('i="1"');
expect(exportedSlideXml).toContain('u="sng"');
expect(exportedSlideXml).toContain('strike="sngStrike"');
expect(exportedSlideXml).toContain('b="1"');
const reimported = await importPptxFile(new File([exportedBlob], "Начертание.pptx", { type: exportedBlob.type }));
expect(reimported.slides[0]).toMatchObject({
titleBold: false,
titleItalics: true,
titleUnderline: true,
titleStrike: true,
subtitleBold: true,
subtitleItalics: true,
subtitleUnderline: true,
subtitleStrike: true
});
});
it("импортирует и экспортирует выравнивание текста слайда PPTX", 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", slideXmlWithTextAlignment("Заголовок", "Подзаголовок"));
const importedBlob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
const imported = await importPptxFile(new File([importedBlob], "Выравнивание.pptx", { type: importedBlob.type }));
expect(imported.slides[0]).toMatchObject({
titleAlign: "center",
subtitleAlign: "right"
});
const exportedBlob = await exportPptxBlob({
title: "Выравнивание.pptx",
slides: [
{
id: "slide-1",
title: "Заголовок",
subtitle: "Подзаголовок",
notes: "",
theme: "classic",
titleAlign: "center",
subtitleAlign: "right"
}
]
});
const exportedZip = await JSZip.loadAsync(await exportedBlob.arrayBuffer());
const exportedSlideXml = (await exportedZip.file("ppt/slides/slide1.xml")?.async("string")) ?? "";
expect(exportedSlideXml).toContain('algn="ctr"');
expect(exportedSlideXml).toContain('algn="r"');
const reimported = await importPptxFile(new File([exportedBlob], "Выравнивание.pptx", { type: exportedBlob.type }));
expect(reimported.slides[0]).toMatchObject({
titleAlign: "center",
subtitleAlign: "right"
});
});
it("импортирует внешние ссылки текста PPTX через hlinkClick 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", slideXmlWithTextHyperlinks("Roadmap", "Open report"));
zip.file(
"ppt/slides/_rels/slide1.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdTitleLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/title" TargetMode="External"/><Relationship Id="rIdSubtitleLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="mailto:team@example.com" TargetMode="External"/></Relationships>`
);
const blob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
const imported = await importPptxFile(new File([blob], "Links.pptx", { type: blob.type }));
expect(imported.slides[0]).toMatchObject({
title: "Roadmap",
titleHyperlink: "https://example.com/title",
subtitle: "Open report",
subtitleHyperlink: "mailto:team@example.com"
});
});
it("экспортирует внешние ссылки заголовка и подзаголовка PPTX", async () => {
const blob = await exportPptxBlob({
title: "Links.pptx",
slides: [
{
id: "slide-1",
title: "Roadmap",
titleHyperlink: "https://example.com/title",
subtitle: "Open report",
subtitleHyperlink: "mailto:team@example.com",
notes: "",
theme: "classic"
}
]
});
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 reimported = await importPptxFile(new File([blob], "Links.pptx", { type: blob.type }));
expect(slideXml).toContain("hlinkClick");
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"');
expect(relationshipsXml).toContain('Target="https://example.com/title"');
expect(relationshipsXml).toContain('Target="mailto:team@example.com"');
expect(reimported.slides[0]).toMatchObject({
titleHyperlink: "https://example.com/title",
subtitleHyperlink: "mailto:team@example.com"
});
});
it("экспортирует и переимпортирует стандартный размер слайда PPTX 4:3", async () => {
const blob = await exportPptxBlob({
title: "Standard.pptx",
slideSize: "standard",
slides: [
{
id: "slide-1",
title: "Standard",
subtitle: "4:3",
notes: "",
theme: "classic"
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const presentationXml = (await zip.file("ppt/presentation.xml")?.async("string")) ?? "";
const reimported = await importPptxFile(new File([blob], "Standard.pptx", { type: blob.type }));
expect(presentationXml).toContain('<p:sldSz cx="9144000" cy="6858000"/>');
expect(reimported.slideSize).toBe("standard");
});
it("нормализует пробелы в импортированном тексте", () => {
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
});
@@ -474,6 +847,18 @@ function slideXmlWithTextStyles(title: string, subtitle: 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><p:sp><p:txBody><a:p><a:r><a:rPr sz="3400"><a:solidFill><a:srgbClr val="C2410C"/></a:solidFill><a:latin typeface="Courier New"/></a:rPr><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:txBody><a:p><a:r><a:rPr sz="2200"><a:solidFill><a:srgbClr val="0F766E"/></a:solidFill><a:latin typeface="Times New Roman"/></a:rPr><a:t>${subtitle}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`;
}
function slideXmlWithTextRunStyles(title: string, subtitle: 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><p:sp><p:txBody><a:p><a:r><a:rPr b="1" i="1" u="sng" strike="sngStrike"/><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:txBody><a:p><a:r><a:rPr b="1" i="1" u="sng" strike="sngStrike"/><a:t>${subtitle}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`;
}
function slideXmlWithTextAlignment(title: string, subtitle: 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><p:sp><p:txBody><a:p><a:pPr algn="ctr"/><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:txBody><a:p><a:pPr algn="r"/><a:r><a:t>${subtitle}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`;
}
function slideXmlWithTextHyperlinks(title: string, subtitle: 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:rPr><a:hlinkClick r:id="rIdTitleLink"/></a:rPr><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:txBody><a:p><a:r><a:rPr><a:hlinkClick r:id="rIdSubtitleLink"/></a:rPr><a:t>${subtitle}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`;
}
function slideXmlWithMultilineSubtitle(title: string, subtitleParagraphs: 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><p:sp><p:txBody><a:p><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:txBody>${subtitleParagraphs
.map((text) => `<a:p><a:r><a:t>${text}</a:t></a:r></a:p>`)
@@ -486,6 +871,18 @@ function slideXmlWithManualBreakSubtitle(title: string, subtitleLines: string[])
.join("")}</a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`;
}
function slideXmlWithListSubtitle(title: string, subtitleLines: string[], listStyle: "bullet" | "number") {
const paragraphProperties =
listStyle === "number" ? '<a:pPr><a:buAutoNum type="arabicPeriod"/></a:pPr>' : '<a:pPr><a:buChar char="•"/></a:pPr>';
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><p:sp><p:txBody><a:p><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:txBody>${subtitleLines
.map((text) => `<a:p>${paragraphProperties}<a:r><a:t>${text}</a:t></a:r></a:p>`)
.join("")}</p:txBody></p:sp></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>`;
}
function slideXmlWithLinkedPicture(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="Логотип"><a:hlinkClick r:id="rIdImageLink"/></p:cNvPr></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>`;
}
+277 -22
View File
@@ -1,18 +1,27 @@
import type { Slide, SlideImage } from "../types";
import type { Slide, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign } from "../types";
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
export interface ImportedPowerPointDeck {
title: string;
slideSize?: SlideSizeId;
slides: Slide[];
}
export interface ExportablePowerPointDeck {
title: string;
slideSize?: SlideSizeId;
slides: Slide[];
}
interface SlideTextBlock {
text: string;
listStyle?: SlideListStyle;
hyperlink?: string;
bold?: boolean;
italics?: boolean;
underline?: boolean;
strike?: boolean;
align?: SlideTextAlign;
color?: string;
fontSize?: number;
fontFamily?: string;
@@ -37,8 +46,13 @@ 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 hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
const emuPerInch = 914400;
const slideTransitions = ["fade", "push", "wipe"] as const;
const slideSizeLayouts: Record<SlideSizeId, { width: number; height: number; cx: number; cy: number; pptxLayout: string }> = {
wide: { width: 13.333, height: 7.5, cx: 12192000, cy: 6858000, pptxLayout: "LAYOUT_WIDE" },
standard: { width: 10, height: 7.5, cx: 9144000, cy: 6858000, pptxLayout: "LAYOUT_4x3" }
};
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
@@ -57,6 +71,7 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
const presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
const slideSize = presentationSlideSize(presentationXml);
if (slidePaths.length === 0) {
throw new Error("В файле PowerPoint не найдены слайды.");
@@ -67,8 +82,11 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
const slideXml = await readRequiredZipText(zip, path, "Не удалось прочитать слайд PowerPoint.");
const parsedSlide = parser.parse(slideXml);
const orderedSlide = orderedParser.parse(slideXml);
const slideTextBlocks = mergeOrderedSlideTextBlocks(extractSlideTextBlocks(parsedSlide), extractOrderedTextBodyTexts(orderedSlide));
const images = await extractSlideImages(zip, parser, parsedSlide, path, index);
const relationshipsPath = `${partDirectory(path)}/_rels/${partFileName(path)}.rels`;
const slideRelationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
const hyperlinkTargets = relationshipTargetsById(slideRelationshipsXml, hyperlinkRelationshipType, path);
const slideTextBlocks = mergeOrderedSlideTextBlocks(extractSlideTextBlocks(parsedSlide, hyperlinkTargets), extractOrderedTextBodyTexts(orderedSlide));
const images = await extractSlideImages(zip, parser, parsedSlide, path, index, slideRelationshipsXml);
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
@@ -77,28 +95,45 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
const backgroundColor = slideBackgroundColor(parsedSlide);
const hidden = slideHidden(parsedSlide);
const transition = slideTransition(parsedSlide);
const subtitleListStyle = slideTextBlocks
.slice(1)
.map((block) => block.listStyle)
.find((style): style is SlideListStyle => Boolean(style));
return {
id: `pptx-slide-${index}`,
title: slideTextBlocks[0]?.text || `Слайд ${index}`,
...(slideTextBlocks[0]?.hyperlink ? { titleHyperlink: slideTextBlocks[0].hyperlink } : {}),
subtitle: slideTextBlocks.slice(1).map((block) => block.text).join("\n"),
...(slideTextBlocks[1]?.hyperlink ? { subtitleHyperlink: slideTextBlocks[1].hyperlink } : {}),
notes,
theme: "classic" as const,
...(hidden ? { hidden } : {}),
...(transition ? { transition } : {}),
...(backgroundColor ? { backgroundColor } : {}),
titleBold: slideTextBlocks[0]?.bold ?? false,
...(slideTextBlocks[0]?.italics ? { titleItalics: true } : {}),
...(slideTextBlocks[0]?.underline ? { titleUnderline: true } : {}),
...(slideTextBlocks[0]?.strike ? { titleStrike: true } : {}),
...(slideTextBlocks[0]?.align ? { titleAlign: slideTextBlocks[0].align } : {}),
...(slideTextBlocks[0]?.color ? { titleColor: slideTextBlocks[0].color } : {}),
...(slideTextBlocks[0]?.fontSize ? { titleFontSize: slideTextBlocks[0].fontSize } : {}),
...(slideTextBlocks[0]?.fontFamily ? { titleFontFamily: slideTextBlocks[0].fontFamily } : {}),
...(slideTextBlocks[1]?.bold ? { subtitleBold: true } : {}),
...(slideTextBlocks[1]?.italics ? { subtitleItalics: true } : {}),
...(slideTextBlocks[1]?.underline ? { subtitleUnderline: true } : {}),
...(slideTextBlocks[1]?.strike ? { subtitleStrike: true } : {}),
...(slideTextBlocks[1]?.align ? { subtitleAlign: slideTextBlocks[1].align } : {}),
...(slideTextBlocks[1]?.color ? { subtitleColor: slideTextBlocks[1].color } : {}),
...(slideTextBlocks[1]?.fontSize ? { subtitleFontSize: slideTextBlocks[1].fontSize } : {}),
...(slideTextBlocks[1]?.fontFamily ? { subtitleFontFamily: slideTextBlocks[1].fontFamily } : {}),
...(subtitleListStyle ? { subtitleListStyle } : {}),
images
};
})
);
return { title, slides };
return { title, ...(slideSize ? { slideSize } : {}), slides };
}
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
@@ -119,8 +154,10 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
};
write: (options: { outputType: "blob" | "arraybuffer" }) => Promise<Blob | ArrayBuffer>;
};
const slideSize = normalizedSlideSizeId(deck.slideSize);
const slideLayout = slideSizeLayouts[slideSize];
pptx.layout = "LAYOUT_WIDE";
pptx.layout = slideLayout.pptxLayout;
pptx.author = "QOffice";
pptx.company = "QOffice";
pptx.subject = "Экспорт QPowerPoint";
@@ -134,6 +171,18 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
const subtitleFontSize = normalizeFontSize(sourceSlide.subtitleFontSize) || 18;
const titleFontFamily = normalizeFontFamily(sourceSlide.titleFontFamily) || "Arial";
const subtitleFontFamily = normalizeFontFamily(sourceSlide.subtitleFontFamily) || "Arial";
const titleBold = sourceSlide.titleBold ?? true;
const titleItalics = Boolean(sourceSlide.titleItalics);
const titleUnderline = Boolean(sourceSlide.titleUnderline);
const titleStrike = Boolean(sourceSlide.titleStrike);
const titleAlign = normalizedSlideTextAlign(sourceSlide.titleAlign);
const subtitleBold = Boolean(sourceSlide.subtitleBold);
const subtitleItalics = Boolean(sourceSlide.subtitleItalics);
const subtitleUnderline = Boolean(sourceSlide.subtitleUnderline);
const subtitleStrike = Boolean(sourceSlide.subtitleStrike);
const subtitleAlign = normalizedSlideTextAlign(sourceSlide.subtitleAlign);
const titleHyperlink = normalizedExternalHyperlinkTarget(sourceSlide.titleHyperlink);
const subtitleHyperlink = normalizedExternalHyperlinkTarget(sourceSlide.subtitleHyperlink);
const slide = pptx.addSlide();
slide.background = { color: normalizeHexColor(sourceSlide.backgroundColor) || colors.background };
sourceSlide.images?.forEach((image, index) => {
@@ -142,38 +191,51 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
return;
}
const imageHyperlink = normalizedExternalHyperlinkTarget(image.hyperlink);
slide.addImage({
data,
x: image.x,
y: image.y,
w: image.width,
h: image.height,
altText: image.alt || `Изображение ${index + 1}`
altText: image.alt || `Изображение ${index + 1}`,
...(imageHyperlink ? { hyperlink: { url: imageHyperlink } } : {})
});
});
slide.addText(sourceSlide.title || "Без заголовка", {
x: 0.7,
y: 0.65,
w: 11.9,
w: Math.max(1, slideLayout.width - 1.4),
h: 0.75,
fontFace: titleFontFamily,
fontSize: titleFontSize,
bold: true,
...(titleAlign ? { align: titleAlign } : {}),
bold: titleBold,
italic: titleItalics,
...(titleUnderline ? { underline: { style: "sng" } } : {}),
...(titleStrike ? { strike: "sngStrike" } : {}),
color: titleColor,
margin: 0.04,
breakLine: false
breakLine: false,
...(titleHyperlink ? { hyperlink: { url: titleHyperlink } } : {})
});
slide.addText(sourceSlide.subtitle || "", {
x: 0.75,
y: 1.65,
w: 11.5,
h: 4.35,
w: Math.max(1, slideLayout.width - 1.5),
h: Math.max(1, slideLayout.height - 3.15),
fontFace: subtitleFontFamily,
fontSize: subtitleFontSize,
...(subtitleAlign ? { align: subtitleAlign } : {}),
bold: subtitleBold,
italic: subtitleItalics,
...(subtitleUnderline ? { underline: { style: "sng" } } : {}),
...(subtitleStrike ? { strike: "sngStrike" } : {}),
color: subtitleColor,
valign: "top",
fit: "shrink",
breakLine: false
breakLine: false,
...(subtitleHyperlink ? { hyperlink: { url: subtitleHyperlink } } : {})
});
if (sourceSlide.notes.trim() && slide.addNotes) {
@@ -183,7 +245,9 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
const result = await pptx.write({ outputType: "blob" });
const blob = result instanceof Blob ? result : new Blob([result], { type: PPTX_MIME_TYPE });
return deck.slides.some((slide) => slide.hidden || slide.transition) ? applySlideXmlOverridesToPptxBlob(blob, deck.slides) : blob;
return deck.slides.some((slide) => slide.hidden || slide.transition || slide.subtitleListStyle)
? applySlideXmlOverridesToPptxBlob(blob, deck.slides)
: blob;
}
export function extractTextBlocks(xmlNode: unknown): string[] {
@@ -192,21 +256,35 @@ export function extractTextBlocks(xmlNode: unknown): string[] {
return blocks.map(normalizeWhitespace).filter(Boolean);
}
function extractSlideTextBlocks(slideXml: unknown): SlideTextBlock[] {
const textBodies = recordsByKey(slideXml, "txBody");
function extractSlideTextBlocks(slideXml: unknown, hyperlinkTargets: Record<string, string> = {}): SlideTextBlock[] {
const textBodies = slideTextBodyRecords(slideXml);
const blocks = textBodies
.map((textBody) => {
.map(({ textBody, hyperlinkSource }): SlideTextBlock | null => {
const text = extractTextBodyText(textBody);
if (!text) {
return null;
}
const runProperties = firstRecordByKey(textBody, "rPr");
const hyperlink = textBodyHyperlink(hyperlinkSource, hyperlinkTargets);
const listStyle = textBodyListStyle(textBody);
const bold = runTextBold(runProperties);
const italics = runTextItalics(runProperties);
const underline = runTextUnderline(runProperties);
const strike = runTextStrike(runProperties);
const align = textBodyAlign(textBody);
const color = runTextColor(runProperties);
const fontSize = runTextFontSize(runProperties);
const fontFamily = runTextFontFamily(runProperties);
return {
text,
...(listStyle ? { listStyle } : {}),
...(hyperlink ? { hyperlink } : {}),
bold,
italics,
underline,
strike,
...(align ? { align } : {}),
...(color ? { color } : {}),
...(fontSize ? { fontSize } : {}),
...(fontFamily ? { fontFamily } : {})
@@ -217,6 +295,28 @@ function extractSlideTextBlocks(slideXml: unknown): SlideTextBlock[] {
return blocks.length > 0 ? blocks : extractTextBlocks(slideXml).map((text) => ({ text }));
}
function slideTextBodyRecords(slideXml: unknown): Array<{ textBody: Record<string, unknown>; hyperlinkSource: Record<string, unknown> }> {
const shapeEntries = recordsByKey(slideXml, "sp")
.map((shape) => {
const textBody = childRecord(shape, "txBody");
return Object.keys(textBody).length > 0 ? { textBody, hyperlinkSource: shape } : null;
})
.filter((entry): entry is { textBody: Record<string, unknown>; hyperlinkSource: Record<string, unknown> } => Boolean(entry));
return shapeEntries.length > 0 ? shapeEntries : recordsByKey(slideXml, "txBody").map((textBody) => ({ textBody, hyperlinkSource: textBody }));
}
function textBodyHyperlink(textBody: Record<string, unknown>, hyperlinkTargets: Record<string, string>) {
const hyperlink = recordsByKey(textBody, "hlinkClick")
.map((record) => {
const id = relationshipId(record);
return id ? normalizedExternalHyperlinkTarget(hyperlinkTargets[id]) : "";
})
.find(Boolean);
return hyperlink ?? "";
}
function mergeOrderedSlideTextBlocks(styledBlocks: SlideTextBlock[], orderedTexts: string[]) {
if (orderedTexts.length === 0) {
return styledBlocks;
@@ -228,6 +328,35 @@ function mergeOrderedSlideTextBlocks(styledBlocks: SlideTextBlock[], orderedText
});
}
function textBodyListStyle(textBody: Record<string, unknown>): SlideListStyle | undefined {
const paragraphStyles = toArray(textBody.p)
.map(paragraphListStyle)
.filter((style): style is SlideListStyle => Boolean(style));
return paragraphStyles.includes("number") ? "number" : paragraphStyles[0];
}
function textBodyAlign(textBody: Record<string, unknown>): SlideTextAlign | undefined {
return toArray(textBody.p).map(paragraphAlign).find((align): align is SlideTextAlign => Boolean(align));
}
function paragraphAlign(paragraph: unknown): SlideTextAlign | undefined {
const paragraphProperties = childRecord(asRecord(paragraph), "pPr");
return slideTextAlignFromXml(stringValue(paragraphProperties.algn ?? paragraphProperties["@_algn"]));
}
function paragraphListStyle(paragraph: unknown): SlideListStyle | undefined {
const paragraphProperties = childRecord(asRecord(paragraph), "pPr");
if (Object.keys(childRecord(paragraphProperties, "buAutoNum")).length > 0) {
return "number";
}
if (Object.keys(childRecord(paragraphProperties, "buChar")).length > 0) {
return "bullet";
}
return undefined;
}
function extractOrderedTextBodyTexts(xmlNode: unknown) {
return orderedElementsByName(xmlNode, "txBody").map(orderedTextBodyText).filter(Boolean);
}
@@ -291,6 +420,19 @@ export function presentationSlidePaths(presentationXml: unknown, presentationRel
return slideIds.map((id) => relationshipTargets[id]).filter((target): target is string => Boolean(target));
}
export function presentationSlideSize(presentationXml: unknown): SlideSizeId | undefined {
const slideSize = childRecord(childRecord(presentationXml, "presentation"), "sldSz");
const cx = numberAttribute(slideSize, "cx");
const cy = numberAttribute(slideSize, "cy");
if (!cx || !cy) {
return undefined;
}
return Object.entries(slideSizeLayouts).find(([, layout]) => Math.abs(cx - layout.cx) <= 5000 && Math.abs(cy - layout.cy) <= 5000)?.[0] as
| SlideSizeId
| undefined;
}
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
const relationships = childRecord(relationshipsXml, "Relationships");
const targets: Record<string, string> = {};
@@ -321,6 +463,9 @@ export function relationshipTargetByType(relationshipsXml: unknown, type: string
export function resolvePptTarget(baseDirectory: string, target: string): string {
const normalizedBase = baseDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedTarget = target.replace(/\\/g, "/");
if (/^[a-z][a-z0-9+.-]*:/i.test(normalizedTarget)) {
return normalizedTarget;
}
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${normalizedBase}/${normalizedTarget}`;
const segments: string[] = [];
@@ -344,6 +489,10 @@ export function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
return value === "standard" ? "standard" : "wide";
}
function normalizeTextWithLineBreaks(value: string): string {
return value
.split("\n")
@@ -358,14 +507,16 @@ async function extractSlideImages(
parser: { parse: (xml: string) => unknown },
slideXml: unknown,
slidePath: string,
slideIndex: number
slideIndex: number,
relationshipsXml?: unknown
): Promise<SlideImage[]> {
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
const imageTargets = relationshipTargetsById(relationshipsXml, imageRelationshipType, slidePath);
const parsedRelationshipsXml = relationshipsXml ?? (await readOptionalParsedXml(zip, parser, relationshipsPath));
const imageTargets = relationshipTargetsById(parsedRelationshipsXml, imageRelationshipType, slidePath);
const hyperlinkTargets = relationshipTargetsById(parsedRelationshipsXml, hyperlinkRelationshipType, slidePath);
const pictures = recordsByKey(slideXml, "pic");
const images = await Promise.all(
pictures.map(async (picture, imageIndex) => slideImageFromPicture(zip, picture, imageTargets, slideIndex, imageIndex))
pictures.map(async (picture, imageIndex) => slideImageFromPicture(zip, picture, imageTargets, hyperlinkTargets, slideIndex, imageIndex))
);
return images.filter((image): image is SlideImage => Boolean(image));
@@ -375,6 +526,7 @@ async function slideImageFromPicture(
zip: ZipArchive,
picture: Record<string, unknown>,
imageTargets: Record<string, string>,
hyperlinkTargets: Record<string, string>,
slideIndex: number,
imageIndex: number
): Promise<SlideImage | null> {
@@ -385,14 +537,27 @@ async function slideImageFromPicture(
return null;
}
const hyperlink = pictureHyperlink(picture, hyperlinkTargets);
return {
id: `pptx-slide-${slideIndex}-image-${imageIndex + 1}`,
src,
alt: pictureAltText(picture) || `Изображение ${imageIndex + 1}`,
...(hyperlink ? { hyperlink } : {}),
...pictureBounds(picture)
};
}
function pictureHyperlink(picture: Record<string, unknown>, hyperlinkTargets: Record<string, string>) {
return (
recordsByKey(picture, "hlinkClick")
.map((record) => {
const id = relationshipId(record);
return id ? normalizedExternalHyperlinkTarget(hyperlinkTargets[id]) : "";
})
.find(Boolean) ?? ""
);
}
function pictureRelationshipId(picture: Record<string, unknown>) {
const blip = firstRecordByKey(picture, "blip");
return stringValue(
@@ -482,7 +647,7 @@ async function applySlideXmlOverridesToPptxBlob(blob: Blob, slides: Slide[]) {
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
await Promise.all(
slides.map(async (slide, index) => {
if (!slide.hidden && !slide.transition) {
if (!slide.hidden && !slide.transition && !slide.subtitleListStyle) {
return;
}
@@ -499,7 +664,8 @@ async function applySlideXmlOverridesToPptxBlob(blob: Blob, slides: Slide[]) {
function pptxSlideXmlWithOverrides(xml: string, slide: Slide) {
const withHiddenState = slide.hidden ? pptxSlideXmlWithHiddenState(xml) : xml;
return slide.transition ? pptxSlideXmlWithTransition(withHiddenState, slide.transition) : withHiddenState;
const withTransition = slide.transition ? pptxSlideXmlWithTransition(withHiddenState, slide.transition) : withHiddenState;
return slide.subtitleListStyle ? pptxSlideXmlWithSubtitleList(withTransition, slide.subtitleListStyle) : withTransition;
}
function pptxSlideXmlWithHiddenState(xml: string) {
@@ -524,17 +690,80 @@ function pptxSlideXmlWithTransition(xml: string, transition: NonNullable<Slide["
return xml.replace(/<p:sld\b[^>]*>/, (match) => `${match}${transitionXml}`);
}
function pptxSlideXmlWithSubtitleList(xml: string, listStyle: SlideListStyle) {
let textBodyIndex = 0;
return xml.replace(/<p:txBody\b[\s\S]*?<\/p:txBody>/g, (textBodyXml) => {
textBodyIndex += 1;
return textBodyIndex === 2 ? pptxTextBodyXmlWithList(textBodyXml, listStyle) : textBodyXml;
});
}
function pptxTextBodyXmlWithList(textBodyXml: string, listStyle: SlideListStyle) {
return textBodyXml.replace(/<a:p>[\s\S]*?<\/a:p>/g, (paragraphXml) => pptxParagraphXmlWithList(paragraphXml, listStyle));
}
function pptxParagraphXmlWithList(paragraphXml: string, listStyle: SlideListStyle) {
const listXml = pptxParagraphListXml(listStyle);
if (/<a:pPr\b[^>]*\/>/.test(paragraphXml)) {
return paragraphXml.replace(/<a:pPr\b([^>]*)\/>/, `<a:pPr$1>${listXml}</a:pPr>`);
}
if (/<a:pPr\b/.test(paragraphXml)) {
return paragraphXml.replace(/<a:pPr\b([^>]*)>([\s\S]*?)<\/a:pPr>/, (_match, attributes: string, content: string) => {
const contentWithoutBullet = content.replace(/<a:bu[A-Za-z]+\b[^>]*\/>/g, "");
return `<a:pPr${attributes}>${listXml}${contentWithoutBullet}</a:pPr>`;
});
}
return paragraphXml.replace("<a:p>", `<a:p><a:pPr>${listXml}</a:pPr>`);
}
function pptxParagraphListXml(listStyle: SlideListStyle) {
return listStyle === "number" ? '<a:buAutoNum type="arabicPeriod"/>' : '<a:buChar char="•"/>';
}
function normalizeHexColor(value?: string) {
const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? "";
return /^[0-9A-F]{6}$/.test(raw) ? raw : "";
}
function normalizedExternalHyperlinkTarget(value?: string) {
const raw = value?.trim();
if (!raw) {
return "";
}
try {
const url = new URL(raw);
return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(url.protocol.toLowerCase()) ? url.href : "";
} catch {
return "";
}
}
function runTextColor(runProperties: Record<string, unknown>) {
const solidFill = childRecord(runProperties, "solidFill");
const srgbColor = childRecord(solidFill, "srgbClr");
return normalizeHexColor(stringValue(srgbColor.val ?? srgbColor["@_val"]));
}
function runTextBold(runProperties: Record<string, unknown>) {
return trueXmlBooleanAttribute(runProperties, "b");
}
function runTextItalics(runProperties: Record<string, unknown>) {
return trueXmlBooleanAttribute(runProperties, "i");
}
function runTextUnderline(runProperties: Record<string, unknown>) {
const value = stringValue(runProperties.u ?? runProperties["@_u"]).trim().toLowerCase();
return Boolean(value && value !== "none");
}
function runTextStrike(runProperties: Record<string, unknown>) {
const value = stringValue(runProperties.strike ?? runProperties["@_strike"]).trim().toLowerCase();
return Boolean(value && value !== "nostrike");
}
function runTextFontSize(runProperties: Record<string, unknown>) {
const rawSize = numberAttribute(runProperties, "sz");
return rawSize ? normalizeFontSize(rawSize / 100) : undefined;
@@ -561,6 +790,26 @@ function normalizeFontFamily(value?: string) {
return normalized && normalized.toLowerCase() !== "arial" ? normalized : "";
}
function normalizedSlideTextAlign(value?: string) {
return value === "left" || value === "center" || value === "right" ? value : undefined;
}
function slideTextAlignFromXml(value: string): SlideTextAlign | undefined {
switch (value.trim().toLowerCase()) {
case "l":
case "left":
return "left";
case "ctr":
case "center":
return "center";
case "r":
case "right":
return "right";
default:
return undefined;
}
}
function numberAttribute(record: Record<string, unknown>, name: string) {
const value = Number.parseFloat(stringValue(record[name] ?? record[`@_${name}`]));
return Number.isFinite(value) ? value : undefined;
@@ -571,6 +820,12 @@ function isFalseXmlBoolean(value: unknown) {
return normalized === "0" || normalized === "false";
}
function trueXmlBooleanAttribute(record: Record<string, unknown>, name: string) {
const value = record[name] ?? record[`@_${name}`];
const normalized = String(value ?? "").trim();
return Boolean(normalized && !isFalseXmlBoolean(value));
}
function emuToInches(value: number) {
return Math.max(0, Math.round((value / emuPerInch) * 1000) / 1000);
}
+217
View File
@@ -10,6 +10,10 @@ describe("wordOffice helpers", () => {
const blocks = htmlToWordBlocks(`
<h1>План проекта</h1>
<h2>Задачи</h2>
<h3>Контроль качества</h3>
<h4>Сценарии</h4>
<h5>Шаги</h5>
<h6>Детали</h6>
<p>Первый абзац.</p>
<ul><li>Подготовить документ</li><li>Проверить таблицу</li></ul>
`);
@@ -17,6 +21,10 @@ describe("wordOffice helpers", () => {
expect(blocks).toEqual([
{ type: "heading", level: 1, text: "План проекта", runs: [{ text: "План проекта" }] },
{ type: "heading", level: 2, text: "Задачи", runs: [{ text: "Задачи" }] },
{ type: "heading", level: 3, text: "Контроль качества", runs: [{ text: "Контроль качества" }] },
{ type: "heading", level: 4, text: "Сценарии", runs: [{ text: "Сценарии" }] },
{ type: "heading", level: 5, text: "Шаги", runs: [{ text: "Шаги" }] },
{ type: "heading", level: 6, text: "Детали", runs: [{ text: "Детали" }] },
{ type: "paragraph", text: "Первый абзац.", runs: [{ text: "Первый абзац." }] },
{
type: "list",
@@ -38,6 +46,20 @@ describe("wordOffice helpers", () => {
]);
});
it("разбирает HTML blockquote как цитату QWord", () => {
const blocks = htmlToWordBlocks('<blockquote id="Quote1">Цитата <strong>важная</strong></blockquote>');
expect(blocks).toEqual([
{
type: "paragraph",
text: "Цитата важная",
runs: [{ text: "Цитата " }, { text: "важная", bold: true }],
bookmark: "Quote1",
quote: true
}
]);
});
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>
@@ -97,6 +119,25 @@ describe("wordOffice helpers", () => {
]);
});
it("preserves HTML subscript and superscript as inline runs for DOCX", () => {
const blocks = htmlToWordBlocks('<p>H<sub>2</sub>O m<sup>2</sup> <span style="vertical-align: super;">x</span></p>');
expect(blocks).toEqual([
{
type: "paragraph",
text: "H2O m2 x",
runs: [
{ text: "H" },
{ text: "2", subscript: true },
{ text: "O m" },
{ text: "2", superscript: true },
{ text: " " },
{ text: "x", superscript: true }
]
}
]);
});
it("сохраняет выравнивание абзацев и заголовков для DOCX", () => {
const blocks = htmlToWordBlocks(`
<h1 align="right">Итоги</h1>
@@ -179,6 +220,26 @@ describe("wordOffice helpers", () => {
expect(documentXml).toMatch(/<w:t[^>]*>Line 1<\/w:t>[\s\S]*<w:br\/>[\s\S]*<w:t[^>]*>Line 2<\/w:t>/);
});
it("записывает h3-h6 как стили Heading3-Heading6 в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Заголовки.docx", "<h3>Раздел</h3><h4>Подраздел</h4><h5>Сценарий</h5><h6>Деталь</h6>");
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/<w:pStyle\b[^>]*w:val="Heading3"[^>]*\/>/);
expect(documentXml).toMatch(/<w:pStyle\b[^>]*w:val="Heading4"[^>]*\/>/);
expect(documentXml).toMatch(/<w:pStyle\b[^>]*w:val="Heading5"[^>]*\/>/);
expect(documentXml).toMatch(/<w:pStyle\b[^>]*w:val="Heading6"[^>]*\/>/);
});
it("записывает blockquote как стиль Quote в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Цитата.docx", "<blockquote>Важная цитата</blockquote>");
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/<w:pStyle\b[^>]*w:val="Quote"[^>]*\/>/);
expect(documentXml).toContain("Важная цитата");
});
it("записывает HTML-разрывы страниц как w:br w:type page в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Разрыв.docx", '<p>Before<span data-qoffice-page-break="true"></span>After</p>');
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
@@ -218,6 +279,17 @@ describe("wordOffice helpers", () => {
expect(documentXml).toContain('<w:jc w:val="right"/>');
});
it("writes QWord page setup to exported DOCX section properties", async () => {
const blob = await exportDocxBlob("Макет.docx", "<p>Landscape letter</p>", {
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" }
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/<w:pgSz\b[^>]*w:w="15840"[^>]*w:h="12240"[^>]*w:orient="landscape"[^>]*\/>/);
expect(documentXml).toMatch(/<w:pgMar\b[^>]*w:top="720"[^>]*w:right="720"[^>]*w:bottom="720"[^>]*w:left="720"[^>]*\/>/);
});
it("записывает внешние гиперссылки в DOCX relationships", async () => {
const blob = await exportDocxBlob(
"Ссылки.docx",
@@ -288,6 +360,82 @@ describe("wordOffice helpers", () => {
]);
});
it("preserves HTML table colspan as DOCX gridSpan", async () => {
const html = '<table><tr><td colspan="2">Merged</td><td>Tail</td></tr></table>';
const blocks = htmlToWordBlocks(html);
const blob = await exportDocxBlob("Table colspan.docx", html);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
expect(blocks).toEqual([
{
type: "table",
rows: [[{ text: "Merged", runs: [{ text: "Merged" }], colSpan: 2 }, "Tail"]]
}
]);
expect(documentXml).toMatch(/<w:gridSpan\b[^>]*w:val="2"[^>]*\/>/);
});
it("сохраняет пустые ячейки и строки таблицы для DOCX", async () => {
const html = `
<table>
<tr><td><br></td><td></td><td>Итог</td></tr>
<tr><td></td><td>&nbsp;</td><td><br></td></tr>
</table>
`;
const blocks = htmlToWordBlocks(html);
const blob = await exportDocxBlob("Пустая таблица.docx", html);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
expect(blocks).toEqual([
{
type: "table",
rows: [
["", "", "Итог"],
["", "", ""]
]
}
]);
expect(documentXml).toContain("<w:tbl>");
expect(documentXml.match(/<w:tr>/g)).toHaveLength(2);
expect(documentXml.match(/<w:tc>/g)).toHaveLength(6);
expect(documentXml).toContain("Итог");
});
it("сохраняет inline-форматирование ячеек таблицы для DOCX", async () => {
const html = `
<table>
<tr>
<td><strong>Итог</strong> <em>готов</em></td>
<td><a href="https://example.com/report">Отчет</a></td>
</tr>
</table>
`;
const blocks = htmlToWordBlocks(html);
const blob = await exportDocxBlob("Формат таблицы.docx", html);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
const relationshipsXml = (await zip.file("word/_rels/document.xml.rels")?.async("string")) ?? "";
expect(blocks).toEqual([
{
type: "table",
rows: [
[
{ text: "Итог готов", runs: [{ text: "Итог", bold: true }, { text: " " }, { text: "готов", italics: true }] },
{ text: "Отчет", runs: [{ text: "Отчет", href: "https://example.com/report" }] }
]
]
}
]);
expect(documentXml).toContain("<w:tbl>");
expect(documentXml).toContain("<w:b/>");
expect(documentXml).toContain("<w:i/>");
expect(documentXml).toContain("<w:hyperlink");
expect(relationshipsXml).toContain('Target="https://example.com/report"');
});
it("сохраняет размер шрифта из HTML inline-стилей для DOCX", () => {
const blocks = htmlToWordBlocks(`
<p>Normal <span style="font-size: 18pt;">Large</span> <span style="font-size: 24px;">Pixel size</span></p>
@@ -363,6 +511,27 @@ describe("wordOffice helpers", () => {
expect(wordInlineRunsToTextRunOptions([{ text: "Deleted", strike: true }])).toEqual([{ text: "Deleted", strike: true }]);
});
it("converts subscript and superscript inline runs into docx parameters", () => {
expect(
wordInlineRunsToTextRunOptions([
{ text: "2", subscript: true },
{ text: "2", superscript: true }
])
).toEqual([
{ text: "2", subScript: true },
{ text: "2", superScript: true }
]);
});
it("writes subscript and superscript to exported DOCX XML", async () => {
const blob = await exportDocxBlob("Indexes.docx", "<p>H<sub>2</sub>O m<sup>2</sup></p>");
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/<w:vertAlign\b[^>]*w:val="subscript"[^>]*\/>/);
expect(documentXml).toMatch(/<w:vertAlign\b[^>]*w:val="superscript"[^>]*\/>/);
});
it("записывает зачеркнутый текст в XML экспортированного DOCX", async () => {
const blob = await exportDocxBlob("Зачеркнутый.docx", "<p><s>Удаленный текст</s></p>");
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
@@ -383,6 +552,30 @@ describe("wordOffice helpers", () => {
]);
});
it("сохраняет HTML-интервалы абзацев и заголовков для DOCX", () => {
const blocks = htmlToWordBlocks(`
<h2 style="margin-top: 6pt; margin-bottom: 10pt;">Title spacing</h2>
<p style="margin-top: 12pt; margin-bottom: 18pt;">Paragraph spacing</p>
`);
expect(blocks).toEqual([
{ type: "heading", level: 2, text: "Title spacing", runs: [{ text: "Title spacing" }], spacing: { before: 120, after: 200 } },
{ type: "paragraph", text: "Paragraph spacing", runs: [{ text: "Paragraph spacing" }], spacing: { before: 240, after: 360 } }
]);
});
it("сохраняет HTML-межстрочный интервал абзацев для DOCX", () => {
const blocks = htmlToWordBlocks(`
<p style="line-height: 1.5;">Auto line height</p>
<p style="line-height: 24pt;">Exact line height</p>
`);
expect(blocks).toEqual([
{ type: "paragraph", text: "Auto line height", runs: [{ text: "Auto line height" }], spacing: { line: 360, lineRule: "auto" } },
{ type: "paragraph", text: "Exact line height", runs: [{ text: "Exact line height" }], spacing: { line: 480, lineRule: "exact" } }
]);
});
it("записывает отступы абзацев в w:ind экспортированного DOCX", async () => {
const blob = await exportDocxBlob(
"Indent.docx",
@@ -395,6 +588,30 @@ describe("wordOffice helpers", () => {
expect(documentXml).toMatch(/<w:ind\b(?=[^>]*w:left="720")(?=[^>]*w:hanging="240")[^>]*\/>/);
});
it("записывает интервалы абзацев в w:spacing экспортированного DOCX", async () => {
const blob = await exportDocxBlob(
"Spacing.docx",
'<h2 style="margin-top: 6pt; margin-bottom: 10pt;">Title spacing</h2><p style="margin-top: 12pt; margin-bottom: 18pt;">Paragraph spacing</p>'
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/<w:spacing\b(?=[^>]*w:before="120")(?=[^>]*w:after="200")[^>]*\/>/);
expect(documentXml).toMatch(/<w:spacing\b(?=[^>]*w:before="240")(?=[^>]*w:after="360")[^>]*\/>/);
});
it("записывает межстрочный интервал абзацев в w:spacing экспортированного DOCX", async () => {
const blob = await exportDocxBlob(
"LineSpacing.docx",
'<p style="line-height: 1.5;">Auto line height</p><p style="line-height: 24pt;">Exact line height</p>'
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const documentXml = await zip.file("word/document.xml")?.async("string");
expect(documentXml).toMatch(/<w:spacing\b(?=[^>]*w:line="360")(?=[^>]*w:lineRule="auto")[^>]*\/>/);
expect(documentXml).toMatch(/<w:spacing\b(?=[^>]*w:line="480")(?=[^>]*w:lineRule="exact")[^>]*\/>/);
});
it("нормализует HTML после импорта DOCX", () => {
expect(normalizeImportedDocxHtml(" <p>Готово</p>\n")).toBe("<p>Готово</p>");
});
+593 -53
View File
File diff suppressed because it is too large Load Diff
+187
View File
@@ -1,10 +1,44 @@
import { describe, expect, it } from "vitest";
import JSZip from "jszip";
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
const tinyPngDataUri = `data:image/png;base64,${tinyPngBase64}`;
describe("wordOffice DOCX import", () => {
it("imports DOCX Heading 3-6 as h3-h6", async () => {
const blob = await exportDocxBlob("Headings.docx", "<h3>Quality gate</h3><h4>Scenario</h4><h5>Step</h5><h6>Detail</h6>");
const file = new File([blob], "Headings.docx", { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" });
const imported = await importDocxFile(file);
expect(imported.html).toContain("<h3>Quality gate</h3>");
expect(imported.html).toContain("<h4>Scenario</h4>");
expect(imported.html).toContain("<h5>Step</h5>");
expect(imported.html).toContain("<h6>Detail</h6>");
expect(htmlToWordBlocks(imported.html)).toEqual([
{ type: "heading", level: 3, text: "Quality gate", runs: [{ text: "Quality gate" }] },
{ type: "heading", level: 4, text: "Scenario", runs: [{ text: "Scenario" }] },
{ type: "heading", level: 5, text: "Step", runs: [{ text: "Step" }] },
{ type: "heading", level: 6, text: "Detail", runs: [{ text: "Detail" }] }
]);
});
it("imports DOCX Quote style as blockquote", async () => {
const blob = await exportDocxBlob("Quote.docx", "<blockquote>Quoted fragment</blockquote>");
const file = new File([blob], "Quote.docx", { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" });
const imported = await importDocxFile(file);
expect(imported.html).toContain("<blockquote>Quoted fragment</blockquote>");
expect(htmlToWordBlocks(imported.html)).toEqual([
{
type: "paragraph",
text: "Quoted fragment",
runs: [{ text: "Quoted fragment" }],
quote: true
}
]);
});
it("imports DOCX text color and highlight from OOXML", async () => {
const blob = await exportDocxBlob(
"Color.docx",
@@ -95,6 +129,59 @@ describe("wordOffice DOCX import", () => {
]);
});
it("does not import DOCX underline when w:u val is none", async () => {
const sourceBlob = await exportDocxBlob("UnderlineNone.docx", '<p><span style="color: #0f5fae;">Not underlined</span></p>');
const zip = await JSZip.loadAsync(await sourceBlob.arrayBuffer());
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
const patchedDocumentXml = documentXml.replace(/(<w:rPr\b[^>]*>[\s\S]*?)(<\/w:rPr>)/, '$1<w:u w:val="none"/>$2');
expect(patchedDocumentXml).toContain('<w:u w:val="none"/>');
zip.file("word/document.xml", patchedDocumentXml);
const patchedBlob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
const imported = await importDocxFile(
new File([patchedBlob], "UnderlineNone.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
})
);
expect(imported.html).not.toContain("<u>Not underlined</u>");
expect(imported.html).not.toContain("<u>");
expect(imported.html).toContain("color: #0F5FAE;");
expect(htmlToWordBlocks(imported.html)).toEqual([
{
type: "paragraph",
text: "Not underlined",
runs: [{ text: "Not underlined", color: "0F5FAE" }]
}
]);
});
it("imports DOCX subscript and superscript from OOXML", async () => {
const blob = await exportDocxBlob("Indexes.docx", "<p>H<sub>2</sub>O m<sup>2</sup></p>");
const file = new File([blob], "Indexes.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
const imported = await importDocxFile(file);
expect(imported.html).toContain("<sub>2</sub>");
expect(imported.html).toContain("<sup>2</sup>");
expect(htmlToWordBlocks(imported.html)).toEqual([
{
type: "paragraph",
text: "H2O m2",
runs: [
{ text: "H" },
{ text: "2", subscript: true },
{ text: "O m" },
{ text: "2", superscript: true }
]
}
]);
});
it("imports DOCX line breaks from OOXML", async () => {
const blob = await exportDocxBlob("LineBreak.docx", "<p>Line 1<br>Line 2</p>");
const file = new File([blob], "LineBreak.docx", {
@@ -225,6 +312,58 @@ describe("wordOffice DOCX import", () => {
]);
});
it("imports DOCX horizontal merged table cells as HTML colspan", async () => {
const blob = await exportDocxBlob(
"Table colspan.docx",
'<table><tr><td colspan="2">Merged</td><td>Tail</td></tr></table>'
);
const file = new File([blob], "Table colspan.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
const imported = await importDocxFile(file);
expect(imported.html).toContain('<td colspan="2"><p>Merged</p></td>');
expect(htmlToWordBlocks(imported.html)).toEqual([
{
type: "table",
rows: [[{ text: "Merged", runs: [{ text: "Merged" }], colSpan: 2 }, "Tail"]]
}
]);
});
it("imports DOCX bullet and numbered lists from OOXML numbering", async () => {
const blob = await exportDocxBlob(
"Lists.docx",
'<ul><li>Bullet <strong>one</strong></li><li><span style="color: #0f5fae;">Bullet two</span></li></ul><ol><li>Step one</li><li>Step two</li></ol>'
);
const file = new File([blob], "Lists.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
const imported = await importDocxFile(file);
expect(imported.html).toContain("<ul>");
expect(imported.html).toContain("<ol>");
expect(imported.html).toContain("color: #0F5FAE;");
expect(htmlToWordBlocks(imported.html)).toEqual([
{
type: "list",
ordered: false,
items: [
{ text: "Bullet one", runs: [{ text: "Bullet " }, { text: "one", bold: true }] },
{ text: "Bullet two", runs: [{ text: "Bullet two", color: "0F5FAE" }] }
]
},
{
type: "list",
ordered: true,
items: [
{ text: "Step one", runs: [{ text: "Step one" }] },
{ text: "Step two", runs: [{ text: "Step two" }] }
]
}
]);
});
it("imports styled DOCX content when the document also contains images", async () => {
const blob = await exportDocxBlob(
"Mixed.docx",
@@ -274,4 +413,52 @@ describe("wordOffice DOCX import", () => {
{ type: "paragraph", text: "Hanging", runs: [{ text: "Hanging" }], indent: { left: 720, hanging: 240 } }
]);
});
it("imports DOCX paragraph spacing from OOXML", async () => {
const blob = await exportDocxBlob(
"Spacing.docx",
'<h2 style="margin-top: 6pt; margin-bottom: 10pt;">Title spacing</h2><p style="margin-top: 12pt; margin-bottom: 18pt;">Paragraph spacing</p>'
);
const file = new File([blob], "Spacing.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
const imported = await importDocxFile(file);
expect(imported.html).toContain("margin-top: 6pt; margin-bottom: 10pt;");
expect(imported.html).toContain("margin-top: 12pt; margin-bottom: 18pt;");
expect(htmlToWordBlocks(imported.html)).toEqual([
{ type: "heading", level: 2, text: "Title spacing", runs: [{ text: "Title spacing" }], spacing: { before: 120, after: 200 } },
{ type: "paragraph", text: "Paragraph spacing", runs: [{ text: "Paragraph spacing" }], spacing: { before: 240, after: 360 } }
]);
});
it("imports DOCX paragraph line spacing from OOXML", async () => {
const blob = await exportDocxBlob(
"LineSpacing.docx",
'<p style="line-height: 1.5;">Auto line height</p><p style="line-height: 24pt;">Exact line height</p>'
);
const file = new File([blob], "LineSpacing.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
const imported = await importDocxFile(file);
expect(imported.html).toContain("line-height: 1.5;");
expect(imported.html).toContain("line-height: 24pt;");
expect(htmlToWordBlocks(imported.html)).toEqual([
{ type: "paragraph", text: "Auto line height", runs: [{ text: "Auto line height" }], spacing: { line: 360, lineRule: "auto" } },
{ type: "paragraph", text: "Exact line height", runs: [{ text: "Exact line height" }], spacing: { line: 480, lineRule: "exact" } }
]);
});
it("imports DOCX page setup from OOXML section properties", async () => {
const blob = await exportDocxBlob("PageSetup.docx", "<p>Layout</p>", {
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" }
});
const file = new File([blob], "PageSetup.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
const imported = await importDocxFile(file);
expect(imported.pageSetup).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" });
});
});
+923 -189
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -1,10 +1,30 @@
export type AppId = "word" | "sheets" | "slides" | "mail";
export type WordPageSizeId = "a4" | "letter";
export type WordPageOrientation = "portrait" | "landscape";
export type WordPageMarginPreset = "normal" | "narrow" | "wide";
export type SheetPageSizeId = "a4" | "letter";
export type SheetPageOrientation = "portrait" | "landscape";
export type SheetPageMarginPreset = "normal" | "narrow" | "wide";
export interface WordPageSetup {
size: WordPageSizeId;
orientation: WordPageOrientation;
marginPreset: WordPageMarginPreset;
}
export interface SheetPageSetup {
size: SheetPageSizeId;
orientation: SheetPageOrientation;
marginPreset: SheetPageMarginPreset;
}
export interface WordDocument {
id: string;
title: string;
updatedAt: string;
html: string;
pageSetup?: WordPageSetup;
}
export interface Workbook {
@@ -29,9 +49,11 @@ export interface Sheet {
showGridLines?: boolean;
zoomScale?: number;
printArea?: string;
pageSetup?: SheetPageSetup;
cells: Record<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
comments?: Record<string, string>;
cellFormats?: Record<string, CellFormat>;
charts?: SheetChart[];
}
@@ -75,38 +97,57 @@ export interface SheetChart {
valueRange: string;
}
export type SlideSizeId = "wide" | "standard";
export interface SlideDeck {
id: string;
title: string;
updatedAt: string;
activeSlideId: string;
slideSize?: SlideSizeId;
slides: Slide[];
}
export interface Slide {
id: string;
title: string;
titleHyperlink?: string;
subtitle: string;
subtitleHyperlink?: string;
subtitleListStyle?: SlideListStyle;
notes: string;
theme: "classic" | "ocean" | "graphite";
hidden?: boolean;
transition?: SlideTransition;
backgroundColor?: string;
titleBold?: boolean;
titleItalics?: boolean;
titleUnderline?: boolean;
titleStrike?: boolean;
titleAlign?: SlideTextAlign;
titleColor?: string;
titleFontSize?: number;
titleFontFamily?: string;
subtitleBold?: boolean;
subtitleItalics?: boolean;
subtitleUnderline?: boolean;
subtitleStrike?: boolean;
subtitleAlign?: SlideTextAlign;
subtitleColor?: string;
subtitleFontSize?: number;
subtitleFontFamily?: string;
images?: SlideImage[];
}
export type SlideListStyle = "bullet" | "number";
export type SlideTextAlign = "left" | "center" | "right";
export type SlideTransition = "fade" | "push" | "wipe";
export interface SlideImage {
id: string;
src: string;
alt?: string;
hyperlink?: string;
x: number;
y: number;
width: number;
@@ -139,6 +180,7 @@ export interface MailMessage {
priority?: string;
subject: string;
body: string;
bodyHtml?: string;
time: string;
read: boolean;
flagged: boolean;
+139
View File
@@ -0,0 +1,139 @@
# Ollama/Gemma Task: QOffice Next Iteration
You are Gemma running through Ollama. Work on the local repository:
```text
G:\Repos\QOffice
```
Project objective: QOffice is a cross-platform Russian-language office suite intended to move toward Microsoft Office-like functionality. It contains separate applications: QWord, QExcell, QPowerPoint, and QOutlook. Microsoft Office file compatibility is a core requirement.
Current verified repository state:
- `git status --short --branch` returned `## main...origin/main`.
- Latest commit before this task: `5487d04 Add QPowerPoint slide size support`.
- QWord already has DOCX import/export, page setup controls, and a Microsoft Word-like ribbon.
- QWord does not yet implement DOCX header/footer support.
## Iteration Goal
Implement QWord support for Microsoft Word / DOCX headers and footers.
The feature must be real DOCX compatibility work. Do not implement this only as internal JSON state. Import and export must read and write actual DOCX header/footer parts or clearly explain why that is impossible after inspecting the local `docx` package API.
## Files To Inspect First
First check for local project instructions:
```powershell
rg --files -g AGENTS.md
```
Then inspect these files before editing:
- `src/types.ts`
- `src/data/sampleData.ts`
- `src/App.tsx`
- `src/io/wordOffice.ts`
- `src/io/wordOffice.test.ts`
- `src/io/wordOfficeImport.test.ts`
- `src/features/qword/QWord.tsx`
- `src/features/qword/QWord.test.tsx`
## Verified Starting Points
- `src/types.ts`: `WordDocument` currently has `id`, `title`, `updatedAt`, `html`, and optional `pageSetup`.
- `src/io/wordOffice.ts`: `importDocxFile` imports DOCX through `mammoth`, `styledDocxHtmlFromArrayBuffer`, and `docxPageSetupFromArrayBuffer`.
- `src/io/wordOffice.ts`: `exportDocxBlob` currently accepts only `pageSetup` in its options object.
- `src/io/wordOffice.ts`: `lastDocxSectionProperties` already finds the final `w:sectPr`.
- `src/features/qword/QWord.tsx`: `openDocx` applies imported `title`, `html`, and `pageSetup`.
- `src/features/qword/QWord.tsx`: `saveDocx` passes only `pageSetup` into `exportDocxBlob`.
- QWord's `layout` ribbon tab already contains page setup controls.
## Implementation Requirements
1. Data model:
Add optional QWord document fields. Preferred names:
```ts
headerHtml?: string;
footerHtml?: string;
```
Existing saved documents without these fields must remain valid.
2. DOCX import:
- Read `word/document.xml`.
- Find the final applicable `w:sectPr`.
- Read `w:headerReference` and `w:footerReference`.
- Resolve their relationship ids through `word/_rels/document.xml.rels`.
- Read the corresponding `word/header*.xml` and `word/footer*.xml` parts.
- For this first iteration, support the `default` reference type.
- Do not break documents that also contain `first` or `even` references; those can be ignored for now.
- Convert header/footer content into HTML using the existing `wordOffice.ts` helper patterns. Preserve at least paragraphs and basic inline text styling.
3. DOCX export:
- Extend `exportDocxBlob` options to accept `headerHtml` and `footerHtml`.
- Use the local `docx` package API to create real DOCX header/footer parts if available.
- Reuse the existing HTML-to-DOCX block pipeline where practical.
- Preserve existing `pageSetup` behavior.
- Generated DOCX must contain real header/footer parts and relationships.
4. QWord UI:
- Add Russian-language controls for header and footer.
- Preferred location: QWord `layout` ribbon tab near page setup.
- Do not add a right sidebar.
- Use clear Russian labels, for example:
- `Верхний колонтитул`
- `Нижний колонтитул`
- Editing these fields must call `onChange` with updated `document.headerHtml` and `document.footerHtml`.
- `openDocx` must apply imported header/footer fields.
- `saveDocx` must pass header/footer fields to `exportDocxBlob`.
5. Tests:
Add focused regression tests:
- Import DOCX with `headerReference` and `footerReference`; assert imported `headerHtml` and `footerHtml`.
- Export DOCX with header/footer; assert header/footer parts and relationships exist.
- Roundtrip export -> import; assert header/footer values survive.
- QWord UI test: changing header/footer controls calls `onChange` with `headerHtml` and `footerHtml`.
## Verification Commands
Run the narrow test set first:
```powershell
npx vitest run src\io\wordOffice.test.ts src\io\wordOfficeImport.test.ts src\features\qword\QWord.test.tsx
```
If the narrow tests pass, run:
```powershell
npm run build
npm test
npm audit --audit-level=high
```
## Constraints
- Do not touch unrelated files.
- Do not revert user changes.
- Do not run destructive git commands.
- Do not add dependencies unless necessary.
- Follow the existing project patterns.
- If you are only an LLM without filesystem access, return a unified diff patch and verification commands.
- If you are running inside an agent with filesystem access, edit the files directly but do not commit or push.
## Final Response Required
Report:
- changed files;
- tests and command results;
- exactly how DOCX import/export was implemented;
- any remaining risks or unverified behavior.