Compare commits
48 Commits
3418725017
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b81341e6 | |||
| 5487d04cca | |||
| 5dfe97013d | |||
| 96aff5b76e | |||
| 217dafb760 | |||
| 3719cb7ec8 | |||
| e5cd922f7d | |||
| c1f8162afd | |||
| 0b993b7709 | |||
| 500d368af4 | |||
| 5def87e738 | |||
| 74db17e348 | |||
| 626576e156 | |||
| c16df37c95 | |||
| 231bb6c076 | |||
| 6251c70ddc | |||
| e857beabd0 | |||
| 81a0de2ba9 | |||
| d86e0470c2 | |||
| be7a0e5c98 | |||
| c72e78ac82 | |||
| ddb04c32a4 | |||
| e5537b726e | |||
| a9f9aa6cca | |||
| 26bf4b5873 | |||
| 06c6a99e6d | |||
| 24fbae8a01 | |||
| c9b09565b3 | |||
| f15467b8a1 | |||
| f5ab0448c7 | |||
| b117e11431 | |||
| c4532902c8 | |||
| b8c347142a | |||
| 7ace47dd2a | |||
| 07f7335c74 | |||
| ffa377d931 | |||
| 68e0678ca6 | |||
| c75efaa766 | |||
| 1c03ec168c | |||
| c4fd683ef3 | |||
| e5727b4484 | |||
| 0a675236b3 | |||
| 2f1c70852d | |||
| 22d2903f10 | |||
| 57cce9ea96 | |||
| 464484bb19 | |||
| c8c1be7a30 | |||
| 91e8a6e9fd |
@@ -16,12 +16,25 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Прямые web-пути отдельных приложений: `/qword`, `/qexcell`, `/qpowerpoint`, `/qoutlook`.
|
||||||
|
|
||||||
## Desktop-оболочка
|
## Desktop-оболочка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run electron:dev
|
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
|
```bash
|
||||||
@@ -31,10 +44,11 @@ npm run build
|
|||||||
|
|
||||||
## Поддержка форматов
|
## Поддержка форматов
|
||||||
|
|
||||||
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из HTML `margin-left`/`text-indent`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта.
|
- 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, внешние и внутренние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений.
|
- 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` внутри текстовых блоков, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, переходы слайдов, заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка.
|
- 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 открывает `.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-режиме остаются браузерный выбор файла и скачивание результата.
|
В desktop-режиме Electron используются системные диалоги открытия и сохранения файлов. В web-режиме остаются браузерный выбор файла и скачивание результата.
|
||||||
Экспорт PDF в desktop-режиме выполняется через Electron `webContents.printToPDF`; в web-режиме команда открывает браузерную печать, где пользователь может выбрать сохранение в PDF.
|
Экспорт PDF в desktop-режиме выполняется через Electron `webContents.printToPDF`; в web-режиме команда открывает браузерную печать, где пользователь может выбрать сохранение в PDF.
|
||||||
@@ -46,7 +60,9 @@ Desktop-меню поддерживает системные команды дл
|
|||||||
- `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF.
|
- `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF.
|
||||||
- `Ctrl/Cmd+P`: печать текущей рабочей области.
|
- `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
@@ -3,6 +3,49 @@ const fs = require("node:fs/promises");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|
||||||
const isDev = !app.isPackaged;
|
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 = {
|
const fileKinds = {
|
||||||
docx: {
|
docx: {
|
||||||
title: "Открыть документ Word",
|
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 isMac = process.platform === "darwin";
|
||||||
const template = [
|
const template = [
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: "QOffice",
|
label: applicationMode.title,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about", label: "О QOffice" },
|
{ role: "about", label: `О ${applicationMode.title}` },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide", label: "Скрыть QOffice" },
|
{ role: "hide", label: `Скрыть ${applicationMode.title}` },
|
||||||
{ role: "hideOthers", label: "Скрыть остальные" },
|
{ role: "hideOthers", label: "Скрыть остальные" },
|
||||||
{ role: "unhide", label: "Показать все" },
|
{ role: "unhide", label: "Показать все" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "quit", label: "Выйти из QOffice" }
|
{ role: "quit", label: `Выйти из ${applicationMode.title}` }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -95,7 +145,7 @@ function createApplicationMenu() {
|
|||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Печать...", accelerator: "CmdOrCtrl+P", click: () => sendFocusedCommand("print") },
|
{ label: "Печать...", accelerator: "CmdOrCtrl+P", click: () => sendFocusedCommand("print") },
|
||||||
{ type: "separator" },
|
{ 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({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1440,
|
width: 1440,
|
||||||
height: 920,
|
height: 920,
|
||||||
minWidth: 1080,
|
minWidth: 1080,
|
||||||
minHeight: 720,
|
minHeight: 720,
|
||||||
title: "QOffice",
|
title: applicationMode.windowTitle,
|
||||||
backgroundColor: "#f6f8fb",
|
backgroundColor: "#f6f8fb",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.cjs"),
|
preload: path.join(__dirname, "preload.cjs"),
|
||||||
@@ -234,19 +284,22 @@ function createWindow() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
mainWindow.loadURL("http://127.0.0.1:5173");
|
mainWindow.loadURL(new URL(applicationMode.route, "http://127.0.0.1:5173").toString());
|
||||||
} else {
|
} 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(() => {
|
app.whenReady().then(() => {
|
||||||
createApplicationMenu();
|
createApplicationMenu(launchApplicationMode);
|
||||||
createWindow();
|
createWindow(launchApplicationMode);
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow(launchApplicationMode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
"preview": "vite preview --host 127.0.0.1",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"electron:dev": "concurrently -k \"npm:dev\" \"wait-on http://127.0.0.1:5173 && electron .\"",
|
"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"
|
"dist:desktop": "npm run build && electron-builder"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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
@@ -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 { AppShell } from "./components/AppShell";
|
||||||
import { QOutlook } from "./features/qoutlook/QOutlook";
|
import { QOutlook } from "./features/qoutlook/QOutlook";
|
||||||
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
|
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
|
||||||
import { QWord } from "./features/qword/QWord";
|
import { QWord, type QWordStatus, type WordViewMode } from "./features/qword/QWord";
|
||||||
import { QExcell } from "./features/qexcell/QExcell";
|
import { QExcell } from "./features/qexcell/QExcell";
|
||||||
import { initialState } from "./data/sampleData";
|
import { initialState } from "./data/sampleData";
|
||||||
import { usePersistentState } from "./hooks/usePersistentState";
|
import { usePersistentState } from "./hooks/usePersistentState";
|
||||||
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 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 {
|
function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
||||||
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
||||||
@@ -19,11 +53,15 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
|||||||
state.word.id === initialState.word.id &&
|
state.word.id === initialState.word.id &&
|
||||||
sampleWordHtml.includes("Ключевой результат") &&
|
sampleWordHtml.includes("Ключевой результат") &&
|
||||||
!state.word.html.includes("Ключевой результат");
|
!state.word.html.includes("Ключевой результат");
|
||||||
|
const needsDeckSlideSize = !state.deck.slideSize;
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (needsSampleWordColor) {
|
if (needsSampleWordColor) {
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (needsDeckSlideSize) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
const messages = state.mail.messages.map((message) => {
|
const messages = state.mail.messages.map((message) => {
|
||||||
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
|
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
|
||||||
return message;
|
return message;
|
||||||
@@ -83,7 +121,7 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
|||||||
...state,
|
...state,
|
||||||
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
|
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
|
||||||
workbook: { ...state.workbook, sheets },
|
workbook: { ...state.workbook, sheets },
|
||||||
deck: { ...state.deck, slides },
|
deck: { ...state.deck, slideSize: state.deck.slideSize ?? initialState.deck.slideSize ?? "wide", slides },
|
||||||
mail: { ...state.mail, messages }
|
mail: { ...state.mail, messages }
|
||||||
}
|
}
|
||||||
: state;
|
: state;
|
||||||
@@ -91,26 +129,107 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
|
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
|
||||||
|
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
|
||||||
|
const [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 status = useMemo(() => {
|
||||||
const updatedAt =
|
const updatedAt =
|
||||||
state.activeApp === "word"
|
activeApp === "word"
|
||||||
? state.word.updatedAt
|
? state.word.updatedAt
|
||||||
: state.activeApp === "sheets"
|
: activeApp === "sheets"
|
||||||
? state.workbook.updatedAt
|
? state.workbook.updatedAt
|
||||||
: state.activeApp === "slides"
|
: activeApp === "slides"
|
||||||
? state.deck.updatedAt
|
? state.deck.updatedAt
|
||||||
: undefined;
|
: 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 {
|
return {
|
||||||
savedLabel: "Сохранено локально",
|
savedLabel: "Сохранено локально",
|
||||||
updatedAt
|
updatedAt
|
||||||
};
|
};
|
||||||
}, [state]);
|
}, [
|
||||||
|
activeApp,
|
||||||
function setActiveApp(activeApp: AppId) {
|
adjustWordZoom,
|
||||||
setState((current) => ({ ...current, activeApp }));
|
state.deck.updatedAt,
|
||||||
}
|
state.word.updatedAt,
|
||||||
|
state.workbook.updatedAt,
|
||||||
|
wordStatus.currentPage,
|
||||||
|
wordStatus.pageCount,
|
||||||
|
wordStatus.wordCount,
|
||||||
|
wordViewMode,
|
||||||
|
wordZoom
|
||||||
|
]);
|
||||||
|
|
||||||
function updateWord(word: WordDocument) {
|
function updateWord(word: WordDocument) {
|
||||||
setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } }));
|
setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } }));
|
||||||
@@ -140,18 +259,25 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeSurface =
|
const activeSurface =
|
||||||
state.activeApp === "word" ? (
|
activeApp === "word" ? (
|
||||||
<QWord document={state.word} onChange={updateWord} />
|
<QWord
|
||||||
) : state.activeApp === "sheets" ? (
|
document={state.word}
|
||||||
|
zoom={wordZoom}
|
||||||
|
viewMode={wordViewMode}
|
||||||
|
onChange={updateWord}
|
||||||
|
onStatusChange={updateWordStatus}
|
||||||
|
onViewModeChange={setWordViewMode}
|
||||||
|
/>
|
||||||
|
) : activeApp === "sheets" ? (
|
||||||
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
|
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
|
||||||
) : state.activeApp === "slides" ? (
|
) : activeApp === "slides" ? (
|
||||||
<QPowerPoint deck={state.deck} onChange={updateDeck} />
|
<QPowerPoint deck={state.deck} onChange={updateDeck} />
|
||||||
) : (
|
) : (
|
||||||
<QOutlook mail={state.mail} onChange={updateMail} onFolderChange={setMailFolder} />
|
<QOutlook mail={state.mail} onChange={updateMail} onFolderChange={setMailFolder} />
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell activeApp={state.activeApp} onAppChange={setActiveApp} status={status}>
|
<AppShell activeApp={activeApp} windowTitle={windowTitle} status={status}>
|
||||||
{activeSurface}
|
{activeSurface}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+48
-109
@@ -1,109 +1,70 @@
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
ChevronDown,
|
||||||
FileText,
|
Redo2,
|
||||||
HelpCircle,
|
|
||||||
Mail,
|
|
||||||
MonitorCog,
|
|
||||||
PanelRight,
|
|
||||||
Presentation,
|
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
|
||||||
Share2,
|
Share2,
|
||||||
Table2
|
Undo2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
|
import type { QOfficeCommand } from "../hooks/useQOfficeCommand";
|
||||||
import type { AppId } from "../types";
|
import type { AppId } from "../types";
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
activeApp: AppId;
|
activeApp: AppId;
|
||||||
onAppChange: (appId: AppId) => void;
|
windowTitle?: string;
|
||||||
status: {
|
status: {
|
||||||
savedLabel: string;
|
savedLabel: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
content?: ReactNode;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apps: Array<{ id: AppId; label: string; icon: typeof FileText; accent: string }> = [
|
const appTitles: Record<AppId, string> = {
|
||||||
{ id: "word", label: "QWord", icon: FileText, accent: "word" },
|
word: "Документ1 - QWord",
|
||||||
{ id: "sheets", label: "QExcell", icon: Table2, accent: "sheets" },
|
sheets: "Книга1 - QExcell",
|
||||||
{ id: "slides", label: "QPowerPoint", icon: Presentation, accent: "slides" },
|
slides: "Презентация1 - QPowerPoint",
|
||||||
{ id: "mail", label: "QOutlook", icon: Mail, accent: "mail" }
|
mail: "Почта - QOutlook"
|
||||||
];
|
};
|
||||||
|
|
||||||
const ribbonGroups = [
|
function dispatchQOfficeCommand(command: QOfficeCommand) {
|
||||||
{
|
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command } }));
|
||||||
title: "Буфер",
|
}
|
||||||
commands: [
|
|
||||||
{ label: "Вставить", icon: FileText },
|
|
||||||
{ label: "Копировать", icon: Save }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Вставка",
|
|
||||||
commands: [
|
|
||||||
{ label: "Таблица", icon: Table2 },
|
|
||||||
{ label: "Диаграмма", icon: BarChart3 },
|
|
||||||
{ label: "Панель", icon: PanelRight }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Совместная работа",
|
|
||||||
commands: [
|
|
||||||
{ label: "Поделиться", icon: Share2 },
|
|
||||||
{ label: "Параметры", icon: MonitorCog }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AppShell({ activeApp, onAppChange, status, children }: AppShellProps) {
|
function keepActiveEditorFocus(event: MouseEvent<HTMLButtonElement>) {
|
||||||
const active = apps.find((app) => app.id === activeApp) ?? apps[0];
|
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 (
|
return (
|
||||||
<div className="qoffice-shell">
|
<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">
|
<main className="workspace">
|
||||||
<header className="titlebar">
|
<header className="titlebar">
|
||||||
<div>
|
<div className="quick-access" aria-label="Панель быстрого доступа">
|
||||||
<strong>{active.label}</strong>
|
<button type="button" title="Сохранить" aria-label="Сохранить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("save")}>
|
||||||
<span>{status.savedLabel}</span>
|
<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>
|
</div>
|
||||||
|
<strong className="window-title">{displayWindowTitle}</strong>
|
||||||
<div className="titlebar-actions">
|
<div className="titlebar-actions">
|
||||||
<label className="command-search">
|
<label className="command-search">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
<input placeholder="Найти команду" aria-label="Найти команду" />
|
<input placeholder="Поиск" aria-label="Поиск" />
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-command" type="button">
|
<button className="primary-command" type="button">
|
||||||
<Share2 size={16} />
|
<Share2 size={16} />
|
||||||
@@ -112,40 +73,18 @@ export function AppShell({ activeApp, onAppChange, status, children }: AppShellP
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
<div className="surface-host">{children}</div>
|
||||||
|
|
||||||
<footer className="statusbar">
|
<footer className={statusClassName}>
|
||||||
|
<div className="statusbar-main">
|
||||||
|
{status.content ?? (
|
||||||
|
<>
|
||||||
<span>Готово</span>
|
<span>Готово</span>
|
||||||
<span>{status.updatedAt ? `Обновлено: ${new Date(status.updatedAt).toLocaleString("ru-RU")}` : "Локальная почта"}</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>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export const initialState: QOfficeState = {
|
|||||||
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
|
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
`
|
`,
|
||||||
|
pageSetup: { size: "a4", orientation: "portrait", marginPreset: "normal" }
|
||||||
},
|
},
|
||||||
workbook: {
|
workbook: {
|
||||||
id: "qexcell-quarter-plan",
|
id: "qexcell-quarter-plan",
|
||||||
@@ -68,6 +69,9 @@ export const initialState: QOfficeState = {
|
|||||||
A8: "Объединённая строка примечания"
|
A8: "Объединённая строка примечания"
|
||||||
},
|
},
|
||||||
mergedCells: [{ start: "A8", end: "E8" }],
|
mergedCells: [{ start: "A8", end: "E8" }],
|
||||||
|
comments: {
|
||||||
|
E2: "Проверить сумму перед отправкой квартального отчета."
|
||||||
|
},
|
||||||
cellFormats: {
|
cellFormats: {
|
||||||
A1: { bold: true, fontSize: 12, textColor: "FFFFFF", fillColor: "0F5FAE", horizontalAlign: "center" },
|
A1: { bold: true, fontSize: 12, textColor: "FFFFFF", fillColor: "0F5FAE", horizontalAlign: "center" },
|
||||||
B1: { 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",
|
title: "Дорожная карта.qpptx",
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
activeSlideId: "slide-1",
|
activeSlideId: "slide-1",
|
||||||
|
slideSize: "wide",
|
||||||
slides: [
|
slides: [
|
||||||
{
|
{
|
||||||
id: "slide-1",
|
id: "slide-1",
|
||||||
title: "Дорожная карта QOffice",
|
title: "Дорожная карта QOffice",
|
||||||
|
titleHyperlink: "https://git.kusoft.xyz/sevenhill/QOffice",
|
||||||
subtitle: "Единая рабочая среда для документов, данных, презентаций и коммуникации.",
|
subtitle: "Единая рабочая среда для документов, данных, презентаций и коммуникации.",
|
||||||
notes: "Начать с цели продукта и текущего этапа.",
|
notes: "Начать с цели продукта и текущего этапа.",
|
||||||
theme: "classic",
|
theme: "classic",
|
||||||
@@ -142,6 +148,8 @@ export const initialState: QOfficeState = {
|
|||||||
cc: "qa@qoffice.local",
|
cc: "qa@qoffice.local",
|
||||||
subject: "Контрольный список запуска",
|
subject: "Контрольный список запуска",
|
||||||
body: "Пожалуйста, проверьте этапы редакторов, цели упаковки и ответственных за качество до пятницы.",
|
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",
|
time: "09:24",
|
||||||
read: false,
|
read: false,
|
||||||
flagged: true,
|
flagged: true,
|
||||||
|
|||||||
+316
-130
@@ -11,6 +11,8 @@ import {
|
|||||||
Bold,
|
Bold,
|
||||||
Download,
|
Download,
|
||||||
Eraser,
|
Eraser,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
FileText,
|
FileText,
|
||||||
FileUp,
|
FileUp,
|
||||||
FunctionSquare,
|
FunctionSquare,
|
||||||
@@ -23,14 +25,15 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Sigma,
|
Sigma,
|
||||||
SquareDashed,
|
SquareDashed,
|
||||||
|
StickyNote,
|
||||||
Strikethrough,
|
Strikethrough,
|
||||||
Trash2,
|
Trash2,
|
||||||
Type,
|
Type,
|
||||||
Underline,
|
Underline,
|
||||||
WrapText
|
WrapText
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types";
|
import type { CellFormat, SheetChart, SheetChartType, SheetPageMarginPreset, SheetPageOrientation, SheetPageSetup, SheetPageSizeId, Workbook } from "../../types";
|
||||||
import { cellId, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
|
import { cellId, columnLabel, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, parseCellId, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
|
||||||
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
|
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
|
||||||
import { replaceFileExtension } from "../../io/fileHelpers";
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
@@ -46,7 +49,9 @@ interface QExcellProps {
|
|||||||
const extraEditableRows = 1;
|
const extraEditableRows = 1;
|
||||||
const extraEditableColumns = 1;
|
const extraEditableColumns = 1;
|
||||||
const emptyHyperlinks: Record<string, string> = {};
|
const emptyHyperlinks: Record<string, string> = {};
|
||||||
|
const emptyComments: Record<string, string> = {};
|
||||||
const emptyCellFormats: Record<string, CellFormat> = {};
|
const emptyCellFormats: Record<string, CellFormat> = {};
|
||||||
|
const emptyHiddenItems: Record<string, boolean> = {};
|
||||||
const emptyCharts: SheetChart[] = [];
|
const emptyCharts: SheetChart[] = [];
|
||||||
|
|
||||||
const fillSwatches = [
|
const fillSwatches = [
|
||||||
@@ -90,6 +95,20 @@ const chartTypeOptions: Array<{ value: SheetChartType; label: string }> = [
|
|||||||
{ value: "line", label: "Линейная" },
|
{ value: "line", label: "Линейная" },
|
||||||
{ value: "pie", label: "Круговая" }
|
{ value: "pie", label: "Круговая" }
|
||||||
];
|
];
|
||||||
|
const sheetPageSizeOptions: Array<{ id: SheetPageSizeId; label: string }> = [
|
||||||
|
{ id: "a4", label: "A4" },
|
||||||
|
{ id: "letter", label: "Letter" }
|
||||||
|
];
|
||||||
|
const sheetPageOrientationOptions: Array<{ id: SheetPageOrientation; label: string }> = [
|
||||||
|
{ id: "portrait", label: "Книжная" },
|
||||||
|
{ id: "landscape", label: "Альбомная" }
|
||||||
|
];
|
||||||
|
const sheetPageMarginOptions: Array<{ id: SheetPageMarginPreset; label: string }> = [
|
||||||
|
{ id: "normal", label: "Обычные" },
|
||||||
|
{ id: "narrow", label: "Узкие" },
|
||||||
|
{ id: "wide", label: "Широкие" }
|
||||||
|
];
|
||||||
|
const defaultSheetPageSetup: SheetPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
|
||||||
|
|
||||||
const horizontalAlignOptions = [
|
const horizontalAlignOptions = [
|
||||||
{ value: "left", label: "По левому краю", icon: AlignLeft },
|
{ value: "left", label: "По левому краю", icon: AlignLeft },
|
||||||
@@ -154,6 +173,18 @@ function normalizedCellFormat(format: CellFormat): CellFormat | undefined {
|
|||||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedSheetPageSetup(setup?: SheetPageSetup): SheetPageSetup {
|
||||||
|
const candidateSize = setup?.size;
|
||||||
|
const candidateOrientation = setup?.orientation;
|
||||||
|
const candidateMarginPreset = setup?.marginPreset;
|
||||||
|
const size = candidateSize && sheetPageSizeOptions.some((option) => option.id === candidateSize) ? candidateSize : defaultSheetPageSetup.size;
|
||||||
|
const orientation =
|
||||||
|
candidateOrientation && sheetPageOrientationOptions.some((option) => option.id === candidateOrientation) ? candidateOrientation : defaultSheetPageSetup.orientation;
|
||||||
|
const marginPreset =
|
||||||
|
candidateMarginPreset && sheetPageMarginOptions.some((option) => option.id === candidateMarginPreset) ? candidateMarginPreset : defaultSheetPageSetup.marginPreset;
|
||||||
|
return { size, orientation, marginPreset };
|
||||||
|
}
|
||||||
|
|
||||||
function cellInputStyle(format?: CellFormat): CSSProperties {
|
function cellInputStyle(format?: CellFormat): CSSProperties {
|
||||||
const textDecoration = [format?.underline ? "underline" : "", format?.strike ? "line-through" : ""].filter(Boolean).join(" ");
|
const textDecoration = [format?.underline ? "underline" : "", format?.strike ? "line-through" : ""].filter(Boolean).join(" ");
|
||||||
return {
|
return {
|
||||||
@@ -224,6 +255,23 @@ function normalizedPrintAreaInput(value: string) {
|
|||||||
return [...new Set(ranges)].join(",");
|
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) {
|
function chartTypeIcon(type: SheetChartType) {
|
||||||
if (type === "line") {
|
if (type === "line") {
|
||||||
return LineChart;
|
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 activeSheet = workbook.sheets.find((sheet) => sheet.id === workbook.activeSheet) ?? workbook.sheets[0] ?? { id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [] };
|
||||||
const activeMergedCells = activeSheet.mergedCells ?? [];
|
const activeMergedCells = activeSheet.mergedCells ?? [];
|
||||||
const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks;
|
const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks;
|
||||||
|
const activeComments = activeSheet.comments ?? emptyComments;
|
||||||
const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats;
|
const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats;
|
||||||
|
const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems;
|
||||||
|
const activeHiddenRows = activeSheet.hiddenRows ?? emptyHiddenItems;
|
||||||
const activeCharts = activeSheet.charts ?? emptyCharts;
|
const activeCharts = activeSheet.charts ?? emptyCharts;
|
||||||
|
const activeSheetPageSetup = normalizedSheetPageSetup(activeSheet.pageSetup);
|
||||||
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
|
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
|
||||||
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
|
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
|
||||||
|
const selectedComment = activeComments[selectedCell] ?? "";
|
||||||
const selectedCellFormat = activeCellFormats[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(
|
const boundsCells = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...Object.keys(activeHyperlinks), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>(
|
[...Object.keys(activeHyperlinks), ...Object.keys(activeComments), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>(
|
||||||
(cells, address) => ({ ...cells, [address]: cells[address] || activeHyperlinks[address] || "format" }),
|
(cells, address) => ({ ...cells, [address]: cells[address] || activeHyperlinks[address] || activeComments[address] || "format" }),
|
||||||
{ ...activeSheet.cells }
|
{ ...activeSheet.cells }
|
||||||
),
|
),
|
||||||
[activeCellFormats, activeHyperlinks, activeSheet.cells]
|
[activeCellFormats, activeComments, activeHyperlinks, activeSheet.cells]
|
||||||
);
|
);
|
||||||
const sheetBounds = useMemo(() => usedSheetBounds(boundsCells, activeMergedCells), [activeMergedCells, boundsCells]);
|
const sheetBounds = useMemo(() => usedSheetBounds(boundsCells, activeMergedCells), [activeMergedCells, boundsCells]);
|
||||||
const rows = useMemo(() => Array.from({ length: sheetBounds.rowCount + extraEditableRows }, (_, index) => index), [sheetBounds.rowCount]);
|
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) {
|
function updateCellFormat(id: string, formatPatch: CellFormat) {
|
||||||
const normalizedId = id.toUpperCase();
|
const normalizedId = id.toUpperCase();
|
||||||
const nextFormats = { ...activeCellFormats };
|
const nextFormats = { ...activeCellFormats };
|
||||||
@@ -342,14 +417,16 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
function clearSelectedCell() {
|
function clearSelectedCell() {
|
||||||
const nextCells = { ...activeSheet.cells };
|
const nextCells = { ...activeSheet.cells };
|
||||||
const nextHyperlinks = { ...activeHyperlinks };
|
const nextHyperlinks = { ...activeHyperlinks };
|
||||||
|
const nextComments = { ...activeComments };
|
||||||
const nextFormats = { ...activeCellFormats };
|
const nextFormats = { ...activeCellFormats };
|
||||||
delete nextCells[selectedCell];
|
delete nextCells[selectedCell];
|
||||||
delete nextHyperlinks[selectedCell];
|
delete nextHyperlinks[selectedCell];
|
||||||
|
delete nextComments[selectedCell];
|
||||||
delete nextFormats[selectedCell];
|
delete nextFormats[selectedCell];
|
||||||
onChange({
|
onChange({
|
||||||
...workbook,
|
...workbook,
|
||||||
sheets: workbook.sheets.map((sheet) =>
|
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) {
|
function selectChart(chart: SheetChart) {
|
||||||
setSelectedChartId(chart.id);
|
setSelectedChartId(chart.id);
|
||||||
setChartTitle(chart.title);
|
setChartTitle(chart.title);
|
||||||
@@ -534,6 +654,26 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
<Link size={16} />
|
<Link size={16} />
|
||||||
Ссылка
|
Ссылка
|
||||||
</button>
|
</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="Формат ячейки">
|
<div className="cell-format-tools" aria-label="Формат ячейки">
|
||||||
<button
|
<button
|
||||||
className={`icon-button format-toggle ${selectedCellFormat.bold ? "is-active" : ""}`.trim()}
|
className={`icon-button format-toggle ${selectedCellFormat.bold ? "is-active" : ""}`.trim()}
|
||||||
@@ -720,21 +860,178 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="grid-wrap">
|
||||||
<table className="sheet-grid">
|
<table className="sheet-grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th aria-label="Номера строк" />
|
<th aria-label="Номера строк" />
|
||||||
{visibleColumnLabels.map((column) => (
|
{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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => {
|
||||||
<tr key={row}>
|
const rowLabel = String(row + 1);
|
||||||
<th>{row + 1}</th>
|
const isHiddenRow = Boolean(activeHiddenRows[rowLabel]);
|
||||||
{visibleColumnLabels.map((_, column) => {
|
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 id = cellId(column, row);
|
||||||
const merge = mergedCellInfo(id, activeMergedCells);
|
const merge = mergedCellInfo(id, activeMergedCells);
|
||||||
if (merge && !merge.isOrigin) {
|
if (merge && !merge.isOrigin) {
|
||||||
@@ -744,12 +1041,14 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
const raw = activeSheet.cells[id] ?? "";
|
const raw = activeSheet.cells[id] ?? "";
|
||||||
const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw;
|
const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw;
|
||||||
const hyperlink = activeHyperlinks[id] ?? "";
|
const hyperlink = activeHyperlinks[id] ?? "";
|
||||||
|
const comment = activeComments[id] ?? "";
|
||||||
const cellFormat = activeCellFormats[id];
|
const cellFormat = activeCellFormats[id];
|
||||||
const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat);
|
const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat);
|
||||||
|
const isHiddenColumn = Boolean(activeHiddenColumns[columnName]);
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={id}
|
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)}
|
style={cellBoxStyle(cellFormat)}
|
||||||
colSpan={merge?.columnSpan}
|
colSpan={merge?.columnSpan}
|
||||||
rowSpan={merge?.rowSpan}
|
rowSpan={merge?.rowSpan}
|
||||||
@@ -758,7 +1057,7 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={selectedCell === id ? raw : display}
|
value={selectedCell === id ? raw : display}
|
||||||
aria-label={`Ячейка ${id}`}
|
aria-label={`Ячейка ${id}`}
|
||||||
title={hyperlink || undefined}
|
title={[hyperlink, comment].filter(Boolean).join("\n") || undefined}
|
||||||
style={cellInputStyle(cellFormat)}
|
style={cellInputStyle(cellFormat)}
|
||||||
onFocus={() => setSelectedCell(id)}
|
onFocus={() => setSelectedCell(id)}
|
||||||
onChange={(event) => updateCell(id, event.target.value)}
|
onChange={(event) => updateCell(id, event.target.value)}
|
||||||
@@ -767,7 +1066,7 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
<input
|
<input
|
||||||
value={selectedCell === id ? raw : display}
|
value={selectedCell === id ? raw : display}
|
||||||
aria-label={`Ячейка ${id}`}
|
aria-label={`Ячейка ${id}`}
|
||||||
title={hyperlink || undefined}
|
title={[hyperlink, comment].filter(Boolean).join("\n") || undefined}
|
||||||
style={cellInputStyle(cellFormat)}
|
style={cellInputStyle(cellFormat)}
|
||||||
onFocus={() => setSelectedCell(id)}
|
onFocus={() => setSelectedCell(id)}
|
||||||
onChange={(event) => updateCell(id, event.target.value)}
|
onChange={(event) => updateCell(id, event.target.value)}
|
||||||
@@ -777,7 +1076,8 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -798,120 +1098,6 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,93 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[unitIndex]}`;
|
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) {
|
export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const draftAttachmentInputRef = useRef<HTMLInputElement>(null);
|
const draftAttachmentInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -73,6 +160,7 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
|
|||||||
message.priority ?? "",
|
message.priority ?? "",
|
||||||
message.subject,
|
message.subject,
|
||||||
message.body,
|
message.body,
|
||||||
|
message.bodyHtml ?? "",
|
||||||
...(message.attachments ?? []).map((attachment) => attachment.fileName)
|
...(message.attachments ?? []).map((attachment) => attachment.fileName)
|
||||||
].some((field) => field.toLowerCase().includes(normalizedQuery));
|
].some((field) => field.toLowerCase().includes(normalizedQuery));
|
||||||
return inFolder && matchesQuery;
|
return inFolder && matchesQuery;
|
||||||
@@ -341,7 +429,15 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
|
|||||||
{activeMessage.priority ? `Priority: ${activeMessage.priority}` : ""}
|
{activeMessage.priority ? `Priority: ${activeMessage.priority}` : ""}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeMessage.bodyHtml ? (
|
||||||
|
<div
|
||||||
|
className="message-html-body"
|
||||||
|
aria-label="HTML содержимое письма"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeMailHtml(activeMessage.bodyHtml) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<p>{activeMessage.body}</p>
|
<p>{activeMessage.body}</p>
|
||||||
|
)}
|
||||||
{activeAttachments.length > 0 ? (
|
{activeAttachments.length > 0 ? (
|
||||||
<section className="attachments-panel" aria-label="Вложения письма">
|
<section className="attachments-panel" aria-label="Вложения письма">
|
||||||
<h3>Вложения</h3>
|
<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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Palette, Play, Plus, Save, Trash2 } from "lucide-react";
|
import { AlignCenter, AlignLeft, AlignRight, Bold, Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Italic, Link, List, ListOrdered, ListX, Palette, Plus, Save, Strikethrough, Trash2, Underline } from "lucide-react";
|
||||||
import type { Slide, SlideDeck, SlideImage, SlideTransition } from "../../types";
|
import type { Slide, SlideDeck, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign, SlideTransition } from "../../types";
|
||||||
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
|
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
|
||||||
import { replaceFileExtension } from "../../io/fileHelpers";
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
@@ -14,6 +14,17 @@ interface QPowerPointProps {
|
|||||||
onChange: (deck: SlideDeck) => void;
|
onChange: (deck: SlideDeck) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SlideTextFormatField =
|
||||||
|
| "titleBold"
|
||||||
|
| "titleItalics"
|
||||||
|
| "titleUnderline"
|
||||||
|
| "titleStrike"
|
||||||
|
| "subtitleBold"
|
||||||
|
| "subtitleItalics"
|
||||||
|
| "subtitleUnderline"
|
||||||
|
| "subtitleStrike";
|
||||||
|
type SlideTextAlignField = "titleAlign" | "subtitleAlign";
|
||||||
|
|
||||||
const themeLabels: Record<Slide["theme"], string> = {
|
const themeLabels: Record<Slide["theme"], string> = {
|
||||||
classic: "Классическая",
|
classic: "Классическая",
|
||||||
ocean: "Океан",
|
ocean: "Океан",
|
||||||
@@ -47,8 +58,20 @@ const slideTransitionOptions: Array<{ value: SlideTransition | ""; label: string
|
|||||||
{ value: "wipe", label: "Шторка" }
|
{ value: "wipe", label: "Шторка" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const slideWidthInches = 13.333;
|
const slideSizeOptions: Array<{ id: SlideSizeId; label: string; width: number; height: number }> = [
|
||||||
const slideHeightInches = 7.5;
|
{ id: "wide", label: "Широкий 16:9", width: 13.333, height: 7.5 },
|
||||||
|
{ id: "standard", label: "Стандартный 4:3", width: 10, height: 7.5 }
|
||||||
|
];
|
||||||
|
|
||||||
|
type SlideDimensions = { width: number; height: number };
|
||||||
|
|
||||||
|
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
|
||||||
|
return value === "standard" ? "standard" : "wide";
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideDimensions(value?: SlideSizeId): SlideDimensions {
|
||||||
|
return slideSizeOptions.find((option) => option.id === normalizedSlideSizeId(value)) ?? slideSizeOptions[0];
|
||||||
|
}
|
||||||
|
|
||||||
function readFileAsDataUrl(file: File) {
|
function readFileAsDataUrl(file: File) {
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
@@ -88,28 +111,42 @@ function roundSlideNumber(value: number) {
|
|||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
function slideImageStyle(image: SlideImage) {
|
function slideImageStyle(image: SlideImage, dimensions: SlideDimensions) {
|
||||||
return {
|
return {
|
||||||
left: `${(image.x / slideWidthInches) * 100}%`,
|
left: `${(image.x / dimensions.width) * 100}%`,
|
||||||
top: `${(image.y / slideHeightInches) * 100}%`,
|
top: `${(image.y / dimensions.height) * 100}%`,
|
||||||
width: `${(image.width / slideWidthInches) * 100}%`,
|
width: `${(image.width / dimensions.width) * 100}%`,
|
||||||
height: `${(image.height / slideHeightInches) * 100}%`
|
height: `${(image.height / dimensions.height) * 100}%`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function slideCanvasStyle(slide: Slide) {
|
function slideCanvasStyle(slide: Slide, dimensions: SlideDimensions): CSSProperties {
|
||||||
const backgroundColor = normalizedHexColor(slide.backgroundColor);
|
const backgroundColor = normalizedHexColor(slide.backgroundColor);
|
||||||
return backgroundColor ? { background: `#${backgroundColor}` } : undefined;
|
return {
|
||||||
|
aspectRatio: `${dimensions.width} / ${dimensions.height}`,
|
||||||
|
...(backgroundColor ? { background: `#${backgroundColor}` } : {})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function slideTextStyle(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 textColor = normalizedHexColor(color);
|
||||||
const normalizedFontSize = normalizeFontSize(fontSize);
|
const normalizedFontSize = normalizeFontSize(fontSize);
|
||||||
const normalizedFamily = normalizedFontFamily(fontFamily);
|
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 = {
|
const style: CSSProperties = {
|
||||||
...(textColor ? { color: `#${textColor}` } : {}),
|
...(textColor ? { color: `#${textColor}` } : {}),
|
||||||
...(normalizedFontSize ? { fontSize: `${normalizedFontSize}pt` } : {}),
|
...(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;
|
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 activeSlide = deck.slides.find((slide) => slide.id === deck.activeSlideId) ?? deck.slides[0];
|
||||||
const activeImages = activeSlide.images ?? [];
|
const activeImages = activeSlide.images ?? [];
|
||||||
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
|
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
|
||||||
|
const activeSlideSize = slideDimensions(deck.slideSize);
|
||||||
|
|
||||||
function updateSlide(nextSlide: Slide) {
|
function updateSlide(nextSlide: Slide) {
|
||||||
onChange({
|
onChange({
|
||||||
@@ -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[]) {
|
function updateImages(images: SlideImage[]) {
|
||||||
updateSlide({ ...activeSlide, images });
|
updateSlide({ ...activeSlide, images });
|
||||||
}
|
}
|
||||||
@@ -183,6 +230,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
setSelectedImageId("");
|
setSelectedImageId("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSubtitleListStyle(listStyle: SlideListStyle | "") {
|
||||||
|
updateSlide({ ...activeSlide, subtitleListStyle: listStyle || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
function addSlide() {
|
function addSlide() {
|
||||||
const id = `slide-${Date.now()}`;
|
const id = `slide-${Date.now()}`;
|
||||||
onChange({
|
onChange({
|
||||||
@@ -231,6 +282,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
...deck,
|
...deck,
|
||||||
title: imported.title,
|
title: imported.title,
|
||||||
activeSlideId: imported.slides[0]?.id ?? "slide-1",
|
activeSlideId: imported.slides[0]?.id ?? "slide-1",
|
||||||
|
slideSize: imported.slideSize ?? deck.slideSize ?? "wide",
|
||||||
slides: imported.slides.length > 0 ? imported.slides : deck.slides
|
slides: imported.slides.length > 0 ? imported.slides : deck.slides
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -254,8 +306,8 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
id: `slide-image-${Date.now()}`,
|
id: `slide-image-${Date.now()}`,
|
||||||
src,
|
src,
|
||||||
alt: file.name,
|
alt: file.name,
|
||||||
x: roundSlideNumber(Math.max(0.4, slideWidthInches - size.width - 0.9)),
|
x: roundSlideNumber(Math.max(0.4, activeSlideSize.width - size.width - 0.9)),
|
||||||
y: roundSlideNumber(Math.max(1.4, slideHeightInches - size.height - 0.8)),
|
y: roundSlideNumber(Math.max(1.4, activeSlideSize.height - size.height - 0.8)),
|
||||||
...size
|
...size
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,58 +446,19 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide)}>
|
<div className="office-workbar slide-workbar" aria-label="Параметры QPowerPoint">
|
||||||
{activeImages.map((image) => (
|
<div className="office-tool-section">
|
||||||
<button
|
<span className="office-tool-title">Оформление</span>
|
||||||
key={image.id}
|
<label className="office-check">
|
||||||
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>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={Boolean(activeSlide.hidden)}
|
checked={Boolean(activeSlide.hidden)}
|
||||||
onChange={(event) => updateSlide({ ...activeSlide, hidden: event.target.checked || undefined })}
|
onChange={(event) => updateSlide({ ...activeSlide, hidden: event.target.checked || undefined })}
|
||||||
/>
|
/>
|
||||||
<span className="slide-hidden-toggle-text">Скрыть при показе слайд-шоу</span>
|
Скрыть слайд
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label className="office-field">
|
||||||
<span>
|
<span>Тема</span>
|
||||||
<Palette size={16} />
|
|
||||||
Тема
|
|
||||||
</span>
|
|
||||||
<select value={activeSlide.theme} onChange={(event) => updateSlide({ ...activeSlide, theme: event.target.value as Slide["theme"] })}>
|
<select value={activeSlide.theme} onChange={(event) => updateSlide({ ...activeSlide, theme: event.target.value as Slide["theme"] })}>
|
||||||
{Object.entries(themeLabels).map(([theme, label]) => (
|
{Object.entries(themeLabels).map(([theme, label]) => (
|
||||||
<option key={theme} value={theme}>
|
<option key={theme} value={theme}>
|
||||||
@@ -454,11 +467,8 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label className="office-field">
|
||||||
<span>
|
<span>Переход</span>
|
||||||
<Play size={16} />
|
|
||||||
Переход
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
value={activeSlide.transition ?? ""}
|
value={activeSlide.transition ?? ""}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -473,7 +483,22 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="office-field">
|
||||||
|
<span>Размер</span>
|
||||||
|
<select
|
||||||
|
value={normalizedSlideSizeId(deck.slideSize)}
|
||||||
|
onChange={(event) => onChange({ ...deck, slideSize: event.target.value as SlideSizeId })}
|
||||||
|
aria-label="Размер слайда"
|
||||||
|
>
|
||||||
|
{slideSizeOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<div className="slide-background-swatches" aria-label="Фон слайда">
|
<div className="slide-background-swatches" aria-label="Фон слайда">
|
||||||
|
<Palette size={16} aria-hidden="true" />
|
||||||
{backgroundColorSwatches.map((swatch) => (
|
{backgroundColorSwatches.map((swatch) => (
|
||||||
<button
|
<button
|
||||||
key={swatch.label}
|
key={swatch.label}
|
||||||
@@ -486,7 +511,11 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="slide-text-controls">
|
</div>
|
||||||
|
|
||||||
|
<div className="office-tool-section">
|
||||||
|
<span className="office-tool-title">Текст</span>
|
||||||
|
<label className="office-field">
|
||||||
<span>Заголовок</span>
|
<span>Заголовок</span>
|
||||||
<select
|
<select
|
||||||
value={activeSlide.titleFontSize ?? ""}
|
value={activeSlide.titleFontSize ?? ""}
|
||||||
@@ -500,6 +529,9 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="office-field">
|
||||||
|
<span>Шрифт</span>
|
||||||
<select
|
<select
|
||||||
value={activeSlide.titleFontFamily ?? ""}
|
value={activeSlide.titleFontFamily ?? ""}
|
||||||
onChange={(event) => updateSlide({ ...activeSlide, titleFontFamily: event.target.value || undefined })}
|
onChange={(event) => updateSlide({ ...activeSlide, titleFontFamily: event.target.value || undefined })}
|
||||||
@@ -513,6 +545,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
<div className="slide-background-swatches" aria-label="Цвет заголовка">
|
<div className="slide-background-swatches" aria-label="Цвет заголовка">
|
||||||
{textColorSwatches.map((swatch) => (
|
{textColorSwatches.map((swatch) => (
|
||||||
<button
|
<button
|
||||||
@@ -526,6 +559,86 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<span>Подзаголовок</span>
|
||||||
<select
|
<select
|
||||||
value={activeSlide.subtitleFontSize ?? ""}
|
value={activeSlide.subtitleFontSize ?? ""}
|
||||||
@@ -539,6 +652,9 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="office-field">
|
||||||
|
<span>Шрифт</span>
|
||||||
<select
|
<select
|
||||||
value={activeSlide.subtitleFontFamily ?? ""}
|
value={activeSlide.subtitleFontFamily ?? ""}
|
||||||
onChange={(event) => updateSlide({ ...activeSlide, subtitleFontFamily: event.target.value || undefined })}
|
onChange={(event) => updateSlide({ ...activeSlide, subtitleFontFamily: event.target.value || undefined })}
|
||||||
@@ -552,6 +668,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
<div className="slide-background-swatches" aria-label="Цвет подзаголовка">
|
<div className="slide-background-swatches" aria-label="Цвет подзаголовка">
|
||||||
{textColorSwatches.map((swatch) => (
|
{textColorSwatches.map((swatch) => (
|
||||||
<button
|
<button
|
||||||
@@ -565,10 +682,118 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
<div className="cell-format-tools slide-format-tools" aria-label="Выравнивание подзаголовка">
|
||||||
<section>
|
<button
|
||||||
<h3>Изображения</h3>
|
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">
|
<div className="slide-image-list">
|
||||||
{activeImages.map((image, index) => (
|
{activeImages.map((image, index) => (
|
||||||
<button
|
<button
|
||||||
@@ -589,10 +814,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max={slideWidthInches}
|
max={activeSlideSize.width}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.x}
|
value={selectedImage.x}
|
||||||
onChange={(event) => updateSelectedImageNumber("x", event.target.value, slideWidthInches)}
|
onChange={(event) => updateSelectedImageNumber("x", event.target.value, activeSlideSize.width)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -600,10 +825,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max={slideHeightInches}
|
max={activeSlideSize.height}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.y}
|
value={selectedImage.y}
|
||||||
onChange={(event) => updateSelectedImageNumber("y", event.target.value, slideHeightInches)}
|
onChange={(event) => updateSelectedImageNumber("y", event.target.value, activeSlideSize.height)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -611,10 +836,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max={slideWidthInches}
|
max={activeSlideSize.width}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.width}
|
value={selectedImage.width}
|
||||||
onChange={(event) => updateSelectedImageNumber("width", event.target.value, slideWidthInches)}
|
onChange={(event) => updateSelectedImageNumber("width", event.target.value, activeSlideSize.width)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -622,30 +847,89 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max={slideHeightInches}
|
max={activeSlideSize.height}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.height}
|
value={selectedImage.height}
|
||||||
onChange={(event) => updateSelectedImageNumber("height", event.target.value, slideHeightInches)}
|
onChange={(event) => updateSelectedImageNumber("height", event.target.value, activeSlideSize.height)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<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}>
|
<button className="compact-command danger-command" type="button" onClick={deleteSelectedImage}>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
Удалить изображение
|
Удалить изображение
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</div>
|
||||||
<section>
|
|
||||||
<h3>Заметки докладчика</h3>
|
<label className="office-tool-section notes-inline">
|
||||||
|
<span className="office-tool-title">Заметки</span>
|
||||||
<textarea
|
<textarea
|
||||||
className="notes-field"
|
className="notes-field"
|
||||||
value={activeSlide.notes}
|
value={activeSlide.notes}
|
||||||
aria-label="Заметки докладчика"
|
aria-label="Заметки докладчика"
|
||||||
onChange={(event) => updateSlide({ ...activeSlide, notes: event.target.value })}
|
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>
|
</section>
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+959
-113
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
export {};
|
export {};
|
||||||
|
|
||||||
type DesktopOfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail";
|
type DesktopOfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail";
|
||||||
type QOfficeDesktopCommand = "open" | "save" | "export-pdf" | "print";
|
type QOfficeDesktopCommand = "open" | "save" | "export-pdf" | "print" | "undo" | "redo";
|
||||||
|
|
||||||
interface DesktopOpenOfficeFileResult {
|
interface DesktopOpenOfficeFileResult {
|
||||||
canceled: boolean;
|
canceled: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, waitFor } from "@testing-library/react";
|
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useQOfficeCommand } from "./useQOfficeCommand";
|
import { useQOfficeCommand } from "./useQOfficeCommand";
|
||||||
|
|
||||||
function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf?: () => void }) {
|
function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf?: () => void }) {
|
||||||
@@ -8,6 +8,10 @@ function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("useQOfficeCommand", () => {
|
describe("useQOfficeCommand", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
it("вызывает обработчик команды QOffice", async () => {
|
it("вызывает обработчик команды QOffice", async () => {
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn();
|
||||||
render(<CommandProbe onSave={onSave} />);
|
render(<CommandProbe onSave={onSave} />);
|
||||||
@@ -39,4 +43,23 @@ describe("useQOfficeCommand", () => {
|
|||||||
await waitFor(() => expect(onExportPdf).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(onExportPdf).toHaveBeenCalledTimes(1));
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("использует команду документа для undo и redo без обработчика приложения", async () => {
|
||||||
|
const originalExecCommand = document.execCommand;
|
||||||
|
const execCommand = vi.fn();
|
||||||
|
Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<CommandProbe onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "undo" } }));
|
||||||
|
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "redo" } }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("redo"));
|
||||||
|
expect(execCommand).toHaveBeenNthCalledWith(1, "undo");
|
||||||
|
expect(execCommand).toHaveBeenNthCalledWith(2, "redo");
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export type QOfficeCommand = "open" | "save" | "export-pdf" | "print";
|
export type QOfficeCommand = "open" | "save" | "export-pdf" | "print" | "undo" | "redo";
|
||||||
export type QOfficeCommandHandlers = Partial<Record<QOfficeCommand, () => void | Promise<void>>>;
|
export type QOfficeCommandHandlers = Partial<Record<QOfficeCommand, () => void | Promise<void>>>;
|
||||||
|
|
||||||
export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
|
export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
|
||||||
@@ -16,6 +16,11 @@ export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
|
|||||||
const handler = handlersRef.current[command];
|
const handler = handlersRef.current[command];
|
||||||
if (handler) {
|
if (handler) {
|
||||||
void handler();
|
void handler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "undo" || command === "redo") {
|
||||||
|
globalThis.document.execCommand?.(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import {
|
|||||||
buildChartXml,
|
buildChartXml,
|
||||||
buildCellXml,
|
buildCellXml,
|
||||||
buildAutoFilterXml,
|
buildAutoFilterXml,
|
||||||
|
buildCommentsXml,
|
||||||
buildColumnWidthsXml,
|
buildColumnWidthsXml,
|
||||||
buildHyperlinksXml,
|
buildHyperlinksXml,
|
||||||
buildMergeCellsXml,
|
buildMergeCellsXml,
|
||||||
|
buildPageMarginsXml,
|
||||||
|
buildPageSetupXml,
|
||||||
buildSheetPrXml,
|
buildSheetPrXml,
|
||||||
buildSheetViewsXml,
|
buildSheetViewsXml,
|
||||||
createCellStyleRegistry,
|
createCellStyleRegistry,
|
||||||
@@ -20,6 +23,7 @@ import {
|
|||||||
workbookPrintAreas,
|
workbookPrintAreas,
|
||||||
workbookRelationshipTargets,
|
workbookRelationshipTargets,
|
||||||
workbookSheetRefs,
|
workbookSheetRefs,
|
||||||
|
workbookUsesDate1904,
|
||||||
worksheetXmlToAutoFilter,
|
worksheetXmlToAutoFilter,
|
||||||
worksheetXmlToCells,
|
worksheetXmlToCells,
|
||||||
worksheetXmlToCellFormats,
|
worksheetXmlToCellFormats,
|
||||||
@@ -28,13 +32,16 @@ import {
|
|||||||
worksheetXmlToHiddenColumns,
|
worksheetXmlToHiddenColumns,
|
||||||
worksheetXmlToHiddenRows,
|
worksheetXmlToHiddenRows,
|
||||||
worksheetXmlToHyperlinks,
|
worksheetXmlToHyperlinks,
|
||||||
|
worksheetCommentsXmlToComments,
|
||||||
worksheetXmlToMergedCells,
|
worksheetXmlToMergedCells,
|
||||||
|
worksheetXmlToPageSetup,
|
||||||
worksheetXmlToRowHeights,
|
worksheetXmlToRowHeights,
|
||||||
worksheetXmlToShowGridLines,
|
worksheetXmlToShowGridLines,
|
||||||
worksheetXmlToTabColor,
|
worksheetXmlToTabColor,
|
||||||
worksheetXmlToZoomScale,
|
worksheetXmlToZoomScale,
|
||||||
xlsxStylesXmlToCellFormatTable,
|
xlsxStylesXmlToCellFormatTable,
|
||||||
xlsxThemeXmlToColors,
|
xlsxThemeXmlToColors,
|
||||||
|
xlsxSafeSheetNames,
|
||||||
xlsxCellToText
|
xlsxCellToText
|
||||||
} from "./excellOffice";
|
} from "./excellOffice";
|
||||||
|
|
||||||
@@ -145,6 +152,58 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
expect(relationshipsXml).not.toContain('TargetMode="External"');
|
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 листа", () => {
|
it("сохраняет базовое форматирование ячеек XLSX в XML листа", () => {
|
||||||
const cellFormats = {
|
const cellFormats = {
|
||||||
A1: { bold: true, fontSize: 14, fontFamily: "Arial", textColor: "0f5fae", fillColor: "fff2cc", horizontalAlign: "center" as const },
|
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");
|
).toBe("sheet-7");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detects the XLSX 1904 date system flag from workbook properties", () => {
|
||||||
|
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "1" } } })).toBe(true);
|
||||||
|
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "true" } } })).toBe(true);
|
||||||
|
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "0" } } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts imported XLSX 1904 date serials to the QExcell 1900 serial system", async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file(
|
||||||
|
"xl/workbook.xml",
|
||||||
|
`<workbook><workbookPr date1904="1"/><sheets><sheet name="Dates" sheetId="1" r:id="rId1"/></sheets></workbook>`
|
||||||
|
);
|
||||||
|
zip.file(
|
||||||
|
"xl/_rels/workbook.xml.rels",
|
||||||
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>`
|
||||||
|
);
|
||||||
|
zip.file("xl/styles.xml", `<styleSheet><cellXfs count="3"><xf numFmtId="0"/><xf numFmtId="14"/><xf numFmtId="20"/></cellXfs></styleSheet>`);
|
||||||
|
zip.file(
|
||||||
|
"xl/worksheets/sheet1.xml",
|
||||||
|
`<worksheet><sheetData><row r="1"><c r="A1" s="1"><v>43465</v></c><c r="B1"><v>5</v></c><c r="C1" s="2"><v>0.5</v></c></row></sheetData></worksheet>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = await zip.generateAsync({
|
||||||
|
type: "blob",
|
||||||
|
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
});
|
||||||
|
const imported = await importXlsxFile(new File([blob], "Date1904.xlsx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(imported.sheets[0].cells).toEqual({ A1: "44927", B1: "5", C1: "0.5" });
|
||||||
|
expect(imported.sheets[0].cellFormats).toEqual({ A1: { numberFormat: "m/d/yy" }, C1: { numberFormat: "h:mm" } });
|
||||||
|
});
|
||||||
|
|
||||||
it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => {
|
it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => {
|
||||||
const blob = await exportXlsxBlob({
|
const blob = await exportXlsxBlob({
|
||||||
id: "book-1",
|
id: "book-1",
|
||||||
@@ -579,6 +670,38 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
expect(imported.activeSheet).toBe("sheet-2");
|
expect(imported.activeSheet).toBe("sheet-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes XLSX sheet names to Excel-safe unique names on export", async () => {
|
||||||
|
expect(
|
||||||
|
xlsxSafeSheetNames([
|
||||||
|
{ name: "Sales/2026:Q1*?" },
|
||||||
|
{ name: "'Very long worksheet name that exceeds thirty one characters'" },
|
||||||
|
{ name: "Sales 2026 Q1" },
|
||||||
|
{ name: "'[]:*?/\\'" },
|
||||||
|
{ name: "History" }
|
||||||
|
])
|
||||||
|
).toEqual(["Sales 2026 Q1", "Very long worksheet name that e", "Sales 2026 Q1 (2)", "Лист 4", "History 1"]);
|
||||||
|
|
||||||
|
const blob = await exportXlsxBlob({
|
||||||
|
id: "book-1",
|
||||||
|
title: "Safe sheet names.xlsx",
|
||||||
|
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
activeSheet: "sheet-1",
|
||||||
|
sheets: [
|
||||||
|
{ id: "sheet-1", name: "Sales/2026:Q1*?", printArea: "A1:B2", cells: { A1: "Sales" } },
|
||||||
|
{ id: "sheet-2", name: "Sales 2026 Q1", cells: { A1: "Duplicate" } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const workbookXml = await zip.file("xl/workbook.xml")?.async("string");
|
||||||
|
const imported = await importXlsxFile(new File([blob], "Safe sheet names.xlsx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1" sheetId="1" r:id="rId1"/>');
|
||||||
|
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1 (2)" sheetId="2" r:id="rId2"/>');
|
||||||
|
expect(workbookXml).toContain('<definedName name="_xlnm.Print_Area" localSheetId="0">\'Sales 2026 Q1\'!$A$1:$B$2</definedName>');
|
||||||
|
expect(imported.sheets.map((sheet) => sheet.name)).toEqual(["Sales 2026 Q1", "Sales 2026 Q1 (2)"]);
|
||||||
|
expect(imported.sheets[0].printArea).toBe("A1:B2");
|
||||||
|
});
|
||||||
|
|
||||||
it("сохраняет и импортирует область печати XLSX через definedName _xlnm.Print_Area", async () => {
|
it("сохраняет и импортирует область печати XLSX через definedName _xlnm.Print_Area", async () => {
|
||||||
expect(
|
expect(
|
||||||
workbookPrintAreas(
|
workbookPrintAreas(
|
||||||
@@ -619,6 +742,45 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
expect(imported.sheets[1].printArea).toBe("B2:C4,E1:E3");
|
expect(imported.sheets[1].printArea).toBe("B2:C4,E1:E3");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("сохраняет и импортирует параметры страницы XLSX через pageMargins и pageSetup", async () => {
|
||||||
|
expect(
|
||||||
|
worksheetXmlToPageSetup({
|
||||||
|
worksheet: {
|
||||||
|
pageMargins: { left: "0.25", right: "0.25", top: "0.75", bottom: "0.75", header: "0.3", footer: "0.3" },
|
||||||
|
pageSetup: { paperSize: "1", orientation: "landscape" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" });
|
||||||
|
expect(buildPageMarginsXml({ size: "a4", orientation: "portrait", marginPreset: "wide" })).toBe(
|
||||||
|
'<pageMargins left="1" right="1" top="1" bottom="1" header="0.5" footer="0.5"/>'
|
||||||
|
);
|
||||||
|
expect(buildPageSetupXml({ size: "letter", orientation: "landscape", marginPreset: "normal" })).toBe(
|
||||||
|
'<pageSetup paperSize="1" orientation="landscape"/>'
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = await exportXlsxBlob({
|
||||||
|
id: "book-1",
|
||||||
|
title: "Page setup.xlsx",
|
||||||
|
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
activeSheet: "sheet-1",
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
id: "sheet-1",
|
||||||
|
name: "Layout",
|
||||||
|
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" },
|
||||||
|
cells: { A1: "Layout" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
|
||||||
|
const imported = await importXlsxFile(new File([blob], "Page setup.xlsx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(sheetXml).toContain('<pageMargins left="0.25" right="0.25" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>');
|
||||||
|
expect(sheetXml).toContain('<pageSetup paperSize="1" orientation="landscape"/>');
|
||||||
|
expect(imported.sheets[0].pageSetup).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" });
|
||||||
|
});
|
||||||
|
|
||||||
it("сохраняет и импортирует скрытые листы XLSX через sheet state", async () => {
|
it("сохраняет и импортирует скрытые листы XLSX через sheet state", async () => {
|
||||||
const blob = await exportXlsxBlob({
|
const blob = await exportXlsxBlob({
|
||||||
id: "book-1",
|
id: "book-1",
|
||||||
|
|||||||
+371
-26
@@ -1,4 +1,15 @@
|
|||||||
import type { CellFormat, MergedCellRange, SheetChart, SheetChartType, SheetFrozenPane, Workbook } from "../types";
|
import type {
|
||||||
|
CellFormat,
|
||||||
|
MergedCellRange,
|
||||||
|
SheetChart,
|
||||||
|
SheetChartType,
|
||||||
|
SheetFrozenPane,
|
||||||
|
SheetPageMarginPreset,
|
||||||
|
SheetPageOrientation,
|
||||||
|
SheetPageSetup,
|
||||||
|
SheetPageSizeId,
|
||||||
|
Workbook
|
||||||
|
} from "../types";
|
||||||
import { evaluateCell, expandRange } from "../utils/spreadsheet";
|
import { evaluateCell, expandRange } from "../utils/spreadsheet";
|
||||||
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers";
|
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers";
|
||||||
|
|
||||||
@@ -19,9 +30,11 @@ export interface ImportedExcellWorkbook {
|
|||||||
showGridLines?: boolean;
|
showGridLines?: boolean;
|
||||||
zoomScale?: number;
|
zoomScale?: number;
|
||||||
printArea?: string;
|
printArea?: string;
|
||||||
|
pageSetup?: SheetPageSetup;
|
||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
|
comments?: Record<string, string>;
|
||||||
cellFormats?: Record<string, CellFormat>;
|
cellFormats?: Record<string, CellFormat>;
|
||||||
charts?: SheetChart[];
|
charts?: SheetChart[];
|
||||||
}>;
|
}>;
|
||||||
@@ -76,9 +89,11 @@ interface ParsedSheet {
|
|||||||
showGridLines?: boolean;
|
showGridLines?: boolean;
|
||||||
zoomScale?: number;
|
zoomScale?: number;
|
||||||
printArea?: string;
|
printArea?: string;
|
||||||
|
pageSetup?: SheetPageSetup;
|
||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
|
comments?: Record<string, string>;
|
||||||
cellFormats?: Record<string, CellFormat>;
|
cellFormats?: Record<string, CellFormat>;
|
||||||
charts?: SheetChart[];
|
charts?: SheetChart[];
|
||||||
}
|
}
|
||||||
@@ -124,6 +139,12 @@ interface ChartPartRef {
|
|||||||
sheetName: string;
|
sheetName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CommentPartRef {
|
||||||
|
sheetIndex: number;
|
||||||
|
commentIndex: number;
|
||||||
|
comments: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
const xmlParserOptions = {
|
const xmlParserOptions = {
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
attributeNamePrefix: "",
|
attributeNamePrefix: "",
|
||||||
@@ -135,6 +156,8 @@ const xmlParserOptions = {
|
|||||||
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
||||||
const drawingRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing";
|
const drawingRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing";
|
||||||
const chartRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
|
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 customNumberFormatStartId = 164;
|
||||||
const defaultXlsxThemeColors = [
|
const defaultXlsxThemeColors = [
|
||||||
"000000",
|
"000000",
|
||||||
@@ -200,6 +223,16 @@ const builtinNumberFormats: Record<number, string> = {
|
|||||||
48: "##0.0E+0",
|
48: "##0.0E+0",
|
||||||
49: "@"
|
49: "@"
|
||||||
};
|
};
|
||||||
|
const defaultSheetPageSetup: SheetPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
|
||||||
|
const xlsxPageSizePaperIds: Record<SheetPageSizeId, number> = {
|
||||||
|
letter: 1,
|
||||||
|
a4: 9
|
||||||
|
};
|
||||||
|
const xlsxPageMargins: Record<SheetPageMarginPreset, { left: number; right: number; top: number; bottom: number; header: number; footer: number }> = {
|
||||||
|
normal: { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 },
|
||||||
|
narrow: { left: 0.25, right: 0.25, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 },
|
||||||
|
wide: { left: 1, right: 1, top: 1, bottom: 1, header: 0.5, footer: 0.5 }
|
||||||
|
};
|
||||||
|
|
||||||
export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook> {
|
export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook> {
|
||||||
if (!file.name.toLowerCase().endsWith(".xlsx")) {
|
if (!file.name.toLowerCase().endsWith(".xlsx")) {
|
||||||
@@ -216,6 +249,7 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
|
|||||||
const themeColors = await readOptionalWorkbookThemeColors(zip, parser);
|
const themeColors = await readOptionalWorkbookThemeColors(zip, parser);
|
||||||
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
|
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
|
||||||
const sheetRefs = workbookSheetRefs(workbookXml);
|
const sheetRefs = workbookSheetRefs(workbookXml);
|
||||||
|
const date1904 = workbookUsesDate1904(workbookXml);
|
||||||
const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
|
const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
|
||||||
const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
|
const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
|
||||||
const sheets = await Promise.all(
|
const sheets = await Promise.all(
|
||||||
@@ -237,7 +271,9 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
|
|||||||
const sheetPath = resolveWorkbookTarget(target);
|
const sheetPath = resolveWorkbookTarget(target);
|
||||||
const sheetXml = parser.parse(await readZipText(zip, sheetPath));
|
const sheetXml = parser.parse(await readZipText(zip, sheetPath));
|
||||||
const sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, 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);
|
const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name);
|
||||||
return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts };
|
return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts };
|
||||||
})
|
})
|
||||||
@@ -255,20 +291,23 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
const JSZip = await loadJsZip();
|
const JSZip = await loadJsZip();
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const sheets = workbook.sheets.length > 0 ? workbook.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [], hyperlinks: {} }];
|
const sheets = workbook.sheets.length > 0 ? workbook.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [], hyperlinks: {} }];
|
||||||
|
const safeSheetNames = xlsxSafeSheetNames(sheets);
|
||||||
|
const safeSheets = sheets.map((sheet, index) => ({ ...sheet, name: safeSheetNames[index] }));
|
||||||
|
|
||||||
const styleRegistry = createCellStyleRegistry(sheets.map((sheet) => sheet.cellFormats));
|
const styleRegistry = createCellStyleRegistry(safeSheets.map((sheet) => sheet.cellFormats));
|
||||||
const chartParts = chartPartRefsForSheets(sheets);
|
const chartParts = chartPartRefsForSheets(safeSheets);
|
||||||
const activeSheetIndex = Math.max(0, sheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
|
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("_rels/.rels", buildRootRelationshipsXml());
|
||||||
zip.file("docProps/app.xml", buildAppPropertiesXml());
|
zip.file("docProps/app.xml", buildAppPropertiesXml());
|
||||||
zip.file("docProps/core.xml", buildCorePropertiesXml());
|
zip.file("docProps/core.xml", buildCorePropertiesXml());
|
||||||
zip.file(
|
zip.file(
|
||||||
"xl/workbook.xml",
|
"xl/workbook.xml",
|
||||||
buildWorkbookXml(
|
buildWorkbookXml(
|
||||||
sheets.map((sheet, index) => ({
|
safeSheets.map((sheet) => ({
|
||||||
name: sheet.name.trim() || `Лист ${index + 1}`,
|
name: sheet.name,
|
||||||
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
|
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
|
||||||
...(sheet.printArea ? { printArea: sheet.printArea } : {})
|
...(sheet.printArea ? { printArea: sheet.printArea } : {})
|
||||||
})),
|
})),
|
||||||
@@ -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/_rels/workbook.xml.rels", buildWorkbookRelationshipsXml(sheets.length));
|
||||||
zip.file("xl/styles.xml", buildStylesXml(styleRegistry));
|
zip.file("xl/styles.xml", buildStylesXml(styleRegistry));
|
||||||
|
|
||||||
sheets.forEach((sheet, index) => {
|
safeSheets.forEach((sheet, index) => {
|
||||||
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
|
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
|
||||||
const 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(
|
zip.file(
|
||||||
`xl/worksheets/sheet${index + 1}.xml`,
|
`xl/worksheets/sheet${index + 1}.xml`,
|
||||||
buildWorksheetXml(
|
buildWorksheetXml(
|
||||||
@@ -298,12 +342,16 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
sheet.hiddenColumns,
|
sheet.hiddenColumns,
|
||||||
sheet.hiddenRows,
|
sheet.hiddenRows,
|
||||||
sheet.showGridLines,
|
sheet.showGridLines,
|
||||||
sheet.zoomScale
|
sheet.zoomScale,
|
||||||
|
legacyDrawingRelationshipId,
|
||||||
|
sheet.pageSetup
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const relationshipsXml = buildWorksheetRelationshipsXml(
|
const relationshipsXml = buildWorksheetRelationshipsXml(
|
||||||
sheet.hyperlinks,
|
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) {
|
if (relationshipsXml) {
|
||||||
zip.file(`xl/worksheets/_rels/sheet${index + 1}.xml.rels`, 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));
|
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 });
|
return zip.generateAsync({ type: "blob", mimeType: XLSX_MIME_TYPE });
|
||||||
@@ -329,7 +382,9 @@ export function parsedWorksheetToSheet(
|
|||||||
sharedStrings: string[],
|
sharedStrings: string[],
|
||||||
relationshipsXml?: unknown,
|
relationshipsXml?: unknown,
|
||||||
styleTable: Array<CellFormat | undefined> = [],
|
styleTable: Array<CellFormat | undefined> = [],
|
||||||
themeColors: string[] = defaultXlsxThemeColors
|
themeColors: string[] = defaultXlsxThemeColors,
|
||||||
|
commentsXml?: unknown,
|
||||||
|
date1904 = false
|
||||||
): ParsedSheet {
|
): ParsedSheet {
|
||||||
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
|
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
|
||||||
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
|
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
|
||||||
@@ -340,6 +395,7 @@ export function parsedWorksheetToSheet(
|
|||||||
const hiddenRows = worksheetXmlToHiddenRows(worksheetXml);
|
const hiddenRows = worksheetXmlToHiddenRows(worksheetXml);
|
||||||
const showGridLines = worksheetXmlToShowGridLines(worksheetXml);
|
const showGridLines = worksheetXmlToShowGridLines(worksheetXml);
|
||||||
const zoomScale = worksheetXmlToZoomScale(worksheetXml);
|
const zoomScale = worksheetXmlToZoomScale(worksheetXml);
|
||||||
|
const pageSetup = worksheetXmlToPageSetup(worksheetXml);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sheet.id || `sheet-${index + 1}`,
|
id: sheet.id || `sheet-${index + 1}`,
|
||||||
@@ -354,15 +410,17 @@ export function parsedWorksheetToSheet(
|
|||||||
...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}),
|
...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}),
|
||||||
...(showGridLines === false ? { showGridLines } : {}),
|
...(showGridLines === false ? { showGridLines } : {}),
|
||||||
...(zoomScale ? { zoomScale } : {}),
|
...(zoomScale ? { zoomScale } : {}),
|
||||||
cells: worksheetXmlToCells(worksheetXml, sharedStrings),
|
...(pageSetup ? { pageSetup } : {}),
|
||||||
|
cells: worksheetXmlToCells(worksheetXml, sharedStrings, styleTable, date1904),
|
||||||
mergedCells: worksheetXmlToMergedCells(worksheetXml),
|
mergedCells: worksheetXmlToMergedCells(worksheetXml),
|
||||||
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
|
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
|
||||||
|
comments: worksheetCommentsXmlToComments(commentsXml),
|
||||||
cellFormats: worksheetXmlToCellFormats(worksheetXml, styleTable),
|
cellFormats: worksheetXmlToCellFormats(worksheetXml, styleTable),
|
||||||
charts: []
|
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 cells: Record<string, string> = {};
|
||||||
const sharedFormulas: SharedFormulaMap = {};
|
const sharedFormulas: SharedFormulaMap = {};
|
||||||
const parsedCells = worksheetCellRecords(worksheetXml);
|
const parsedCells = worksheetCellRecords(worksheetXml);
|
||||||
@@ -378,9 +436,10 @@ export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string
|
|||||||
|
|
||||||
parsedCells.forEach(({ address, cell }) => {
|
parsedCells.forEach(({ address, cell }) => {
|
||||||
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
|
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
|
||||||
|
const importedText = date1904 ? date1904AdjustedCellText(cell, text, styleTable[numberAttribute(cell.s)]) : text;
|
||||||
|
|
||||||
if (text !== "") {
|
if (importedText !== "") {
|
||||||
cells[address] = text;
|
cells[address] = importedText;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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 {
|
export function worksheetXmlToFrozenPane(worksheetXml: unknown): SheetFrozenPane | undefined {
|
||||||
const worksheet = childRecord(worksheetXml, "worksheet");
|
const worksheet = childRecord(worksheetXml, "worksheet");
|
||||||
const sheetViews = childRecord(worksheet, "sheetViews");
|
const sheetViews = childRecord(worksheet, "sheetViews");
|
||||||
@@ -491,6 +566,18 @@ export function worksheetXmlToZoomScale(worksheetXml: unknown) {
|
|||||||
return normalizedSheetZoomScale(sheetView.zoomScale);
|
return normalizedSheetZoomScale(sheetView.zoomScale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function worksheetXmlToPageSetup(worksheetXml: unknown): SheetPageSetup | undefined {
|
||||||
|
const worksheet = childRecord(worksheetXml, "worksheet");
|
||||||
|
const pageSetup = childRecord(worksheet, "pageSetup");
|
||||||
|
const pageMargins = childRecord(worksheet, "pageMargins");
|
||||||
|
const size = sheetPageSizeFromPaperId(pageSetup.paperSize) ?? defaultSheetPageSetup.size;
|
||||||
|
const orientation = normalizedSheetPageOrientation(pageSetup.orientation) ?? defaultSheetPageSetup.orientation;
|
||||||
|
const marginPreset = sheetPageMarginPresetFromRecord(pageMargins) ?? defaultSheetPageSetup.marginPreset;
|
||||||
|
const hasPageSetup = Object.keys(pageSetup).length > 0;
|
||||||
|
const hasPageMargins = Object.keys(pageMargins).length > 0;
|
||||||
|
return hasPageSetup || hasPageMargins ? { size, orientation, marginPreset } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function worksheetXmlToAutoFilter(worksheetXml: unknown) {
|
export function worksheetXmlToAutoFilter(worksheetXml: unknown) {
|
||||||
const worksheet = childRecord(worksheetXml, "worksheet");
|
const worksheet = childRecord(worksheetXml, "worksheet");
|
||||||
const autoFilter = childRecord(worksheet, "autoFilter");
|
const autoFilter = childRecord(worksheet, "autoFilter");
|
||||||
@@ -754,7 +841,9 @@ export function buildWorksheetXml(
|
|||||||
hiddenColumns: Record<string, boolean> = {},
|
hiddenColumns: Record<string, boolean> = {},
|
||||||
hiddenRows: Record<string, boolean> = {},
|
hiddenRows: Record<string, boolean> = {},
|
||||||
showGridLines?: boolean,
|
showGridLines?: boolean,
|
||||||
zoomScale?: number
|
zoomScale?: number,
|
||||||
|
legacyDrawingRelationshipId = "",
|
||||||
|
pageSetup?: SheetPageSetup
|
||||||
) {
|
) {
|
||||||
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
|
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
|
||||||
const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows);
|
const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows);
|
||||||
@@ -791,12 +880,15 @@ export function buildWorksheetXml(
|
|||||||
const autoFilterXml = buildAutoFilterXml(autoFilter);
|
const autoFilterXml = buildAutoFilterXml(autoFilter);
|
||||||
const hyperlinksXml = buildHyperlinksXml(hyperlinks);
|
const hyperlinksXml = buildHyperlinksXml(hyperlinks);
|
||||||
const drawingXml = drawingRelationshipId ? `<drawing r:id="${escapeXmlAttribute(drawingRelationshipId)}"/>` : "";
|
const drawingXml = drawingRelationshipId ? `<drawing r:id="${escapeXmlAttribute(drawingRelationshipId)}"/>` : "";
|
||||||
|
const legacyDrawingXml = legacyDrawingRelationshipId ? `<legacyDrawing r:id="${escapeXmlAttribute(legacyDrawingRelationshipId)}"/>` : "";
|
||||||
const sheetPrXml = buildSheetPrXml(tabColor);
|
const sheetPrXml = buildSheetPrXml(tabColor);
|
||||||
const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale);
|
const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale);
|
||||||
const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns);
|
const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns);
|
||||||
|
const pageMarginsXml = buildPageMarginsXml(pageSetup);
|
||||||
|
const pageSetupXml = buildPageSetupXml(pageSetup);
|
||||||
|
|
||||||
return xmlDeclaration(
|
return xmlDeclaration(
|
||||||
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}</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>` : "";
|
return color ? `<sheetPr><tabColor rgb="FF${color}"/></sheetPr>` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildPageMarginsXml(pageSetup?: SheetPageSetup) {
|
||||||
|
if (!pageSetup) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const margins = xlsxPageMargins[normalizedSheetPageSetup(pageSetup).marginPreset];
|
||||||
|
return `<pageMargins left="${formatSheetDimension(margins.left)}" right="${formatSheetDimension(margins.right)}" top="${formatSheetDimension(margins.top)}" bottom="${formatSheetDimension(margins.bottom)}" header="${formatSheetDimension(margins.header)}" footer="${formatSheetDimension(margins.footer)}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPageSetupXml(pageSetup?: SheetPageSetup) {
|
||||||
|
if (!pageSetup) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizedSheetPageSetup(pageSetup);
|
||||||
|
return `<pageSetup paperSize="${xlsxPageSizePaperIds[normalized.size]}" orientation="${normalized.orientation}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildColumnWidthsXml(columnWidths: Record<string, number> = {}, hiddenColumns: Record<string, boolean> = {}) {
|
export function buildColumnWidthsXml(columnWidths: Record<string, number> = {}, hiddenColumns: Record<string, boolean> = {}) {
|
||||||
const columnsByNumber = new Map<number, { width?: number; hidden?: boolean }>();
|
const columnsByNumber = new Map<number, { width?: number; hidden?: boolean }>();
|
||||||
normalizedSheetColumnWidths(columnWidths).forEach(({ column, width }) => {
|
normalizedSheetColumnWidths(columnWidths).forEach(({ column, width }) => {
|
||||||
@@ -917,22 +1027,31 @@ export function buildAutoFilterXml(autoFilter?: string) {
|
|||||||
return ref ? `<autoFilter ref="${escapeXmlAttribute(ref)}"/>` : "";
|
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);
|
const refs = sortedExternalHyperlinkEntries(hyperlinks);
|
||||||
if (refs.length === 0 && !drawingTarget) {
|
if (refs.length === 0 && !drawingTarget && !commentsTarget && !vmlDrawingTarget) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const relationships = refs
|
const hyperlinkRelationships = refs
|
||||||
.map(
|
.map(
|
||||||
(entry, index) =>
|
(entry, index) =>
|
||||||
`<Relationship Id="rId${index + 1}" Type="${hyperlinkRelationshipType}" Target="${escapeXmlAttribute(entry.target)}" TargetMode="External"/>`
|
`<Relationship Id="rId${index + 1}" Type="${hyperlinkRelationshipType}" Target="${escapeXmlAttribute(entry.target)}" TargetMode="External"/>`
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
let nextRelationshipId = refs.length + 1;
|
||||||
const drawingRelationship = drawingTarget
|
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[] {
|
export function workbookSheetRefs(workbookXml: unknown): WorkbookSheetRef[] {
|
||||||
@@ -975,6 +1094,12 @@ export function workbookActiveSheetId(workbookXml: unknown, sheets: Array<{ id:
|
|||||||
return sheets[activeTab]?.id ?? "";
|
return sheets[activeTab]?.id ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function workbookUsesDate1904(workbookXml: unknown) {
|
||||||
|
const workbook = childRecord(workbookXml, "workbook");
|
||||||
|
const workbookPr = childRecord(workbook, "workbookPr");
|
||||||
|
return booleanAttribute(workbookPr.date1904 ?? workbookPr["@_date1904"]);
|
||||||
|
}
|
||||||
|
|
||||||
export function workbookPrintAreas(workbookXml: unknown, sheets: Array<{ id: string; name: string }>): Record<string, string> {
|
export function workbookPrintAreas(workbookXml: unknown, sheets: Array<{ id: string; name: string }>): Record<string, string> {
|
||||||
const workbook = childRecord(workbookXml, "workbook");
|
const workbook = childRecord(workbookXml, "workbook");
|
||||||
const definedNames = childRecord(workbook, "definedNames");
|
const definedNames = childRecord(workbook, "definedNames");
|
||||||
@@ -1028,7 +1153,7 @@ export function worksheetRelationshipTargets(relationshipsXml: unknown) {
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = []) {
|
function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = [], commentParts: CommentPartRef[] = []) {
|
||||||
const sheetOverrides = Array.from({ length: sheetCount }, (_, index) => {
|
const sheetOverrides = Array.from({ length: sheetCount }, (_, index) => {
|
||||||
return `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
|
return `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
@@ -1038,9 +1163,13 @@ function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = [
|
|||||||
const chartOverrides = chartParts
|
const chartOverrides = chartParts
|
||||||
.map((part) => `<Override PartName="/xl/charts/chart${part.chartIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>`)
|
.map((part) => `<Override PartName="/xl/charts/chart${part.chartIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>`)
|
||||||
.join("");
|
.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(
|
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) {
|
function buildWorkbookXml(sheets: Array<{ name: string; visibility?: WorkbookSheetRef["visibility"]; printArea?: string }>, activeSheetIndex = 0) {
|
||||||
const activeTab = Math.min(Math.max(0, activeSheetIndex), Math.max(0, sheets.length - 1));
|
const activeTab = Math.min(Math.max(0, activeSheetIndex), Math.max(0, sheets.length - 1));
|
||||||
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;
|
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;
|
||||||
@@ -1122,6 +1290,63 @@ function chartPartRefsForSheets(sheets: Workbook["sheets"]): ChartPartRef[] {
|
|||||||
return refs;
|
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[] = []) {
|
function normalizedSheetCharts(charts: SheetChart[] = []) {
|
||||||
return charts
|
return charts
|
||||||
.map(normalizedChart)
|
.map(normalizedChart)
|
||||||
@@ -1632,6 +1857,37 @@ function normalizedNumberFormat(value?: string) {
|
|||||||
return raw.slice(0, 255);
|
return raw.slice(0, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function date1904AdjustedCellText(cell: XmlRecord, text: string, format?: CellFormat) {
|
||||||
|
if (!isNumericXlsxCell(cell) || !isDateSerialXlsxNumberFormat(format?.numberFormat)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serial = Number(text);
|
||||||
|
if (!Number.isFinite(serial) || serial < 0) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedXlsxNumberText(serial + 1462);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumericXlsxCell(cell: XmlRecord) {
|
||||||
|
const type = typeof cell.t === "string" ? cell.t.trim() : "";
|
||||||
|
return type === "" || type === "n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateSerialXlsxNumberFormat(value?: string) {
|
||||||
|
const pattern = normalizedNumberFormat(value)
|
||||||
|
.replace(/"[^"]*"/g, "")
|
||||||
|
.replace(/\\./g, "")
|
||||||
|
.replace(/\[[^\]]+]/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
return /[dy]/.test(pattern) || (pattern.includes("m") && !/[hs]/.test(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedXlsxNumberText(value: number) {
|
||||||
|
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(10)));
|
||||||
|
}
|
||||||
|
|
||||||
function xlsxNumberFormats(styleSheet: XmlRecord) {
|
function xlsxNumberFormats(styleSheet: XmlRecord) {
|
||||||
const formats = { ...builtinNumberFormats };
|
const formats = { ...builtinNumberFormats };
|
||||||
toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {
|
toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {
|
||||||
@@ -1717,6 +1973,58 @@ function normalizedSheetZoomScale(value: unknown) {
|
|||||||
return zoomScale >= 10 && zoomScale <= 400 && zoomScale !== 100 ? zoomScale : 0;
|
return zoomScale >= 10 && zoomScale <= 400 && zoomScale !== 100 ? zoomScale : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedSheetPageSetup(pageSetup?: SheetPageSetup): SheetPageSetup {
|
||||||
|
const size = isSheetPageSizeId(pageSetup?.size) ? pageSetup.size : defaultSheetPageSetup.size;
|
||||||
|
const orientation = normalizedSheetPageOrientation(pageSetup?.orientation) ?? defaultSheetPageSetup.orientation;
|
||||||
|
const marginPreset = isSheetPageMarginPreset(pageSetup?.marginPreset) ? pageSetup.marginPreset : defaultSheetPageSetup.marginPreset;
|
||||||
|
return { size, orientation, marginPreset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSheetPageOrientation(value: unknown): SheetPageOrientation | undefined {
|
||||||
|
return value === "portrait" || value === "landscape" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSheetPageSizeId(value: unknown): value is SheetPageSizeId {
|
||||||
|
return value === "a4" || value === "letter";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSheetPageMarginPreset(value: unknown): value is SheetPageMarginPreset {
|
||||||
|
return value === "normal" || value === "narrow" || value === "wide";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sheetPageSizeFromPaperId(value: unknown): SheetPageSizeId | undefined {
|
||||||
|
const paperSize = positiveIntegerAttribute(value);
|
||||||
|
return (Object.keys(xlsxPageSizePaperIds) as SheetPageSizeId[]).find((sizeId) => xlsxPageSizePaperIds[sizeId] === paperSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sheetPageMarginPresetFromRecord(record: XmlRecord): SheetPageMarginPreset | undefined {
|
||||||
|
const left = positiveNumberAttribute(record.left);
|
||||||
|
const right = positiveNumberAttribute(record.right);
|
||||||
|
const top = positiveNumberAttribute(record.top);
|
||||||
|
const bottom = positiveNumberAttribute(record.bottom);
|
||||||
|
const header = positiveNumberAttribute(record.header);
|
||||||
|
const footer = positiveNumberAttribute(record.footer);
|
||||||
|
if (!left || !right || !top || !bottom || !header || !footer) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Object.keys(xlsxPageMargins) as SheetPageMarginPreset[]).find((preset) => {
|
||||||
|
const margins = xlsxPageMargins[preset];
|
||||||
|
return (
|
||||||
|
isCloseDimension(left, margins.left) &&
|
||||||
|
isCloseDimension(right, margins.right) &&
|
||||||
|
isCloseDimension(top, margins.top) &&
|
||||||
|
isCloseDimension(bottom, margins.bottom) &&
|
||||||
|
isCloseDimension(header, margins.header) &&
|
||||||
|
isCloseDimension(footer, margins.footer)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCloseDimension(actual: number, expected: number) {
|
||||||
|
return Math.abs(actual - expected) <= 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
function buildRowAttributes(height?: number, hidden?: boolean) {
|
function buildRowAttributes(height?: number, hidden?: boolean) {
|
||||||
return `${height ? ` ht="${formatSheetDimension(height)}" customHeight="1"` : ""}${hidden ? ' hidden="1"' : ""}`;
|
return `${height ? ` ht="${formatSheetDimension(height)}" customHeight="1"` : ""}${hidden ? ' hidden="1"' : ""}`;
|
||||||
}
|
}
|
||||||
@@ -1820,6 +2128,11 @@ async function readOptionalRelationships(zip: ZipInstance, parser: InstanceType<
|
|||||||
return file ? parser.parse(await file.async("string")) : undefined;
|
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) {
|
async function readZipText(zip: ZipInstance, path: string) {
|
||||||
const file = zip.file(path);
|
const file = zip.file(path);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -1867,6 +2180,11 @@ function relationshipTargetsByType(relationshipsXml: unknown, type: string, sour
|
|||||||
return targets;
|
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) {
|
function resolvePartTarget(baseDirectory: string, target: string) {
|
||||||
const normalizedTarget = target.replace(/\\/g, "/");
|
const normalizedTarget = target.replace(/\\/g, "/");
|
||||||
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${baseDirectory}/${normalizedTarget}`;
|
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${baseDirectory}/${normalizedTarget}`;
|
||||||
@@ -2046,6 +2364,17 @@ function normalizeWhitespace(value: string) {
|
|||||||
return value.replace(/\s+/g, " ").trim();
|
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>) {
|
function sortedCellEntries(cells: Record<string, string>) {
|
||||||
return Object.entries(cells)
|
return Object.entries(cells)
|
||||||
.filter(([address, value]) => isCellAddress(address) && value !== "")
|
.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> = {}) {
|
function sortedWorksheetEntries(cells: Record<string, string>, cellFormats: Record<string, CellFormat> = {}) {
|
||||||
const entries = new Map<string, string>();
|
const entries = new Map<string, string>();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
decodeTransferBody,
|
decodeTransferBody,
|
||||||
extractMessageAttachments,
|
extractMessageAttachments,
|
||||||
extractMessageBody,
|
extractMessageBody,
|
||||||
|
extractMessageHtmlBody,
|
||||||
htmlToPlainText,
|
htmlToPlainText,
|
||||||
importEmlFile,
|
importEmlFile,
|
||||||
isSupportedEmlFileName,
|
isSupportedEmlFileName,
|
||||||
@@ -203,6 +204,7 @@ describe("outlookMail helpers", () => {
|
|||||||
|
|
||||||
expect(imported.subject).toBe("Мультипарт");
|
expect(imported.subject).toBe("Мультипарт");
|
||||||
expect(imported.body).toBe("Привет, письмо");
|
expect(imported.body).toBe("Привет, письмо");
|
||||||
|
expect(imported.bodyHtml).toBe("<p>HTML</p>");
|
||||||
expect(imported.from).toBe("sender@example.com");
|
expect(imported.from).toBe("sender@example.com");
|
||||||
expect(imported.to).toBe("reader@example.com");
|
expect(imported.to).toBe("reader@example.com");
|
||||||
expect(imported.cc).toBe("copy@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 () => {
|
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
|
||||||
const message = msgDataToMailMessage(
|
const message = msgDataToMailMessage(
|
||||||
{
|
{
|
||||||
@@ -298,11 +376,13 @@ describe("outlookMail helpers", () => {
|
|||||||
xPriority: "5",
|
xPriority: "5",
|
||||||
priority: "non-urgent",
|
priority: "non-urgent",
|
||||||
subject: "MSG отчет",
|
subject: "MSG отчет",
|
||||||
|
bodyHtml: "<p>HTML текст</p>",
|
||||||
body: "HTML текст",
|
body: "HTML текст",
|
||||||
time: "Mon, 01 Jun 2026 10:00:00 GMT",
|
time: "Mon, 01 Jun 2026 10:00:00 GMT",
|
||||||
read: false,
|
read: false,
|
||||||
flagged: false
|
flagged: false
|
||||||
});
|
});
|
||||||
|
expect(message.bodyHtml).toBe("<p>HTML текст</p>");
|
||||||
expect(message.id).toMatch(/^msg-/);
|
expect(message.id).toMatch(/^msg-/);
|
||||||
expect(message.attachments).toEqual([
|
expect(message.attachments).toEqual([
|
||||||
{
|
{
|
||||||
@@ -355,6 +435,72 @@ describe("outlookMail helpers", () => {
|
|||||||
expect(eml.endsWith("Текст")).toBe(true);
|
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", () => {
|
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
|
||||||
const message: MailMessage = {
|
const message: MailMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
@@ -372,6 +518,7 @@ describe("outlookMail helpers", () => {
|
|||||||
priority: "urgent",
|
priority: "urgent",
|
||||||
subject: "Тема",
|
subject: "Тема",
|
||||||
body: "Текст",
|
body: "Текст",
|
||||||
|
bodyHtml: "<p><strong>Текст</strong></p>",
|
||||||
time: "Mon, 01 Jan 2024 10:00:00 +0000",
|
time: "Mon, 01 Jan 2024 10:00:00 +0000",
|
||||||
read: true,
|
read: true,
|
||||||
flagged: false,
|
flagged: false,
|
||||||
@@ -392,6 +539,7 @@ describe("outlookMail helpers", () => {
|
|||||||
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
|
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
|
||||||
|
|
||||||
expect(eml).toContain("Content-Type: multipart/mixed");
|
expect(eml).toContain("Content-Type: multipart/mixed");
|
||||||
|
expect(eml).toContain("Content-Type: multipart/alternative");
|
||||||
expect(eml).toContain("Cc: copy@example.com");
|
expect(eml).toContain("Cc: copy@example.com");
|
||||||
expect(eml).toContain("Bcc: hidden@example.com");
|
expect(eml).toContain("Bcc: hidden@example.com");
|
||||||
expect(eml).toContain("Reply-To: support@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("Priority: urgent");
|
||||||
expect(eml).toContain("filename*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82%2E%74%78%74");
|
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(extractMessageBody(parsed.headers, parsed.body)).toBe("Текст");
|
||||||
|
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>Текст</strong></p>");
|
||||||
expect(attachments[0]).toMatchObject({
|
expect(attachments[0]).toMatchObject({
|
||||||
fileName: "отчет.txt",
|
fileName: "отчет.txt",
|
||||||
contentType: "text/plain",
|
contentType: "text/plain",
|
||||||
|
|||||||
+255
-5
@@ -81,6 +81,8 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
|||||||
|
|
||||||
const raw = await file.text();
|
const raw = await file.text();
|
||||||
const parsed = parseEmlSource(raw);
|
const parsed = parseEmlSource(raw);
|
||||||
|
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
|
||||||
|
const bodyHtml = htmlWithEmbeddedCidAttachments(extractMessageHtmlBody(parsed.headers, parsed.body), attachments);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `eml-${Date.now()}`,
|
id: `eml-${Date.now()}`,
|
||||||
@@ -94,10 +96,11 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
|||||||
...mailPriorityHeaders(parsed.headers),
|
...mailPriorityHeaders(parsed.headers),
|
||||||
subject: decodeMimeWords(parsed.headers.subject || "Без темы"),
|
subject: decodeMimeWords(parsed.headers.subject || "Без темы"),
|
||||||
body: extractMessageBody(parsed.headers, parsed.body),
|
body: extractMessageBody(parsed.headers, parsed.body),
|
||||||
|
...(bodyHtml ? { bodyHtml } : {}),
|
||||||
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
|
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
|
||||||
read: false,
|
read: false,
|
||||||
flagged: false,
|
flagged: false,
|
||||||
attachments: extractMessageAttachments(parsed.headers, parsed.body)
|
attachments
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +124,7 @@ export function msgDataToMailMessage(reader: Pick<MsgReaderInstance, "getAttachm
|
|||||||
const cc = msgRecipientList(data.recipients, "cc") || decodeMimeWords(headerFields.cc || "");
|
const cc = msgRecipientList(data.recipients, "cc") || decodeMimeWords(headerFields.cc || "");
|
||||||
const bcc = msgRecipientList(data.recipients, "bcc") || decodeMimeWords(headerFields.bcc || "");
|
const bcc = msgRecipientList(data.recipients, "bcc") || decodeMimeWords(headerFields.bcc || "");
|
||||||
const replyTo = decodeMimeWords(headerFields["reply-to"] || "");
|
const replyTo = decodeMimeWords(headerFields["reply-to"] || "");
|
||||||
|
const bodyHtml = msgBodyHtml(data);
|
||||||
return {
|
return {
|
||||||
id: `msg-${Date.now()}`,
|
id: `msg-${Date.now()}`,
|
||||||
folder: "inbox",
|
folder: "inbox",
|
||||||
@@ -133,6 +137,7 @@ export function msgDataToMailMessage(reader: Pick<MsgReaderInstance, "getAttachm
|
|||||||
...mailPriorityHeaders(headerFields),
|
...mailPriorityHeaders(headerFields),
|
||||||
subject: firstText(data.subject, decodeMimeWords(headerFields.subject || ""), "Без темы"),
|
subject: firstText(data.subject, decodeMimeWords(headerFields.subject || ""), "Без темы"),
|
||||||
body: msgBodyText(data),
|
body: msgBodyText(data),
|
||||||
|
...(bodyHtml ? { bodyHtml } : {}),
|
||||||
time: firstText(data.messageDeliveryTime, data.clientSubmitTime, data.creationTime, data.lastModificationTime, headerFields.date, new Date().toLocaleString("ru-RU")),
|
time: firstText(data.messageDeliveryTime, data.clientSubmitTime, data.creationTime, data.lastModificationTime, headerFields.date, new Date().toLocaleString("ru-RU")),
|
||||||
read: false,
|
read: false,
|
||||||
flagged: 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();
|
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[] {
|
export function extractMessageAttachments(headers: MailHeaders, body: string): MailAttachment[] {
|
||||||
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
|
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function htmlWithEmbeddedCidAttachments(html: string, attachments: MailAttachment[] = []): string {
|
||||||
|
if (!html.trim() || attachments.length === 0) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineImagesByContentId = new Map<string, { contentBase64: string; contentType: string }>();
|
||||||
|
attachments.forEach((attachment) => {
|
||||||
|
const contentId = normalizedContentId(attachment.contentId);
|
||||||
|
const contentType = sanitizeMimeType(attachment.contentType);
|
||||||
|
const contentBase64 = attachment.contentBase64.trim();
|
||||||
|
if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inlineImagesByContentId.has(contentId)) {
|
||||||
|
inlineImagesByContentId.set(contentId, { contentBase64, contentType });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inlineImagesByContentId.size === 0) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.replace(/\bsrc\s*=\s*(?:(["'])\s*cid:([^"'>\s]+)\1|cid:([^\s"'<>]+))/gi, (match, quote: string | undefined, quotedCid: string | undefined, unquotedCid: string | undefined) => {
|
||||||
|
const contentId = normalizedContentId(decodeUriComponentSafe(quotedCid ?? unquotedCid ?? ""));
|
||||||
|
const attachment = inlineImagesByContentId.get(contentId);
|
||||||
|
if (!attachment) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUri = `data:${attachment.contentType};base64,${attachment.contentBase64}`;
|
||||||
|
return quote ? `src=${quote}${dataUri}${quote}` : `src="${dataUri}"`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlWithCidReferencesForInlineAttachments(html: string, attachments: MailAttachment[] = []): string {
|
||||||
|
if (!html.trim() || attachments.length === 0) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentIdsByDataUri = new Map<string, string>();
|
||||||
|
attachments.forEach((attachment) => {
|
||||||
|
const contentId = cleanContentId(attachment.contentId);
|
||||||
|
const contentType = sanitizeMimeType(attachment.contentType);
|
||||||
|
const contentBase64 = attachment.contentBase64.trim();
|
||||||
|
if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = cidImageDataUriKey(contentType, contentBase64);
|
||||||
|
if (!contentIdsByDataUri.has(key)) {
|
||||||
|
contentIdsByDataUri.set(key, contentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contentIdsByDataUri.size === 0) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.replace(
|
||||||
|
/\bsrc\s*=\s*(?:(["'])\s*data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^"'>\s]+)\1|data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^\s"'<>]+))/gi,
|
||||||
|
(match, quote: string | undefined, quotedType: string | undefined, quotedBase64: string | undefined, unquotedType: string | undefined, unquotedBase64: string | undefined) => {
|
||||||
|
const contentType = quotedType ?? unquotedType ?? "";
|
||||||
|
const contentBase64 = (quotedBase64 ?? unquotedBase64 ?? "").trim();
|
||||||
|
const contentId = contentIdsByDataUri.get(cidImageDataUriKey(contentType, contentBase64));
|
||||||
|
if (!contentId) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cidReference = escapeHtmlAttribute(`cid:${contentId}`);
|
||||||
|
return quote ? `src=${quote}${cidReference}${quote}` : `src="${cidReference}"`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
||||||
return {
|
return {
|
||||||
id: createAttachmentId(file.name),
|
id: createAttachmentId(file.name),
|
||||||
@@ -271,6 +376,19 @@ function msgBodyText(data: MsgFileData): string {
|
|||||||
return "";
|
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) {
|
function msgCharset(data: MsgFileData) {
|
||||||
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
|
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
|
||||||
return parseContentType(headerFields["content-type"]).parameters.charset || "utf-8";
|
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 {
|
export function buildEmlSource(message: MailMessage): string {
|
||||||
const attachments = message.attachments ?? [];
|
const attachments = message.attachments ?? [];
|
||||||
|
const hasHtmlBody = Boolean(message.bodyHtml?.trim());
|
||||||
if (attachments.length > 0) {
|
if (attachments.length > 0) {
|
||||||
const boundary = createMultipartBoundary(message);
|
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 = [
|
const lines = [
|
||||||
`From: ${sanitizeHeaderValue(message.from)}`,
|
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||||
`To: ${sanitizeHeaderValue(message.to)}`,
|
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||||
@@ -431,11 +573,36 @@ export function buildEmlSource(message: MailMessage): string {
|
|||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||||
"",
|
"",
|
||||||
`--${boundary}`,
|
`--${boundary}`,
|
||||||
"Content-Type: text/plain; charset=utf-8",
|
...(inlineRelatedAttachments.length > 0
|
||||||
"Content-Transfer-Encoding: 8bit",
|
? 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 || ""),
|
`--${boundary}`,
|
||||||
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
...buildPlainTextPartLines(message),
|
||||||
|
`--${boundary}`,
|
||||||
|
...buildHtmlPartLines(message),
|
||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
""
|
""
|
||||||
];
|
];
|
||||||
@@ -461,6 +628,43 @@ export function buildEmlSource(message: MailMessage): string {
|
|||||||
return lines.join("\r\n");
|
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[] {
|
function optionalAddressHeaders(message: MailMessage): string[] {
|
||||||
return [
|
return [
|
||||||
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
||||||
@@ -659,6 +863,14 @@ function createMultipartBoundary(message: MailMessage): string {
|
|||||||
return `qoffice-${normalizedSeed || "message"}`;
|
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[] {
|
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
||||||
const safeContentType = sanitizeMimeType(attachment.contentType);
|
const safeContentType = sanitizeMimeType(attachment.contentType);
|
||||||
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
||||||
@@ -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";
|
return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mediaType) ? mediaType : "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEmbeddableCidImageContentType(value: string): boolean {
|
||||||
|
return ["image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp"].includes(normalizedCidImageContentType(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedCidImageContentType(value: string): string {
|
||||||
|
const contentType = value.toLowerCase();
|
||||||
|
return contentType === "image/jpg" ? "image/jpeg" : contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cidImageDataUriKey(contentType: string, contentBase64: string): string {
|
||||||
|
return `${normalizedCidImageContentType(contentType)}:${contentBase64.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanContentId(value = ""): string {
|
||||||
|
return value.trim().replace(/^<|>$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedContentId(value = ""): string {
|
||||||
|
return cleanContentId(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUriComponentSafe(value: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlAttribute(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
function wrapBase64(value: string): string {
|
function wrapBase64(value: string): string {
|
||||||
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
|
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
listSlidePaths,
|
listSlidePaths,
|
||||||
normalizeWhitespace,
|
normalizeWhitespace,
|
||||||
presentationSlidePaths,
|
presentationSlidePaths,
|
||||||
|
presentationSlideSize,
|
||||||
relationshipTargetByType,
|
relationshipTargetByType,
|
||||||
resolvePptTarget
|
resolvePptTarget
|
||||||
} from "./powerPointOffice";
|
} from "./powerPointOffice";
|
||||||
@@ -81,6 +82,30 @@ describe("powerPointOffice helpers", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("читает размер слайда из presentation.xml", () => {
|
||||||
|
expect(
|
||||||
|
presentationSlideSize({
|
||||||
|
presentation: {
|
||||||
|
sldSz: { cx: "12192000", cy: "6858000" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toBe("wide");
|
||||||
|
expect(
|
||||||
|
presentationSlideSize({
|
||||||
|
presentation: {
|
||||||
|
sldSz: { "@_cx": "9144000", "@_cy": "6858000" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toBe("standard");
|
||||||
|
expect(
|
||||||
|
presentationSlideSize({
|
||||||
|
presentation: {
|
||||||
|
sldSz: { cx: "9144000", cy: "5143500" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("разрешает относительные targets заметок от части слайда", () => {
|
it("разрешает относительные targets заметок от части слайда", () => {
|
||||||
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
|
expect(resolvePptTarget("ppt/slides", "../notesSlides/notesSlide7.xml")).toBe("ppt/notesSlides/notesSlide7.xml");
|
||||||
expect(
|
expect(
|
||||||
@@ -104,7 +129,7 @@ describe("powerPointOffice helpers", () => {
|
|||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
zip.file(
|
zip.file(
|
||||||
"ppt/presentation.xml",
|
"ppt/presentation.xml",
|
||||||
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="255" r:id="rId1"/></p:sldIdLst></p:presentation>`
|
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldSz cx="9144000" cy="6858000"/><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="255" r:id="rId1"/></p:sldIdLst></p:presentation>`
|
||||||
);
|
);
|
||||||
zip.file(
|
zip.file(
|
||||||
"ppt/_rels/presentation.xml.rels",
|
"ppt/_rels/presentation.xml.rels",
|
||||||
@@ -125,6 +150,7 @@ describe("powerPointOffice helpers", () => {
|
|||||||
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
|
const imported = await importPptxFile(new File([blob], "Дорожная карта.pptx", { type: blob.type }));
|
||||||
|
|
||||||
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
|
expect(imported.slides.map((slide) => slide.title)).toEqual(["Первый по порядку", "Первый файл"]);
|
||||||
|
expect(imported.slideSize).toBe("standard");
|
||||||
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
|
expect(imported.slides[0].subtitle).toBe("Содержимое первого слайда");
|
||||||
expect(imported.slides[0].notes).toBe("Заметка докладчика");
|
expect(imported.slides[0].notes).toBe("Заметка докладчика");
|
||||||
});
|
});
|
||||||
@@ -173,6 +199,86 @@ describe("powerPointOffice helpers", () => {
|
|||||||
expect(imported.slides[0].subtitle).toBe("Plan\nBuild\nShip");
|
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 () => {
|
it("импортирует изображения слайда из PPTX media relationships", async () => {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
zip.file(
|
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 () => {
|
it("экспортирует изображения слайда в PPTX media relationships", async () => {
|
||||||
const blob = await exportPptxBlob({
|
const blob = await exportPptxBlob({
|
||||||
title: "Слайды с изображением.pptx",
|
title: "Слайды с изображением.pptx",
|
||||||
@@ -245,6 +380,42 @@ describe("powerPointOffice helpers", () => {
|
|||||||
expect(mediaFiles.length).toBeGreaterThan(0);
|
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 () => {
|
it("экспортирует заметки докладчика в PPTX notesSlide relationships", async () => {
|
||||||
const blob = await exportPptxBlob({
|
const blob = await exportPptxBlob({
|
||||||
title: "Заметки.pptx",
|
title: "Заметки.pptx",
|
||||||
@@ -447,6 +618,208 @@ describe("powerPointOffice helpers", () => {
|
|||||||
expect(exportedSlideXml).toContain('typeface="Times New Roman"');
|
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("нормализует пробелы в импортированном тексте", () => {
|
it("нормализует пробелы в импортированном тексте", () => {
|
||||||
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
|
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>`;
|
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[]) {
|
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
|
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>`)
|
.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>`;
|
.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) {
|
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>`;
|
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
@@ -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";
|
import { officeTitleFromFileName, PPTX_MIME_TYPE, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
||||||
|
|
||||||
export interface ImportedPowerPointDeck {
|
export interface ImportedPowerPointDeck {
|
||||||
title: string;
|
title: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
slides: Slide[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportablePowerPointDeck {
|
export interface ExportablePowerPointDeck {
|
||||||
title: string;
|
title: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
slides: Slide[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SlideTextBlock {
|
interface SlideTextBlock {
|
||||||
text: string;
|
text: string;
|
||||||
|
listStyle?: SlideListStyle;
|
||||||
|
hyperlink?: string;
|
||||||
|
bold?: boolean;
|
||||||
|
italics?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
strike?: boolean;
|
||||||
|
align?: SlideTextAlign;
|
||||||
color?: string;
|
color?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
@@ -37,8 +46,13 @@ const slidePathPattern = /^ppt\/slides\/slide(\d+)\.xml$/;
|
|||||||
const slideRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
|
const slideRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
|
||||||
const notesRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
|
const notesRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
|
||||||
const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
||||||
|
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
||||||
const emuPerInch = 914400;
|
const emuPerInch = 914400;
|
||||||
const slideTransitions = ["fade", "push", "wipe"] as const;
|
const slideTransitions = ["fade", "push", "wipe"] as const;
|
||||||
|
const slideSizeLayouts: Record<SlideSizeId, { width: number; height: number; cx: number; cy: number; pptxLayout: string }> = {
|
||||||
|
wide: { width: 13.333, height: 7.5, cx: 12192000, cy: 6858000, pptxLayout: "LAYOUT_WIDE" },
|
||||||
|
standard: { width: 10, height: 7.5, cx: 9144000, cy: 6858000, pptxLayout: "LAYOUT_4x3" }
|
||||||
|
};
|
||||||
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
|
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
|
||||||
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
|
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
|
||||||
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
|
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
|
||||||
@@ -57,6 +71,7 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
|
|||||||
const presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
|
const presentationXml = await readOptionalParsedXml(zip, parser, "ppt/presentation.xml");
|
||||||
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
|
const presentationRelationshipsXml = await readOptionalParsedXml(zip, parser, "ppt/_rels/presentation.xml.rels");
|
||||||
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
|
const slidePaths = listSlidePaths(zip, presentationXml, presentationRelationshipsXml);
|
||||||
|
const slideSize = presentationSlideSize(presentationXml);
|
||||||
|
|
||||||
if (slidePaths.length === 0) {
|
if (slidePaths.length === 0) {
|
||||||
throw new Error("В файле PowerPoint не найдены слайды.");
|
throw new Error("В файле PowerPoint не найдены слайды.");
|
||||||
@@ -67,8 +82,11 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
|
|||||||
const slideXml = await readRequiredZipText(zip, path, "Не удалось прочитать слайд PowerPoint.");
|
const slideXml = await readRequiredZipText(zip, path, "Не удалось прочитать слайд PowerPoint.");
|
||||||
const parsedSlide = parser.parse(slideXml);
|
const parsedSlide = parser.parse(slideXml);
|
||||||
const orderedSlide = orderedParser.parse(slideXml);
|
const orderedSlide = orderedParser.parse(slideXml);
|
||||||
const slideTextBlocks = mergeOrderedSlideTextBlocks(extractSlideTextBlocks(parsedSlide), extractOrderedTextBodyTexts(orderedSlide));
|
const relationshipsPath = `${partDirectory(path)}/_rels/${partFileName(path)}.rels`;
|
||||||
const images = await extractSlideImages(zip, parser, parsedSlide, path, index);
|
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 notesPath = await slideNotesPath(zip, parser, path);
|
||||||
const notesXml = notesPath ? await readOptionalZipText(zip, notesPath) : await readOptionalZipText(zip, `ppt/notesSlides/notesSlide${index}.xml`);
|
const notesXml = notesPath ? await readOptionalZipText(zip, notesPath) : await readOptionalZipText(zip, `ppt/notesSlides/notesSlide${index}.xml`);
|
||||||
const notes = notesXml
|
const notes = notesXml
|
||||||
@@ -77,28 +95,45 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
|
|||||||
const backgroundColor = slideBackgroundColor(parsedSlide);
|
const backgroundColor = slideBackgroundColor(parsedSlide);
|
||||||
const hidden = slideHidden(parsedSlide);
|
const hidden = slideHidden(parsedSlide);
|
||||||
const transition = slideTransition(parsedSlide);
|
const transition = slideTransition(parsedSlide);
|
||||||
|
const subtitleListStyle = slideTextBlocks
|
||||||
|
.slice(1)
|
||||||
|
.map((block) => block.listStyle)
|
||||||
|
.find((style): style is SlideListStyle => Boolean(style));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `pptx-slide-${index}`,
|
id: `pptx-slide-${index}`,
|
||||||
title: slideTextBlocks[0]?.text || `Слайд ${index}`,
|
title: slideTextBlocks[0]?.text || `Слайд ${index}`,
|
||||||
|
...(slideTextBlocks[0]?.hyperlink ? { titleHyperlink: slideTextBlocks[0].hyperlink } : {}),
|
||||||
subtitle: slideTextBlocks.slice(1).map((block) => block.text).join("\n"),
|
subtitle: slideTextBlocks.slice(1).map((block) => block.text).join("\n"),
|
||||||
|
...(slideTextBlocks[1]?.hyperlink ? { subtitleHyperlink: slideTextBlocks[1].hyperlink } : {}),
|
||||||
notes,
|
notes,
|
||||||
theme: "classic" as const,
|
theme: "classic" as const,
|
||||||
...(hidden ? { hidden } : {}),
|
...(hidden ? { hidden } : {}),
|
||||||
...(transition ? { transition } : {}),
|
...(transition ? { transition } : {}),
|
||||||
...(backgroundColor ? { backgroundColor } : {}),
|
...(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]?.color ? { titleColor: slideTextBlocks[0].color } : {}),
|
||||||
...(slideTextBlocks[0]?.fontSize ? { titleFontSize: slideTextBlocks[0].fontSize } : {}),
|
...(slideTextBlocks[0]?.fontSize ? { titleFontSize: slideTextBlocks[0].fontSize } : {}),
|
||||||
...(slideTextBlocks[0]?.fontFamily ? { titleFontFamily: slideTextBlocks[0].fontFamily } : {}),
|
...(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]?.color ? { subtitleColor: slideTextBlocks[1].color } : {}),
|
||||||
...(slideTextBlocks[1]?.fontSize ? { subtitleFontSize: slideTextBlocks[1].fontSize } : {}),
|
...(slideTextBlocks[1]?.fontSize ? { subtitleFontSize: slideTextBlocks[1].fontSize } : {}),
|
||||||
...(slideTextBlocks[1]?.fontFamily ? { subtitleFontFamily: slideTextBlocks[1].fontFamily } : {}),
|
...(slideTextBlocks[1]?.fontFamily ? { subtitleFontFamily: slideTextBlocks[1].fontFamily } : {}),
|
||||||
|
...(subtitleListStyle ? { subtitleListStyle } : {}),
|
||||||
images
|
images
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return { title, slides };
|
return { title, ...(slideSize ? { slideSize } : {}), slides };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
|
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>;
|
write: (options: { outputType: "blob" | "arraybuffer" }) => Promise<Blob | ArrayBuffer>;
|
||||||
};
|
};
|
||||||
|
const slideSize = normalizedSlideSizeId(deck.slideSize);
|
||||||
|
const slideLayout = slideSizeLayouts[slideSize];
|
||||||
|
|
||||||
pptx.layout = "LAYOUT_WIDE";
|
pptx.layout = slideLayout.pptxLayout;
|
||||||
pptx.author = "QOffice";
|
pptx.author = "QOffice";
|
||||||
pptx.company = "QOffice";
|
pptx.company = "QOffice";
|
||||||
pptx.subject = "Экспорт QPowerPoint";
|
pptx.subject = "Экспорт QPowerPoint";
|
||||||
@@ -134,6 +171,18 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
|
|||||||
const subtitleFontSize = normalizeFontSize(sourceSlide.subtitleFontSize) || 18;
|
const subtitleFontSize = normalizeFontSize(sourceSlide.subtitleFontSize) || 18;
|
||||||
const titleFontFamily = normalizeFontFamily(sourceSlide.titleFontFamily) || "Arial";
|
const titleFontFamily = normalizeFontFamily(sourceSlide.titleFontFamily) || "Arial";
|
||||||
const subtitleFontFamily = normalizeFontFamily(sourceSlide.subtitleFontFamily) || "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();
|
const slide = pptx.addSlide();
|
||||||
slide.background = { color: normalizeHexColor(sourceSlide.backgroundColor) || colors.background };
|
slide.background = { color: normalizeHexColor(sourceSlide.backgroundColor) || colors.background };
|
||||||
sourceSlide.images?.forEach((image, index) => {
|
sourceSlide.images?.forEach((image, index) => {
|
||||||
@@ -142,38 +191,51 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageHyperlink = normalizedExternalHyperlinkTarget(image.hyperlink);
|
||||||
slide.addImage({
|
slide.addImage({
|
||||||
data,
|
data,
|
||||||
x: image.x,
|
x: image.x,
|
||||||
y: image.y,
|
y: image.y,
|
||||||
w: image.width,
|
w: image.width,
|
||||||
h: image.height,
|
h: image.height,
|
||||||
altText: image.alt || `Изображение ${index + 1}`
|
altText: image.alt || `Изображение ${index + 1}`,
|
||||||
|
...(imageHyperlink ? { hyperlink: { url: imageHyperlink } } : {})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
slide.addText(sourceSlide.title || "Без заголовка", {
|
slide.addText(sourceSlide.title || "Без заголовка", {
|
||||||
x: 0.7,
|
x: 0.7,
|
||||||
y: 0.65,
|
y: 0.65,
|
||||||
w: 11.9,
|
w: Math.max(1, slideLayout.width - 1.4),
|
||||||
h: 0.75,
|
h: 0.75,
|
||||||
fontFace: titleFontFamily,
|
fontFace: titleFontFamily,
|
||||||
fontSize: titleFontSize,
|
fontSize: titleFontSize,
|
||||||
bold: true,
|
...(titleAlign ? { align: titleAlign } : {}),
|
||||||
|
bold: titleBold,
|
||||||
|
italic: titleItalics,
|
||||||
|
...(titleUnderline ? { underline: { style: "sng" } } : {}),
|
||||||
|
...(titleStrike ? { strike: "sngStrike" } : {}),
|
||||||
color: titleColor,
|
color: titleColor,
|
||||||
margin: 0.04,
|
margin: 0.04,
|
||||||
breakLine: false
|
breakLine: false,
|
||||||
|
...(titleHyperlink ? { hyperlink: { url: titleHyperlink } } : {})
|
||||||
});
|
});
|
||||||
slide.addText(sourceSlide.subtitle || "", {
|
slide.addText(sourceSlide.subtitle || "", {
|
||||||
x: 0.75,
|
x: 0.75,
|
||||||
y: 1.65,
|
y: 1.65,
|
||||||
w: 11.5,
|
w: Math.max(1, slideLayout.width - 1.5),
|
||||||
h: 4.35,
|
h: Math.max(1, slideLayout.height - 3.15),
|
||||||
fontFace: subtitleFontFamily,
|
fontFace: subtitleFontFamily,
|
||||||
fontSize: subtitleFontSize,
|
fontSize: subtitleFontSize,
|
||||||
|
...(subtitleAlign ? { align: subtitleAlign } : {}),
|
||||||
|
bold: subtitleBold,
|
||||||
|
italic: subtitleItalics,
|
||||||
|
...(subtitleUnderline ? { underline: { style: "sng" } } : {}),
|
||||||
|
...(subtitleStrike ? { strike: "sngStrike" } : {}),
|
||||||
color: subtitleColor,
|
color: subtitleColor,
|
||||||
valign: "top",
|
valign: "top",
|
||||||
fit: "shrink",
|
fit: "shrink",
|
||||||
breakLine: false
|
breakLine: false,
|
||||||
|
...(subtitleHyperlink ? { hyperlink: { url: subtitleHyperlink } } : {})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sourceSlide.notes.trim() && slide.addNotes) {
|
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 result = await pptx.write({ outputType: "blob" });
|
||||||
const blob = result instanceof Blob ? result : new Blob([result], { type: PPTX_MIME_TYPE });
|
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[] {
|
export function extractTextBlocks(xmlNode: unknown): string[] {
|
||||||
@@ -192,21 +256,35 @@ export function extractTextBlocks(xmlNode: unknown): string[] {
|
|||||||
return blocks.map(normalizeWhitespace).filter(Boolean);
|
return blocks.map(normalizeWhitespace).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSlideTextBlocks(slideXml: unknown): SlideTextBlock[] {
|
function extractSlideTextBlocks(slideXml: unknown, hyperlinkTargets: Record<string, string> = {}): SlideTextBlock[] {
|
||||||
const textBodies = recordsByKey(slideXml, "txBody");
|
const textBodies = slideTextBodyRecords(slideXml);
|
||||||
const blocks = textBodies
|
const blocks = textBodies
|
||||||
.map((textBody) => {
|
.map(({ textBody, hyperlinkSource }): SlideTextBlock | null => {
|
||||||
const text = extractTextBodyText(textBody);
|
const text = extractTextBodyText(textBody);
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runProperties = firstRecordByKey(textBody, "rPr");
|
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 color = runTextColor(runProperties);
|
||||||
const fontSize = runTextFontSize(runProperties);
|
const fontSize = runTextFontSize(runProperties);
|
||||||
const fontFamily = runTextFontFamily(runProperties);
|
const fontFamily = runTextFontFamily(runProperties);
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
|
...(listStyle ? { listStyle } : {}),
|
||||||
|
...(hyperlink ? { hyperlink } : {}),
|
||||||
|
bold,
|
||||||
|
italics,
|
||||||
|
underline,
|
||||||
|
strike,
|
||||||
|
...(align ? { align } : {}),
|
||||||
...(color ? { color } : {}),
|
...(color ? { color } : {}),
|
||||||
...(fontSize ? { fontSize } : {}),
|
...(fontSize ? { fontSize } : {}),
|
||||||
...(fontFamily ? { fontFamily } : {})
|
...(fontFamily ? { fontFamily } : {})
|
||||||
@@ -217,6 +295,28 @@ function extractSlideTextBlocks(slideXml: unknown): SlideTextBlock[] {
|
|||||||
return blocks.length > 0 ? blocks : extractTextBlocks(slideXml).map((text) => ({ text }));
|
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[]) {
|
function mergeOrderedSlideTextBlocks(styledBlocks: SlideTextBlock[], orderedTexts: string[]) {
|
||||||
if (orderedTexts.length === 0) {
|
if (orderedTexts.length === 0) {
|
||||||
return styledBlocks;
|
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) {
|
function extractOrderedTextBodyTexts(xmlNode: unknown) {
|
||||||
return orderedElementsByName(xmlNode, "txBody").map(orderedTextBodyText).filter(Boolean);
|
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));
|
return slideIds.map((id) => relationshipTargets[id]).filter((target): target is string => Boolean(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function presentationSlideSize(presentationXml: unknown): SlideSizeId | undefined {
|
||||||
|
const slideSize = childRecord(childRecord(presentationXml, "presentation"), "sldSz");
|
||||||
|
const cx = numberAttribute(slideSize, "cx");
|
||||||
|
const cy = numberAttribute(slideSize, "cy");
|
||||||
|
if (!cx || !cy) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(slideSizeLayouts).find(([, layout]) => Math.abs(cx - layout.cx) <= 5000 && Math.abs(cy - layout.cy) <= 5000)?.[0] as
|
||||||
|
| SlideSizeId
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
|
export function relationshipTargetsById(relationshipsXml: unknown, type?: string, sourcePartPath = "ppt/presentation.xml"): Record<string, string> {
|
||||||
const relationships = childRecord(relationshipsXml, "Relationships");
|
const relationships = childRecord(relationshipsXml, "Relationships");
|
||||||
const targets: Record<string, string> = {};
|
const targets: Record<string, string> = {};
|
||||||
@@ -321,6 +463,9 @@ export function relationshipTargetByType(relationshipsXml: unknown, type: string
|
|||||||
export function resolvePptTarget(baseDirectory: string, target: string): string {
|
export function resolvePptTarget(baseDirectory: string, target: string): string {
|
||||||
const normalizedBase = baseDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
|
const normalizedBase = baseDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const normalizedTarget = target.replace(/\\/g, "/");
|
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 rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${normalizedBase}/${normalizedTarget}`;
|
||||||
const segments: string[] = [];
|
const segments: string[] = [];
|
||||||
|
|
||||||
@@ -344,6 +489,10 @@ export function normalizeWhitespace(value: string): string {
|
|||||||
return value.replace(/\s+/g, " ").trim();
|
return value.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId {
|
||||||
|
return value === "standard" ? "standard" : "wide";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTextWithLineBreaks(value: string): string {
|
function normalizeTextWithLineBreaks(value: string): string {
|
||||||
return value
|
return value
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -358,14 +507,16 @@ async function extractSlideImages(
|
|||||||
parser: { parse: (xml: string) => unknown },
|
parser: { parse: (xml: string) => unknown },
|
||||||
slideXml: unknown,
|
slideXml: unknown,
|
||||||
slidePath: string,
|
slidePath: string,
|
||||||
slideIndex: number
|
slideIndex: number,
|
||||||
|
relationshipsXml?: unknown
|
||||||
): Promise<SlideImage[]> {
|
): Promise<SlideImage[]> {
|
||||||
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
|
const relationshipsPath = `${partDirectory(slidePath)}/_rels/${partFileName(slidePath)}.rels`;
|
||||||
const relationshipsXml = await readOptionalParsedXml(zip, parser, relationshipsPath);
|
const parsedRelationshipsXml = relationshipsXml ?? (await readOptionalParsedXml(zip, parser, relationshipsPath));
|
||||||
const imageTargets = relationshipTargetsById(relationshipsXml, imageRelationshipType, slidePath);
|
const imageTargets = relationshipTargetsById(parsedRelationshipsXml, imageRelationshipType, slidePath);
|
||||||
|
const hyperlinkTargets = relationshipTargetsById(parsedRelationshipsXml, hyperlinkRelationshipType, slidePath);
|
||||||
const pictures = recordsByKey(slideXml, "pic");
|
const pictures = recordsByKey(slideXml, "pic");
|
||||||
const images = await Promise.all(
|
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));
|
return images.filter((image): image is SlideImage => Boolean(image));
|
||||||
@@ -375,6 +526,7 @@ async function slideImageFromPicture(
|
|||||||
zip: ZipArchive,
|
zip: ZipArchive,
|
||||||
picture: Record<string, unknown>,
|
picture: Record<string, unknown>,
|
||||||
imageTargets: Record<string, string>,
|
imageTargets: Record<string, string>,
|
||||||
|
hyperlinkTargets: Record<string, string>,
|
||||||
slideIndex: number,
|
slideIndex: number,
|
||||||
imageIndex: number
|
imageIndex: number
|
||||||
): Promise<SlideImage | null> {
|
): Promise<SlideImage | null> {
|
||||||
@@ -385,14 +537,27 @@ async function slideImageFromPicture(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hyperlink = pictureHyperlink(picture, hyperlinkTargets);
|
||||||
return {
|
return {
|
||||||
id: `pptx-slide-${slideIndex}-image-${imageIndex + 1}`,
|
id: `pptx-slide-${slideIndex}-image-${imageIndex + 1}`,
|
||||||
src,
|
src,
|
||||||
alt: pictureAltText(picture) || `Изображение ${imageIndex + 1}`,
|
alt: pictureAltText(picture) || `Изображение ${imageIndex + 1}`,
|
||||||
|
...(hyperlink ? { hyperlink } : {}),
|
||||||
...pictureBounds(picture)
|
...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>) {
|
function pictureRelationshipId(picture: Record<string, unknown>) {
|
||||||
const blip = firstRecordByKey(picture, "blip");
|
const blip = firstRecordByKey(picture, "blip");
|
||||||
return stringValue(
|
return stringValue(
|
||||||
@@ -482,7 +647,7 @@ async function applySlideXmlOverridesToPptxBlob(blob: Blob, slides: Slide[]) {
|
|||||||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
slides.map(async (slide, index) => {
|
slides.map(async (slide, index) => {
|
||||||
if (!slide.hidden && !slide.transition) {
|
if (!slide.hidden && !slide.transition && !slide.subtitleListStyle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +664,8 @@ async function applySlideXmlOverridesToPptxBlob(blob: Blob, slides: Slide[]) {
|
|||||||
|
|
||||||
function pptxSlideXmlWithOverrides(xml: string, slide: Slide) {
|
function pptxSlideXmlWithOverrides(xml: string, slide: Slide) {
|
||||||
const withHiddenState = slide.hidden ? pptxSlideXmlWithHiddenState(xml) : xml;
|
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) {
|
function pptxSlideXmlWithHiddenState(xml: string) {
|
||||||
@@ -524,17 +690,80 @@ function pptxSlideXmlWithTransition(xml: string, transition: NonNullable<Slide["
|
|||||||
return xml.replace(/<p:sld\b[^>]*>/, (match) => `${match}${transitionXml}`);
|
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) {
|
function normalizeHexColor(value?: string) {
|
||||||
const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? "";
|
const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? "";
|
||||||
return /^[0-9A-F]{6}$/.test(raw) ? raw : "";
|
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>) {
|
function runTextColor(runProperties: Record<string, unknown>) {
|
||||||
const solidFill = childRecord(runProperties, "solidFill");
|
const solidFill = childRecord(runProperties, "solidFill");
|
||||||
const srgbColor = childRecord(solidFill, "srgbClr");
|
const srgbColor = childRecord(solidFill, "srgbClr");
|
||||||
return normalizeHexColor(stringValue(srgbColor.val ?? srgbColor["@_val"]));
|
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>) {
|
function runTextFontSize(runProperties: Record<string, unknown>) {
|
||||||
const rawSize = numberAttribute(runProperties, "sz");
|
const rawSize = numberAttribute(runProperties, "sz");
|
||||||
return rawSize ? normalizeFontSize(rawSize / 100) : undefined;
|
return rawSize ? normalizeFontSize(rawSize / 100) : undefined;
|
||||||
@@ -561,6 +790,26 @@ function normalizeFontFamily(value?: string) {
|
|||||||
return normalized && normalized.toLowerCase() !== "arial" ? normalized : "";
|
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) {
|
function numberAttribute(record: Record<string, unknown>, name: string) {
|
||||||
const value = Number.parseFloat(stringValue(record[name] ?? record[`@_${name}`]));
|
const value = Number.parseFloat(stringValue(record[name] ?? record[`@_${name}`]));
|
||||||
return Number.isFinite(value) ? value : undefined;
|
return Number.isFinite(value) ? value : undefined;
|
||||||
@@ -571,6 +820,12 @@ function isFalseXmlBoolean(value: unknown) {
|
|||||||
return normalized === "0" || normalized === "false";
|
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) {
|
function emuToInches(value: number) {
|
||||||
return Math.max(0, Math.round((value / emuPerInch) * 1000) / 1000);
|
return Math.max(0, Math.round((value / emuPerInch) * 1000) / 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ describe("wordOffice helpers", () => {
|
|||||||
const blocks = htmlToWordBlocks(`
|
const blocks = htmlToWordBlocks(`
|
||||||
<h1>План проекта</h1>
|
<h1>План проекта</h1>
|
||||||
<h2>Задачи</h2>
|
<h2>Задачи</h2>
|
||||||
|
<h3>Контроль качества</h3>
|
||||||
|
<h4>Сценарии</h4>
|
||||||
|
<h5>Шаги</h5>
|
||||||
|
<h6>Детали</h6>
|
||||||
<p>Первый абзац.</p>
|
<p>Первый абзац.</p>
|
||||||
<ul><li>Подготовить документ</li><li>Проверить таблицу</li></ul>
|
<ul><li>Подготовить документ</li><li>Проверить таблицу</li></ul>
|
||||||
`);
|
`);
|
||||||
@@ -17,6 +21,10 @@ describe("wordOffice helpers", () => {
|
|||||||
expect(blocks).toEqual([
|
expect(blocks).toEqual([
|
||||||
{ type: "heading", level: 1, text: "План проекта", runs: [{ text: "План проекта" }] },
|
{ type: "heading", level: 1, text: "План проекта", runs: [{ text: "План проекта" }] },
|
||||||
{ type: "heading", level: 2, 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: "paragraph", text: "Первый абзац.", runs: [{ text: "Первый абзац." }] },
|
||||||
{
|
{
|
||||||
type: "list",
|
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", () => {
|
it("сохраняет базовое inline-форматирование текста для DOCX", () => {
|
||||||
const blocks = htmlToWordBlocks(`
|
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>
|
<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", () => {
|
it("сохраняет выравнивание абзацев и заголовков для DOCX", () => {
|
||||||
const blocks = htmlToWordBlocks(`
|
const blocks = htmlToWordBlocks(`
|
||||||
<h1 align="right">Итоги</h1>
|
<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>/);
|
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 () => {
|
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 blob = await exportDocxBlob("Разрыв.docx", '<p>Before<span data-qoffice-page-break="true"></span>After</p>');
|
||||||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
@@ -218,6 +279,17 @@ describe("wordOffice helpers", () => {
|
|||||||
expect(documentXml).toContain('<w:jc w:val="right"/>');
|
expect(documentXml).toContain('<w:jc w:val="right"/>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes QWord page setup to exported DOCX section properties", async () => {
|
||||||
|
const blob = await exportDocxBlob("Макет.docx", "<p>Landscape letter</p>", {
|
||||||
|
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" }
|
||||||
|
});
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||||||
|
|
||||||
|
expect(documentXml).toMatch(/<w:pgSz\b[^>]*w:w="15840"[^>]*w:h="12240"[^>]*w:orient="landscape"[^>]*\/>/);
|
||||||
|
expect(documentXml).toMatch(/<w:pgMar\b[^>]*w:top="720"[^>]*w:right="720"[^>]*w:bottom="720"[^>]*w:left="720"[^>]*\/>/);
|
||||||
|
});
|
||||||
|
|
||||||
it("записывает внешние гиперссылки в DOCX relationships", async () => {
|
it("записывает внешние гиперссылки в DOCX relationships", async () => {
|
||||||
const blob = await exportDocxBlob(
|
const blob = await exportDocxBlob(
|
||||||
"Ссылки.docx",
|
"Ссылки.docx",
|
||||||
@@ -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> </td><td><br></td></tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
const blocks = htmlToWordBlocks(html);
|
||||||
|
const blob = await exportDocxBlob("Пустая таблица.docx", html);
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
rows: [
|
||||||
|
["", "", "Итог"],
|
||||||
|
["", "", ""]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(documentXml).toContain("<w:tbl>");
|
||||||
|
expect(documentXml.match(/<w:tr>/g)).toHaveLength(2);
|
||||||
|
expect(documentXml.match(/<w:tc>/g)).toHaveLength(6);
|
||||||
|
expect(documentXml).toContain("Итог");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет inline-форматирование ячеек таблицы для DOCX", async () => {
|
||||||
|
const html = `
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Итог</strong> <em>готов</em></td>
|
||||||
|
<td><a href="https://example.com/report">Отчет</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
const blocks = htmlToWordBlocks(html);
|
||||||
|
const blob = await exportDocxBlob("Формат таблицы.docx", html);
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
|
||||||
|
const relationshipsXml = (await zip.file("word/_rels/document.xml.rels")?.async("string")) ?? "";
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
rows: [
|
||||||
|
[
|
||||||
|
{ text: "Итог готов", runs: [{ text: "Итог", bold: true }, { text: " " }, { text: "готов", italics: true }] },
|
||||||
|
{ text: "Отчет", runs: [{ text: "Отчет", href: "https://example.com/report" }] }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(documentXml).toContain("<w:tbl>");
|
||||||
|
expect(documentXml).toContain("<w:b/>");
|
||||||
|
expect(documentXml).toContain("<w:i/>");
|
||||||
|
expect(documentXml).toContain("<w:hyperlink");
|
||||||
|
expect(relationshipsXml).toContain('Target="https://example.com/report"');
|
||||||
|
});
|
||||||
|
|
||||||
it("сохраняет размер шрифта из HTML inline-стилей для DOCX", () => {
|
it("сохраняет размер шрифта из HTML inline-стилей для DOCX", () => {
|
||||||
const blocks = htmlToWordBlocks(`
|
const blocks = htmlToWordBlocks(`
|
||||||
<p>Normal <span style="font-size: 18pt;">Large</span> <span style="font-size: 24px;">Pixel size</span></p>
|
<p>Normal <span style="font-size: 18pt;">Large</span> <span style="font-size: 24px;">Pixel size</span></p>
|
||||||
@@ -363,6 +511,27 @@ describe("wordOffice helpers", () => {
|
|||||||
expect(wordInlineRunsToTextRunOptions([{ text: "Deleted", strike: true }])).toEqual([{ text: "Deleted", strike: true }]);
|
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 () => {
|
it("записывает зачеркнутый текст в XML экспортированного DOCX", async () => {
|
||||||
const blob = await exportDocxBlob("Зачеркнутый.docx", "<p><s>Удаленный текст</s></p>");
|
const blob = await exportDocxBlob("Зачеркнутый.docx", "<p><s>Удаленный текст</s></p>");
|
||||||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
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 () => {
|
it("записывает отступы абзацев в w:ind экспортированного DOCX", async () => {
|
||||||
const blob = await exportDocxBlob(
|
const blob = await exportDocxBlob(
|
||||||
"Indent.docx",
|
"Indent.docx",
|
||||||
@@ -395,6 +588,30 @@ describe("wordOffice helpers", () => {
|
|||||||
expect(documentXml).toMatch(/<w:ind\b(?=[^>]*w:left="720")(?=[^>]*w:hanging="240")[^>]*\/>/);
|
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", () => {
|
it("нормализует HTML после импорта DOCX", () => {
|
||||||
expect(normalizeImportedDocxHtml(" <p>Готово</p>\n")).toBe("<p>Готово</p>");
|
expect(normalizeImportedDocxHtml(" <p>Готово</p>\n")).toBe("<p>Готово</p>");
|
||||||
});
|
});
|
||||||
|
|||||||
+593
-53
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,44 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import JSZip from "jszip";
|
||||||
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
|
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
|
||||||
|
|
||||||
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
const tinyPngDataUri = `data:image/png;base64,${tinyPngBase64}`;
|
const tinyPngDataUri = `data:image/png;base64,${tinyPngBase64}`;
|
||||||
|
|
||||||
describe("wordOffice DOCX import", () => {
|
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 () => {
|
it("imports DOCX text color and highlight from OOXML", async () => {
|
||||||
const blob = await exportDocxBlob(
|
const blob = await exportDocxBlob(
|
||||||
"Color.docx",
|
"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 () => {
|
it("imports DOCX line breaks from OOXML", async () => {
|
||||||
const blob = await exportDocxBlob("LineBreak.docx", "<p>Line 1<br>Line 2</p>");
|
const blob = await exportDocxBlob("LineBreak.docx", "<p>Line 1<br>Line 2</p>");
|
||||||
const file = new File([blob], "LineBreak.docx", {
|
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 () => {
|
it("imports styled DOCX content when the document also contains images", async () => {
|
||||||
const blob = await exportDocxBlob(
|
const blob = await exportDocxBlob(
|
||||||
"Mixed.docx",
|
"Mixed.docx",
|
||||||
@@ -274,4 +413,52 @@ describe("wordOffice DOCX import", () => {
|
|||||||
{ type: "paragraph", text: "Hanging", runs: [{ text: "Hanging" }], indent: { left: 720, hanging: 240 } }
|
{ 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
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,30 @@
|
|||||||
export type AppId = "word" | "sheets" | "slides" | "mail";
|
export type AppId = "word" | "sheets" | "slides" | "mail";
|
||||||
|
|
||||||
|
export type WordPageSizeId = "a4" | "letter";
|
||||||
|
export type WordPageOrientation = "portrait" | "landscape";
|
||||||
|
export type WordPageMarginPreset = "normal" | "narrow" | "wide";
|
||||||
|
export type SheetPageSizeId = "a4" | "letter";
|
||||||
|
export type SheetPageOrientation = "portrait" | "landscape";
|
||||||
|
export type SheetPageMarginPreset = "normal" | "narrow" | "wide";
|
||||||
|
|
||||||
|
export interface WordPageSetup {
|
||||||
|
size: WordPageSizeId;
|
||||||
|
orientation: WordPageOrientation;
|
||||||
|
marginPreset: WordPageMarginPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SheetPageSetup {
|
||||||
|
size: SheetPageSizeId;
|
||||||
|
orientation: SheetPageOrientation;
|
||||||
|
marginPreset: SheetPageMarginPreset;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WordDocument {
|
export interface WordDocument {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
pageSetup?: WordPageSetup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Workbook {
|
export interface Workbook {
|
||||||
@@ -29,9 +49,11 @@ export interface Sheet {
|
|||||||
showGridLines?: boolean;
|
showGridLines?: boolean;
|
||||||
zoomScale?: number;
|
zoomScale?: number;
|
||||||
printArea?: string;
|
printArea?: string;
|
||||||
|
pageSetup?: SheetPageSetup;
|
||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
|
comments?: Record<string, string>;
|
||||||
cellFormats?: Record<string, CellFormat>;
|
cellFormats?: Record<string, CellFormat>;
|
||||||
charts?: SheetChart[];
|
charts?: SheetChart[];
|
||||||
}
|
}
|
||||||
@@ -75,38 +97,57 @@ export interface SheetChart {
|
|||||||
valueRange: string;
|
valueRange: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SlideSizeId = "wide" | "standard";
|
||||||
|
|
||||||
export interface SlideDeck {
|
export interface SlideDeck {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
activeSlideId: string;
|
activeSlideId: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
slides: Slide[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Slide {
|
export interface Slide {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
titleHyperlink?: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
subtitleHyperlink?: string;
|
||||||
|
subtitleListStyle?: SlideListStyle;
|
||||||
notes: string;
|
notes: string;
|
||||||
theme: "classic" | "ocean" | "graphite";
|
theme: "classic" | "ocean" | "graphite";
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
transition?: SlideTransition;
|
transition?: SlideTransition;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
titleBold?: boolean;
|
||||||
|
titleItalics?: boolean;
|
||||||
|
titleUnderline?: boolean;
|
||||||
|
titleStrike?: boolean;
|
||||||
|
titleAlign?: SlideTextAlign;
|
||||||
titleColor?: string;
|
titleColor?: string;
|
||||||
titleFontSize?: number;
|
titleFontSize?: number;
|
||||||
titleFontFamily?: string;
|
titleFontFamily?: string;
|
||||||
|
subtitleBold?: boolean;
|
||||||
|
subtitleItalics?: boolean;
|
||||||
|
subtitleUnderline?: boolean;
|
||||||
|
subtitleStrike?: boolean;
|
||||||
|
subtitleAlign?: SlideTextAlign;
|
||||||
subtitleColor?: string;
|
subtitleColor?: string;
|
||||||
subtitleFontSize?: number;
|
subtitleFontSize?: number;
|
||||||
subtitleFontFamily?: string;
|
subtitleFontFamily?: string;
|
||||||
images?: SlideImage[];
|
images?: SlideImage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SlideListStyle = "bullet" | "number";
|
||||||
|
export type SlideTextAlign = "left" | "center" | "right";
|
||||||
export type SlideTransition = "fade" | "push" | "wipe";
|
export type SlideTransition = "fade" | "push" | "wipe";
|
||||||
|
|
||||||
export interface SlideImage {
|
export interface SlideImage {
|
||||||
id: string;
|
id: string;
|
||||||
src: string;
|
src: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
hyperlink?: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -139,6 +180,7 @@ export interface MailMessage {
|
|||||||
priority?: string;
|
priority?: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
bodyHtml?: string;
|
||||||
time: string;
|
time: string;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
flagged: boolean;
|
flagged: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Ollama/Gemma Task: QOffice Next Iteration
|
||||||
|
|
||||||
|
You are Gemma running through Ollama. Work on the local repository:
|
||||||
|
|
||||||
|
```text
|
||||||
|
G:\Repos\QOffice
|
||||||
|
```
|
||||||
|
|
||||||
|
Project objective: QOffice is a cross-platform Russian-language office suite intended to move toward Microsoft Office-like functionality. It contains separate applications: QWord, QExcell, QPowerPoint, and QOutlook. Microsoft Office file compatibility is a core requirement.
|
||||||
|
|
||||||
|
Current verified repository state:
|
||||||
|
|
||||||
|
- `git status --short --branch` returned `## main...origin/main`.
|
||||||
|
- Latest commit before this task: `5487d04 Add QPowerPoint slide size support`.
|
||||||
|
- QWord already has DOCX import/export, page setup controls, and a Microsoft Word-like ribbon.
|
||||||
|
- QWord does not yet implement DOCX header/footer support.
|
||||||
|
|
||||||
|
## Iteration Goal
|
||||||
|
|
||||||
|
Implement QWord support for Microsoft Word / DOCX headers and footers.
|
||||||
|
|
||||||
|
The feature must be real DOCX compatibility work. Do not implement this only as internal JSON state. Import and export must read and write actual DOCX header/footer parts or clearly explain why that is impossible after inspecting the local `docx` package API.
|
||||||
|
|
||||||
|
## Files To Inspect First
|
||||||
|
|
||||||
|
First check for local project instructions:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg --files -g AGENTS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inspect these files before editing:
|
||||||
|
|
||||||
|
- `src/types.ts`
|
||||||
|
- `src/data/sampleData.ts`
|
||||||
|
- `src/App.tsx`
|
||||||
|
- `src/io/wordOffice.ts`
|
||||||
|
- `src/io/wordOffice.test.ts`
|
||||||
|
- `src/io/wordOfficeImport.test.ts`
|
||||||
|
- `src/features/qword/QWord.tsx`
|
||||||
|
- `src/features/qword/QWord.test.tsx`
|
||||||
|
|
||||||
|
## Verified Starting Points
|
||||||
|
|
||||||
|
- `src/types.ts`: `WordDocument` currently has `id`, `title`, `updatedAt`, `html`, and optional `pageSetup`.
|
||||||
|
- `src/io/wordOffice.ts`: `importDocxFile` imports DOCX through `mammoth`, `styledDocxHtmlFromArrayBuffer`, and `docxPageSetupFromArrayBuffer`.
|
||||||
|
- `src/io/wordOffice.ts`: `exportDocxBlob` currently accepts only `pageSetup` in its options object.
|
||||||
|
- `src/io/wordOffice.ts`: `lastDocxSectionProperties` already finds the final `w:sectPr`.
|
||||||
|
- `src/features/qword/QWord.tsx`: `openDocx` applies imported `title`, `html`, and `pageSetup`.
|
||||||
|
- `src/features/qword/QWord.tsx`: `saveDocx` passes only `pageSetup` into `exportDocxBlob`.
|
||||||
|
- QWord's `layout` ribbon tab already contains page setup controls.
|
||||||
|
|
||||||
|
## Implementation Requirements
|
||||||
|
|
||||||
|
1. Data model:
|
||||||
|
|
||||||
|
Add optional QWord document fields. Preferred names:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
headerHtml?: string;
|
||||||
|
footerHtml?: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing saved documents without these fields must remain valid.
|
||||||
|
|
||||||
|
2. DOCX import:
|
||||||
|
|
||||||
|
- Read `word/document.xml`.
|
||||||
|
- Find the final applicable `w:sectPr`.
|
||||||
|
- Read `w:headerReference` and `w:footerReference`.
|
||||||
|
- Resolve their relationship ids through `word/_rels/document.xml.rels`.
|
||||||
|
- Read the corresponding `word/header*.xml` and `word/footer*.xml` parts.
|
||||||
|
- For this first iteration, support the `default` reference type.
|
||||||
|
- Do not break documents that also contain `first` or `even` references; those can be ignored for now.
|
||||||
|
- Convert header/footer content into HTML using the existing `wordOffice.ts` helper patterns. Preserve at least paragraphs and basic inline text styling.
|
||||||
|
|
||||||
|
3. DOCX export:
|
||||||
|
|
||||||
|
- Extend `exportDocxBlob` options to accept `headerHtml` and `footerHtml`.
|
||||||
|
- Use the local `docx` package API to create real DOCX header/footer parts if available.
|
||||||
|
- Reuse the existing HTML-to-DOCX block pipeline where practical.
|
||||||
|
- Preserve existing `pageSetup` behavior.
|
||||||
|
- Generated DOCX must contain real header/footer parts and relationships.
|
||||||
|
|
||||||
|
4. QWord UI:
|
||||||
|
|
||||||
|
- Add Russian-language controls for header and footer.
|
||||||
|
- Preferred location: QWord `layout` ribbon tab near page setup.
|
||||||
|
- Do not add a right sidebar.
|
||||||
|
- Use clear Russian labels, for example:
|
||||||
|
- `Верхний колонтитул`
|
||||||
|
- `Нижний колонтитул`
|
||||||
|
- Editing these fields must call `onChange` with updated `document.headerHtml` and `document.footerHtml`.
|
||||||
|
- `openDocx` must apply imported header/footer fields.
|
||||||
|
- `saveDocx` must pass header/footer fields to `exportDocxBlob`.
|
||||||
|
|
||||||
|
5. Tests:
|
||||||
|
|
||||||
|
Add focused regression tests:
|
||||||
|
|
||||||
|
- Import DOCX with `headerReference` and `footerReference`; assert imported `headerHtml` and `footerHtml`.
|
||||||
|
- Export DOCX with header/footer; assert header/footer parts and relationships exist.
|
||||||
|
- Roundtrip export -> import; assert header/footer values survive.
|
||||||
|
- QWord UI test: changing header/footer controls calls `onChange` with `headerHtml` and `footerHtml`.
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
Run the narrow test set first:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npx vitest run src\io\wordOffice.test.ts src\io\wordOfficeImport.test.ts src\features\qword\QWord.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
If the narrow tests pass, run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
npm audit --audit-level=high
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not touch unrelated files.
|
||||||
|
- Do not revert user changes.
|
||||||
|
- Do not run destructive git commands.
|
||||||
|
- Do not add dependencies unless necessary.
|
||||||
|
- Follow the existing project patterns.
|
||||||
|
- If you are only an LLM without filesystem access, return a unified diff patch and verification commands.
|
||||||
|
- If you are running inside an agent with filesystem access, edit the files directly but do not commit or push.
|
||||||
|
|
||||||
|
## Final Response Required
|
||||||
|
|
||||||
|
Report:
|
||||||
|
|
||||||
|
- changed files;
|
||||||
|
- tests and command results;
|
||||||
|
- exactly how DOCX import/export was implemented;
|
||||||
|
- any remaining risks or unverified behavior.
|
||||||
Reference in New Issue
Block a user