Compare commits
32 Commits
b8c347142a
...
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 |
@@ -44,9 +44,9 @@ npm run build
|
|||||||
|
|
||||||
## Поддержка форматов
|
## Поддержка форматов
|
||||||
|
|
||||||
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает заголовки Word `Heading 1`–`Heading 6`, стиль Word `Quote` как HTML `blockquote`, выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, маркированные и нумерованные списки через `w:numPr`/`numbering.xml`, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, нижний и верхний индекс через `w:vertAlign`, а при экспорте DOCX сохраняются заголовки HTML `h1`–`h6`, цитаты HTML `blockquote` как стиль Word `Quote`, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из 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, внешние и внутренние гиперссылки ячеек, legacy-заметки ячеек через `comments.xml` и VML `legacyDrawing`, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые 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` внутри текстовых блоков, маркированные и нумерованные списки подзаголовка через `a:buChar`/`a:buAutoNum`, внешние ссылки текста заголовка/подзаголовка и изображений через `a:hlinkClick` и hyperlink relationships, скрытые слайды через атрибут `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`.
|
- QOutlook сохраняет HTML-тело импортированных EML/MSG писем в модели письма, показывает его в панели чтения через ограниченный HTML-preview без скриптов и экспортирует письма с HTML-телом в EML как `multipart/alternative`; при наличии вложений HTML и текстовая версии вкладываются внутрь `multipart/mixed`.
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ 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` и `w:vertAlign` для переносов строк/разрывов страниц/табуляции/цвета/подсветки/размера/семейства/зачеркивания/нижнего и верхнего индекса текста DOCX, выравнивание и отступы абзацев DOCX через `w:jc`/`w:ind`, закладки 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, цвет, размер и семейство шрифта текста заголовка/подзаголовка 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`.
|
Для QOutlook EML round-trip дополнительно покрывает HTML body через `multipart/alternative` без вложений и с вложениями внутри `multipart/mixed`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { initialState } from "./data/sampleData";
|
||||||
|
import type { QOfficeState } from "./types";
|
||||||
|
import { App, qofficeWindowTitle } from "./App";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
window.localStorage.clear();
|
||||||
|
window.history.pushState({}, "", "/");
|
||||||
|
window.document.title = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("qofficeWindowTitle", () => {
|
||||||
|
it("uses the current artifact name for standalone application title bars", () => {
|
||||||
|
expect(qofficeWindowTitle(initialState, "word")).toBe(`${initialState.word.title} - QWord`);
|
||||||
|
expect(qofficeWindowTitle(initialState, "sheets")).toBe(`${initialState.workbook.title} - QExcell`);
|
||||||
|
expect(qofficeWindowTitle(initialState, "slides")).toBe(`${initialState.deck.title} - QPowerPoint`);
|
||||||
|
|
||||||
|
const activeMessage = initialState.mail.messages.find((message) => message.id === initialState.mail.activeMessageId);
|
||||||
|
expect(qofficeWindowTitle(initialState, "mail")).toBe(`${activeMessage?.subject} - QOutlook`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to default standalone titles when names are blank", () => {
|
||||||
|
const state: QOfficeState = {
|
||||||
|
...initialState,
|
||||||
|
word: { ...initialState.word, title: " " },
|
||||||
|
workbook: { ...initialState.workbook, title: "" },
|
||||||
|
deck: { ...initialState.deck, title: "\n\t" },
|
||||||
|
mail: {
|
||||||
|
...initialState.mail,
|
||||||
|
messages: initialState.mail.messages.map((message) =>
|
||||||
|
message.id === initialState.mail.activeMessageId ? { ...message, subject: "" } : message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(qofficeWindowTitle(state, "word")).toBe("Документ1 - QWord");
|
||||||
|
expect(qofficeWindowTitle(state, "sheets")).toBe("Книга1 - QExcell");
|
||||||
|
expect(qofficeWindowTitle(state, "slides")).toBe("Презентация1 - QPowerPoint");
|
||||||
|
expect(qofficeWindowTitle(state, "mail")).toBe("Почта - QOutlook");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs the browser document title with the active standalone application", async () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
window.history.pushState({}, "", "/qword");
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(window.document.title).toBe(qofficeWindowTitle(initialState, "word")));
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs QWord statusbar view buttons with the active document view mode", () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
window.history.pushState({}, "", "/qword");
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<App />);
|
||||||
|
const stage = screen.getByLabelText("Редактор QWord");
|
||||||
|
const printButton = screen.getByRole("button", { name: "Разметка страницы" });
|
||||||
|
const webButton = screen.getByRole("button", { name: "Веб-документ" });
|
||||||
|
|
||||||
|
expect(stage).toHaveClass("qword-view-print");
|
||||||
|
expect(printButton).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.queryByRole("button", { name: "Черновик" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(webButton);
|
||||||
|
|
||||||
|
expect(stage).toHaveClass("qword-view-web");
|
||||||
|
expect(webButton).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(printButton).toHaveAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Вид" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Чтение" }));
|
||||||
|
|
||||||
|
expect(stage).toHaveClass("qword-view-read");
|
||||||
|
expect(screen.getByRole("button", { name: "Режим чтения" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+67
-17
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { BookOpen, Columns2, FileText, Minus, Plus, Rows3 } from "lucide-react";
|
import { BookOpen, Columns2, FileText, Minus, Plus } from "lucide-react";
|
||||||
import { AppShell } from "./components/AppShell";
|
import { AppShell } from "./components/AppShell";
|
||||||
import { QOutlook } from "./features/qoutlook/QOutlook";
|
import { QOutlook } from "./features/qoutlook/QOutlook";
|
||||||
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
|
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
|
||||||
import { QWord, type QWordStatus } from "./features/qword/QWord";
|
import { QWord, type QWordStatus, type WordViewMode } from "./features/qword/QWord";
|
||||||
import { QExcell } from "./features/qexcell/QExcell";
|
import { QExcell } from "./features/qexcell/QExcell";
|
||||||
import { initialState } from "./data/sampleData";
|
import { initialState } from "./data/sampleData";
|
||||||
import { usePersistentState } from "./hooks/usePersistentState";
|
import { usePersistentState } from "./hooks/usePersistentState";
|
||||||
@@ -12,11 +12,38 @@ import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } fro
|
|||||||
|
|
||||||
const STORAGE_KEY = "qoffice-state-v1";
|
const STORAGE_KEY = "qoffice-state-v1";
|
||||||
const defaultQWordStatus: QWordStatus = { currentPage: 1, pageCount: 1, wordCount: 0 };
|
const defaultQWordStatus: QWordStatus = { currentPage: 1, pageCount: 1, wordCount: 0 };
|
||||||
|
const wordStatusbarViewModes = [
|
||||||
|
{ id: "print", label: "Разметка страницы", Icon: FileText },
|
||||||
|
{ id: "read", label: "Режим чтения", Icon: BookOpen },
|
||||||
|
{ id: "web", label: "Веб-документ", Icon: Columns2 }
|
||||||
|
] as const;
|
||||||
|
|
||||||
function clampedWordZoom(value: number) {
|
function clampedWordZoom(value: number) {
|
||||||
return Math.min(200, Math.max(50, value));
|
return Math.min(200, Math.max(50, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedWindowTitlePart(value: string | undefined, fallback: string) {
|
||||||
|
const normalized = value?.replace(/\s+/g, " ").trim();
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qofficeWindowTitle(state: QOfficeState, activeApp: QOfficeState["activeApp"]) {
|
||||||
|
if (activeApp === "word") {
|
||||||
|
return `${normalizedWindowTitlePart(state.word.title, "Документ1")} - QWord`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeApp === "sheets") {
|
||||||
|
return `${normalizedWindowTitlePart(state.workbook.title, "Книга1")} - QExcell`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeApp === "slides") {
|
||||||
|
return `${normalizedWindowTitlePart(state.deck.title, "Презентация1")} - QPowerPoint`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMessage = state.mail.messages.find((message) => message.id === state.mail.activeMessageId);
|
||||||
|
return `${normalizedWindowTitlePart(activeMessage?.subject, "Почта")} - QOutlook`;
|
||||||
|
}
|
||||||
|
|
||||||
function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
||||||
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
||||||
const sampleSheet = initialState.workbook.sheets.find((sheet) => sheet.id === "sheet-1");
|
const sampleSheet = initialState.workbook.sheets.find((sheet) => sheet.id === "sheet-1");
|
||||||
@@ -26,11 +53,15 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
|||||||
state.word.id === initialState.word.id &&
|
state.word.id === initialState.word.id &&
|
||||||
sampleWordHtml.includes("Ключевой результат") &&
|
sampleWordHtml.includes("Ключевой результат") &&
|
||||||
!state.word.html.includes("Ключевой результат");
|
!state.word.html.includes("Ключевой результат");
|
||||||
|
const needsDeckSlideSize = !state.deck.slideSize;
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (needsSampleWordColor) {
|
if (needsSampleWordColor) {
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (needsDeckSlideSize) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
const messages = state.mail.messages.map((message) => {
|
const messages = state.mail.messages.map((message) => {
|
||||||
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
|
if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) {
|
||||||
return message;
|
return message;
|
||||||
@@ -90,7 +121,7 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
|||||||
...state,
|
...state,
|
||||||
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
|
word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word,
|
||||||
workbook: { ...state.workbook, sheets },
|
workbook: { ...state.workbook, sheets },
|
||||||
deck: { ...state.deck, slides },
|
deck: { ...state.deck, slideSize: state.deck.slideSize ?? initialState.deck.slideSize ?? "wide", slides },
|
||||||
mail: { ...state.mail, messages }
|
mail: { ...state.mail, messages }
|
||||||
}
|
}
|
||||||
: state;
|
: state;
|
||||||
@@ -100,7 +131,13 @@ export function App() {
|
|||||||
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
|
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
|
||||||
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
|
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
|
||||||
const [wordZoom, setWordZoom] = useState(100);
|
const [wordZoom, setWordZoom] = useState(100);
|
||||||
|
const [wordViewMode, setWordViewMode] = useState<WordViewMode>("print");
|
||||||
const activeApp = activeAppFromLocation(window.location);
|
const activeApp = activeAppFromLocation(window.location);
|
||||||
|
const windowTitle = qofficeWindowTitle(state, activeApp);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.document.title = windowTitle;
|
||||||
|
}, [windowTitle]);
|
||||||
|
|
||||||
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
|
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
|
||||||
setWordStatus((current) =>
|
setWordStatus((current) =>
|
||||||
@@ -137,18 +174,23 @@ export function App() {
|
|||||||
trailing: (
|
trailing: (
|
||||||
<div className="word-statusbar-tools" aria-label="Режимы просмотра и масштаб QWord">
|
<div className="word-statusbar-tools" aria-label="Режимы просмотра и масштаб QWord">
|
||||||
<div className="word-view-buttons" aria-label="Режимы просмотра">
|
<div className="word-view-buttons" aria-label="Режимы просмотра">
|
||||||
<button className="is-active" type="button" title="Разметка страницы" aria-label="Разметка страницы" aria-pressed="true">
|
{wordStatusbarViewModes.map((mode) => {
|
||||||
<FileText size={15} />
|
const Icon = mode.Icon;
|
||||||
</button>
|
const isActive = wordViewMode === mode.id;
|
||||||
<button type="button" title="Режим чтения" aria-label="Режим чтения">
|
return (
|
||||||
<BookOpen size={15} />
|
<button
|
||||||
</button>
|
key={mode.id}
|
||||||
<button type="button" title="Веб-документ" aria-label="Веб-документ">
|
className={isActive ? "is-active" : undefined}
|
||||||
<Columns2 size={15} />
|
type="button"
|
||||||
</button>
|
title={mode.label}
|
||||||
<button type="button" title="Черновик" aria-label="Черновик">
|
aria-label={mode.label}
|
||||||
<Rows3 size={15} />
|
aria-pressed={isActive}
|
||||||
|
onClick={() => setWordViewMode(mode.id)}
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" title="Уменьшить масштаб" aria-label="Уменьшить масштаб" onClick={() => adjustWordZoom(-10)}>
|
<button type="button" title="Уменьшить масштаб" aria-label="Уменьшить масштаб" onClick={() => adjustWordZoom(-10)}>
|
||||||
<Minus size={14} />
|
<Minus size={14} />
|
||||||
@@ -185,6 +227,7 @@ export function App() {
|
|||||||
wordStatus.currentPage,
|
wordStatus.currentPage,
|
||||||
wordStatus.pageCount,
|
wordStatus.pageCount,
|
||||||
wordStatus.wordCount,
|
wordStatus.wordCount,
|
||||||
|
wordViewMode,
|
||||||
wordZoom
|
wordZoom
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -217,7 +260,14 @@ export function App() {
|
|||||||
|
|
||||||
const activeSurface =
|
const activeSurface =
|
||||||
activeApp === "word" ? (
|
activeApp === "word" ? (
|
||||||
<QWord document={state.word} zoom={wordZoom} onChange={updateWord} onStatusChange={updateWordStatus} />
|
<QWord
|
||||||
|
document={state.word}
|
||||||
|
zoom={wordZoom}
|
||||||
|
viewMode={wordViewMode}
|
||||||
|
onChange={updateWord}
|
||||||
|
onStatusChange={updateWordStatus}
|
||||||
|
onViewModeChange={setWordViewMode}
|
||||||
|
/>
|
||||||
) : activeApp === "sheets" ? (
|
) : activeApp === "sheets" ? (
|
||||||
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
|
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
|
||||||
) : activeApp === "slides" ? (
|
) : activeApp === "slides" ? (
|
||||||
@@ -227,7 +277,7 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell activeApp={activeApp} status={status}>
|
<AppShell activeApp={activeApp} windowTitle={windowTitle} status={status}>
|
||||||
{activeSurface}
|
{activeSurface}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { AppShell } from "./AppShell";
|
import { AppShell } from "./AppShell";
|
||||||
|
|
||||||
describe("AppShell", () => {
|
describe("AppShell", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
it("не показывает общую ленту навигации между приложениями", () => {
|
it("не показывает общую ленту навигации между приложениями", () => {
|
||||||
const view = render(
|
const view = render(
|
||||||
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
|
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
|
||||||
@@ -37,4 +41,39 @@ describe("AppShell", () => {
|
|||||||
expect(screen.getByText("Число слов: 42")).toBeInTheDocument();
|
expect(screen.getByText("Число слов: 42")).toBeInTheDocument();
|
||||||
expect(screen.getByText("125%")).toBeInTheDocument();
|
expect(screen.getByText("125%")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("показывает заголовок текущего файла активного приложения", () => {
|
||||||
|
render(
|
||||||
|
<AppShell activeApp="word" windowTitle="Отчет.docx - QWord" status={{ savedLabel: "Сохранено локально" }}>
|
||||||
|
<div>Рабочая область QWord</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Отчет.docx - QWord")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("отправляет команды панели быстрого доступа активному приложению", () => {
|
||||||
|
const commands: string[] = [];
|
||||||
|
function onCommand(event: WindowEventMap["qoffice-command"]) {
|
||||||
|
commands.push(event.detail.command);
|
||||||
|
}
|
||||||
|
window.addEventListener("qoffice-command", onCommand);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const view = render(
|
||||||
|
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
|
||||||
|
<div>Рабочая область QWord</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
const quickAccessButtons = view.container.querySelectorAll(".quick-access button");
|
||||||
|
|
||||||
|
fireEvent.click(quickAccessButtons[0]);
|
||||||
|
fireEvent.click(quickAccessButtons[1]);
|
||||||
|
fireEvent.click(quickAccessButtons[2]);
|
||||||
|
|
||||||
|
expect(commands).toEqual(["save", "undo", "redo"]);
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener("qoffice-command", onCommand);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
Undo2
|
Undo2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
|
import type { QOfficeCommand } from "../hooks/useQOfficeCommand";
|
||||||
import type { AppId } from "../types";
|
import type { AppId } from "../types";
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
activeApp: AppId;
|
activeApp: AppId;
|
||||||
|
windowTitle?: string;
|
||||||
status: {
|
status: {
|
||||||
savedLabel: string;
|
savedLabel: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -28,28 +30,37 @@ const appTitles: Record<AppId, string> = {
|
|||||||
mail: "Почта - QOutlook"
|
mail: "Почта - QOutlook"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppShell({ activeApp, status, children }: AppShellProps) {
|
function dispatchQOfficeCommand(command: QOfficeCommand) {
|
||||||
|
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepActiveEditorFocus(event: MouseEvent<HTMLButtonElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ activeApp, windowTitle, status, children }: AppShellProps) {
|
||||||
const statusClassName = ["statusbar", status.className].filter(Boolean).join(" ");
|
const statusClassName = ["statusbar", status.className].filter(Boolean).join(" ");
|
||||||
|
const displayWindowTitle = windowTitle?.trim() || appTitles[activeApp];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="qoffice-shell">
|
<div className="qoffice-shell">
|
||||||
<main className="workspace">
|
<main className="workspace">
|
||||||
<header className="titlebar">
|
<header className="titlebar">
|
||||||
<div className="quick-access" aria-label="Панель быстрого доступа">
|
<div className="quick-access" aria-label="Панель быстрого доступа">
|
||||||
<button type="button" title="Сохранить" aria-label="Сохранить">
|
<button type="button" title="Сохранить" aria-label="Сохранить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("save")}>
|
||||||
<Save size={16} />
|
<Save size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="Отменить" aria-label="Отменить">
|
<button type="button" title="Отменить" aria-label="Отменить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("undo")}>
|
||||||
<Undo2 size={16} />
|
<Undo2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="Повторить" aria-label="Повторить">
|
<button type="button" title="Повторить" aria-label="Повторить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("redo")}>
|
||||||
<Redo2 size={16} />
|
<Redo2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="Настроить панель" aria-label="Настроить панель">
|
<button type="button" title="Настроить панель" aria-label="Настроить панель">
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<strong className="window-title">{appTitles[activeApp]}</strong>
|
<strong className="window-title">{displayWindowTitle}</strong>
|
||||||
<div className="titlebar-actions">
|
<div className="titlebar-actions">
|
||||||
<label className="command-search">
|
<label className="command-search">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export const initialState: QOfficeState = {
|
|||||||
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
|
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
`
|
`,
|
||||||
|
pageSetup: { size: "a4", orientation: "portrait", marginPreset: "normal" }
|
||||||
},
|
},
|
||||||
workbook: {
|
workbook: {
|
||||||
id: "qexcell-quarter-plan",
|
id: "qexcell-quarter-plan",
|
||||||
@@ -108,6 +109,7 @@ export const initialState: QOfficeState = {
|
|||||||
title: "Дорожная карта.qpptx",
|
title: "Дорожная карта.qpptx",
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
activeSlideId: "slide-1",
|
activeSlideId: "slide-1",
|
||||||
|
slideSize: "wide",
|
||||||
slides: [
|
slides: [
|
||||||
{
|
{
|
||||||
id: "slide-1",
|
id: "slide-1",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
Underline,
|
Underline,
|
||||||
WrapText
|
WrapText
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types";
|
import type { CellFormat, SheetChart, SheetChartType, SheetPageMarginPreset, SheetPageOrientation, SheetPageSetup, SheetPageSizeId, Workbook } from "../../types";
|
||||||
import { cellId, columnLabel, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, parseCellId, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
|
import { cellId, columnLabel, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, parseCellId, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
|
||||||
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
|
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
|
||||||
@@ -95,6 +95,20 @@ const chartTypeOptions: Array<{ value: SheetChartType; label: string }> = [
|
|||||||
{ value: "line", label: "Линейная" },
|
{ value: "line", label: "Линейная" },
|
||||||
{ value: "pie", label: "Круговая" }
|
{ value: "pie", label: "Круговая" }
|
||||||
];
|
];
|
||||||
|
const sheetPageSizeOptions: Array<{ id: SheetPageSizeId; label: string }> = [
|
||||||
|
{ id: "a4", label: "A4" },
|
||||||
|
{ id: "letter", label: "Letter" }
|
||||||
|
];
|
||||||
|
const sheetPageOrientationOptions: Array<{ id: SheetPageOrientation; label: string }> = [
|
||||||
|
{ id: "portrait", label: "Книжная" },
|
||||||
|
{ id: "landscape", label: "Альбомная" }
|
||||||
|
];
|
||||||
|
const sheetPageMarginOptions: Array<{ id: SheetPageMarginPreset; label: string }> = [
|
||||||
|
{ id: "normal", label: "Обычные" },
|
||||||
|
{ id: "narrow", label: "Узкие" },
|
||||||
|
{ id: "wide", label: "Широкие" }
|
||||||
|
];
|
||||||
|
const defaultSheetPageSetup: SheetPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
|
||||||
|
|
||||||
const horizontalAlignOptions = [
|
const horizontalAlignOptions = [
|
||||||
{ value: "left", label: "По левому краю", icon: AlignLeft },
|
{ value: "left", label: "По левому краю", icon: AlignLeft },
|
||||||
@@ -159,6 +173,18 @@ function normalizedCellFormat(format: CellFormat): CellFormat | undefined {
|
|||||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedSheetPageSetup(setup?: SheetPageSetup): SheetPageSetup {
|
||||||
|
const candidateSize = setup?.size;
|
||||||
|
const candidateOrientation = setup?.orientation;
|
||||||
|
const candidateMarginPreset = setup?.marginPreset;
|
||||||
|
const size = candidateSize && sheetPageSizeOptions.some((option) => option.id === candidateSize) ? candidateSize : defaultSheetPageSetup.size;
|
||||||
|
const orientation =
|
||||||
|
candidateOrientation && sheetPageOrientationOptions.some((option) => option.id === candidateOrientation) ? candidateOrientation : defaultSheetPageSetup.orientation;
|
||||||
|
const marginPreset =
|
||||||
|
candidateMarginPreset && sheetPageMarginOptions.some((option) => option.id === candidateMarginPreset) ? candidateMarginPreset : defaultSheetPageSetup.marginPreset;
|
||||||
|
return { size, orientation, marginPreset };
|
||||||
|
}
|
||||||
|
|
||||||
function cellInputStyle(format?: CellFormat): CSSProperties {
|
function cellInputStyle(format?: CellFormat): CSSProperties {
|
||||||
const textDecoration = [format?.underline ? "underline" : "", format?.strike ? "line-through" : ""].filter(Boolean).join(" ");
|
const textDecoration = [format?.underline ? "underline" : "", format?.strike ? "line-through" : ""].filter(Boolean).join(" ");
|
||||||
return {
|
return {
|
||||||
@@ -276,6 +302,7 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems;
|
const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems;
|
||||||
const activeHiddenRows = activeSheet.hiddenRows ?? emptyHiddenItems;
|
const activeHiddenRows = activeSheet.hiddenRows ?? emptyHiddenItems;
|
||||||
const activeCharts = activeSheet.charts ?? emptyCharts;
|
const activeCharts = activeSheet.charts ?? emptyCharts;
|
||||||
|
const activeSheetPageSetup = normalizedSheetPageSetup(activeSheet.pageSetup);
|
||||||
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
|
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
|
||||||
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
|
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
|
||||||
const selectedComment = activeComments[selectedCell] ?? "";
|
const selectedComment = activeComments[selectedCell] ?? "";
|
||||||
@@ -418,6 +445,15 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateActiveSheetPageSetup(pageSetupPatch: Partial<SheetPageSetup>) {
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) =>
|
||||||
|
sheet.id === activeSheet.id ? { ...sheet, pageSetup: normalizedSheetPageSetup({ ...activeSheetPageSetup, ...pageSetupPatch }) } : sheet
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addSheet() {
|
function addSheet() {
|
||||||
const id = `sheet-${workbook.sheets.length + 1}`;
|
const id = `sheet-${workbook.sheets.length + 1}`;
|
||||||
onChange({
|
onChange({
|
||||||
@@ -854,6 +890,39 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
onBlur={(event) => updateActiveSheetPrintArea(normalizedPrintAreaInput(event.target.value))}
|
onBlur={(event) => updateActiveSheetPrintArea(normalizedPrintAreaInput(event.target.value))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="office-field">
|
||||||
|
<span>Размер</span>
|
||||||
|
<select value={activeSheetPageSetup.size} onChange={(event) => updateActiveSheetPageSetup({ size: event.target.value as SheetPageSizeId })}>
|
||||||
|
{sheetPageSizeOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="office-field">
|
||||||
|
<span>Ориентация</span>
|
||||||
|
<select
|
||||||
|
value={activeSheetPageSetup.orientation}
|
||||||
|
onChange={(event) => updateActiveSheetPageSetup({ orientation: event.target.value as SheetPageOrientation })}
|
||||||
|
>
|
||||||
|
{sheetPageOrientationOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="office-field">
|
||||||
|
<span>Поля</span>
|
||||||
|
<select value={activeSheetPageSetup.marginPreset} onChange={(event) => updateActiveSheetPageSetup({ marginPreset: event.target.value as SheetPageMarginPreset })}>
|
||||||
|
{sheetPageMarginOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="office-tool-section">
|
<div className="office-tool-section">
|
||||||
|
|||||||
@@ -54,4 +54,47 @@ describe("QPowerPoint", () => {
|
|||||||
const nextDeck = lastCall?.[0] as SlideDeck;
|
const nextDeck = lastCall?.[0] as SlideDeck;
|
||||||
expect(nextDeck.slides[0].images?.[0].hyperlink).toBe("https://example.com/updated-logo");
|
expect(nextDeck.slides[0].images?.[0].hyperlink).toBe("https://example.com/updated-logo");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("переключает размер слайда презентации", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const view = render(<QPowerPoint deck={deckWithLinkedImage()} onChange={onChange} />);
|
||||||
|
const slideSizeSelect = view.container.querySelector('select[aria-label="Размер слайда"]') as HTMLSelectElement;
|
||||||
|
const canvas = view.container.querySelector(".slide-canvas") as HTMLElement;
|
||||||
|
|
||||||
|
expect(slideSizeSelect).toHaveValue("wide");
|
||||||
|
expect(canvas.style.aspectRatio).toBe("13.333 / 7.5");
|
||||||
|
|
||||||
|
fireEvent.change(slideSizeSelect, { target: { value: "standard" } });
|
||||||
|
|
||||||
|
const nextDeck = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as SlideDeck;
|
||||||
|
expect(nextDeck.slideSize).toBe("standard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("переключает начертание текста слайда", () => {
|
||||||
|
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, Link, List, ListOrdered, ListX, Palette, 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, SlideListStyle, 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 });
|
||||||
}
|
}
|
||||||
@@ -235,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) {
|
||||||
@@ -258,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
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -435,6 +483,20 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="office-field">
|
||||||
|
<span>Размер</span>
|
||||||
|
<select
|
||||||
|
value={normalizedSlideSizeId(deck.slideSize)}
|
||||||
|
onChange={(event) => onChange({ ...deck, slideSize: event.target.value as SlideSizeId })}
|
||||||
|
aria-label="Размер слайда"
|
||||||
|
>
|
||||||
|
{slideSizeOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<div className="slide-background-swatches" aria-label="Фон слайда">
|
<div className="slide-background-swatches" aria-label="Фон слайда">
|
||||||
<Palette size={16} aria-hidden="true" />
|
<Palette size={16} aria-hidden="true" />
|
||||||
{backgroundColorSwatches.map((swatch) => (
|
{backgroundColorSwatches.map((swatch) => (
|
||||||
@@ -497,6 +559,73 @@ 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">
|
<label className="office-field office-field-wide">
|
||||||
<span>
|
<span>
|
||||||
<Link size={14} />
|
<Link size={14} />
|
||||||
@@ -553,6 +682,73 @@ 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 className="cell-format-tools slide-format-tools" aria-label="Выравнивание подзаголовка">
|
||||||
|
<button
|
||||||
|
className={`icon-button format-toggle ${(activeSlide.subtitleAlign ?? "left") === "left" ? "is-active" : ""}`.trim()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSlideTextAlign("subtitleAlign", "left")}
|
||||||
|
title="Подзаголовок по левому краю"
|
||||||
|
aria-label="Подзаголовок по левому краю"
|
||||||
|
>
|
||||||
|
<AlignLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`icon-button format-toggle ${activeSlide.subtitleAlign === "center" ? "is-active" : ""}`.trim()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSlideTextAlign("subtitleAlign", "center")}
|
||||||
|
title="Подзаголовок по центру"
|
||||||
|
aria-label="Подзаголовок по центру"
|
||||||
|
>
|
||||||
|
<AlignCenter size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`icon-button format-toggle ${activeSlide.subtitleAlign === "right" ? "is-active" : ""}`.trim()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSlideTextAlign("subtitleAlign", "right")}
|
||||||
|
title="Подзаголовок по правому краю"
|
||||||
|
aria-label="Подзаголовок по правому краю"
|
||||||
|
>
|
||||||
|
<AlignRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<label className="office-field office-field-wide">
|
<label className="office-field office-field-wide">
|
||||||
<span>
|
<span>
|
||||||
<Link size={14} />
|
<Link size={14} />
|
||||||
@@ -618,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>
|
||||||
@@ -629,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>
|
||||||
@@ -640,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>
|
||||||
@@ -651,10 +847,10 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max={slideHeightInches}
|
max={activeSlideSize.height}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={selectedImage.height}
|
value={selectedImage.height}
|
||||||
onChange={(event) => updateSelectedImageNumber("height", event.target.value, slideHeightInches)}
|
onChange={(event) => updateSelectedImageNumber("height", event.target.value, activeSlideSize.height)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -689,13 +885,13 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide)}>
|
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide, activeSlideSize)}>
|
||||||
{activeImages.map((image) => (
|
{activeImages.map((image) => (
|
||||||
<button
|
<button
|
||||||
key={image.id}
|
key={image.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
|
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
|
||||||
style={slideImageStyle(image)}
|
style={slideImageStyle(image, activeSlideSize)}
|
||||||
onClick={() => setSelectedImageId(image.id)}
|
onClick={() => setSelectedImageId(image.id)}
|
||||||
title={image.alt || "Изображение"}
|
title={image.alt || "Изображение"}
|
||||||
aria-label={image.alt || "Изображение слайда"}
|
aria-label={image.alt || "Изображение слайда"}
|
||||||
@@ -707,14 +903,27 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
|
|||||||
className="slide-title-input"
|
className="slide-title-input"
|
||||||
value={activeSlide.title}
|
value={activeSlide.title}
|
||||||
aria-label="Заголовок слайда"
|
aria-label="Заголовок слайда"
|
||||||
style={slideTextStyle(activeSlide.titleColor, activeSlide.titleFontSize, activeSlide.titleFontFamily)}
|
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 })}
|
onChange={(event) => updateSlide({ ...activeSlide, title: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
className={`slide-subtitle-input ${activeSlide.subtitleListStyle ? `is-${activeSlide.subtitleListStyle}-list` : ""}`.trim()}
|
className={`slide-subtitle-input ${activeSlide.subtitleListStyle ? `is-${activeSlide.subtitleListStyle}-list` : ""}`.trim()}
|
||||||
value={activeSlide.subtitle}
|
value={activeSlide.subtitle}
|
||||||
aria-label="Подзаголовок слайда"
|
aria-label="Подзаголовок слайда"
|
||||||
style={slideTextStyle(activeSlide.subtitleColor, activeSlide.subtitleFontSize, activeSlide.subtitleFontFamily)}
|
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 })}
|
onChange={(event) => updateSlide({ ...activeSlide, subtitle: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className="slide-accent-line" />
|
<div className="slide-accent-line" />
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { useState } from "react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import type { WordDocument } from "../../types";
|
import type { WordDocument } from "../../types";
|
||||||
import { QWord, qwordCurrentPageFromViewport } from "./QWord";
|
import { QWord, qwordCurrentPageFromViewport, qwordPageCountFromContent } from "./QWord";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
describe("qwordCurrentPageFromViewport", () => {
|
describe("qwordCurrentPageFromViewport", () => {
|
||||||
it("вычисляет текущую страницу по положению листа в области просмотра", () => {
|
it("вычисляет текущую страницу по положению листа в области просмотра", () => {
|
||||||
@@ -15,9 +20,609 @@ describe("qwordCurrentPageFromViewport", () => {
|
|||||||
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(2);
|
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(2);
|
||||||
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1.2 })).toBe(1);
|
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1.2 })).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not advance the current page while the viewport is inside the inter-page gap", () => {
|
||||||
|
expect(qwordCurrentPageFromViewport({ stageTop: 0, paperTop: -110, pageHeight: 100, pageGap: 20, pageCount: 3, zoom: 1 })).toBe(1);
|
||||||
|
expect(qwordCurrentPageFromViewport({ stageTop: 0, paperTop: -125, pageHeight: 100, pageGap: 20, pageCount: 3, zoom: 1 })).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("qwordPageCountFromContent", () => {
|
||||||
|
it("keeps manual page breaks as a minimum page count even when content height is short", () => {
|
||||||
|
expect(qwordPageCountFromContent({ scrollHeight: 300, pageHeight: 1123, manualPageBreakCount: 2 })).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content height page count when it exceeds manual page breaks", () => {
|
||||||
|
expect(qwordPageCountFromContent({ scrollHeight: 2400, pageHeight: 1123, manualPageBreakCount: 1 })).toBe(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("QWord", () => {
|
describe("QWord", () => {
|
||||||
|
it("держит ленту QWord вне прокручиваемой области документа", () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
|
||||||
|
const document: WordDocument = {
|
||||||
|
id: "word-ribbon-layout-test",
|
||||||
|
title: "Лента.docx",
|
||||||
|
updatedAt: "2026-06-02T00:00:00.000Z",
|
||||||
|
html: "<p>Текст</p>"
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const view = render(<QWord document={document} onChange={vi.fn()} />);
|
||||||
|
const toolbar = view.container.querySelector(".word-module-toolbar") as HTMLElement;
|
||||||
|
const stage = view.container.querySelector(".document-stage") as HTMLElement;
|
||||||
|
|
||||||
|
expect(toolbar).toBeInTheDocument();
|
||||||
|
expect(stage).toBeInTheDocument();
|
||||||
|
expect(stage).not.toContainElement(toolbar);
|
||||||
|
expect(toolbar.querySelector(".word-ribbon-tabs")).toBeInTheDocument();
|
||||||
|
expect(stage.querySelector(".page-ruler")).toBeInTheDocument();
|
||||||
|
expect(stage.querySelector(".paper-frame")).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("показывает отдельные листы печатной разметки по расчетному числу страниц", async () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
|
||||||
|
const document: WordDocument = {
|
||||||
|
id: "word-print-pages-test",
|
||||||
|
title: "Страницы.docx",
|
||||||
|
updatedAt: "2026-06-02T00:00:00.000Z",
|
||||||
|
html: "<p>Первая страница</p><p>Вторая страница</p>"
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const view = render(<QWord document={document} onChange={vi.fn()} />);
|
||||||
|
const stage = view.getByLabelText("Редактор QWord");
|
||||||
|
const paper = view.getByLabelText("Содержимое документа");
|
||||||
|
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
|
||||||
|
|
||||||
|
paper.style.setProperty("--qword-page-height", "1123px");
|
||||||
|
Object.defineProperty(paper, "scrollHeight", { configurable: true, value: 2400 });
|
||||||
|
fireEvent.scroll(stage);
|
||||||
|
|
||||||
|
await waitFor(() => expect(view.container.querySelectorAll(".page-sheet")).toHaveLength(3));
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-count")).toBe("3");
|
||||||
|
expect(frame.style.minHeight).toBe("3425px");
|
||||||
|
expect(frame.style.paddingBottom).toBe("56px");
|
||||||
|
expect(view.container.querySelectorAll(".page-break-guides span")).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("показывает отдельный лист для ручного разрыва страницы даже при коротком содержимом", async () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
|
||||||
|
const document: WordDocument = {
|
||||||
|
id: "word-manual-break-pages-test",
|
||||||
|
title: "Разрыв.docx",
|
||||||
|
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||||
|
html: '<p>Первая страница</p><span data-qoffice-page-break="true" contenteditable="false"></span><p>Вторая страница</p>'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const view = render(<QWord document={document} onChange={vi.fn()} />);
|
||||||
|
const stage = view.getByLabelText("Редактор QWord");
|
||||||
|
const paper = view.getByLabelText("Содержимое документа");
|
||||||
|
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
|
||||||
|
|
||||||
|
paper.style.setProperty("--qword-page-height", "1123px");
|
||||||
|
Object.defineProperty(paper, "scrollHeight", { configurable: true, value: 300 });
|
||||||
|
fireEvent.scroll(stage);
|
||||||
|
|
||||||
|
await waitFor(() => expect(view.container.querySelectorAll(".page-sheet")).toHaveLength(2));
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-count")).toBe("2");
|
||||||
|
expect(frame.style.minHeight).toBe("2274px");
|
||||||
|
expect(view.container.querySelectorAll(".page-break-guides span")).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("меняет поля, размер и ориентацию страницы на вкладке Макет", async () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
|
||||||
|
const document: WordDocument = {
|
||||||
|
id: "word-page-setup-test",
|
||||||
|
title: "Макет.docx",
|
||||||
|
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||||
|
html: "<p>Текст страницы</p>"
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatefulQWord() {
|
||||||
|
const [currentDocument, setCurrentDocument] = useState(document);
|
||||||
|
return (
|
||||||
|
<QWord
|
||||||
|
document={currentDocument}
|
||||||
|
onChange={(nextDocument) => {
|
||||||
|
onChange(nextDocument);
|
||||||
|
setCurrentDocument(nextDocument);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const view = render(<StatefulQWord />);
|
||||||
|
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
|
||||||
|
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("794px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-height")).toBe("1123px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-margin-left")).toBe("86px");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Макет" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Поля страницы"), { target: { value: "narrow" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Размер страницы"), { target: { value: "letter" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Ориентация страницы"), { target: { value: "landscape" } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(frame.style.getPropertyValue("--qword-page-height")).toBe("816px"));
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("1056px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("48px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-margin-right")).toBe("48px");
|
||||||
|
expect(frame.style.minHeight).toBe("816px");
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" } }));
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("применяет сохраненные в документе параметры страницы при открытии QWord", () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
|
||||||
|
const document: WordDocument = {
|
||||||
|
id: "word-saved-page-setup-test",
|
||||||
|
title: "Сохраненный макет.docx",
|
||||||
|
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||||
|
html: "<p>Текст страницы</p>",
|
||||||
|
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "wide" }
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const view = render(<QWord document={document} onChange={vi.fn()} />);
|
||||||
|
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
|
||||||
|
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("1056px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-height")).toBe("816px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-margin-left")).toBe("116px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("92px");
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("переключает вкладки ленты и показывает команды выбранной вкладки", () => {
|
||||||
|
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 () => {
|
it("обновляет текущую страницу в статусе при прокрутке документа", async () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|||||||
+654
-31
@@ -1,5 +1,5 @@
|
|||||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties, MouseEvent } from "react";
|
||||||
import {
|
import {
|
||||||
AlignCenter,
|
AlignCenter,
|
||||||
AlignJustify,
|
AlignJustify,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AlignRight,
|
AlignRight,
|
||||||
Bold,
|
Bold,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
|
Columns2,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -26,10 +27,12 @@ import {
|
|||||||
Strikethrough,
|
Strikethrough,
|
||||||
Subscript,
|
Subscript,
|
||||||
Superscript,
|
Superscript,
|
||||||
|
Table2,
|
||||||
Type,
|
Type,
|
||||||
|
Rows3,
|
||||||
Underline
|
Underline
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { WordDocument } from "../../types";
|
import type { WordDocument, WordPageMarginPreset, WordPageOrientation, WordPageSetup, WordPageSizeId } from "../../types";
|
||||||
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
import { exportDocxBlob, importDocxFile } from "../../io/wordOffice";
|
import { exportDocxBlob, importDocxFile } from "../../io/wordOffice";
|
||||||
import { replaceFileExtension } from "../../io/fileHelpers";
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
@@ -40,8 +43,23 @@ import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
|
|||||||
interface QWordProps {
|
interface QWordProps {
|
||||||
document: WordDocument;
|
document: WordDocument;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
viewMode?: WordViewMode;
|
||||||
onChange: (document: WordDocument) => void;
|
onChange: (document: WordDocument) => void;
|
||||||
onStatusChange?: (status: QWordStatus) => void;
|
onStatusChange?: (status: QWordStatus) => void;
|
||||||
|
onViewModeChange?: (viewMode: WordViewMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableCellLocation {
|
||||||
|
tableIndex: number;
|
||||||
|
rowIndex: number;
|
||||||
|
cellIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorSelectionBookmark {
|
||||||
|
anchorOffset: number;
|
||||||
|
anchorPath: number[];
|
||||||
|
focusOffset: number;
|
||||||
|
focusPath: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QWordStatus {
|
export interface QWordStatus {
|
||||||
@@ -143,35 +161,309 @@ const styleGalleryOptions = [
|
|||||||
{ value: "h6", label: "Заголовок 6" },
|
{ value: "h6", label: "Заголовок 6" },
|
||||||
{ value: "blockquote", label: "Цитата" }
|
{ value: "blockquote", label: "Цитата" }
|
||||||
];
|
];
|
||||||
const wordRibbonTabs = ["Файл", "Главная", "Вставка", "Конструктор", "Макет", "Ссылки", "Рассылки", "Рецензирование", "Вид", "Справка"];
|
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
|
||||||
|
export type WordViewMode = "print" | "read" | "web";
|
||||||
|
const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [
|
||||||
|
{ id: "file", label: "Файл" },
|
||||||
|
{ id: "home", label: "Главная" },
|
||||||
|
{ id: "insert", label: "Вставка" },
|
||||||
|
{ id: "design", label: "Конструктор" },
|
||||||
|
{ id: "layout", label: "Макет" },
|
||||||
|
{ id: "references", label: "Ссылки" },
|
||||||
|
{ id: "mailings", label: "Рассылки" },
|
||||||
|
{ id: "review", label: "Рецензирование" },
|
||||||
|
{ id: "view", label: "Вид" },
|
||||||
|
{ id: "help", label: "Справка" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const wordViewModes: Array<{ id: WordViewMode; label: string }> = [
|
||||||
|
{ id: "print", label: "Разметка" },
|
||||||
|
{ id: "read", label: "Чтение" },
|
||||||
|
{ id: "web", label: "Веб-документ" }
|
||||||
|
];
|
||||||
|
const wordPageSizeOptions: Array<{ id: WordPageSizeId; label: string; width: number; height: number }> = [
|
||||||
|
{ id: "a4", label: "A4", width: 794, height: 1123 },
|
||||||
|
{ id: "letter", label: "Letter", width: 816, height: 1056 }
|
||||||
|
];
|
||||||
|
const wordPageOrientationOptions: Array<{ id: WordPageOrientation; label: string }> = [
|
||||||
|
{ id: "portrait", label: "Книжная" },
|
||||||
|
{ id: "landscape", label: "Альбомная" }
|
||||||
|
];
|
||||||
|
const wordPageMarginOptions: Array<{ id: WordPageMarginPreset; label: string; margins: { top: number; right: number; bottom: number; left: number } }> = [
|
||||||
|
{ id: "normal", label: "Обычные", margins: { top: 72, right: 86, bottom: 72, left: 86 } },
|
||||||
|
{ id: "narrow", label: "Узкие", margins: { top: 48, right: 48, bottom: 48, left: 48 } },
|
||||||
|
{ id: "wide", label: "Широкие", margins: { top: 92, right: 116, bottom: 92, left: 116 } }
|
||||||
|
];
|
||||||
|
const defaultWordPageSetup: WordPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
|
||||||
|
const tableSizeOptions = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
|
|
||||||
function findInPage(query: string) {
|
function findInPage(query: string) {
|
||||||
return (window as PageSearchWindow).find?.(query) ?? false;
|
return (window as PageSearchWindow).find?.(query) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultPageViewHeight = 1180;
|
const defaultPageViewHeight = 1180;
|
||||||
|
const defaultPageGap = 28;
|
||||||
|
|
||||||
function pageHeightFromStyles(element: HTMLElement) {
|
function pageHeightFromStyles(element: HTMLElement) {
|
||||||
const pageHeight = Number.parseFloat(window.getComputedStyle(element).getPropertyValue("--qword-page-height"));
|
const pageHeight = Number.parseFloat(window.getComputedStyle(element).getPropertyValue("--qword-page-height"));
|
||||||
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
|
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number }) {
|
function qwordPageDimensions(setup: WordPageSetup) {
|
||||||
const pageCount = Math.max(1, input.pageCount);
|
const size = wordPageSizeOptions.find((option) => option.id === setup.size) ?? wordPageSizeOptions[0];
|
||||||
const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1;
|
return setup.orientation === "landscape" ? { width: size.height, height: size.width } : { width: size.width, height: size.height };
|
||||||
const visualPageHeight = Math.max(1, input.pageHeight * zoom);
|
|
||||||
const visiblePaperOffset = Math.max(0, input.stageTop - input.paperTop);
|
|
||||||
return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageHeight) + 1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordProps) {
|
function qwordPageMargins(setup: WordPageSetup) {
|
||||||
|
return (wordPageMarginOptions.find((option) => option.id === setup.marginPreset) ?? wordPageMarginOptions[0]).margins;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedWordPageSetup(setup?: WordDocument["pageSetup"]): WordPageSetup {
|
||||||
|
const candidateSize = setup?.size;
|
||||||
|
const candidateOrientation = setup?.orientation;
|
||||||
|
const candidateMarginPreset = setup?.marginPreset;
|
||||||
|
const size = candidateSize && wordPageSizeOptions.some((option) => option.id === candidateSize) ? candidateSize : defaultWordPageSetup.size;
|
||||||
|
const orientation =
|
||||||
|
candidateOrientation && wordPageOrientationOptions.some((option) => option.id === candidateOrientation) ? candidateOrientation : defaultWordPageSetup.orientation;
|
||||||
|
const marginPreset =
|
||||||
|
candidateMarginPreset && wordPageMarginOptions.some((option) => option.id === candidateMarginPreset) ? candidateMarginPreset : defaultWordPageSetup.marginPreset;
|
||||||
|
return { size, orientation, marginPreset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampedTableSize(value: number) {
|
||||||
|
return Number.isFinite(value) ? Math.min(8, Math.max(1, Math.trunc(value))) : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qwordTableHtml(rows: number, columns: number) {
|
||||||
|
const rowCount = clampedTableSize(rows);
|
||||||
|
const columnCount = clampedTableSize(columns);
|
||||||
|
const cells = Array.from({ length: columnCount }, () => "<td><br></td>").join("");
|
||||||
|
const tableRows = Array.from({ length: rowCount }, () => `<tr>${cells}</tr>`).join("");
|
||||||
|
return `<table><tbody>${tableRows}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBlankTableCell(tagName: "td" | "th" = "td") {
|
||||||
|
const cell = globalThis.document.createElement(tagName);
|
||||||
|
cell.append(globalThis.document.createElement("br"));
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableCellFromSelection(editor: HTMLElement): HTMLTableCellElement | undefined {
|
||||||
|
const node = window.getSelection()?.anchorNode;
|
||||||
|
const element = node instanceof Element ? node : node?.parentElement;
|
||||||
|
const cell = element?.closest("td,th");
|
||||||
|
return cell instanceof HTMLTableCellElement && editor.contains(cell) ? cell : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableCellLocation(editor: HTMLElement, cell: HTMLTableCellElement): TableCellLocation | undefined {
|
||||||
|
const table = cell.closest("table");
|
||||||
|
const row = cell.parentElement;
|
||||||
|
if (!(table instanceof HTMLTableElement) || !(row instanceof HTMLTableRowElement) || !editor.contains(table)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableIndex = Array.from(editor.querySelectorAll("table")).indexOf(table);
|
||||||
|
const rowIndex = Array.from(table.rows).indexOf(row);
|
||||||
|
const cellIndex = cell.cellIndex;
|
||||||
|
return tableIndex >= 0 && rowIndex >= 0 && cellIndex >= 0 ? { cellIndex, rowIndex, tableIndex } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableCellFromLocation(editor: HTMLElement, location: TableCellLocation): HTMLTableCellElement | undefined {
|
||||||
|
const table = Array.from(editor.querySelectorAll("table"))[location.tableIndex];
|
||||||
|
const cell = table?.rows[location.rowIndex]?.cells[location.cellIndex];
|
||||||
|
return cell instanceof HTMLTableCellElement ? cell : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorContainsNode(editor: HTMLElement, node: Node) {
|
||||||
|
return node === editor || editor.contains(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorNodePath(editor: HTMLElement, node: Node): number[] | undefined {
|
||||||
|
if (node === editor) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const path: number[] = [];
|
||||||
|
let current: Node | null = node;
|
||||||
|
while (current && current !== editor) {
|
||||||
|
const parent: Node | null = current.parentNode;
|
||||||
|
if (!parent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblings = Array.from(parent.childNodes) as Node[];
|
||||||
|
const index = siblings.indexOf(current);
|
||||||
|
if (index < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.unshift(index);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current === editor ? path : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorNodeFromPath(editor: HTMLElement, path: number[]): Node | undefined {
|
||||||
|
let current: Node = editor;
|
||||||
|
for (const index of path) {
|
||||||
|
const child = current.childNodes[index];
|
||||||
|
if (!child) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = child;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampedSelectionOffset(node: Node, offset: number) {
|
||||||
|
const maxOffset = node.nodeType === Node.TEXT_NODE ? (node.textContent ?? "").length : node.childNodes.length;
|
||||||
|
return Math.min(Math.max(0, offset), maxOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorSelectionBookmark(editor: HTMLElement): EditorSelectionBookmark | undefined {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const anchorNode = selection?.anchorNode;
|
||||||
|
const focusNode = selection?.focusNode;
|
||||||
|
if (!selection || selection.rangeCount === 0 || !anchorNode || !focusNode || !editorContainsNode(editor, anchorNode) || !editorContainsNode(editor, focusNode)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorPath = editorNodePath(editor, anchorNode);
|
||||||
|
const focusPath = editorNodePath(editor, focusNode);
|
||||||
|
if (!anchorPath || !focusPath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
anchorOffset: selection.anchorOffset,
|
||||||
|
anchorPath,
|
||||||
|
focusOffset: selection.focusOffset,
|
||||||
|
focusPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEditorSelection(editor: HTMLElement, bookmark: EditorSelectionBookmark) {
|
||||||
|
const anchorNode = editorNodeFromPath(editor, bookmark.anchorPath);
|
||||||
|
const focusNode = editorNodeFromPath(editor, bookmark.focusPath);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!anchorNode || !focusNode || !selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorOffset = clampedSelectionOffset(anchorNode, bookmark.anchorOffset);
|
||||||
|
const focusOffset = clampedSelectionOffset(focusNode, bookmark.focusOffset);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
if (typeof selection.setBaseAndExtent === "function") {
|
||||||
|
selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = globalThis.document.createRange();
|
||||||
|
range.setStart(anchorNode, anchorOffset);
|
||||||
|
range.setEnd(focusNode, focusOffset);
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeCaretInTableCell(cell: HTMLTableCellElement) {
|
||||||
|
if (cell.childNodes.length === 0) {
|
||||||
|
cell.append(globalThis.document.createElement("br"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = globalThis.document.createRange();
|
||||||
|
range.selectNodeContents(cell);
|
||||||
|
range.collapse(true);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepEditorSelection(event: MouseEvent<HTMLButtonElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number; pageGap?: number }) {
|
||||||
|
const pageCount = Math.max(1, input.pageCount);
|
||||||
|
const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1;
|
||||||
|
const pageGap = Number.isFinite(input.pageGap) && input.pageGap ? Math.max(0, input.pageGap) : 0;
|
||||||
|
const visualPageStride = Math.max(1, (input.pageHeight + pageGap) * zoom);
|
||||||
|
const visiblePaperOffset = Math.max(0, input.stageTop - input.paperTop);
|
||||||
|
return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageStride) + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qwordPageCountFromContent(input: { scrollHeight: number; pageHeight: number; manualPageBreakCount?: number }) {
|
||||||
|
const pageHeight = Number.isFinite(input.pageHeight) && input.pageHeight > 0 ? input.pageHeight : defaultPageViewHeight;
|
||||||
|
const scrollHeight = Number.isFinite(input.scrollHeight) && input.scrollHeight > 0 ? input.scrollHeight : 0;
|
||||||
|
const manualPageBreakCount =
|
||||||
|
Number.isFinite(input.manualPageBreakCount) && input.manualPageBreakCount ? Math.max(0, Math.trunc(input.manualPageBreakCount)) : 0;
|
||||||
|
return Math.max(1, Math.ceil(scrollHeight / pageHeight), manualPageBreakCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange, onViewModeChange }: QWordProps) {
|
||||||
const stageRef = useRef<HTMLElement>(null);
|
const stageRef = useRef<HTMLElement>(null);
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeTableCellRef = useRef<HTMLTableCellElement | null>(null);
|
||||||
|
const activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
|
||||||
|
const restoreActiveTableCellAfterRenderRef = useRef(false);
|
||||||
|
const pendingEditorSelectionRestoreRef = useRef<EditorSelectionBookmark | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
||||||
|
const [internalViewMode, setInternalViewMode] = useState<WordViewMode>("print");
|
||||||
|
const [tableRowCount, setTableRowCount] = useState(3);
|
||||||
|
const [tableColumnCount, setTableColumnCount] = useState(3);
|
||||||
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
|
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
|
||||||
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
||||||
|
const pageSetup = useMemo(() => normalizedWordPageSetup(document.pageSetup), [document.pageSetup]);
|
||||||
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
|
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
|
||||||
|
const pageDimensions = qwordPageDimensions(pageSetup);
|
||||||
|
const pageMargins = qwordPageMargins(pageSetup);
|
||||||
|
const pageFrameStyle = {
|
||||||
|
"--qword-page-count": String(pageView.count),
|
||||||
|
"--qword-page-gap": `${defaultPageGap}px`,
|
||||||
|
"--qword-page-width": `${pageDimensions.width}px`,
|
||||||
|
"--qword-page-height": `${pageDimensions.height}px`,
|
||||||
|
"--qword-page-margin-top": `${pageMargins.top}px`,
|
||||||
|
"--qword-page-margin-right": `${pageMargins.right}px`,
|
||||||
|
"--qword-page-margin-bottom": `${pageMargins.bottom}px`,
|
||||||
|
"--qword-page-margin-left": `${pageMargins.left}px`,
|
||||||
|
minHeight: `${pageView.count * pageView.height + Math.max(0, pageView.count - 1) * defaultPageGap}px`,
|
||||||
|
paddingBottom: pageView.count > 1 ? `${(pageView.count - 1) * defaultPageGap}px` : undefined
|
||||||
|
} as CSSProperties;
|
||||||
|
const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная";
|
||||||
|
const activeViewMode = viewMode ?? internalViewMode;
|
||||||
|
const stageClassName = ["document-stage", `qword-view-${activeViewMode}`].join(" ");
|
||||||
|
|
||||||
|
const changeViewMode = useCallback(
|
||||||
|
(nextViewMode: WordViewMode) => {
|
||||||
|
setInternalViewMode(nextViewMode);
|
||||||
|
onViewModeChange?.(nextViewMode);
|
||||||
|
},
|
||||||
|
[onViewModeChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const changePageSetup = useCallback(
|
||||||
|
(nextPageSetup: Partial<WordPageSetup>) => {
|
||||||
|
onChange({ ...document, pageSetup: normalizedWordPageSetup({ ...pageSetup, ...nextPageSetup }) });
|
||||||
|
refreshPageView();
|
||||||
|
},
|
||||||
|
[document, onChange, pageSetup]
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor || editor.innerHTML === document.html) {
|
||||||
|
pendingEditorSelectionRestoreRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSelection = pendingEditorSelectionRestoreRef.current;
|
||||||
|
pendingEditorSelectionRestoreRef.current = null;
|
||||||
|
editor.innerHTML = document.html;
|
||||||
|
if (pendingSelection) {
|
||||||
|
restoreEditorSelection(editor, pendingSelection);
|
||||||
|
}
|
||||||
|
}, [document.html]);
|
||||||
|
|
||||||
function currentPageFromStage(pageCount: number, pageHeight: number) {
|
function currentPageFromStage(pageCount: number, pageHeight: number) {
|
||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
@@ -188,7 +480,8 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
paperTop: paperRect.top,
|
paperTop: paperRect.top,
|
||||||
pageHeight,
|
pageHeight,
|
||||||
pageCount,
|
pageCount,
|
||||||
zoom: Number.isFinite(currentZoom) ? currentZoom : 1
|
zoom: Number.isFinite(currentZoom) ? currentZoom : 1,
|
||||||
|
pageGap: defaultPageGap
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +493,8 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
}
|
}
|
||||||
|
|
||||||
const height = pageHeightFromStyles(editor);
|
const height = pageHeightFromStyles(editor);
|
||||||
const count = Math.max(1, Math.ceil(editor.scrollHeight / height));
|
const manualPageBreakCount = editor.querySelectorAll('[data-qoffice-page-break="true"]').length;
|
||||||
|
const count = qwordPageCountFromContent({ scrollHeight: editor.scrollHeight, pageHeight: height, manualPageBreakCount });
|
||||||
const currentPage = currentPageFromStage(count, height);
|
const currentPage = currentPageFromStage(count, height);
|
||||||
setPageView((current) =>
|
setPageView((current) =>
|
||||||
current.count === count && current.height === height && current.current === currentPage ? current : { count, current: currentPage, height }
|
current.count === count && current.height === height && current.current === currentPage ? current : { count, current: currentPage, height }
|
||||||
@@ -218,7 +512,26 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
const observer = new ResizeObserver(refreshPageView);
|
const observer = new ResizeObserver(refreshPageView);
|
||||||
observer.observe(editor);
|
observer.observe(editor);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [document.html, zoom]);
|
}, [document.html, pageDimensions.height, pageDimensions.width, pageMargins.bottom, pageMargins.left, pageMargins.right, pageMargins.top, zoom]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!restoreActiveTableCellAfterRenderRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreActiveTableCellAfterRenderRef.current = false;
|
||||||
|
const editor = editorRef.current;
|
||||||
|
const location = activeTableCellLocationRef.current;
|
||||||
|
if (!editor || !location) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredCell = tableCellFromLocation(editor, location);
|
||||||
|
if (restoredCell) {
|
||||||
|
activeTableCellRef.current = restoredCell;
|
||||||
|
placeCaretInTableCell(restoredCell);
|
||||||
|
}
|
||||||
|
}, [document.html]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
onStatusChange?.({ currentPage: pageView.current, pageCount: pageView.count, wordCount });
|
onStatusChange?.({ currentPage: pageView.current, pageCount: pageView.count, wordCount });
|
||||||
@@ -227,9 +540,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
function runCommand(command: string, value?: string) {
|
function runCommand(command: string, value?: string) {
|
||||||
editorRef.current?.focus();
|
editorRef.current?.focus();
|
||||||
globalThis.document.execCommand(command, false, value);
|
globalThis.document.execCommand(command, false, value);
|
||||||
if (editorRef.current) {
|
updateHtml();
|
||||||
onChange({ ...document, html: editorRef.current.innerHTML });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTextColor(color: string) {
|
function applyTextColor(color: string) {
|
||||||
@@ -311,6 +622,169 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
runCommand("insertHTML", '<span data-qoffice-page-break="true" contenteditable="false"></span><p><br></p>');
|
runCommand("insertHTML", '<span data-qoffice-page-break="true" contenteditable="false"></span><p><br></p>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setActiveTableCell(cell: HTMLTableCellElement) {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
activeTableCellRef.current = cell;
|
||||||
|
if (editor) {
|
||||||
|
activeTableCellLocationRef.current = tableCellLocation(editor, cell) ?? activeTableCellLocationRef.current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueActiveTableCellRestore(cell: HTMLTableCellElement) {
|
||||||
|
setActiveTableCell(cell);
|
||||||
|
restoreActiveTableCellAfterRenderRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberActiveTableCell(): HTMLTableCellElement | undefined {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const cell = tableCellFromSelection(editor);
|
||||||
|
if (cell) {
|
||||||
|
setActiveTableCell(cell);
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberActiveTableCellFromEvent(event: MouseEvent<HTMLElement>) {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
const target = event.target;
|
||||||
|
const cell = target instanceof Element ? target.closest("td,th") : undefined;
|
||||||
|
if (editor && cell instanceof HTMLTableCellElement && editor.contains(cell)) {
|
||||||
|
setActiveTableCell(cell);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberActiveTableCell();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeTableCell(): HTMLTableCellElement | undefined {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCell = rememberActiveTableCell();
|
||||||
|
if (selectedCell) {
|
||||||
|
return selectedCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rememberedCell = activeTableCellRef.current;
|
||||||
|
if (rememberedCell && editor.contains(rememberedCell)) {
|
||||||
|
return rememberedCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rememberedLocation = activeTableCellLocationRef.current;
|
||||||
|
if (!rememberedLocation) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredCell = tableCellFromLocation(editor, rememberedLocation);
|
||||||
|
if (restoredCell) {
|
||||||
|
activeTableCellRef.current = restoredCell;
|
||||||
|
}
|
||||||
|
return restoredCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTable() {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.focus();
|
||||||
|
const html = `${qwordTableHtml(tableRowCount, tableColumnCount)}<p><br></p>`;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = selection?.rangeCount ? selection.getRangeAt(0) : undefined;
|
||||||
|
if (range && (range.commonAncestorContainer === editor || editor.contains(range.commonAncestorContainer))) {
|
||||||
|
range.deleteContents();
|
||||||
|
const fragment = globalThis.document.createRange().createContextualFragment(html);
|
||||||
|
const firstCell = fragment.querySelector("td,th") as HTMLTableCellElement | null;
|
||||||
|
const lastNode = fragment.lastChild;
|
||||||
|
range.insertNode(fragment);
|
||||||
|
if (firstCell) {
|
||||||
|
queueActiveTableCellRestore(firstCell);
|
||||||
|
placeCaretInTableCell(firstCell);
|
||||||
|
} else if (lastNode) {
|
||||||
|
range.setStartAfter(lastNode);
|
||||||
|
range.collapse(true);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editor.insertAdjacentHTML("beforeend", html);
|
||||||
|
const tables = editor.querySelectorAll("table");
|
||||||
|
const firstCell = tables[tables.length - 1]?.querySelector("td,th") as HTMLTableCellElement | null;
|
||||||
|
if (firstCell) {
|
||||||
|
queueActiveTableCellRestore(firstCell);
|
||||||
|
placeCaretInTableCell(firstCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTableRowBelow() {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = activeTableCell();
|
||||||
|
const row = cell?.parentElement;
|
||||||
|
if (!cell || !(row instanceof HTMLTableRowElement)) {
|
||||||
|
window.alert("Поместите курсор в ячейку таблицы.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRow = globalThis.document.createElement("tr");
|
||||||
|
Array.from({ length: Math.max(1, row.cells.length) }, () => createBlankTableCell()).forEach((newCell) => newRow.append(newCell));
|
||||||
|
row.after(newRow);
|
||||||
|
const nextCell = newRow.cells[Math.min(cell.cellIndex, newRow.cells.length - 1)] ?? newRow.cells[0];
|
||||||
|
queueActiveTableCellRestore(nextCell);
|
||||||
|
placeCaretInTableCell(nextCell);
|
||||||
|
updateHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTableColumnRight() {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = activeTableCell();
|
||||||
|
const row = cell?.parentElement;
|
||||||
|
const table = cell?.closest("table");
|
||||||
|
if (!cell || !(row instanceof HTMLTableRowElement) || !(table instanceof HTMLTableElement)) {
|
||||||
|
window.alert("Поместите курсор в ячейку таблицы.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnIndex = cell.cellIndex;
|
||||||
|
let selectedNewCell: HTMLTableCellElement | undefined;
|
||||||
|
Array.from(table.rows).forEach((tableRow) => {
|
||||||
|
const referenceCell = tableRow.cells[Math.min(columnIndex, Math.max(0, tableRow.cells.length - 1))];
|
||||||
|
const tagName = referenceCell?.tagName.toLowerCase() === "th" ? "th" : "td";
|
||||||
|
const newCell = createBlankTableCell(tagName);
|
||||||
|
if (referenceCell?.nextSibling) {
|
||||||
|
tableRow.insertBefore(newCell, referenceCell.nextSibling);
|
||||||
|
} else {
|
||||||
|
tableRow.append(newCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableRow === row) {
|
||||||
|
selectedNewCell = newCell;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedNewCell) {
|
||||||
|
queueActiveTableCellRestore(selectedNewCell);
|
||||||
|
placeCaretInTableCell(selectedNewCell);
|
||||||
|
}
|
||||||
|
updateHtml();
|
||||||
|
}
|
||||||
|
|
||||||
async function insertImage(file: File | undefined) {
|
async function insertImage(file: File | undefined) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
@@ -338,8 +812,11 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateHtml() {
|
function updateHtml() {
|
||||||
if (editorRef.current) {
|
rememberActiveTableCell();
|
||||||
onChange({ ...document, html: editorRef.current.innerHTML });
|
const editor = editorRef.current;
|
||||||
|
if (editor) {
|
||||||
|
pendingEditorSelectionRestoreRef.current = editorSelectionBookmark(editor) ?? null;
|
||||||
|
onChange({ ...document, html: editor.innerHTML });
|
||||||
refreshPageView();
|
refreshPageView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,7 +828,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const imported = await importDocxFile(file);
|
const imported = await importDocxFile(file);
|
||||||
onChange({ ...document, title: imported.title, html: imported.html });
|
onChange({ ...document, title: imported.title, html: imported.html, ...(imported.pageSetup ? { pageSetup: imported.pageSetup } : {}) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error instanceof Error ? error.message : "Не удалось открыть DOCX.");
|
window.alert(error instanceof Error ? error.message : "Не удалось открыть DOCX.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -364,7 +841,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
async function saveDocx() {
|
async function saveDocx() {
|
||||||
try {
|
try {
|
||||||
const fileName = replaceFileExtension(document.title, ".docx");
|
const fileName = replaceFileExtension(document.title, ".docx");
|
||||||
const blob = await exportDocxBlob(document.title, editorRef.current?.innerHTML ?? document.html);
|
const blob = await exportDocxBlob(document.title, editorRef.current?.innerHTML ?? document.html, { pageSetup: document.pageSetup });
|
||||||
if (isDesktopOfficeBridgeAvailable()) {
|
if (isDesktopOfficeBridgeAvailable()) {
|
||||||
await saveDesktopOfficeFile("docx", fileName, blob);
|
await saveDesktopOfficeFile("docx", fileName, blob);
|
||||||
return;
|
return;
|
||||||
@@ -403,7 +880,6 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-layout qword-layout">
|
<div className="editor-layout qword-layout">
|
||||||
<section ref={stageRef} className="document-stage" style={stageStyle} onScroll={refreshPageView} aria-label="Редактор QWord">
|
|
||||||
<div className="module-toolbar word-module-toolbar">
|
<div className="module-toolbar word-module-toolbar">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -422,13 +898,29 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
aria-label="Вставить изображение"
|
aria-label="Вставить изображение"
|
||||||
/>
|
/>
|
||||||
<div className="word-ribbon-tabs" role="tablist" aria-label="Вкладки QWord">
|
<div className="word-ribbon-tabs" role="tablist" aria-label="Вкладки QWord">
|
||||||
{wordRibbonTabs.map((tab, index) => (
|
{wordRibbonTabs.map((tab) => (
|
||||||
<button key={tab} className={index === 1 ? "is-active" : ""} type="button" role="tab" aria-selected={index === 1}>
|
<button
|
||||||
{tab}
|
key={tab.id}
|
||||||
|
id={`qword-ribbon-tab-${tab.id}`}
|
||||||
|
className={activeRibbonTab === tab.id ? "is-active" : ""}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="qword-ribbon-panel"
|
||||||
|
aria-selected={activeRibbonTab === tab.id}
|
||||||
|
onClick={() => setActiveRibbonTab(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="word-ribbon" aria-label="Лента команд QWord">
|
<div
|
||||||
|
id="qword-ribbon-panel"
|
||||||
|
className={`word-ribbon word-ribbon-${activeRibbonTab}`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-label={`Лента команд QWord: ${activeRibbonTabLabel}`}
|
||||||
|
aria-labelledby={`qword-ribbon-tab-${activeRibbonTab}`}
|
||||||
|
>
|
||||||
|
{activeRibbonTab === "file" ? (
|
||||||
<section className="word-ribbon-group word-file-group" aria-label="Файл">
|
<section className="word-ribbon-group word-file-group" aria-label="Файл">
|
||||||
<div className="word-file-actions">
|
<div className="word-file-actions">
|
||||||
<button className="word-icon-command" type="button" onClick={() => void chooseDocx()} title="Открыть" aria-label="Открыть">
|
<button className="word-icon-command" type="button" onClick={() => void chooseDocx()} title="Открыть" aria-label="Открыть">
|
||||||
@@ -452,7 +944,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</div>
|
</div>
|
||||||
<span className="word-ribbon-title">Файл</span>
|
<span className="word-ribbon-title">Файл</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" ? (
|
||||||
<section className="word-ribbon-group word-clipboard-group" aria-label="Буфер обмена">
|
<section className="word-ribbon-group word-clipboard-group" aria-label="Буфер обмена">
|
||||||
<button className="word-large-command" type="button" onClick={() => runCommand("paste")} title="Вставить">
|
<button className="word-large-command" type="button" onClick={() => runCommand("paste")} title="Вставить">
|
||||||
<ClipboardPaste size={22} />
|
<ClipboardPaste size={22} />
|
||||||
@@ -474,7 +968,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</div>
|
</div>
|
||||||
<span className="word-ribbon-title">Буфер обмена</span>
|
<span className="word-ribbon-title">Буфер обмена</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" || activeRibbonTab === "design" ? (
|
||||||
<section className="word-ribbon-group word-font-group" aria-label="Шрифт">
|
<section className="word-ribbon-group word-font-group" aria-label="Шрифт">
|
||||||
<div className="word-select-row">
|
<div className="word-select-row">
|
||||||
<select className="word-font-select" aria-label="Шрифт" defaultValue="Calibri" onChange={(event) => applyFontFamily(event.target.value)}>
|
<select className="word-font-select" aria-label="Шрифт" defaultValue="Calibri" onChange={(event) => applyFontFamily(event.target.value)}>
|
||||||
@@ -544,7 +1040,59 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</div>
|
</div>
|
||||||
<span className="word-ribbon-title">Шрифт</span>
|
<span className="word-ribbon-title">Шрифт</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "layout" ? (
|
||||||
|
<section className="word-ribbon-group word-page-setup-group" aria-label="Параметры страницы">
|
||||||
|
<div className="word-page-setup-controls">
|
||||||
|
<label className="word-mini-field">
|
||||||
|
<span>Поля</span>
|
||||||
|
<select
|
||||||
|
aria-label="Поля страницы"
|
||||||
|
value={pageSetup.marginPreset}
|
||||||
|
onChange={(event) => changePageSetup({ marginPreset: event.target.value as WordPageMarginPreset })}
|
||||||
|
>
|
||||||
|
{wordPageMarginOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="word-mini-field">
|
||||||
|
<span>Размер</span>
|
||||||
|
<select
|
||||||
|
aria-label="Размер страницы"
|
||||||
|
value={pageSetup.size}
|
||||||
|
onChange={(event) => changePageSetup({ size: event.target.value as WordPageSizeId })}
|
||||||
|
>
|
||||||
|
{wordPageSizeOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="word-mini-field">
|
||||||
|
<span>Ориентация</span>
|
||||||
|
<select
|
||||||
|
aria-label="Ориентация страницы"
|
||||||
|
value={pageSetup.orientation}
|
||||||
|
onChange={(event) => changePageSetup({ orientation: event.target.value as WordPageOrientation })}
|
||||||
|
>
|
||||||
|
{wordPageOrientationOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span className="word-ribbon-title">Параметры страницы</span>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" || activeRibbonTab === "layout" ? (
|
||||||
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
|
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
|
||||||
<div className="segmented-tools" aria-label="Списки">
|
<div className="segmented-tools" aria-label="Списки">
|
||||||
<button type="button" onClick={() => runCommand("insertUnorderedList")} title="Маркированный список">
|
<button type="button" onClick={() => runCommand("insertUnorderedList")} title="Маркированный список">
|
||||||
@@ -574,7 +1122,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</button>
|
</button>
|
||||||
<span className="word-ribbon-title">Абзац</span>
|
<span className="word-ribbon-title">Абзац</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" || activeRibbonTab === "design" ? (
|
||||||
<section className="word-ribbon-group word-styles-group" aria-label="Стили">
|
<section className="word-ribbon-group word-styles-group" aria-label="Стили">
|
||||||
<div className="word-style-gallery">
|
<div className="word-style-gallery">
|
||||||
{styleGalleryOptions.map((style) => (
|
{styleGalleryOptions.map((style) => (
|
||||||
@@ -586,7 +1136,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</div>
|
</div>
|
||||||
<span className="word-ribbon-title">Стили</span>
|
<span className="word-ribbon-title">Стили</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "insert" || activeRibbonTab === "references" ? (
|
||||||
<section className="word-ribbon-group word-insert-group" aria-label="Вставка">
|
<section className="word-ribbon-group word-insert-group" aria-label="Вставка">
|
||||||
<button className="compact-command" type="button" onClick={insertLink}>
|
<button className="compact-command" type="button" onClick={insertLink}>
|
||||||
<Link size={16} />
|
<Link size={16} />
|
||||||
@@ -598,7 +1150,70 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</button>
|
</button>
|
||||||
<span className="word-ribbon-title">Вставка</span>
|
<span className="word-ribbon-title">Вставка</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "insert" ? (
|
||||||
|
<section className="word-ribbon-group word-table-group" aria-label="Таблица">
|
||||||
|
<div className="word-table-controls">
|
||||||
|
<label className="word-mini-field">
|
||||||
|
<span>Строки</span>
|
||||||
|
<select aria-label="Строки таблицы" value={tableRowCount} onChange={(event) => setTableRowCount(clampedTableSize(Number(event.target.value)))}>
|
||||||
|
{tableSizeOptions.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="word-mini-field">
|
||||||
|
<span>Столбцы</span>
|
||||||
|
<select aria-label="Столбцы таблицы" value={tableColumnCount} onChange={(event) => setTableColumnCount(clampedTableSize(Number(event.target.value)))}>
|
||||||
|
{tableSizeOptions.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="compact-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTable}>
|
||||||
|
<Table2 size={16} />
|
||||||
|
Таблица
|
||||||
|
</button>
|
||||||
|
<button className="compact-command word-table-structure-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTableRowBelow}>
|
||||||
|
<Rows3 size={16} />
|
||||||
|
Строка ниже
|
||||||
|
</button>
|
||||||
|
<button className="compact-command word-table-structure-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTableColumnRight}>
|
||||||
|
<Columns2 size={16} />
|
||||||
|
Столбец справа
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="word-ribbon-title">Таблица</span>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "view" ? (
|
||||||
|
<section className="word-ribbon-group word-view-group" aria-label="Режимы просмотра">
|
||||||
|
<div className="word-view-mode-row">
|
||||||
|
{wordViewModes.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
className={`compact-command${activeViewMode === mode.id ? " is-active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={mode.label}
|
||||||
|
aria-pressed={activeViewMode === mode.id}
|
||||||
|
onClick={() => changeViewMode(mode.id)}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
{mode.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="word-ribbon-title">Вид</span>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" || activeRibbonTab === "review" ? (
|
||||||
<section className="word-ribbon-group word-edit-group" aria-label="Редактирование">
|
<section className="word-ribbon-group word-edit-group" aria-label="Редактирование">
|
||||||
<button className="compact-command" type="button" onClick={findText}>
|
<button className="compact-command" type="button" onClick={findText}>
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
@@ -610,9 +1225,11 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</button>
|
</button>
|
||||||
<span className="word-ribbon-title">Редактирование</span>
|
<span className="word-ribbon-title">Редактирование</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section ref={stageRef} className={stageClassName} style={stageStyle} onScroll={refreshPageView} aria-label="Редактор QWord">
|
||||||
<div className="page-ruler" aria-hidden="true">
|
<div className="page-ruler" aria-hidden="true">
|
||||||
{Array.from({ length: 9 }, (_, index) => (
|
{Array.from({ length: 9 }, (_, index) => (
|
||||||
<span key={index}>{index + 1}</span>
|
<span key={index}>{index + 1}</span>
|
||||||
@@ -625,22 +1242,28 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="paper-frame">
|
<div className="paper-frame" style={pageFrameStyle}>
|
||||||
|
<div className="page-sheets" aria-hidden="true">
|
||||||
|
{Array.from({ length: pageView.count }, (_, index) => (
|
||||||
|
<span key={index} className="page-sheet" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<article
|
<article
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
className="paper document-content"
|
className="paper document-content"
|
||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
onInput={updateHtml}
|
onInput={updateHtml}
|
||||||
dangerouslySetInnerHTML={{ __html: document.html }}
|
onFocus={rememberActiveTableCell}
|
||||||
|
onKeyUp={rememberActiveTableCell}
|
||||||
|
onMouseDown={rememberActiveTableCellFromEvent}
|
||||||
|
onMouseUp={rememberActiveTableCellFromEvent}
|
||||||
aria-label="Содержимое документа"
|
aria-label="Содержимое документа"
|
||||||
/>
|
/>
|
||||||
{pageView.count > 1 ? (
|
{pageView.count > 1 ? (
|
||||||
<div className="page-break-guides" aria-hidden="true">
|
<div className="page-break-guides" aria-hidden="true">
|
||||||
{Array.from({ length: pageView.count - 1 }, (_, index) => (
|
{Array.from({ length: pageView.count - 1 }, (_, index) => (
|
||||||
<span key={index} style={{ top: `${(index + 1) * pageView.height}px` }}>
|
<span key={index} style={{ top: `${(index + 1) * pageView.height + index * defaultPageGap}px` }} />
|
||||||
Страница {index + 2}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
buildColumnWidthsXml,
|
buildColumnWidthsXml,
|
||||||
buildHyperlinksXml,
|
buildHyperlinksXml,
|
||||||
buildMergeCellsXml,
|
buildMergeCellsXml,
|
||||||
|
buildPageMarginsXml,
|
||||||
|
buildPageSetupXml,
|
||||||
buildSheetPrXml,
|
buildSheetPrXml,
|
||||||
buildSheetViewsXml,
|
buildSheetViewsXml,
|
||||||
createCellStyleRegistry,
|
createCellStyleRegistry,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
workbookPrintAreas,
|
workbookPrintAreas,
|
||||||
workbookRelationshipTargets,
|
workbookRelationshipTargets,
|
||||||
workbookSheetRefs,
|
workbookSheetRefs,
|
||||||
|
workbookUsesDate1904,
|
||||||
worksheetXmlToAutoFilter,
|
worksheetXmlToAutoFilter,
|
||||||
worksheetXmlToCells,
|
worksheetXmlToCells,
|
||||||
worksheetXmlToCellFormats,
|
worksheetXmlToCellFormats,
|
||||||
@@ -31,12 +34,14 @@ import {
|
|||||||
worksheetXmlToHyperlinks,
|
worksheetXmlToHyperlinks,
|
||||||
worksheetCommentsXmlToComments,
|
worksheetCommentsXmlToComments,
|
||||||
worksheetXmlToMergedCells,
|
worksheetXmlToMergedCells,
|
||||||
|
worksheetXmlToPageSetup,
|
||||||
worksheetXmlToRowHeights,
|
worksheetXmlToRowHeights,
|
||||||
worksheetXmlToShowGridLines,
|
worksheetXmlToShowGridLines,
|
||||||
worksheetXmlToTabColor,
|
worksheetXmlToTabColor,
|
||||||
worksheetXmlToZoomScale,
|
worksheetXmlToZoomScale,
|
||||||
xlsxStylesXmlToCellFormatTable,
|
xlsxStylesXmlToCellFormatTable,
|
||||||
xlsxThemeXmlToColors,
|
xlsxThemeXmlToColors,
|
||||||
|
xlsxSafeSheetNames,
|
||||||
xlsxCellToText
|
xlsxCellToText
|
||||||
} from "./excellOffice";
|
} from "./excellOffice";
|
||||||
|
|
||||||
@@ -614,6 +619,38 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
).toBe("sheet-7");
|
).toBe("sheet-7");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detects the XLSX 1904 date system flag from workbook properties", () => {
|
||||||
|
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "1" } } })).toBe(true);
|
||||||
|
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "true" } } })).toBe(true);
|
||||||
|
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "0" } } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts imported XLSX 1904 date serials to the QExcell 1900 serial system", async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file(
|
||||||
|
"xl/workbook.xml",
|
||||||
|
`<workbook><workbookPr date1904="1"/><sheets><sheet name="Dates" sheetId="1" r:id="rId1"/></sheets></workbook>`
|
||||||
|
);
|
||||||
|
zip.file(
|
||||||
|
"xl/_rels/workbook.xml.rels",
|
||||||
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>`
|
||||||
|
);
|
||||||
|
zip.file("xl/styles.xml", `<styleSheet><cellXfs count="3"><xf numFmtId="0"/><xf numFmtId="14"/><xf numFmtId="20"/></cellXfs></styleSheet>`);
|
||||||
|
zip.file(
|
||||||
|
"xl/worksheets/sheet1.xml",
|
||||||
|
`<worksheet><sheetData><row r="1"><c r="A1" s="1"><v>43465</v></c><c r="B1"><v>5</v></c><c r="C1" s="2"><v>0.5</v></c></row></sheetData></worksheet>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = await zip.generateAsync({
|
||||||
|
type: "blob",
|
||||||
|
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
});
|
||||||
|
const imported = await importXlsxFile(new File([blob], "Date1904.xlsx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(imported.sheets[0].cells).toEqual({ A1: "44927", B1: "5", C1: "0.5" });
|
||||||
|
expect(imported.sheets[0].cellFormats).toEqual({ A1: { numberFormat: "m/d/yy" }, C1: { numberFormat: "h:mm" } });
|
||||||
|
});
|
||||||
|
|
||||||
it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => {
|
it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => {
|
||||||
const blob = await exportXlsxBlob({
|
const blob = await exportXlsxBlob({
|
||||||
id: "book-1",
|
id: "book-1",
|
||||||
@@ -633,6 +670,38 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
expect(imported.activeSheet).toBe("sheet-2");
|
expect(imported.activeSheet).toBe("sheet-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes XLSX sheet names to Excel-safe unique names on export", async () => {
|
||||||
|
expect(
|
||||||
|
xlsxSafeSheetNames([
|
||||||
|
{ name: "Sales/2026:Q1*?" },
|
||||||
|
{ name: "'Very long worksheet name that exceeds thirty one characters'" },
|
||||||
|
{ name: "Sales 2026 Q1" },
|
||||||
|
{ name: "'[]:*?/\\'" },
|
||||||
|
{ name: "History" }
|
||||||
|
])
|
||||||
|
).toEqual(["Sales 2026 Q1", "Very long worksheet name that e", "Sales 2026 Q1 (2)", "Лист 4", "History 1"]);
|
||||||
|
|
||||||
|
const blob = await exportXlsxBlob({
|
||||||
|
id: "book-1",
|
||||||
|
title: "Safe sheet names.xlsx",
|
||||||
|
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
activeSheet: "sheet-1",
|
||||||
|
sheets: [
|
||||||
|
{ id: "sheet-1", name: "Sales/2026:Q1*?", printArea: "A1:B2", cells: { A1: "Sales" } },
|
||||||
|
{ id: "sheet-2", name: "Sales 2026 Q1", cells: { A1: "Duplicate" } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const workbookXml = await zip.file("xl/workbook.xml")?.async("string");
|
||||||
|
const imported = await importXlsxFile(new File([blob], "Safe sheet names.xlsx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1" sheetId="1" r:id="rId1"/>');
|
||||||
|
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1 (2)" sheetId="2" r:id="rId2"/>');
|
||||||
|
expect(workbookXml).toContain('<definedName name="_xlnm.Print_Area" localSheetId="0">\'Sales 2026 Q1\'!$A$1:$B$2</definedName>');
|
||||||
|
expect(imported.sheets.map((sheet) => sheet.name)).toEqual(["Sales 2026 Q1", "Sales 2026 Q1 (2)"]);
|
||||||
|
expect(imported.sheets[0].printArea).toBe("A1:B2");
|
||||||
|
});
|
||||||
|
|
||||||
it("сохраняет и импортирует область печати XLSX через definedName _xlnm.Print_Area", async () => {
|
it("сохраняет и импортирует область печати XLSX через definedName _xlnm.Print_Area", async () => {
|
||||||
expect(
|
expect(
|
||||||
workbookPrintAreas(
|
workbookPrintAreas(
|
||||||
@@ -673,6 +742,45 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
expect(imported.sheets[1].printArea).toBe("B2:C4,E1:E3");
|
expect(imported.sheets[1].printArea).toBe("B2:C4,E1:E3");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("сохраняет и импортирует параметры страницы XLSX через pageMargins и pageSetup", async () => {
|
||||||
|
expect(
|
||||||
|
worksheetXmlToPageSetup({
|
||||||
|
worksheet: {
|
||||||
|
pageMargins: { left: "0.25", right: "0.25", top: "0.75", bottom: "0.75", header: "0.3", footer: "0.3" },
|
||||||
|
pageSetup: { paperSize: "1", orientation: "landscape" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" });
|
||||||
|
expect(buildPageMarginsXml({ size: "a4", orientation: "portrait", marginPreset: "wide" })).toBe(
|
||||||
|
'<pageMargins left="1" right="1" top="1" bottom="1" header="0.5" footer="0.5"/>'
|
||||||
|
);
|
||||||
|
expect(buildPageSetupXml({ size: "letter", orientation: "landscape", marginPreset: "normal" })).toBe(
|
||||||
|
'<pageSetup paperSize="1" orientation="landscape"/>'
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = await exportXlsxBlob({
|
||||||
|
id: "book-1",
|
||||||
|
title: "Page setup.xlsx",
|
||||||
|
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
activeSheet: "sheet-1",
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
id: "sheet-1",
|
||||||
|
name: "Layout",
|
||||||
|
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" },
|
||||||
|
cells: { A1: "Layout" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
|
||||||
|
const imported = await importXlsxFile(new File([blob], "Page setup.xlsx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(sheetXml).toContain('<pageMargins left="0.25" right="0.25" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>');
|
||||||
|
expect(sheetXml).toContain('<pageSetup paperSize="1" orientation="landscape"/>');
|
||||||
|
expect(imported.sheets[0].pageSetup).toEqual({ size: "letter", orientation: "landscape", marginPreset: "narrow" });
|
||||||
|
});
|
||||||
|
|
||||||
it("сохраняет и импортирует скрытые листы XLSX через sheet state", async () => {
|
it("сохраняет и импортирует скрытые листы XLSX через sheet state", async () => {
|
||||||
const blob = await exportXlsxBlob({
|
const blob = await exportXlsxBlob({
|
||||||
id: "book-1",
|
id: "book-1",
|
||||||
|
|||||||
+209
-17
@@ -1,4 +1,15 @@
|
|||||||
import type { CellFormat, MergedCellRange, SheetChart, SheetChartType, SheetFrozenPane, Workbook } from "../types";
|
import type {
|
||||||
|
CellFormat,
|
||||||
|
MergedCellRange,
|
||||||
|
SheetChart,
|
||||||
|
SheetChartType,
|
||||||
|
SheetFrozenPane,
|
||||||
|
SheetPageMarginPreset,
|
||||||
|
SheetPageOrientation,
|
||||||
|
SheetPageSetup,
|
||||||
|
SheetPageSizeId,
|
||||||
|
Workbook
|
||||||
|
} from "../types";
|
||||||
import { evaluateCell, expandRange } from "../utils/spreadsheet";
|
import { evaluateCell, expandRange } from "../utils/spreadsheet";
|
||||||
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers";
|
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers";
|
||||||
|
|
||||||
@@ -19,6 +30,7 @@ export interface ImportedExcellWorkbook {
|
|||||||
showGridLines?: boolean;
|
showGridLines?: boolean;
|
||||||
zoomScale?: number;
|
zoomScale?: number;
|
||||||
printArea?: string;
|
printArea?: string;
|
||||||
|
pageSetup?: SheetPageSetup;
|
||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
@@ -77,6 +89,7 @@ interface ParsedSheet {
|
|||||||
showGridLines?: boolean;
|
showGridLines?: boolean;
|
||||||
zoomScale?: number;
|
zoomScale?: number;
|
||||||
printArea?: string;
|
printArea?: string;
|
||||||
|
pageSetup?: SheetPageSetup;
|
||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
@@ -210,6 +223,16 @@ const builtinNumberFormats: Record<number, string> = {
|
|||||||
48: "##0.0E+0",
|
48: "##0.0E+0",
|
||||||
49: "@"
|
49: "@"
|
||||||
};
|
};
|
||||||
|
const defaultSheetPageSetup: SheetPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
|
||||||
|
const xlsxPageSizePaperIds: Record<SheetPageSizeId, number> = {
|
||||||
|
letter: 1,
|
||||||
|
a4: 9
|
||||||
|
};
|
||||||
|
const xlsxPageMargins: Record<SheetPageMarginPreset, { left: number; right: number; top: number; bottom: number; header: number; footer: number }> = {
|
||||||
|
normal: { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 },
|
||||||
|
narrow: { left: 0.25, right: 0.25, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 },
|
||||||
|
wide: { left: 1, right: 1, top: 1, bottom: 1, header: 0.5, footer: 0.5 }
|
||||||
|
};
|
||||||
|
|
||||||
export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook> {
|
export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook> {
|
||||||
if (!file.name.toLowerCase().endsWith(".xlsx")) {
|
if (!file.name.toLowerCase().endsWith(".xlsx")) {
|
||||||
@@ -226,6 +249,7 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
|
|||||||
const themeColors = await readOptionalWorkbookThemeColors(zip, parser);
|
const themeColors = await readOptionalWorkbookThemeColors(zip, parser);
|
||||||
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
|
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
|
||||||
const sheetRefs = workbookSheetRefs(workbookXml);
|
const sheetRefs = workbookSheetRefs(workbookXml);
|
||||||
|
const date1904 = workbookUsesDate1904(workbookXml);
|
||||||
const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
|
const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
|
||||||
const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
|
const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
|
||||||
const sheets = await Promise.all(
|
const sheets = await Promise.all(
|
||||||
@@ -249,7 +273,7 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
|
|||||||
const sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath);
|
const sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath);
|
||||||
const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath);
|
const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath);
|
||||||
const commentsXml = commentsPath ? await readOptionalParsedXml(zip, parser, commentsPath) : undefined;
|
const commentsXml = commentsPath ? await readOptionalParsedXml(zip, parser, commentsPath) : undefined;
|
||||||
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors, commentsXml);
|
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors, commentsXml, date1904);
|
||||||
const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name);
|
const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name);
|
||||||
return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts };
|
return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts };
|
||||||
})
|
})
|
||||||
@@ -267,11 +291,13 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
const JSZip = await loadJsZip();
|
const JSZip = await loadJsZip();
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const sheets = workbook.sheets.length > 0 ? workbook.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [], hyperlinks: {} }];
|
const sheets = workbook.sheets.length > 0 ? workbook.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [], hyperlinks: {} }];
|
||||||
|
const safeSheetNames = xlsxSafeSheetNames(sheets);
|
||||||
|
const safeSheets = sheets.map((sheet, index) => ({ ...sheet, name: safeSheetNames[index] }));
|
||||||
|
|
||||||
const styleRegistry = createCellStyleRegistry(sheets.map((sheet) => sheet.cellFormats));
|
const styleRegistry = createCellStyleRegistry(safeSheets.map((sheet) => sheet.cellFormats));
|
||||||
const chartParts = chartPartRefsForSheets(sheets);
|
const chartParts = chartPartRefsForSheets(safeSheets);
|
||||||
const commentParts = commentPartRefsForSheets(sheets);
|
const commentParts = commentPartRefsForSheets(safeSheets);
|
||||||
const activeSheetIndex = Math.max(0, sheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
|
const activeSheetIndex = Math.max(0, safeSheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
|
||||||
|
|
||||||
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts, commentParts));
|
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts, commentParts));
|
||||||
zip.file("_rels/.rels", buildRootRelationshipsXml());
|
zip.file("_rels/.rels", buildRootRelationshipsXml());
|
||||||
@@ -280,8 +306,8 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
zip.file(
|
zip.file(
|
||||||
"xl/workbook.xml",
|
"xl/workbook.xml",
|
||||||
buildWorkbookXml(
|
buildWorkbookXml(
|
||||||
sheets.map((sheet, index) => ({
|
safeSheets.map((sheet) => ({
|
||||||
name: sheet.name.trim() || `Лист ${index + 1}`,
|
name: sheet.name,
|
||||||
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
|
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
|
||||||
...(sheet.printArea ? { printArea: sheet.printArea } : {})
|
...(sheet.printArea ? { printArea: sheet.printArea } : {})
|
||||||
})),
|
})),
|
||||||
@@ -291,7 +317,7 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
zip.file("xl/_rels/workbook.xml.rels", buildWorkbookRelationshipsXml(sheets.length));
|
zip.file("xl/_rels/workbook.xml.rels", buildWorkbookRelationshipsXml(sheets.length));
|
||||||
zip.file("xl/styles.xml", buildStylesXml(styleRegistry));
|
zip.file("xl/styles.xml", buildStylesXml(styleRegistry));
|
||||||
|
|
||||||
sheets.forEach((sheet, index) => {
|
safeSheets.forEach((sheet, index) => {
|
||||||
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
|
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
|
||||||
const sheetCommentPart = commentParts.find((part) => part.sheetIndex === index);
|
const sheetCommentPart = commentParts.find((part) => part.sheetIndex === index);
|
||||||
const externalHyperlinkCount = sortedExternalHyperlinkEntries(sheet.hyperlinks).length;
|
const externalHyperlinkCount = sortedExternalHyperlinkEntries(sheet.hyperlinks).length;
|
||||||
@@ -317,7 +343,8 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
sheet.hiddenRows,
|
sheet.hiddenRows,
|
||||||
sheet.showGridLines,
|
sheet.showGridLines,
|
||||||
sheet.zoomScale,
|
sheet.zoomScale,
|
||||||
legacyDrawingRelationshipId
|
legacyDrawingRelationshipId,
|
||||||
|
sheet.pageSetup
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const relationshipsXml = buildWorksheetRelationshipsXml(
|
const relationshipsXml = buildWorksheetRelationshipsXml(
|
||||||
@@ -356,7 +383,8 @@ export function parsedWorksheetToSheet(
|
|||||||
relationshipsXml?: unknown,
|
relationshipsXml?: unknown,
|
||||||
styleTable: Array<CellFormat | undefined> = [],
|
styleTable: Array<CellFormat | undefined> = [],
|
||||||
themeColors: string[] = defaultXlsxThemeColors,
|
themeColors: string[] = defaultXlsxThemeColors,
|
||||||
commentsXml?: unknown
|
commentsXml?: unknown,
|
||||||
|
date1904 = false
|
||||||
): ParsedSheet {
|
): ParsedSheet {
|
||||||
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
|
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
|
||||||
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
|
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
|
||||||
@@ -367,6 +395,7 @@ export function parsedWorksheetToSheet(
|
|||||||
const hiddenRows = worksheetXmlToHiddenRows(worksheetXml);
|
const hiddenRows = worksheetXmlToHiddenRows(worksheetXml);
|
||||||
const showGridLines = worksheetXmlToShowGridLines(worksheetXml);
|
const showGridLines = worksheetXmlToShowGridLines(worksheetXml);
|
||||||
const zoomScale = worksheetXmlToZoomScale(worksheetXml);
|
const zoomScale = worksheetXmlToZoomScale(worksheetXml);
|
||||||
|
const pageSetup = worksheetXmlToPageSetup(worksheetXml);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sheet.id || `sheet-${index + 1}`,
|
id: sheet.id || `sheet-${index + 1}`,
|
||||||
@@ -381,7 +410,8 @@ export function parsedWorksheetToSheet(
|
|||||||
...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}),
|
...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}),
|
||||||
...(showGridLines === false ? { showGridLines } : {}),
|
...(showGridLines === false ? { showGridLines } : {}),
|
||||||
...(zoomScale ? { zoomScale } : {}),
|
...(zoomScale ? { zoomScale } : {}),
|
||||||
cells: worksheetXmlToCells(worksheetXml, sharedStrings),
|
...(pageSetup ? { pageSetup } : {}),
|
||||||
|
cells: worksheetXmlToCells(worksheetXml, sharedStrings, styleTable, date1904),
|
||||||
mergedCells: worksheetXmlToMergedCells(worksheetXml),
|
mergedCells: worksheetXmlToMergedCells(worksheetXml),
|
||||||
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
|
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
|
||||||
comments: worksheetCommentsXmlToComments(commentsXml),
|
comments: worksheetCommentsXmlToComments(commentsXml),
|
||||||
@@ -390,7 +420,7 @@ export function parsedWorksheetToSheet(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[]) {
|
export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[], styleTable: Array<CellFormat | undefined> = [], date1904 = false) {
|
||||||
const cells: Record<string, string> = {};
|
const cells: Record<string, string> = {};
|
||||||
const sharedFormulas: SharedFormulaMap = {};
|
const sharedFormulas: SharedFormulaMap = {};
|
||||||
const parsedCells = worksheetCellRecords(worksheetXml);
|
const parsedCells = worksheetCellRecords(worksheetXml);
|
||||||
@@ -406,9 +436,10 @@ export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string
|
|||||||
|
|
||||||
parsedCells.forEach(({ address, cell }) => {
|
parsedCells.forEach(({ address, cell }) => {
|
||||||
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
|
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
|
||||||
|
const importedText = date1904 ? date1904AdjustedCellText(cell, text, styleTable[numberAttribute(cell.s)]) : text;
|
||||||
|
|
||||||
if (text !== "") {
|
if (importedText !== "") {
|
||||||
cells[address] = text;
|
cells[address] = importedText;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -535,6 +566,18 @@ export function worksheetXmlToZoomScale(worksheetXml: unknown) {
|
|||||||
return normalizedSheetZoomScale(sheetView.zoomScale);
|
return normalizedSheetZoomScale(sheetView.zoomScale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function worksheetXmlToPageSetup(worksheetXml: unknown): SheetPageSetup | undefined {
|
||||||
|
const worksheet = childRecord(worksheetXml, "worksheet");
|
||||||
|
const pageSetup = childRecord(worksheet, "pageSetup");
|
||||||
|
const pageMargins = childRecord(worksheet, "pageMargins");
|
||||||
|
const size = sheetPageSizeFromPaperId(pageSetup.paperSize) ?? defaultSheetPageSetup.size;
|
||||||
|
const orientation = normalizedSheetPageOrientation(pageSetup.orientation) ?? defaultSheetPageSetup.orientation;
|
||||||
|
const marginPreset = sheetPageMarginPresetFromRecord(pageMargins) ?? defaultSheetPageSetup.marginPreset;
|
||||||
|
const hasPageSetup = Object.keys(pageSetup).length > 0;
|
||||||
|
const hasPageMargins = Object.keys(pageMargins).length > 0;
|
||||||
|
return hasPageSetup || hasPageMargins ? { size, orientation, marginPreset } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function worksheetXmlToAutoFilter(worksheetXml: unknown) {
|
export function worksheetXmlToAutoFilter(worksheetXml: unknown) {
|
||||||
const worksheet = childRecord(worksheetXml, "worksheet");
|
const worksheet = childRecord(worksheetXml, "worksheet");
|
||||||
const autoFilter = childRecord(worksheet, "autoFilter");
|
const autoFilter = childRecord(worksheet, "autoFilter");
|
||||||
@@ -799,7 +842,8 @@ export function buildWorksheetXml(
|
|||||||
hiddenRows: Record<string, boolean> = {},
|
hiddenRows: Record<string, boolean> = {},
|
||||||
showGridLines?: boolean,
|
showGridLines?: boolean,
|
||||||
zoomScale?: number,
|
zoomScale?: number,
|
||||||
legacyDrawingRelationshipId = ""
|
legacyDrawingRelationshipId = "",
|
||||||
|
pageSetup?: SheetPageSetup
|
||||||
) {
|
) {
|
||||||
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
|
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
|
||||||
const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows);
|
const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows);
|
||||||
@@ -840,9 +884,11 @@ export function buildWorksheetXml(
|
|||||||
const sheetPrXml = buildSheetPrXml(tabColor);
|
const sheetPrXml = buildSheetPrXml(tabColor);
|
||||||
const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale);
|
const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale);
|
||||||
const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns);
|
const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns);
|
||||||
|
const pageMarginsXml = buildPageMarginsXml(pageSetup);
|
||||||
|
const pageSetupXml = buildPageSetupXml(pageSetup);
|
||||||
|
|
||||||
return xmlDeclaration(
|
return xmlDeclaration(
|
||||||
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}${legacyDrawingXml}</worksheet>`
|
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${pageMarginsXml}${pageSetupXml}${drawingXml}${legacyDrawingXml}</worksheet>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -935,6 +981,24 @@ export function buildSheetPrXml(tabColor?: string) {
|
|||||||
return color ? `<sheetPr><tabColor rgb="FF${color}"/></sheetPr>` : "";
|
return color ? `<sheetPr><tabColor rgb="FF${color}"/></sheetPr>` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildPageMarginsXml(pageSetup?: SheetPageSetup) {
|
||||||
|
if (!pageSetup) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const margins = xlsxPageMargins[normalizedSheetPageSetup(pageSetup).marginPreset];
|
||||||
|
return `<pageMargins left="${formatSheetDimension(margins.left)}" right="${formatSheetDimension(margins.right)}" top="${formatSheetDimension(margins.top)}" bottom="${formatSheetDimension(margins.bottom)}" header="${formatSheetDimension(margins.header)}" footer="${formatSheetDimension(margins.footer)}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPageSetupXml(pageSetup?: SheetPageSetup) {
|
||||||
|
if (!pageSetup) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizedSheetPageSetup(pageSetup);
|
||||||
|
return `<pageSetup paperSize="${xlsxPageSizePaperIds[normalized.size]}" orientation="${normalized.orientation}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildColumnWidthsXml(columnWidths: Record<string, number> = {}, hiddenColumns: Record<string, boolean> = {}) {
|
export function buildColumnWidthsXml(columnWidths: Record<string, number> = {}, hiddenColumns: Record<string, boolean> = {}) {
|
||||||
const columnsByNumber = new Map<number, { width?: number; hidden?: boolean }>();
|
const columnsByNumber = new Map<number, { width?: number; hidden?: boolean }>();
|
||||||
normalizedSheetColumnWidths(columnWidths).forEach(({ column, width }) => {
|
normalizedSheetColumnWidths(columnWidths).forEach(({ column, width }) => {
|
||||||
@@ -1030,6 +1094,12 @@ export function workbookActiveSheetId(workbookXml: unknown, sheets: Array<{ id:
|
|||||||
return sheets[activeTab]?.id ?? "";
|
return sheets[activeTab]?.id ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function workbookUsesDate1904(workbookXml: unknown) {
|
||||||
|
const workbook = childRecord(workbookXml, "workbook");
|
||||||
|
const workbookPr = childRecord(workbook, "workbookPr");
|
||||||
|
return booleanAttribute(workbookPr.date1904 ?? workbookPr["@_date1904"]);
|
||||||
|
}
|
||||||
|
|
||||||
export function workbookPrintAreas(workbookXml: unknown, sheets: Array<{ id: string; name: string }>): Record<string, string> {
|
export function workbookPrintAreas(workbookXml: unknown, sheets: Array<{ id: string; name: string }>): Record<string, string> {
|
||||||
const workbook = childRecord(workbookXml, "workbook");
|
const workbook = childRecord(workbookXml, "workbook");
|
||||||
const definedNames = childRecord(workbook, "definedNames");
|
const definedNames = childRecord(workbook, "definedNames");
|
||||||
@@ -1109,6 +1179,45 @@ function buildRootRelationshipsXml() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function xlsxSafeSheetNames(sheets: Array<{ name: string }>) {
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
return sheets.map((sheet, index) => uniqueXlsxSheetName(sheet.name, index, usedNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueXlsxSheetName(value: string, index: number, usedNames: Set<string>) {
|
||||||
|
const fallback = `Лист ${index + 1}`;
|
||||||
|
const normalizedName = normalizedXlsxSheetName(value);
|
||||||
|
const baseName = xlsxReservedSheetName(normalizedName) ? `${normalizedName} 1` : normalizedName || fallback;
|
||||||
|
let suffixNumber = 1;
|
||||||
|
let candidate = truncateXlsxSheetName(baseName);
|
||||||
|
|
||||||
|
while (usedNames.has(candidate.toLowerCase())) {
|
||||||
|
suffixNumber += 1;
|
||||||
|
const suffix = ` (${suffixNumber})`;
|
||||||
|
candidate = `${truncateXlsxSheetName(baseName, 31 - suffix.length)}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
usedNames.add(candidate.toLowerCase());
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedXlsxSheetName(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/[\u0000-\u001f\\/?*:[\]]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/^'+|'+$/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function xlsxReservedSheetName(value: string) {
|
||||||
|
return value.trim().toLowerCase() === "history";
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateXlsxSheetName(value: string, maxLength = 31) {
|
||||||
|
return Array.from(value).slice(0, Math.max(1, maxLength)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function buildWorkbookXml(sheets: Array<{ name: string; visibility?: WorkbookSheetRef["visibility"]; printArea?: string }>, activeSheetIndex = 0) {
|
function buildWorkbookXml(sheets: Array<{ name: string; visibility?: WorkbookSheetRef["visibility"]; printArea?: string }>, activeSheetIndex = 0) {
|
||||||
const activeTab = Math.min(Math.max(0, activeSheetIndex), Math.max(0, sheets.length - 1));
|
const activeTab = Math.min(Math.max(0, activeSheetIndex), Math.max(0, sheets.length - 1));
|
||||||
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;
|
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;
|
||||||
@@ -1748,6 +1857,37 @@ function normalizedNumberFormat(value?: string) {
|
|||||||
return raw.slice(0, 255);
|
return raw.slice(0, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function date1904AdjustedCellText(cell: XmlRecord, text: string, format?: CellFormat) {
|
||||||
|
if (!isNumericXlsxCell(cell) || !isDateSerialXlsxNumberFormat(format?.numberFormat)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serial = Number(text);
|
||||||
|
if (!Number.isFinite(serial) || serial < 0) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedXlsxNumberText(serial + 1462);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumericXlsxCell(cell: XmlRecord) {
|
||||||
|
const type = typeof cell.t === "string" ? cell.t.trim() : "";
|
||||||
|
return type === "" || type === "n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateSerialXlsxNumberFormat(value?: string) {
|
||||||
|
const pattern = normalizedNumberFormat(value)
|
||||||
|
.replace(/"[^"]*"/g, "")
|
||||||
|
.replace(/\\./g, "")
|
||||||
|
.replace(/\[[^\]]+]/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
return /[dy]/.test(pattern) || (pattern.includes("m") && !/[hs]/.test(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedXlsxNumberText(value: number) {
|
||||||
|
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(10)));
|
||||||
|
}
|
||||||
|
|
||||||
function xlsxNumberFormats(styleSheet: XmlRecord) {
|
function xlsxNumberFormats(styleSheet: XmlRecord) {
|
||||||
const formats = { ...builtinNumberFormats };
|
const formats = { ...builtinNumberFormats };
|
||||||
toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {
|
toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {
|
||||||
@@ -1833,6 +1973,58 @@ function normalizedSheetZoomScale(value: unknown) {
|
|||||||
return zoomScale >= 10 && zoomScale <= 400 && zoomScale !== 100 ? zoomScale : 0;
|
return zoomScale >= 10 && zoomScale <= 400 && zoomScale !== 100 ? zoomScale : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedSheetPageSetup(pageSetup?: SheetPageSetup): SheetPageSetup {
|
||||||
|
const size = isSheetPageSizeId(pageSetup?.size) ? pageSetup.size : defaultSheetPageSetup.size;
|
||||||
|
const orientation = normalizedSheetPageOrientation(pageSetup?.orientation) ?? defaultSheetPageSetup.orientation;
|
||||||
|
const marginPreset = isSheetPageMarginPreset(pageSetup?.marginPreset) ? pageSetup.marginPreset : defaultSheetPageSetup.marginPreset;
|
||||||
|
return { size, orientation, marginPreset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSheetPageOrientation(value: unknown): SheetPageOrientation | undefined {
|
||||||
|
return value === "portrait" || value === "landscape" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSheetPageSizeId(value: unknown): value is SheetPageSizeId {
|
||||||
|
return value === "a4" || value === "letter";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSheetPageMarginPreset(value: unknown): value is SheetPageMarginPreset {
|
||||||
|
return value === "normal" || value === "narrow" || value === "wide";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sheetPageSizeFromPaperId(value: unknown): SheetPageSizeId | undefined {
|
||||||
|
const paperSize = positiveIntegerAttribute(value);
|
||||||
|
return (Object.keys(xlsxPageSizePaperIds) as SheetPageSizeId[]).find((sizeId) => xlsxPageSizePaperIds[sizeId] === paperSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sheetPageMarginPresetFromRecord(record: XmlRecord): SheetPageMarginPreset | undefined {
|
||||||
|
const left = positiveNumberAttribute(record.left);
|
||||||
|
const right = positiveNumberAttribute(record.right);
|
||||||
|
const top = positiveNumberAttribute(record.top);
|
||||||
|
const bottom = positiveNumberAttribute(record.bottom);
|
||||||
|
const header = positiveNumberAttribute(record.header);
|
||||||
|
const footer = positiveNumberAttribute(record.footer);
|
||||||
|
if (!left || !right || !top || !bottom || !header || !footer) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Object.keys(xlsxPageMargins) as SheetPageMarginPreset[]).find((preset) => {
|
||||||
|
const margins = xlsxPageMargins[preset];
|
||||||
|
return (
|
||||||
|
isCloseDimension(left, margins.left) &&
|
||||||
|
isCloseDimension(right, margins.right) &&
|
||||||
|
isCloseDimension(top, margins.top) &&
|
||||||
|
isCloseDimension(bottom, margins.bottom) &&
|
||||||
|
isCloseDimension(header, margins.header) &&
|
||||||
|
isCloseDimension(footer, margins.footer)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCloseDimension(actual: number, expected: number) {
|
||||||
|
return Math.abs(actual - expected) <= 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
function buildRowAttributes(height?: number, hidden?: boolean) {
|
function buildRowAttributes(height?: number, hidden?: boolean) {
|
||||||
return `${height ? ` ht="${formatSheetDimension(height)}" customHeight="1"` : ""}${hidden ? ' hidden="1"' : ""}`;
|
return `${height ? ` ht="${formatSheetDimension(height)}" customHeight="1"` : ""}${hidden ? ' hidden="1"' : ""}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,6 +254,82 @@ describe("outlookMail helpers", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("embeds inline CID images in imported EML HTML body", async () => {
|
||||||
|
const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
|
const eml = [
|
||||||
|
"From: sender@example.com",
|
||||||
|
"To: reader@example.com",
|
||||||
|
"Subject: Inline logo",
|
||||||
|
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
|
||||||
|
'Content-Type: multipart/related; boundary="related"',
|
||||||
|
"",
|
||||||
|
"--related",
|
||||||
|
"Content-Type: text/html; charset=utf-8",
|
||||||
|
"",
|
||||||
|
'<p>Logo</p><img alt="logo" src="cid:Logo%40Example.com">',
|
||||||
|
"--related",
|
||||||
|
'Content-Type: image/png; name="logo.png"',
|
||||||
|
'Content-Disposition: inline; filename="logo.png"',
|
||||||
|
"Content-ID: <logo@example.com>",
|
||||||
|
"Content-Transfer-Encoding: base64",
|
||||||
|
"",
|
||||||
|
imageBase64,
|
||||||
|
"--related--"
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
const imported = await importEmlFile(new File([eml], "inline.eml", { type: "message/rfc822" }));
|
||||||
|
|
||||||
|
expect(imported.body).toBe("Logo");
|
||||||
|
expect(imported.bodyHtml).toContain(`src="data:image/png;base64,${imageBase64}"`);
|
||||||
|
expect(imported.bodyHtml).not.toContain("cid:");
|
||||||
|
expect(imported.attachments).toEqual([
|
||||||
|
{
|
||||||
|
id: "attachment-logo-png-1",
|
||||||
|
fileName: "logo.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
size: 68,
|
||||||
|
contentBase64: imageBase64,
|
||||||
|
disposition: "inline",
|
||||||
|
contentId: "logo@example.com"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-image CID references in imported EML HTML body", async () => {
|
||||||
|
const eml = [
|
||||||
|
"From: sender@example.com",
|
||||||
|
"To: reader@example.com",
|
||||||
|
"Subject: Inline document",
|
||||||
|
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
|
||||||
|
'Content-Type: multipart/related; boundary="related"',
|
||||||
|
"",
|
||||||
|
"--related",
|
||||||
|
"Content-Type: text/html; charset=utf-8",
|
||||||
|
"",
|
||||||
|
'<p>Document</p><img src="cid:document">',
|
||||||
|
"--related",
|
||||||
|
'Content-Type: text/plain; name="document.txt"',
|
||||||
|
'Content-Disposition: inline; filename="document.txt"',
|
||||||
|
"Content-ID: <document>",
|
||||||
|
"Content-Transfer-Encoding: base64",
|
||||||
|
"",
|
||||||
|
"SGVsbG8=",
|
||||||
|
"--related--"
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
const imported = await importEmlFile(new File([eml], "inline.eml", { type: "message/rfc822" }));
|
||||||
|
|
||||||
|
expect(imported.bodyHtml).toContain('src="cid:document"');
|
||||||
|
expect(imported.bodyHtml).not.toContain("data:text/plain");
|
||||||
|
expect(imported.attachments?.[0]).toMatchObject({
|
||||||
|
fileName: "document.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
contentBase64: "SGVsbG8=",
|
||||||
|
disposition: "inline",
|
||||||
|
contentId: "document"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
|
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
|
||||||
const message = msgDataToMailMessage(
|
const message = msgDataToMailMessage(
|
||||||
{
|
{
|
||||||
@@ -381,6 +457,50 @@ describe("outlookMail helpers", () => {
|
|||||||
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>HTML text</strong></p>");
|
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>HTML text</strong></p>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports inline data images as CID references in related EML", () => {
|
||||||
|
const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
|
const message: MailMessage = {
|
||||||
|
id: "inline-image",
|
||||||
|
folder: "sent",
|
||||||
|
from: "sender@example.com",
|
||||||
|
to: "reader@example.com",
|
||||||
|
subject: "Inline image",
|
||||||
|
body: "Logo",
|
||||||
|
bodyHtml: `<p>Logo</p><img alt="logo" src="data:image/png;base64,${imageBase64}">`,
|
||||||
|
time: "Mon, 01 Jan 2024 10:00:00 +0000",
|
||||||
|
read: true,
|
||||||
|
flagged: false,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "logo",
|
||||||
|
fileName: "logo.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
size: 68,
|
||||||
|
contentBase64: imageBase64,
|
||||||
|
disposition: "inline",
|
||||||
|
contentId: "logo@example.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const eml = buildEmlSource(message);
|
||||||
|
const parsed = parseEmlSource(eml);
|
||||||
|
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
|
||||||
|
|
||||||
|
expect(eml).toContain("Content-Type: multipart/related");
|
||||||
|
expect(eml).toContain("Content-ID: <logo@example.com>");
|
||||||
|
expect(eml).toContain('Content-Disposition: inline; filename="logo.png"');
|
||||||
|
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe('<p>Logo</p><img alt="logo" src="cid:logo@example.com">');
|
||||||
|
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).not.toContain("data:image/png;base64");
|
||||||
|
expect(attachments[0]).toMatchObject({
|
||||||
|
fileName: "logo.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
contentBase64: imageBase64,
|
||||||
|
disposition: "inline",
|
||||||
|
contentId: "logo@example.com"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
|
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
|
||||||
const message: MailMessage = {
|
const message: MailMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
|
|||||||
+166
-5
@@ -81,7 +81,8 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
|||||||
|
|
||||||
const raw = await file.text();
|
const raw = await file.text();
|
||||||
const parsed = parseEmlSource(raw);
|
const parsed = parseEmlSource(raw);
|
||||||
const bodyHtml = extractMessageHtmlBody(parsed.headers, parsed.body);
|
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
|
||||||
|
const bodyHtml = htmlWithEmbeddedCidAttachments(extractMessageHtmlBody(parsed.headers, parsed.body), attachments);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `eml-${Date.now()}`,
|
id: `eml-${Date.now()}`,
|
||||||
@@ -99,7 +100,7 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
|||||||
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
|
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
|
||||||
read: false,
|
read: false,
|
||||||
flagged: false,
|
flagged: false,
|
||||||
attachments: extractMessageAttachments(parsed.headers, parsed.body)
|
attachments
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +263,81 @@ export function extractMessageAttachments(headers: MailHeaders, body: string): M
|
|||||||
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
|
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function htmlWithEmbeddedCidAttachments(html: string, attachments: MailAttachment[] = []): string {
|
||||||
|
if (!html.trim() || attachments.length === 0) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineImagesByContentId = new Map<string, { contentBase64: string; contentType: string }>();
|
||||||
|
attachments.forEach((attachment) => {
|
||||||
|
const contentId = normalizedContentId(attachment.contentId);
|
||||||
|
const contentType = sanitizeMimeType(attachment.contentType);
|
||||||
|
const contentBase64 = attachment.contentBase64.trim();
|
||||||
|
if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inlineImagesByContentId.has(contentId)) {
|
||||||
|
inlineImagesByContentId.set(contentId, { contentBase64, contentType });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inlineImagesByContentId.size === 0) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.replace(/\bsrc\s*=\s*(?:(["'])\s*cid:([^"'>\s]+)\1|cid:([^\s"'<>]+))/gi, (match, quote: string | undefined, quotedCid: string | undefined, unquotedCid: string | undefined) => {
|
||||||
|
const contentId = normalizedContentId(decodeUriComponentSafe(quotedCid ?? unquotedCid ?? ""));
|
||||||
|
const attachment = inlineImagesByContentId.get(contentId);
|
||||||
|
if (!attachment) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUri = `data:${attachment.contentType};base64,${attachment.contentBase64}`;
|
||||||
|
return quote ? `src=${quote}${dataUri}${quote}` : `src="${dataUri}"`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlWithCidReferencesForInlineAttachments(html: string, attachments: MailAttachment[] = []): string {
|
||||||
|
if (!html.trim() || attachments.length === 0) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentIdsByDataUri = new Map<string, string>();
|
||||||
|
attachments.forEach((attachment) => {
|
||||||
|
const contentId = cleanContentId(attachment.contentId);
|
||||||
|
const contentType = sanitizeMimeType(attachment.contentType);
|
||||||
|
const contentBase64 = attachment.contentBase64.trim();
|
||||||
|
if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = cidImageDataUriKey(contentType, contentBase64);
|
||||||
|
if (!contentIdsByDataUri.has(key)) {
|
||||||
|
contentIdsByDataUri.set(key, contentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contentIdsByDataUri.size === 0) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.replace(
|
||||||
|
/\bsrc\s*=\s*(?:(["'])\s*data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^"'>\s]+)\1|data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^\s"'<>]+))/gi,
|
||||||
|
(match, quote: string | undefined, quotedType: string | undefined, quotedBase64: string | undefined, unquotedType: string | undefined, unquotedBase64: string | undefined) => {
|
||||||
|
const contentType = quotedType ?? unquotedType ?? "";
|
||||||
|
const contentBase64 = (quotedBase64 ?? unquotedBase64 ?? "").trim();
|
||||||
|
const contentId = contentIdsByDataUri.get(cidImageDataUriKey(contentType, contentBase64));
|
||||||
|
if (!contentId) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cidReference = escapeHtmlAttribute(`cid:${contentId}`);
|
||||||
|
return quote ? `src=${quote}${cidReference}${quote}` : `src="${cidReference}"`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
||||||
return {
|
return {
|
||||||
id: createAttachmentId(file.name),
|
id: createAttachmentId(file.name),
|
||||||
@@ -463,6 +539,28 @@ export function buildEmlSource(message: MailMessage): string {
|
|||||||
if (attachments.length > 0) {
|
if (attachments.length > 0) {
|
||||||
const boundary = createMultipartBoundary(message);
|
const boundary = createMultipartBoundary(message);
|
||||||
const alternativeBoundary = createAlternativeBoundary(message);
|
const alternativeBoundary = createAlternativeBoundary(message);
|
||||||
|
const inlineRelatedAttachments = hasHtmlBody ? attachments.filter(isInlineRelatedAttachment) : [];
|
||||||
|
const mixedAttachments = inlineRelatedAttachments.length > 0 ? attachments.filter((attachment) => !isInlineRelatedAttachment(attachment)) : attachments;
|
||||||
|
if (hasHtmlBody && inlineRelatedAttachments.length > 0 && mixedAttachments.length === 0) {
|
||||||
|
const relatedBoundary = createRelatedBoundary(message);
|
||||||
|
const lines = [
|
||||||
|
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||||
|
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||||
|
...optionalAddressHeaders(message),
|
||||||
|
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
|
||||||
|
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
|
||||||
|
...optionalThreadHeaders(message),
|
||||||
|
...optionalPriorityHeaders(message),
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
`Content-Type: multipart/related; boundary="${relatedBoundary}"`,
|
||||||
|
"",
|
||||||
|
...buildRelatedBodyLines(relatedBoundary, alternativeBoundary, message, inlineRelatedAttachments),
|
||||||
|
""
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`From: ${sanitizeHeaderValue(message.from)}`,
|
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||||
`To: ${sanitizeHeaderValue(message.to)}`,
|
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||||
@@ -475,8 +573,12 @@ export function buildEmlSource(message: MailMessage): string {
|
|||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||||
"",
|
"",
|
||||||
`--${boundary}`,
|
`--${boundary}`,
|
||||||
...(hasHtmlBody ? buildAlternativePartLines(alternativeBoundary, message) : buildPlainTextPartLines(message)),
|
...(inlineRelatedAttachments.length > 0
|
||||||
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
? buildRelatedPartLines(createRelatedBoundary(message), alternativeBoundary, message, inlineRelatedAttachments)
|
||||||
|
: hasHtmlBody
|
||||||
|
? buildAlternativePartLines(alternativeBoundary, message)
|
||||||
|
: buildPlainTextPartLines(message)),
|
||||||
|
...mixedAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
""
|
""
|
||||||
];
|
];
|
||||||
@@ -531,7 +633,12 @@ function buildPlainTextPartLines(message: MailMessage): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildHtmlPartLines(message: MailMessage): string[] {
|
function buildHtmlPartLines(message: MailMessage): string[] {
|
||||||
return ["Content-Type: text/html; charset=utf-8", "Content-Transfer-Encoding: 8bit", "", normalizeMailNewlines(message.bodyHtml || "")];
|
return [
|
||||||
|
"Content-Type: text/html; charset=utf-8",
|
||||||
|
"Content-Transfer-Encoding: 8bit",
|
||||||
|
"",
|
||||||
|
normalizeMailNewlines(htmlWithCidReferencesForInlineAttachments(message.bodyHtml || "", message.attachments ?? []))
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAlternativePartLines(boundary: string, message: MailMessage): string[] {
|
function buildAlternativePartLines(boundary: string, message: MailMessage): string[] {
|
||||||
@@ -546,6 +653,18 @@ function buildAlternativePartLines(boundary: string, message: MailMessage): stri
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRelatedPartLines(boundary: string, alternativeBoundary: string, message: MailMessage, inlineAttachments: MailAttachment[]): string[] {
|
||||||
|
return [`Content-Type: multipart/related; boundary="${boundary}"`, "", ...buildRelatedBodyLines(boundary, alternativeBoundary, message, inlineAttachments)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRelatedBodyLines(boundary: string, alternativeBoundary: string, message: MailMessage, inlineAttachments: MailAttachment[]): string[] {
|
||||||
|
return [`--${boundary}`, ...buildAlternativePartLines(alternativeBoundary, message), ...inlineAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)), `--${boundary}--`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInlineRelatedAttachment(attachment: MailAttachment): boolean {
|
||||||
|
return attachment.disposition === "inline" && Boolean(cleanContentId(attachment.contentId));
|
||||||
|
}
|
||||||
|
|
||||||
function optionalAddressHeaders(message: MailMessage): string[] {
|
function optionalAddressHeaders(message: MailMessage): string[] {
|
||||||
return [
|
return [
|
||||||
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
||||||
@@ -748,6 +867,10 @@ function createAlternativeBoundary(message: MailMessage): string {
|
|||||||
return `${createMultipartBoundary(message)}-alternative`;
|
return `${createMultipartBoundary(message)}-alternative`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRelatedBoundary(message: MailMessage): string {
|
||||||
|
return `${createMultipartBoundary(message)}-related`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
||||||
const safeContentType = sanitizeMimeType(attachment.contentType);
|
const safeContentType = sanitizeMimeType(attachment.contentType);
|
||||||
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
||||||
@@ -783,6 +906,44 @@ function sanitizeMimeType(value = "application/octet-stream"): string {
|
|||||||
return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mediaType) ? mediaType : "application/octet-stream";
|
return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mediaType) ? mediaType : "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEmbeddableCidImageContentType(value: string): boolean {
|
||||||
|
return ["image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp"].includes(normalizedCidImageContentType(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedCidImageContentType(value: string): string {
|
||||||
|
const contentType = value.toLowerCase();
|
||||||
|
return contentType === "image/jpg" ? "image/jpeg" : contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cidImageDataUriKey(contentType: string, contentBase64: string): string {
|
||||||
|
return `${normalizedCidImageContentType(contentType)}:${contentBase64.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanContentId(value = ""): string {
|
||||||
|
return value.trim().replace(/^<|>$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedContentId(value = ""): string {
|
||||||
|
return cleanContentId(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUriComponentSafe(value: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlAttribute(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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("Заметка докладчика");
|
||||||
});
|
});
|
||||||
@@ -592,6 +618,126 @@ 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 () => {
|
it("импортирует внешние ссылки текста PPTX через hlinkClick relationships", async () => {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
zip.file(
|
zip.file(
|
||||||
@@ -652,6 +798,28 @@ describe("powerPointOffice helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("экспортирует и переимпортирует стандартный размер слайда PPTX 4:3", async () => {
|
||||||
|
const blob = await exportPptxBlob({
|
||||||
|
title: "Standard.pptx",
|
||||||
|
slideSize: "standard",
|
||||||
|
slides: [
|
||||||
|
{
|
||||||
|
id: "slide-1",
|
||||||
|
title: "Standard",
|
||||||
|
subtitle: "4:3",
|
||||||
|
notes: "",
|
||||||
|
theme: "classic"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const presentationXml = (await zip.file("ppt/presentation.xml")?.async("string")) ?? "";
|
||||||
|
const reimported = await importPptxFile(new File([blob], "Standard.pptx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(presentationXml).toContain('<p:sldSz cx="9144000" cy="6858000"/>');
|
||||||
|
expect(reimported.slideSize).toBe("standard");
|
||||||
|
});
|
||||||
|
|
||||||
it("нормализует пробелы в импортированном тексте", () => {
|
it("нормализует пробелы в импортированном тексте", () => {
|
||||||
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
|
expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами");
|
||||||
});
|
});
|
||||||
@@ -679,6 +847,14 @@ 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) {
|
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-8
@@ -1,13 +1,15 @@
|
|||||||
import type { Slide, SlideImage, SlideListStyle } 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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +17,11 @@ interface SlideTextBlock {
|
|||||||
text: string;
|
text: string;
|
||||||
listStyle?: SlideListStyle;
|
listStyle?: SlideListStyle;
|
||||||
hyperlink?: string;
|
hyperlink?: string;
|
||||||
|
bold?: boolean;
|
||||||
|
italics?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
strike?: boolean;
|
||||||
|
align?: SlideTextAlign;
|
||||||
color?: string;
|
color?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
@@ -42,6 +49,10 @@ const imageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/
|
|||||||
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
||||||
const emuPerInch = 914400;
|
const emuPerInch = 914400;
|
||||||
const slideTransitions = ["fade", "push", "wipe"] as const;
|
const slideTransitions = ["fade", "push", "wipe"] as const;
|
||||||
|
const slideSizeLayouts: Record<SlideSizeId, { width: number; height: number; cx: number; cy: number; pptxLayout: string }> = {
|
||||||
|
wide: { width: 13.333, height: 7.5, cx: 12192000, cy: 6858000, pptxLayout: "LAYOUT_WIDE" },
|
||||||
|
standard: { width: 10, height: 7.5, cx: 9144000, cy: 6858000, pptxLayout: "LAYOUT_4x3" }
|
||||||
|
};
|
||||||
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
|
const themeColors: Record<Slide["theme"], { background: string; title: string; subtitle: string }> = {
|
||||||
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
|
classic: { background: "FFFFFF", title: "202124", subtitle: "3C4043" },
|
||||||
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
|
ocean: { background: "EAF6FF", title: "075985", subtitle: "0F766E" },
|
||||||
@@ -60,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 не найдены слайды.");
|
||||||
@@ -99,9 +111,19 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
|
|||||||
...(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 } : {}),
|
||||||
@@ -111,7 +133,7 @@ export async function importPptxFile(file: File): Promise<ImportedPowerPointDeck
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return { title, slides };
|
return { title, ...(slideSize ? { slideSize } : {}), slides };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
|
export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Blob> {
|
||||||
@@ -132,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";
|
||||||
@@ -147,6 +171,16 @@ 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 titleHyperlink = normalizedExternalHyperlinkTarget(sourceSlide.titleHyperlink);
|
||||||
const subtitleHyperlink = normalizedExternalHyperlinkTarget(sourceSlide.subtitleHyperlink);
|
const subtitleHyperlink = normalizedExternalHyperlinkTarget(sourceSlide.subtitleHyperlink);
|
||||||
const slide = pptx.addSlide();
|
const slide = pptx.addSlide();
|
||||||
@@ -171,11 +205,15 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
|
|||||||
slide.addText(sourceSlide.title || "Без заголовка", {
|
slide.addText(sourceSlide.title || "Без заголовка", {
|
||||||
x: 0.7,
|
x: 0.7,
|
||||||
y: 0.65,
|
y: 0.65,
|
||||||
w: 11.9,
|
w: Math.max(1, slideLayout.width - 1.4),
|
||||||
h: 0.75,
|
h: 0.75,
|
||||||
fontFace: titleFontFamily,
|
fontFace: titleFontFamily,
|
||||||
fontSize: titleFontSize,
|
fontSize: titleFontSize,
|
||||||
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,
|
||||||
@@ -184,10 +222,15 @@ export async function exportPptxBlob(deck: ExportablePowerPointDeck): Promise<Bl
|
|||||||
slide.addText(sourceSlide.subtitle || "", {
|
slide.addText(sourceSlide.subtitle || "", {
|
||||||
x: 0.75,
|
x: 0.75,
|
||||||
y: 1.65,
|
y: 1.65,
|
||||||
w: 11.5,
|
w: Math.max(1, slideLayout.width - 1.5),
|
||||||
h: 4.35,
|
h: Math.max(1, slideLayout.height - 3.15),
|
||||||
fontFace: subtitleFontFamily,
|
fontFace: subtitleFontFamily,
|
||||||
fontSize: subtitleFontSize,
|
fontSize: subtitleFontSize,
|
||||||
|
...(subtitleAlign ? { align: subtitleAlign } : {}),
|
||||||
|
bold: subtitleBold,
|
||||||
|
italic: subtitleItalics,
|
||||||
|
...(subtitleUnderline ? { underline: { style: "sng" } } : {}),
|
||||||
|
...(subtitleStrike ? { strike: "sngStrike" } : {}),
|
||||||
color: subtitleColor,
|
color: subtitleColor,
|
||||||
valign: "top",
|
valign: "top",
|
||||||
fit: "shrink",
|
fit: "shrink",
|
||||||
@@ -216,7 +259,7 @@ export function extractTextBlocks(xmlNode: unknown): string[] {
|
|||||||
function extractSlideTextBlocks(slideXml: unknown, hyperlinkTargets: Record<string, string> = {}): SlideTextBlock[] {
|
function extractSlideTextBlocks(slideXml: unknown, hyperlinkTargets: Record<string, string> = {}): SlideTextBlock[] {
|
||||||
const textBodies = slideTextBodyRecords(slideXml);
|
const textBodies = slideTextBodyRecords(slideXml);
|
||||||
const blocks = textBodies
|
const blocks = textBodies
|
||||||
.map(({ textBody, hyperlinkSource }) => {
|
.map(({ textBody, hyperlinkSource }): SlideTextBlock | null => {
|
||||||
const text = extractTextBodyText(textBody);
|
const text = extractTextBodyText(textBody);
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return null;
|
return null;
|
||||||
@@ -225,6 +268,11 @@ function extractSlideTextBlocks(slideXml: unknown, hyperlinkTargets: Record<stri
|
|||||||
const runProperties = firstRecordByKey(textBody, "rPr");
|
const runProperties = firstRecordByKey(textBody, "rPr");
|
||||||
const hyperlink = textBodyHyperlink(hyperlinkSource, hyperlinkTargets);
|
const hyperlink = textBodyHyperlink(hyperlinkSource, hyperlinkTargets);
|
||||||
const listStyle = textBodyListStyle(textBody);
|
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);
|
||||||
@@ -232,6 +280,11 @@ function extractSlideTextBlocks(slideXml: unknown, hyperlinkTargets: Record<stri
|
|||||||
text,
|
text,
|
||||||
...(listStyle ? { listStyle } : {}),
|
...(listStyle ? { listStyle } : {}),
|
||||||
...(hyperlink ? { hyperlink } : {}),
|
...(hyperlink ? { hyperlink } : {}),
|
||||||
|
bold,
|
||||||
|
italics,
|
||||||
|
underline,
|
||||||
|
strike,
|
||||||
|
...(align ? { align } : {}),
|
||||||
...(color ? { color } : {}),
|
...(color ? { color } : {}),
|
||||||
...(fontSize ? { fontSize } : {}),
|
...(fontSize ? { fontSize } : {}),
|
||||||
...(fontFamily ? { fontFamily } : {})
|
...(fontFamily ? { fontFamily } : {})
|
||||||
@@ -283,6 +336,15 @@ function textBodyListStyle(textBody: Record<string, unknown>): SlideListStyle |
|
|||||||
return paragraphStyles.includes("number") ? "number" : paragraphStyles[0];
|
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 {
|
function paragraphListStyle(paragraph: unknown): SlideListStyle | undefined {
|
||||||
const paragraphProperties = childRecord(asRecord(paragraph), "pPr");
|
const paragraphProperties = childRecord(asRecord(paragraph), "pPr");
|
||||||
if (Object.keys(childRecord(paragraphProperties, "buAutoNum")).length > 0) {
|
if (Object.keys(childRecord(paragraphProperties, "buAutoNum")).length > 0) {
|
||||||
@@ -358,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> = {};
|
||||||
@@ -414,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")
|
||||||
@@ -667,6 +746,24 @@ function runTextColor(runProperties: Record<string, unknown>) {
|
|||||||
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;
|
||||||
@@ -693,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;
|
||||||
@@ -703,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,17 @@ describe("wordOffice helpers", () => {
|
|||||||
expect(documentXml).toContain('<w:jc w:val="right"/>');
|
expect(documentXml).toContain('<w:jc w:val="right"/>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes QWord page setup to exported DOCX section properties", async () => {
|
||||||
|
const blob = await exportDocxBlob("Макет.docx", "<p>Landscape letter</p>", {
|
||||||
|
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" }
|
||||||
|
});
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||||||
|
|
||||||
|
expect(documentXml).toMatch(/<w:pgSz\b[^>]*w:w="15840"[^>]*w:h="12240"[^>]*w:orient="landscape"[^>]*\/>/);
|
||||||
|
expect(documentXml).toMatch(/<w:pgMar\b[^>]*w:top="720"[^>]*w:right="720"[^>]*w:bottom="720"[^>]*w:left="720"[^>]*\/>/);
|
||||||
|
});
|
||||||
|
|
||||||
it("записывает внешние гиперссылки в DOCX relationships", async () => {
|
it("записывает внешние гиперссылки в DOCX relationships", async () => {
|
||||||
const blob = await exportDocxBlob(
|
const blob = await exportDocxBlob(
|
||||||
"Ссылки.docx",
|
"Ссылки.docx",
|
||||||
@@ -349,6 +360,82 @@ describe("wordOffice helpers", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves HTML table colspan as DOCX gridSpan", async () => {
|
||||||
|
const html = '<table><tr><td colspan="2">Merged</td><td>Tail</td></tr></table>';
|
||||||
|
const blocks = htmlToWordBlocks(html);
|
||||||
|
const blob = await exportDocxBlob("Table colspan.docx", html);
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
rows: [[{ text: "Merged", runs: [{ text: "Merged" }], colSpan: 2 }, "Tail"]]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(documentXml).toMatch(/<w:gridSpan\b[^>]*w:val="2"[^>]*\/>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет пустые ячейки и строки таблицы для DOCX", async () => {
|
||||||
|
const html = `
|
||||||
|
<table>
|
||||||
|
<tr><td><br></td><td></td><td>Итог</td></tr>
|
||||||
|
<tr><td></td><td> </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>
|
||||||
@@ -465,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",
|
||||||
@@ -477,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>");
|
||||||
});
|
});
|
||||||
|
|||||||
+371
-28
@@ -1,8 +1,10 @@
|
|||||||
import { DOCX_MIME_TYPE, officeTitleFromFileName, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
import { DOCX_MIME_TYPE, officeTitleFromFileName, readOfficeFileAsArrayBuffer } from "./fileHelpers";
|
||||||
|
import type { WordPageMarginPreset, WordPageOrientation, WordPageSetup, WordPageSizeId } from "../types";
|
||||||
|
|
||||||
export interface ImportedWordDocument {
|
export interface ImportedWordDocument {
|
||||||
title: string;
|
title: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
pageSetup?: WordPageSetup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WordInlineRun {
|
export interface WordInlineRun {
|
||||||
@@ -25,6 +27,8 @@ export interface WordListItem {
|
|||||||
runs: WordInlineRun[];
|
runs: WordInlineRun[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WordTableCell = string | { text: string; runs: WordInlineRun[]; colSpan?: number };
|
||||||
|
|
||||||
export interface WordImageBlock {
|
export interface WordImageBlock {
|
||||||
type: "image";
|
type: "image";
|
||||||
src: string;
|
src: string;
|
||||||
@@ -41,13 +45,40 @@ export interface WordParagraphIndent {
|
|||||||
hanging?: number;
|
hanging?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WordParagraphSpacing {
|
||||||
|
before?: number;
|
||||||
|
after?: number;
|
||||||
|
line?: number;
|
||||||
|
lineRule?: WordParagraphLineRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WordParagraphLineRule = "auto" | "exact" | "atLeast";
|
||||||
|
|
||||||
export type WordHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
export type WordHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
|
||||||
export type WordBlock =
|
export type WordBlock =
|
||||||
| { type: "heading"; level: WordHeadingLevel; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment; indent?: WordParagraphIndent; bookmark?: string }
|
| {
|
||||||
| { type: "paragraph"; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment; indent?: WordParagraphIndent; bookmark?: string; quote?: boolean }
|
type: "heading";
|
||||||
|
level: WordHeadingLevel;
|
||||||
|
text: string;
|
||||||
|
runs: WordInlineRun[];
|
||||||
|
alignment?: WordParagraphAlignment;
|
||||||
|
indent?: WordParagraphIndent;
|
||||||
|
spacing?: WordParagraphSpacing;
|
||||||
|
bookmark?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "paragraph";
|
||||||
|
text: string;
|
||||||
|
runs: WordInlineRun[];
|
||||||
|
alignment?: WordParagraphAlignment;
|
||||||
|
indent?: WordParagraphIndent;
|
||||||
|
spacing?: WordParagraphSpacing;
|
||||||
|
bookmark?: string;
|
||||||
|
quote?: boolean;
|
||||||
|
}
|
||||||
| { type: "list"; ordered: boolean; items: WordListItem[] }
|
| { type: "list"; ordered: boolean; items: WordListItem[] }
|
||||||
| { type: "table"; rows: string[][] }
|
| { type: "table"; rows: WordTableCell[][] }
|
||||||
| WordImageBlock;
|
| WordImageBlock;
|
||||||
|
|
||||||
type MammothModule = {
|
type MammothModule = {
|
||||||
@@ -86,6 +117,8 @@ type DocxModule = {
|
|||||||
AlignmentType?: Record<string, string>;
|
AlignmentType?: Record<string, string>;
|
||||||
Document: new (options: Record<string, unknown>) => unknown;
|
Document: new (options: Record<string, unknown>) => unknown;
|
||||||
HeadingLevel: Record<string, string>;
|
HeadingLevel: Record<string, string>;
|
||||||
|
LineRuleType?: Record<string, string>;
|
||||||
|
PageOrientation?: Record<string, string>;
|
||||||
Packer: {
|
Packer: {
|
||||||
toBlob?: (document: unknown) => Promise<Blob>;
|
toBlob?: (document: unknown) => Promise<Blob>;
|
||||||
toBuffer?: (document: unknown) => Promise<ArrayBuffer | Uint8Array>;
|
toBuffer?: (document: unknown) => Promise<ArrayBuffer | Uint8Array>;
|
||||||
@@ -104,6 +137,8 @@ type DocxModule = {
|
|||||||
ImageRun?: new (options: Record<string, unknown>) => unknown;
|
ImageRun?: new (options: Record<string, unknown>) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultParagraphSpacingAfterTwips = 160;
|
||||||
|
|
||||||
type InlineFormat = Omit<WordInlineRun, "text">;
|
type InlineFormat = Omit<WordInlineRun, "text">;
|
||||||
type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock };
|
type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock };
|
||||||
type DocxInlineRecord = { run: unknown; text: string; href?: string };
|
type DocxInlineRecord = { run: unknown; text: string; href?: string };
|
||||||
@@ -133,6 +168,16 @@ const wordImageRelationshipType = "http://schemas.openxmlformats.org/officeDocum
|
|||||||
const emptyDocxRelationshipTargets = { hyperlinks: {}, images: {} };
|
const emptyDocxRelationshipTargets = { hyperlinks: {}, images: {} };
|
||||||
const pageBreakMarker = "\f";
|
const pageBreakMarker = "\f";
|
||||||
const pageBreakHtml = '<span data-qoffice-page-break="true"></span>';
|
const pageBreakHtml = '<span data-qoffice-page-break="true"></span>';
|
||||||
|
const defaultWordPageSetup: WordPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
|
||||||
|
const wordPageSizes: Record<WordPageSizeId, { width: number; height: number }> = {
|
||||||
|
a4: { width: 794, height: 1123 },
|
||||||
|
letter: { width: 816, height: 1056 }
|
||||||
|
};
|
||||||
|
const wordPageMargins: Record<WordPageMarginPreset, { top: number; right: number; bottom: number; left: number }> = {
|
||||||
|
normal: { top: 72, right: 86, bottom: 72, left: 86 },
|
||||||
|
narrow: { top: 48, right: 48, bottom: 48, left: 48 },
|
||||||
|
wide: { top: 92, right: 116, bottom: 92, left: 116 }
|
||||||
|
};
|
||||||
|
|
||||||
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
|
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
|
||||||
if (!file.name.toLowerCase().endsWith(".docx")) {
|
if (!file.name.toLowerCase().endsWith(".docx")) {
|
||||||
@@ -141,7 +186,7 @@ export async function importDocxFile(file: File): Promise<ImportedWordDocument>
|
|||||||
|
|
||||||
const mammoth = await loadMammoth();
|
const mammoth = await loadMammoth();
|
||||||
const arrayBuffer = await readOfficeFileAsArrayBuffer(file);
|
const arrayBuffer = await readOfficeFileAsArrayBuffer(file);
|
||||||
const [result, styledHtml] = await Promise.all([
|
const [result, styledHtml, pageSetup] = await Promise.all([
|
||||||
mammoth.convertToHtml(
|
mammoth.convertToHtml(
|
||||||
{ arrayBuffer, buffer: arrayBuffer },
|
{ arrayBuffer, buffer: arrayBuffer },
|
||||||
{
|
{
|
||||||
@@ -149,20 +194,23 @@ export async function importDocxFile(file: File): Promise<ImportedWordDocument>
|
|||||||
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
|
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
styledDocxHtmlFromArrayBuffer(arrayBuffer)
|
styledDocxHtmlFromArrayBuffer(arrayBuffer),
|
||||||
|
docxPageSetupFromArrayBuffer(arrayBuffer)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: officeTitleFromFileName(file.name, "Документ QWord.docx"),
|
title: officeTitleFromFileName(file.name, "Документ QWord.docx"),
|
||||||
html: normalizeImportedDocxHtml(styledHtml || result.value)
|
html: normalizeImportedDocxHtml(styledHtml || result.value),
|
||||||
|
...(pageSetup ? { pageSetup } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportDocxBlob(title: string, html: string): Promise<Blob> {
|
export async function exportDocxBlob(title: string, html: string, options: { pageSetup?: WordPageSetup } = {}): Promise<Blob> {
|
||||||
const docx = await loadDocx();
|
const docx = await loadDocx();
|
||||||
const blocks = htmlToWordBlocks(html);
|
const blocks = htmlToWordBlocks(html);
|
||||||
const children = blocks.flatMap((block) => wordBlockToDocxChildren(block, docx));
|
const children = blocks.flatMap((block) => wordBlockToDocxChildren(block, docx));
|
||||||
const documentTitle = title.trim() || "Документ QWord";
|
const documentTitle = title.trim() || "Документ QWord";
|
||||||
|
const sectionProperties = wordSectionProperties(options.pageSetup, docx);
|
||||||
|
|
||||||
const document = new docx.Document({
|
const document = new docx.Document({
|
||||||
creator: "QOffice",
|
creator: "QOffice",
|
||||||
@@ -189,7 +237,7 @@ export async function exportDocxBlob(title: string, html: string): Promise<Blob>
|
|||||||
},
|
},
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
properties: {},
|
properties: sectionProperties,
|
||||||
children:
|
children:
|
||||||
children.length > 0
|
children.length > 0
|
||||||
? children
|
? children
|
||||||
@@ -214,6 +262,62 @@ export async function exportDocxBlob(title: string, html: string): Promise<Blob>
|
|||||||
throw new Error("Не удалось создать DOCX: библиотека docx не вернула поддерживаемый упаковщик.");
|
throw new Error("Не удалось создать DOCX: библиотека docx не вернула поддерживаемый упаковщик.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wordSectionProperties(pageSetup: WordPageSetup | undefined, docx: DocxModule) {
|
||||||
|
const normalizedPageSetup = normalizedWordPageSetup(pageSetup);
|
||||||
|
const size = wordPageSizeInTwips(normalizedPageSetup);
|
||||||
|
const margins = wordPageMargins[normalizedPageSetup.marginPreset];
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: {
|
||||||
|
size: {
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
orientation:
|
||||||
|
normalizedPageSetup.orientation === "landscape"
|
||||||
|
? (docx.PageOrientation?.LANDSCAPE ?? "landscape")
|
||||||
|
: (docx.PageOrientation?.PORTRAIT ?? "portrait")
|
||||||
|
},
|
||||||
|
margin: {
|
||||||
|
top: pxToTwips(margins.top),
|
||||||
|
right: pxToTwips(margins.right),
|
||||||
|
bottom: pxToTwips(margins.bottom),
|
||||||
|
left: pxToTwips(margins.left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedWordPageSetup(pageSetup?: WordPageSetup): WordPageSetup {
|
||||||
|
const size = isWordPageSizeId(pageSetup?.size) ? pageSetup.size : defaultWordPageSetup.size;
|
||||||
|
const orientation = isWordPageOrientation(pageSetup?.orientation) ? pageSetup.orientation : defaultWordPageSetup.orientation;
|
||||||
|
const marginPreset = isWordPageMarginPreset(pageSetup?.marginPreset) ? pageSetup.marginPreset : defaultWordPageSetup.marginPreset;
|
||||||
|
return { size, orientation, marginPreset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordPageSizeInTwips(pageSetup: WordPageSetup) {
|
||||||
|
const size = wordPageSizes[pageSetup.size];
|
||||||
|
return {
|
||||||
|
width: pxToTwips(size.width),
|
||||||
|
height: pxToTwips(size.height)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pxToTwips(value: number) {
|
||||||
|
return Math.round(value * 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWordPageSizeId(value: unknown): value is WordPageSizeId {
|
||||||
|
return value === "a4" || value === "letter";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWordPageOrientation(value: unknown): value is WordPageOrientation {
|
||||||
|
return value === "portrait" || value === "landscape";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWordPageMarginPreset(value: unknown): value is WordPageMarginPreset {
|
||||||
|
return value === "normal" || value === "narrow" || value === "wide";
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeImportedDocxHtml(html: string) {
|
export function normalizeImportedDocxHtml(html: string) {
|
||||||
return html.trim();
|
return html.trim();
|
||||||
}
|
}
|
||||||
@@ -251,6 +355,7 @@ export function htmlToWordBlocks(html: string): WordBlock[] {
|
|||||||
const text = inlineRunsText(runs);
|
const text = inlineRunsText(runs);
|
||||||
const alignment = paragraphAlignmentFromElement(node);
|
const alignment = paragraphAlignmentFromElement(node);
|
||||||
const indent = paragraphIndentFromElement(node);
|
const indent = paragraphIndentFromElement(node);
|
||||||
|
const spacing = paragraphSpacingFromElement(node);
|
||||||
const bookmark = bookmarkFromElement(node);
|
const bookmark = bookmarkFromElement(node);
|
||||||
if (text) {
|
if (text) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
@@ -260,6 +365,7 @@ export function htmlToWordBlocks(html: string): WordBlock[] {
|
|||||||
runs,
|
runs,
|
||||||
...(alignment ? { alignment } : {}),
|
...(alignment ? { alignment } : {}),
|
||||||
...(indent ? { indent } : {}),
|
...(indent ? { indent } : {}),
|
||||||
|
...(spacing ? { spacing } : {}),
|
||||||
...(bookmark ? { bookmark } : {})
|
...(bookmark ? { bookmark } : {})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -308,7 +414,7 @@ function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) {
|
|||||||
return [
|
return [
|
||||||
new docx.Paragraph({
|
new docx.Paragraph({
|
||||||
heading: docxHeadingLevel(block.level, docx),
|
heading: docxHeadingLevel(block.level, docx),
|
||||||
spacing: { after: 160 },
|
spacing: docxParagraphSpacing(block.spacing, defaultParagraphSpacingAfterTwips),
|
||||||
...docxAlignmentOption(block.alignment, docx),
|
...docxAlignmentOption(block.alignment, docx),
|
||||||
...docxIndentOption(block.indent),
|
...docxIndentOption(block.indent),
|
||||||
children: bookmarkDocxChildren(block.bookmark, children, docx)
|
children: bookmarkDocxChildren(block.bookmark, children, docx)
|
||||||
@@ -321,7 +427,7 @@ function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) {
|
|||||||
return [
|
return [
|
||||||
new docx.Paragraph({
|
new docx.Paragraph({
|
||||||
...(block.quote ? { style: "Quote" } : {}),
|
...(block.quote ? { style: "Quote" } : {}),
|
||||||
spacing: { after: 160 },
|
spacing: docxParagraphSpacing(block.spacing, defaultParagraphSpacingAfterTwips),
|
||||||
...docxAlignmentOption(block.alignment, docx),
|
...docxAlignmentOption(block.alignment, docx),
|
||||||
...docxIndentOption(block.indent),
|
...docxIndentOption(block.indent),
|
||||||
children: bookmarkDocxChildren(block.bookmark, children, docx)
|
children: bookmarkDocxChildren(block.bookmark, children, docx)
|
||||||
@@ -372,16 +478,27 @@ function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) {
|
|||||||
new docx.TableRow({
|
new docx.TableRow({
|
||||||
children: row.map(
|
children: row.map(
|
||||||
(cell) =>
|
(cell) =>
|
||||||
new docx.TableCell({
|
new docx.TableCell(wordTableCellToDocxOptions(cell, docx))
|
||||||
children: [
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordTableCellToDocxOptions(cell: WordTableCell, docx: DocxModule) {
|
||||||
|
const colSpan = typeof cell === "string" ? undefined : cell.colSpan;
|
||||||
|
return {
|
||||||
|
children: wordTableCellToDocxParagraphs(cell, docx),
|
||||||
|
...(colSpan ? { columnSpan: colSpan } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordTableCellToDocxParagraphs(cell: WordTableCell, docx: DocxModule) {
|
||||||
|
const runs = typeof cell === "string" ? [{ text: cell }] : cell.runs;
|
||||||
|
return [
|
||||||
new docx.Paragraph({
|
new docx.Paragraph({
|
||||||
children: [new docx.TextRun(cell)]
|
children: wordInlineRunsToTextRuns(runs, docx)
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -525,6 +642,31 @@ function docxIndentOption(indent: WordParagraphIndent | undefined) {
|
|||||||
return Object.keys(normalized).length > 0 ? { indent: normalized } : {};
|
return Object.keys(normalized).length > 0 ? { indent: normalized } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function docxParagraphSpacing(spacing: WordParagraphSpacing | undefined, defaultAfter: number) {
|
||||||
|
const normalized = normalizedParagraphSpacing(spacing);
|
||||||
|
return {
|
||||||
|
after: normalized.after ?? defaultAfter,
|
||||||
|
...(normalized.before ? { before: normalized.before } : {}),
|
||||||
|
...(normalized.line ? { line: normalized.line, lineRule: normalized.lineRule } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedParagraphSpacing(spacing: WordParagraphSpacing | undefined) {
|
||||||
|
const before = normalizedTwips(spacing?.before);
|
||||||
|
const after = normalizedTwips(spacing?.after);
|
||||||
|
const line = normalizedTwips(spacing?.line);
|
||||||
|
const lineRule = normalizedParagraphLineRule(spacing?.lineRule);
|
||||||
|
return {
|
||||||
|
...(before ? { before } : {}),
|
||||||
|
...(after !== undefined ? { after } : {}),
|
||||||
|
...(line ? { line, lineRule } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedParagraphLineRule(lineRule: WordParagraphLineRule | undefined) {
|
||||||
|
return lineRule === "exact" || lineRule === "atLeast" ? lineRule : "auto";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizedParagraphIndent(indent: WordParagraphIndent | undefined) {
|
function normalizedParagraphIndent(indent: WordParagraphIndent | undefined) {
|
||||||
const left = normalizedTwips(indent?.left);
|
const left = normalizedTwips(indent?.left);
|
||||||
const firstLine = normalizedTwips(indent?.firstLine);
|
const firstLine = normalizedTwips(indent?.firstLine);
|
||||||
@@ -590,18 +732,23 @@ function parseHtmlFragment(html: string) {
|
|||||||
|
|
||||||
function tableRows(table: Element) {
|
function tableRows(table: Element) {
|
||||||
return Array.from(table.querySelectorAll("tr"))
|
return Array.from(table.querySelectorAll("tr"))
|
||||||
.map((row) =>
|
.map((row) => Array.from(row.querySelectorAll("th,td")).map(tableCellValue))
|
||||||
Array.from(row.querySelectorAll("th,td"))
|
|
||||||
.map(elementText)
|
|
||||||
.filter((cell) => cell.length > 0)
|
|
||||||
)
|
|
||||||
.filter((row) => row.length > 0);
|
.filter((row) => row.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tableCellValue(cell: Element): WordTableCell {
|
||||||
|
const runs = elementInlineRuns(cell);
|
||||||
|
const text = inlineRunsText(runs);
|
||||||
|
const hasFormattedRuns = runs.some((run) => Object.keys(cleanInlineFormat(run)).length > 0);
|
||||||
|
const colSpan = normalizedTableSpan(cell.getAttribute("colspan"));
|
||||||
|
return hasFormattedRuns || colSpan ? { text, runs, ...(colSpan ? { colSpan } : {}) } : text;
|
||||||
|
}
|
||||||
|
|
||||||
function paragraphElementToBlocks(element: Element, options: { quote?: boolean } = {}): WordBlock[] {
|
function paragraphElementToBlocks(element: Element, options: { quote?: boolean } = {}): WordBlock[] {
|
||||||
const segments = collectInlineSegments(Array.from(element.childNodes), {});
|
const segments = collectInlineSegments(Array.from(element.childNodes), {});
|
||||||
const alignment = paragraphAlignmentFromElement(element);
|
const alignment = paragraphAlignmentFromElement(element);
|
||||||
const indent = paragraphIndentFromElement(element);
|
const indent = paragraphIndentFromElement(element);
|
||||||
|
const spacing = paragraphSpacingFromElement(element);
|
||||||
const bookmark = bookmarkFromElement(element);
|
const bookmark = bookmarkFromElement(element);
|
||||||
const blocks: WordBlock[] = [];
|
const blocks: WordBlock[] = [];
|
||||||
let pendingRuns: WordInlineRun[] = [];
|
let pendingRuns: WordInlineRun[] = [];
|
||||||
@@ -618,6 +765,7 @@ function paragraphElementToBlocks(element: Element, options: { quote?: boolean }
|
|||||||
runs,
|
runs,
|
||||||
...(alignment ? { alignment } : {}),
|
...(alignment ? { alignment } : {}),
|
||||||
...(indent ? { indent } : {}),
|
...(indent ? { indent } : {}),
|
||||||
|
...(spacing ? { spacing } : {}),
|
||||||
...(bookmark && !bookmarkApplied ? { bookmark } : {}),
|
...(bookmark && !bookmarkApplied ? { bookmark } : {}),
|
||||||
...(options.quote ? { quote: true } : {})
|
...(options.quote ? { quote: true } : {})
|
||||||
});
|
});
|
||||||
@@ -666,6 +814,19 @@ function paragraphIndentFromElement(element: Element): WordParagraphIndent | und
|
|||||||
return Object.keys(indent).length > 0 ? indent : undefined;
|
return Object.keys(indent).length > 0 ? indent : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paragraphSpacingFromElement(element: Element): WordParagraphSpacing | undefined {
|
||||||
|
const style = element.getAttribute("style") ?? "";
|
||||||
|
const before = cssTwipsValue(style, "margin-top");
|
||||||
|
const after = cssTwipsValue(style, "margin-bottom");
|
||||||
|
const lineHeight = cssLineHeightValue(style);
|
||||||
|
const spacing = {
|
||||||
|
...(before && before > 0 ? { before } : {}),
|
||||||
|
...(after && after > 0 ? { after } : {}),
|
||||||
|
...(lineHeight ? lineHeight : {})
|
||||||
|
};
|
||||||
|
return Object.keys(spacing).length > 0 ? spacing : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function bookmarkFromElement(element: Element) {
|
function bookmarkFromElement(element: Element) {
|
||||||
return normalizedDocxAnchor(element.getAttribute("id") || element.getAttribute("name"));
|
return normalizedDocxAnchor(element.getAttribute("id") || element.getAttribute("name"));
|
||||||
}
|
}
|
||||||
@@ -674,11 +835,33 @@ function cssTextAlignValue(style: string) {
|
|||||||
return /(?:^|;)\s*text-align\s*:\s*([^;]+)/i.exec(style)?.[1] ?? "";
|
return /(?:^|;)\s*text-align\s*:\s*([^;]+)/i.exec(style)?.[1] ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function cssTwipsValue(style: string, property: "margin-left" | "text-indent") {
|
function cssTwipsValue(style: string, property: "margin-left" | "margin-top" | "margin-bottom" | "text-indent") {
|
||||||
const raw = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1]?.trim().toLowerCase() ?? "";
|
const raw = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1]?.trim().toLowerCase() ?? "";
|
||||||
return cssLengthToTwips(raw);
|
return cssLengthToTwips(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssLineHeightValue(style: string): WordParagraphSpacing | undefined {
|
||||||
|
const raw = /(?:^|;)\s*line-height\s*:\s*([^;]+)/i.exec(style)?.[1]?.trim().toLowerCase() ?? "";
|
||||||
|
if (!raw || ["inherit", "initial", "unset", "revert", "normal"].includes(raw)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratioMatch = /^([0-9]+(?:\.[0-9]+)?)$/.exec(raw);
|
||||||
|
if (ratioMatch) {
|
||||||
|
const ratio = Number.parseFloat(ratioMatch[1]);
|
||||||
|
return Number.isFinite(ratio) && ratio > 0 ? { line: Math.round(ratio * 240), lineRule: "auto" } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentMatch = /^([0-9]+(?:\.[0-9]+)?)%$/.exec(raw);
|
||||||
|
if (percentMatch) {
|
||||||
|
const ratio = Number.parseFloat(percentMatch[1]) / 100;
|
||||||
|
return Number.isFinite(ratio) && ratio > 0 ? { line: Math.round(ratio * 240), lineRule: "auto" } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = cssLengthToTwips(raw);
|
||||||
|
return line && line > 0 ? { line, lineRule: "exact" } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function cssLengthToTwips(raw: string) {
|
function cssLengthToTwips(raw: string) {
|
||||||
if (!raw || ["inherit", "initial", "unset", "revert", "auto"].includes(raw)) {
|
if (!raw || ["inherit", "initial", "unset", "revert", "auto"].includes(raw)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -828,6 +1011,108 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function docxPageSetupFromArrayBuffer(arrayBuffer: ArrayBuffer): Promise<WordPageSetup | undefined> {
|
||||||
|
try {
|
||||||
|
const [JSZip, XMLParser] = await Promise.all([loadJsZip(), loadXmlParser()]);
|
||||||
|
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||||
|
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||||||
|
if (!documentXml) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new XMLParser(xmlParserOptions);
|
||||||
|
return docxPageSetupFromDocumentXml(parser.parse(documentXml));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxPageSetupFromDocumentXml(documentXml: unknown): WordPageSetup | undefined {
|
||||||
|
const body = childRecord(childRecord(documentXml, "w:document"), "w:body");
|
||||||
|
const sectionProperties = lastDocxSectionProperties(body);
|
||||||
|
if (!hasXmlRecord(sectionProperties)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return docxPageSetupFromSectionProperties(sectionProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastDocxSectionProperties(body: XmlRecord): XmlRecord {
|
||||||
|
const paragraphSectionProperties = toArray(asRecord(body)["w:p"])
|
||||||
|
.map((paragraph) => childRecord(childRecord(paragraph, "w:pPr"), "w:sectPr"))
|
||||||
|
.filter(hasXmlRecord);
|
||||||
|
const bodySectionProperties = childRecord(body, "w:sectPr");
|
||||||
|
const sections = hasXmlRecord(bodySectionProperties) ? [...paragraphSectionProperties, bodySectionProperties] : paragraphSectionProperties;
|
||||||
|
return sections[sections.length - 1] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxPageSetupFromSectionProperties(sectionProperties: XmlRecord): WordPageSetup | undefined {
|
||||||
|
const pageSize = childRecord(sectionProperties, "w:pgSz");
|
||||||
|
const pageMargins = childRecord(sectionProperties, "w:pgMar");
|
||||||
|
const width = docxTwipsAttribute(pageSize, ["w:w"]);
|
||||||
|
const height = docxTwipsAttribute(pageSize, ["w:h"]);
|
||||||
|
if (!width || !height) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orientation = docxPageOrientation(pageSize, width, height);
|
||||||
|
const baseWidth = orientation === "landscape" ? height : width;
|
||||||
|
const baseHeight = orientation === "landscape" ? width : height;
|
||||||
|
const size = wordPageSizeIdFromTwips(baseWidth, baseHeight);
|
||||||
|
if (!size) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marginPreset = hasXmlRecord(pageMargins) ? (wordPageMarginPresetFromTwips(pageMargins) ?? defaultWordPageSetup.marginPreset) : defaultWordPageSetup.marginPreset;
|
||||||
|
return { size, orientation, marginPreset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxPageOrientation(pageSize: XmlRecord, width: number, height: number): WordPageOrientation {
|
||||||
|
const value = docxAttribute(pageSize, "w:orient").trim().toLowerCase();
|
||||||
|
if (value === "landscape") {
|
||||||
|
return "landscape";
|
||||||
|
}
|
||||||
|
if (value === "portrait") {
|
||||||
|
return "portrait";
|
||||||
|
}
|
||||||
|
return width > height ? "landscape" : "portrait";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordPageSizeIdFromTwips(width: number, height: number): WordPageSizeId | undefined {
|
||||||
|
return (Object.keys(wordPageSizes) as WordPageSizeId[]).find((sizeId) => {
|
||||||
|
const size = wordPageSizes[sizeId];
|
||||||
|
return isCloseTwips(width, pxToTwips(size.width)) && isCloseTwips(height, pxToTwips(size.height));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordPageMarginPresetFromTwips(pageMargins: XmlRecord): WordPageMarginPreset | undefined {
|
||||||
|
const top = docxTwipsAttribute(pageMargins, ["w:top"]);
|
||||||
|
const right = docxTwipsAttribute(pageMargins, ["w:right"]);
|
||||||
|
const bottom = docxTwipsAttribute(pageMargins, ["w:bottom"]);
|
||||||
|
const left = docxTwipsAttribute(pageMargins, ["w:left"]);
|
||||||
|
if (!top || !right || !bottom || !left) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Object.keys(wordPageMargins) as WordPageMarginPreset[]).find((preset) => {
|
||||||
|
const margins = wordPageMargins[preset];
|
||||||
|
return (
|
||||||
|
isCloseTwips(top, pxToTwips(margins.top)) &&
|
||||||
|
isCloseTwips(right, pxToTwips(margins.right)) &&
|
||||||
|
isCloseTwips(bottom, pxToTwips(margins.bottom)) &&
|
||||||
|
isCloseTwips(left, pxToTwips(margins.left))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCloseTwips(actual: number, expected: number) {
|
||||||
|
return Math.abs(actual - expected) <= 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasXmlRecord(record: XmlRecord) {
|
||||||
|
return Object.keys(record).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function isSimpleStyledDocxDocument(documentXml: string) {
|
function isSimpleStyledDocxDocument(documentXml: string) {
|
||||||
return !/<w:(pict|sdt)\b/i.test(documentXml);
|
return !/<w:(pict|sdt)\b/i.test(documentXml);
|
||||||
}
|
}
|
||||||
@@ -1018,7 +1303,9 @@ function orderedDocxTableToHtml(
|
|||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell));
|
const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell));
|
||||||
return content ? `<td>${content}</td>` : "<td></td>";
|
const colSpan = orderedDocxTableCellColspan(cell);
|
||||||
|
const attributes = colSpan ? ` colspan="${colSpan}"` : "";
|
||||||
|
return content ? `<td${attributes}>${content}</td>` : `<td${attributes}></td>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
@@ -1029,6 +1316,12 @@ function orderedDocxTableToHtml(
|
|||||||
return { html: rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : "", hasImage, hasLineBreak, hasPageBreak, hasTab };
|
return { html: rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : "", hasImage, hasLineBreak, hasPageBreak, hasTab };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderedDocxTableCellColspan(cell: unknown) {
|
||||||
|
const properties = firstOrderedElement(orderedElementChildren(cell), "w:tcPr");
|
||||||
|
const gridSpan = firstOrderedElement(orderedElementChildren(properties), "w:gridSpan");
|
||||||
|
return normalizedTableSpan(docxAttribute(orderedElementAttributes(gridSpan), "w:val") || docxAttribute(orderedElementAttributes(gridSpan), "val"));
|
||||||
|
}
|
||||||
|
|
||||||
function orderedDocxCellText(cell: unknown) {
|
function orderedDocxCellText(cell: unknown) {
|
||||||
return orderedDirectElements(cell, "w:p")
|
return orderedDirectElements(cell, "w:p")
|
||||||
.flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {}))
|
.flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {}))
|
||||||
@@ -1069,7 +1362,8 @@ function docxParagraphStyleAttribute(properties: XmlRecord) {
|
|||||||
const htmlAlignment = alignment === "both" ? "justify" : alignment;
|
const htmlAlignment = alignment === "both" ? "justify" : alignment;
|
||||||
const styles = [
|
const styles = [
|
||||||
["left", "center", "right", "justify"].includes(htmlAlignment) ? `text-align: ${htmlAlignment};` : "",
|
["left", "center", "right", "justify"].includes(htmlAlignment) ? `text-align: ${htmlAlignment};` : "",
|
||||||
...docxParagraphIndentStyles(properties)
|
...docxParagraphIndentStyles(properties),
|
||||||
|
...docxParagraphSpacingStyles(properties)
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return styles.join(" ");
|
return styles.join(" ");
|
||||||
}
|
}
|
||||||
@@ -1086,6 +1380,36 @@ function docxParagraphIndentStyles(properties: XmlRecord) {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function docxParagraphSpacingStyles(properties: XmlRecord) {
|
||||||
|
const spacing = childRecord(properties, "w:spacing");
|
||||||
|
const before = docxTwipsAttribute(spacing, ["w:before"]);
|
||||||
|
const after = docxTwipsAttribute(spacing, ["w:after"]);
|
||||||
|
const line = docxTwipsAttribute(spacing, ["w:line"]);
|
||||||
|
const lineRule = docxAttribute(spacing, "w:lineRule");
|
||||||
|
return [
|
||||||
|
before ? `margin-top: ${formatPointSize(before / 20)}pt;` : "",
|
||||||
|
after && after !== defaultParagraphSpacingAfterTwips ? `margin-bottom: ${formatPointSize(after / 20)}pt;` : "",
|
||||||
|
docxLineHeightStyle(line, lineRule)
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxLineHeightStyle(line: number | undefined, lineRule: string) {
|
||||||
|
if (!line || line <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRule = lineRule.trim().toLowerCase();
|
||||||
|
if (!normalizedRule || normalizedRule === "auto") {
|
||||||
|
return `line-height: ${formatLineHeightRatio(line / 240)};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `line-height: ${formatPointSize(line / 20)}pt;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLineHeightRatio(value: number) {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
function docxParagraphListInfo(properties: XmlRecord, numberingDefinitions: DocxNumberingDefinitions): DocxListInfo | undefined {
|
function docxParagraphListInfo(properties: XmlRecord, numberingDefinitions: DocxNumberingDefinitions): DocxListInfo | undefined {
|
||||||
const numbering = childRecord(properties, "w:numPr");
|
const numbering = childRecord(properties, "w:numPr");
|
||||||
const numId = docxAttribute(childRecord(numbering, "w:numId"), "w:val");
|
const numId = docxAttribute(childRecord(numbering, "w:numId"), "w:val");
|
||||||
@@ -1358,7 +1682,7 @@ function docxRunToHtml(run: unknown, imageDataByRelationshipId: DocxImageDataByR
|
|||||||
if (hasDocxBoolean(properties, "w:i")) {
|
if (hasDocxBoolean(properties, "w:i")) {
|
||||||
html = `<em>${html}</em>`;
|
html = `<em>${html}</em>`;
|
||||||
}
|
}
|
||||||
if (hasDocxBoolean(properties, "w:u")) {
|
if (hasDocxUnderline(properties)) {
|
||||||
html = `<u>${html}</u>`;
|
html = `<u>${html}</u>`;
|
||||||
}
|
}
|
||||||
if (hasStrike) {
|
if (hasStrike) {
|
||||||
@@ -1572,6 +1896,15 @@ function hasDocxBoolean(properties: XmlRecord, key: string) {
|
|||||||
return !["0", "false", "off"].includes(value.toLowerCase());
|
return !["0", "false", "off"].includes(value.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDocxUnderline(properties: XmlRecord) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(properties, "w:u")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = (docxAttribute(childRecord(properties, "w:u"), "w:val") || docxAttribute(childRecord(properties, "w:u"), "val")).trim().toLowerCase();
|
||||||
|
return !["0", "false", "off", "none"].includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
function childRecord(value: unknown, key: string): XmlRecord {
|
function childRecord(value: unknown, key: string): XmlRecord {
|
||||||
const child = asRecord(value)[key];
|
const child = asRecord(value)[key];
|
||||||
if (Array.isArray(child)) {
|
if (Array.isArray(child)) {
|
||||||
@@ -1854,6 +2187,16 @@ function numericAttribute(element: Element, name: string) {
|
|||||||
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedTableSpan(value?: string | null) {
|
||||||
|
const raw = value?.trim() ?? "";
|
||||||
|
if (!/^\d+$/.test(raw)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = Number.parseInt(raw, 10);
|
||||||
|
return Number.isSafeInteger(span) && span > 1 ? Math.min(span, 63) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function cssPixelValue(style: string, property: "width" | "height") {
|
function cssPixelValue(style: string, property: "width" | "height") {
|
||||||
const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style);
|
const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import JSZip from "jszip";
|
||||||
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
|
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
|
||||||
|
|
||||||
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
@@ -128,6 +129,36 @@ describe("wordOffice DOCX import", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not import DOCX underline when w:u val is none", async () => {
|
||||||
|
const sourceBlob = await exportDocxBlob("UnderlineNone.docx", '<p><span style="color: #0f5fae;">Not underlined</span></p>');
|
||||||
|
const zip = await JSZip.loadAsync(await sourceBlob.arrayBuffer());
|
||||||
|
const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? "";
|
||||||
|
const patchedDocumentXml = documentXml.replace(/(<w:rPr\b[^>]*>[\s\S]*?)(<\/w:rPr>)/, '$1<w:u w:val="none"/>$2');
|
||||||
|
expect(patchedDocumentXml).toContain('<w:u w:val="none"/>');
|
||||||
|
zip.file("word/document.xml", patchedDocumentXml);
|
||||||
|
const patchedBlob = await zip.generateAsync({
|
||||||
|
type: "blob",
|
||||||
|
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
});
|
||||||
|
|
||||||
|
const imported = await importDocxFile(
|
||||||
|
new File([patchedBlob], "UnderlineNone.docx", {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported.html).not.toContain("<u>Not underlined</u>");
|
||||||
|
expect(imported.html).not.toContain("<u>");
|
||||||
|
expect(imported.html).toContain("color: #0F5FAE;");
|
||||||
|
expect(htmlToWordBlocks(imported.html)).toEqual([
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "Not underlined",
|
||||||
|
runs: [{ text: "Not underlined", color: "0F5FAE" }]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("imports DOCX subscript and superscript from OOXML", async () => {
|
it("imports DOCX subscript and superscript from OOXML", async () => {
|
||||||
const blob = await exportDocxBlob("Indexes.docx", "<p>H<sub>2</sub>O m<sup>2</sup></p>");
|
const blob = await exportDocxBlob("Indexes.docx", "<p>H<sub>2</sub>O m<sup>2</sup></p>");
|
||||||
const file = new File([blob], "Indexes.docx", {
|
const file = new File([blob], "Indexes.docx", {
|
||||||
@@ -281,6 +312,25 @@ describe("wordOffice DOCX import", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports DOCX horizontal merged table cells as HTML colspan", async () => {
|
||||||
|
const blob = await exportDocxBlob(
|
||||||
|
"Table colspan.docx",
|
||||||
|
'<table><tr><td colspan="2">Merged</td><td>Tail</td></tr></table>'
|
||||||
|
);
|
||||||
|
const file = new File([blob], "Table colspan.docx", {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
});
|
||||||
|
const imported = await importDocxFile(file);
|
||||||
|
|
||||||
|
expect(imported.html).toContain('<td colspan="2"><p>Merged</p></td>');
|
||||||
|
expect(htmlToWordBlocks(imported.html)).toEqual([
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
rows: [[{ text: "Merged", runs: [{ text: "Merged" }], colSpan: 2 }, "Tail"]]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("imports DOCX bullet and numbered lists from OOXML numbering", async () => {
|
it("imports DOCX bullet and numbered lists from OOXML numbering", async () => {
|
||||||
const blob = await exportDocxBlob(
|
const blob = await exportDocxBlob(
|
||||||
"Lists.docx",
|
"Lists.docx",
|
||||||
@@ -363,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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+190
-48
@@ -365,6 +365,10 @@ button {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qword-layout {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.document-stage,
|
.document-stage,
|
||||||
.spreadsheet-stage,
|
.spreadsheet-stage,
|
||||||
.slide-stage {
|
.slide-stage {
|
||||||
@@ -393,6 +397,9 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.word-module-toolbar {
|
.word-module-toolbar {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
z-index: 3;
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 154px;
|
min-height: 154px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -439,8 +446,7 @@ button {
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
height: 118px;
|
height: 118px;
|
||||||
min-height: 118px;
|
min-height: 118px;
|
||||||
overflow-x: auto;
|
overflow: hidden;
|
||||||
overflow-y: hidden;
|
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +458,7 @@ button {
|
|||||||
height: 118px;
|
height: 118px;
|
||||||
min-height: 118px;
|
min-height: 118px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 8px 10px 4px;
|
padding: 7px 8px 4px;
|
||||||
border-right: 1px solid #d6dde8;
|
border-right: 1px solid #d6dde8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,23 +480,23 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.word-file-group {
|
.word-file-group {
|
||||||
flex: 0 0 76px;
|
flex: 0 0 68px;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-file-actions {
|
.word-file-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 28px);
|
grid-template-columns: repeat(2, 26px);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-icon-command {
|
.word-icon-command {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 28px;
|
width: 26px;
|
||||||
height: 28px;
|
height: 26px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -504,12 +510,13 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.word-clipboard-group {
|
.word-clipboard-group {
|
||||||
flex: 0 0 150px;
|
flex: 0 1 136px;
|
||||||
grid-template-columns: auto auto;
|
grid-template-columns: auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-clipboard-group .word-ribbon-title,
|
.word-clipboard-group .word-ribbon-title,
|
||||||
.word-font-group .word-ribbon-title,
|
.word-font-group .word-ribbon-title,
|
||||||
|
.word-page-setup-group .word-ribbon-title,
|
||||||
.word-paragraph-group .word-ribbon-title,
|
.word-paragraph-group .word-ribbon-title,
|
||||||
.word-styles-group .word-ribbon-title,
|
.word-styles-group .word-ribbon-title,
|
||||||
.word-insert-group .word-ribbon-title,
|
.word-insert-group .word-ribbon-title,
|
||||||
@@ -522,7 +529,7 @@ button {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-width: 66px;
|
min-width: 58px;
|
||||||
min-height: 70px;
|
min-height: 70px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -549,6 +556,7 @@ button {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
min-height: 22px;
|
min-height: 22px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -556,11 +564,14 @@ button {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-font-group {
|
.word-font-group {
|
||||||
flex: 0 0 266px;
|
flex: 1 1 226px;
|
||||||
|
min-width: 214px;
|
||||||
grid-template-rows: 28px 28px minmax(0, 1fr) 18px;
|
grid-template-rows: 28px 28px minmax(0, 1fr) 18px;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -573,7 +584,7 @@ button {
|
|||||||
|
|
||||||
.word-font-select {
|
.word-font-select {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 170px;
|
flex: 1 1 132px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-font-size-select {
|
.word-font-size-select {
|
||||||
@@ -606,11 +617,24 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.word-paragraph-group {
|
.word-paragraph-group {
|
||||||
flex: 0 0 170px;
|
flex: 0 1 154px;
|
||||||
grid-template-rows: 28px 28px minmax(0, 1fr) 18px;
|
grid-template-rows: 28px 28px minmax(0, 1fr) 18px;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.word-page-setup-group {
|
||||||
|
flex: 0 0 272px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-page-setup-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
align-content: start;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.word-paragraph-group > .segmented-tools,
|
.word-paragraph-group > .segmented-tools,
|
||||||
.word-paragraph-group > .compact-command {
|
.word-paragraph-group > .compact-command {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
@@ -621,7 +645,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.word-styles-group {
|
.word-styles-group {
|
||||||
flex: 0 0 360px;
|
flex: 1 1 238px;
|
||||||
|
min-width: 190px;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,7 +661,7 @@ button {
|
|||||||
.word-style-gallery button {
|
.word-style-gallery button {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
flex: 0 0 82px;
|
flex: 0 0 78px;
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border: 1px solid #d6dde8;
|
border: 1px solid #d6dde8;
|
||||||
@@ -645,17 +670,23 @@ button {
|
|||||||
color: #334155;
|
color: #334155;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-style-gallery button span {
|
.word-style-gallery button span {
|
||||||
color: #1e5fa7;
|
color: #1e5fa7;
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-insert-group {
|
.word-insert-group {
|
||||||
flex: 0 0 96px;
|
flex: 0 0 86px;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,11 +695,69 @@ button {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-edit-group {
|
.word-table-group {
|
||||||
flex: 0 0 132px;
|
flex: 0 0 236px;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.word-table-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-content: start;
|
||||||
|
gap: 5px 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-mini-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
color: #69778a;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-mini-field select {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-table-controls .compact-command:not(.word-table-structure-command) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-table-structure-command {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-edit-group {
|
||||||
|
flex: 0 0 118px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-view-group {
|
||||||
|
flex: 0 0 320px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-view-mode-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-view-mode-row .compact-command {
|
||||||
|
flex: 1 1 0;
|
||||||
|
height: 58px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.word-edit-group .toolbar-status {
|
.word-edit-group .toolbar-status {
|
||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -683,8 +772,12 @@ button {
|
|||||||
|
|
||||||
.word-ribbon .compact-command {
|
.word-ribbon .compact-command {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
min-width: 0;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden-file-input {
|
.hidden-file-input {
|
||||||
@@ -849,6 +942,11 @@ button {
|
|||||||
color: #0f5fae;
|
color: #0f5fae;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slide-format-tools {
|
||||||
|
padding-left: 0;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.fill-swatches {
|
.fill-swatches {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -969,27 +1067,88 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.paper-frame {
|
.paper-frame {
|
||||||
|
--qword-page-gap: 28px;
|
||||||
|
--qword-page-height: 1123px;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: var(--qword-page-width, 794px);
|
width: var(--qword-page-width, 794px);
|
||||||
zoom: var(--qword-zoom, 1);
|
zoom: var(--qword-zoom, 1);
|
||||||
margin: 0 auto 38px;
|
margin: 0 auto 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper {
|
.page-sheets {
|
||||||
--qword-page-height: 1123px;
|
position: absolute;
|
||||||
min-height: var(--qword-page-height);
|
inset: 0 0 auto;
|
||||||
|
z-index: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--qword-page-gap);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-sheet {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
height: var(--qword-page-height);
|
||||||
padding: 72px 86px;
|
|
||||||
border: 1px solid #c7d1dd;
|
border: 1px solid #c7d1dd;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper {
|
||||||
|
min-height: var(--qword-page-height);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--qword-page-margin-top, 72px) var(--qword-page-margin-right, 86px) var(--qword-page-margin-bottom, 72px) var(--qword-page-margin-left, 86px);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qword-view-read .page-ruler,
|
||||||
|
.qword-view-read .vertical-page-ruler,
|
||||||
|
.qword-view-read .page-sheets,
|
||||||
|
.qword-view-read .page-break-guides,
|
||||||
|
.qword-view-web .page-ruler,
|
||||||
|
.qword-view-web .vertical-page-ruler,
|
||||||
|
.qword-view-web .page-sheets,
|
||||||
|
.qword-view-web .page-break-guides {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qword-view-read .paper-frame {
|
||||||
|
width: min(920px, calc(100% - 96px));
|
||||||
|
margin-top: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qword-view-read .paper {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 56px 72px;
|
||||||
|
border: 1px solid #c7d1dd;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qword-view-web .paper-frame {
|
||||||
|
width: min(1040px, calc(100% - 64px));
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qword-view-web .paper {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.page-break-guides {
|
.page-break-guides {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -997,30 +1156,8 @@ button {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: grid;
|
height: var(--qword-page-gap);
|
||||||
place-items: center;
|
background: #dedede;
|
||||||
height: 24px;
|
|
||||||
border-top: 1px dashed #94a3b8;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 11px;
|
|
||||||
transform: translateY(-12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-break-guides span::before,
|
|
||||||
.page-break-guides span::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 11px;
|
|
||||||
width: calc(50% - 58px);
|
|
||||||
border-top: 1px dashed #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-break-guides span::before {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-break-guides span::after {
|
|
||||||
right: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-content h1 {
|
.document-content h1 {
|
||||||
@@ -2120,6 +2257,11 @@ button {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.word-module-toolbar {
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.file-title-input {
|
.file-title-input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 220px;
|
flex: 1 1 220px;
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
export type AppId = "word" | "sheets" | "slides" | "mail";
|
export type AppId = "word" | "sheets" | "slides" | "mail";
|
||||||
|
|
||||||
|
export type WordPageSizeId = "a4" | "letter";
|
||||||
|
export type WordPageOrientation = "portrait" | "landscape";
|
||||||
|
export type WordPageMarginPreset = "normal" | "narrow" | "wide";
|
||||||
|
export type SheetPageSizeId = "a4" | "letter";
|
||||||
|
export type SheetPageOrientation = "portrait" | "landscape";
|
||||||
|
export type SheetPageMarginPreset = "normal" | "narrow" | "wide";
|
||||||
|
|
||||||
|
export interface WordPageSetup {
|
||||||
|
size: WordPageSizeId;
|
||||||
|
orientation: WordPageOrientation;
|
||||||
|
marginPreset: WordPageMarginPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SheetPageSetup {
|
||||||
|
size: SheetPageSizeId;
|
||||||
|
orientation: SheetPageOrientation;
|
||||||
|
marginPreset: SheetPageMarginPreset;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WordDocument {
|
export interface WordDocument {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
pageSetup?: WordPageSetup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Workbook {
|
export interface Workbook {
|
||||||
@@ -29,6 +49,7 @@ export interface Sheet {
|
|||||||
showGridLines?: boolean;
|
showGridLines?: boolean;
|
||||||
zoomScale?: number;
|
zoomScale?: number;
|
||||||
printArea?: string;
|
printArea?: string;
|
||||||
|
pageSetup?: SheetPageSetup;
|
||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
@@ -76,11 +97,14 @@ export interface SheetChart {
|
|||||||
valueRange: string;
|
valueRange: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SlideSizeId = "wide" | "standard";
|
||||||
|
|
||||||
export interface SlideDeck {
|
export interface SlideDeck {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
activeSlideId: string;
|
activeSlideId: string;
|
||||||
|
slideSize?: SlideSizeId;
|
||||||
slides: Slide[];
|
slides: Slide[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +120,19 @@ export interface Slide {
|
|||||||
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;
|
||||||
@@ -106,6 +140,7 @@ export interface Slide {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SlideListStyle = "bullet" | "number";
|
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 {
|
||||||
|
|||||||
@@ -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