Preserve QExcell cell notes in XLSX
This commit is contained in:
@@ -45,7 +45,7 @@ npm run build
|
|||||||
## Поддержка форматов
|
## Поддержка форматов
|
||||||
|
|
||||||
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из HTML `margin-left`/`text-indent`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта.
|
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из HTML `margin-left`/`text-indent`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта.
|
||||||
- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width` и `col hidden`, высоту и скрытие строк через `row ht/customHeight` и `row hidden`, управление скрытием выбранной строки и столбца в интерфейсе, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений.
|
- 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`, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, переходы слайдов, заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, списки подзаголовка, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка.
|
- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, переносы абзацев и ручные переносы строк `a:br` внутри текстовых блоков, маркированные и нумерованные списки подзаголовка через `a:buChar`/`a:buAutoNum`, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, переходы слайдов, заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, списки подзаголовка, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка.
|
||||||
- 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` пока не реализованы.
|
||||||
|
|
||||||
@@ -59,7 +59,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` для переносов строк/разрывов страниц/табуляции/цвета/подсветки/размера/семейства/зачеркивания текста DOCX, выравнивание и отступы абзацев DOCX через `w:jc`/`w:ind`, закладки DOCX через `w:bookmarkStart`/`w:bookmarkEnd`, внешние `w:hyperlink r:id` и внутренние `w:hyperlink w:anchor` гиперссылки DOCX, внешние и внутренние гиперссылки XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов, скрытые/veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати XLSX через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width`/`col hidden`, высоту и скрытие строк через `row ht/customHeight`/`row hidden` и активный лист XLSX через `workbookView activeTab`, ячейки, формулы, shared formulas XLSX с относительными ссылками, базовые merged cells, базовые стили, RGB/theme/indexed цвета XLSX с учетом `xl/theme/theme1.xml`, размеры и семейства шрифта, зачеркивание ячеек, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX с переносами абзацев и ручными переносами строк `a:br` при импорте, скрытые слайды PPTX через атрибут `show`, переходы PPTX через `p:transition` для `fade`/`push`/`wipe`, пользовательский фон слайдов PPTX, цвет, размер и семейство шрифта текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc/Reply-To`, заголовки цепочки писем `Message-ID`/`In-Reply-To`/`References`, заголовки приоритета `Importance`/`X-Priority`/`Priority`, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения EML, inline-вложения EML с `Content-ID` и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены.
|
Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:br`, `w:br w:type="page"`, `w:tab`, `w:color`, `w:shd`, `w:highlight`, `w:sz`, `w:rFonts` и `w:strike` для переносов строк/разрывов страниц/табуляции/цвета/подсветки/размера/семейства/зачеркивания текста DOCX, выравнивание и отступы абзацев DOCX через `w:jc`/`w:ind`, закладки DOCX через `w:bookmarkStart`/`w:bookmarkEnd`, внешние `w:hyperlink r:id` и внутренние `w:hyperlink w:anchor` гиперссылки DOCX, внешние и внутренние гиперссылки 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 через атрибут `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 в текущем инкременте не заявлены.
|
||||||
|
|
||||||
## Сборка пакета
|
## Сборка пакета
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export const initialState: QOfficeState = {
|
|||||||
A8: "Объединённая строка примечания"
|
A8: "Объединённая строка примечания"
|
||||||
},
|
},
|
||||||
mergedCells: [{ start: "A8", end: "E8" }],
|
mergedCells: [{ start: "A8", end: "E8" }],
|
||||||
|
comments: {
|
||||||
|
E2: "Проверить сумму перед отправкой квартального отчета."
|
||||||
|
},
|
||||||
cellFormats: {
|
cellFormats: {
|
||||||
A1: { bold: true, fontSize: 12, textColor: "FFFFFF", fillColor: "0F5FAE", horizontalAlign: "center" },
|
A1: { bold: true, fontSize: 12, textColor: "FFFFFF", fillColor: "0F5FAE", horizontalAlign: "center" },
|
||||||
B1: { bold: true, fontSize: 12, textColor: "FFFFFF", fillColor: "0F5FAE", horizontalAlign: "center" },
|
B1: { bold: true, fontSize: 12, textColor: "FFFFFF", fillColor: "0F5FAE", horizontalAlign: "center" },
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Sigma,
|
Sigma,
|
||||||
SquareDashed,
|
SquareDashed,
|
||||||
|
StickyNote,
|
||||||
Strikethrough,
|
Strikethrough,
|
||||||
Trash2,
|
Trash2,
|
||||||
Type,
|
Type,
|
||||||
@@ -48,6 +49,7 @@ interface QExcellProps {
|
|||||||
const extraEditableRows = 1;
|
const extraEditableRows = 1;
|
||||||
const extraEditableColumns = 1;
|
const extraEditableColumns = 1;
|
||||||
const emptyHyperlinks: Record<string, string> = {};
|
const emptyHyperlinks: Record<string, string> = {};
|
||||||
|
const emptyComments: Record<string, string> = {};
|
||||||
const emptyCellFormats: Record<string, CellFormat> = {};
|
const emptyCellFormats: Record<string, CellFormat> = {};
|
||||||
const emptyHiddenItems: Record<string, boolean> = {};
|
const emptyHiddenItems: Record<string, boolean> = {};
|
||||||
const emptyCharts: SheetChart[] = [];
|
const emptyCharts: SheetChart[] = [];
|
||||||
@@ -269,12 +271,14 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
const activeSheet = workbook.sheets.find((sheet) => sheet.id === workbook.activeSheet) ?? workbook.sheets[0] ?? { id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [] };
|
const activeSheet = workbook.sheets.find((sheet) => sheet.id === workbook.activeSheet) ?? workbook.sheets[0] ?? { id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [] };
|
||||||
const activeMergedCells = activeSheet.mergedCells ?? [];
|
const activeMergedCells = activeSheet.mergedCells ?? [];
|
||||||
const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks;
|
const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks;
|
||||||
|
const activeComments = activeSheet.comments ?? emptyComments;
|
||||||
const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats;
|
const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats;
|
||||||
const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems;
|
const 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 selectedRawValue = activeSheet.cells[selectedCell] ?? "";
|
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
|
||||||
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
|
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
|
||||||
|
const selectedComment = activeComments[selectedCell] ?? "";
|
||||||
const selectedCellFormat = activeCellFormats[selectedCell] ?? {};
|
const selectedCellFormat = activeCellFormats[selectedCell] ?? {};
|
||||||
const selectedCellPosition = parseCellId(selectedCell) ?? { column: 0, row: 0 };
|
const selectedCellPosition = parseCellId(selectedCell) ?? { column: 0, row: 0 };
|
||||||
const selectedColumnLabel = columnLabel(selectedCellPosition.column);
|
const selectedColumnLabel = columnLabel(selectedCellPosition.column);
|
||||||
@@ -285,11 +289,11 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
const hiddenRowCount = Object.values(activeHiddenRows).filter(Boolean).length;
|
const hiddenRowCount = Object.values(activeHiddenRows).filter(Boolean).length;
|
||||||
const boundsCells = useMemo(
|
const boundsCells = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...Object.keys(activeHyperlinks), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>(
|
[...Object.keys(activeHyperlinks), ...Object.keys(activeComments), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>(
|
||||||
(cells, address) => ({ ...cells, [address]: cells[address] || activeHyperlinks[address] || "format" }),
|
(cells, address) => ({ ...cells, [address]: cells[address] || activeHyperlinks[address] || activeComments[address] || "format" }),
|
||||||
{ ...activeSheet.cells }
|
{ ...activeSheet.cells }
|
||||||
),
|
),
|
||||||
[activeCellFormats, activeHyperlinks, activeSheet.cells]
|
[activeCellFormats, activeComments, activeHyperlinks, activeSheet.cells]
|
||||||
);
|
);
|
||||||
const sheetBounds = useMemo(() => usedSheetBounds(boundsCells, activeMergedCells), [activeMergedCells, boundsCells]);
|
const sheetBounds = useMemo(() => usedSheetBounds(boundsCells, activeMergedCells), [activeMergedCells, boundsCells]);
|
||||||
const rows = useMemo(() => Array.from({ length: sheetBounds.rowCount + extraEditableRows }, (_, index) => index), [sheetBounds.rowCount]);
|
const rows = useMemo(() => Array.from({ length: sheetBounds.rowCount + extraEditableRows }, (_, index) => index), [sheetBounds.rowCount]);
|
||||||
@@ -326,6 +330,22 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateComment(id: string, comment: string) {
|
||||||
|
const normalizedId = id.toUpperCase();
|
||||||
|
const nextComments = { ...activeComments };
|
||||||
|
const nextComment = comment.trim();
|
||||||
|
if (nextComment) {
|
||||||
|
nextComments[normalizedId] = nextComment;
|
||||||
|
} else {
|
||||||
|
delete nextComments[normalizedId];
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...workbook,
|
||||||
|
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, comments: nextComments } : sheet))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateCellFormat(id: string, formatPatch: CellFormat) {
|
function updateCellFormat(id: string, formatPatch: CellFormat) {
|
||||||
const normalizedId = id.toUpperCase();
|
const normalizedId = id.toUpperCase();
|
||||||
const nextFormats = { ...activeCellFormats };
|
const nextFormats = { ...activeCellFormats };
|
||||||
@@ -370,14 +390,16 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
function clearSelectedCell() {
|
function clearSelectedCell() {
|
||||||
const nextCells = { ...activeSheet.cells };
|
const nextCells = { ...activeSheet.cells };
|
||||||
const nextHyperlinks = { ...activeHyperlinks };
|
const nextHyperlinks = { ...activeHyperlinks };
|
||||||
|
const nextComments = { ...activeComments };
|
||||||
const nextFormats = { ...activeCellFormats };
|
const nextFormats = { ...activeCellFormats };
|
||||||
delete nextCells[selectedCell];
|
delete nextCells[selectedCell];
|
||||||
delete nextHyperlinks[selectedCell];
|
delete nextHyperlinks[selectedCell];
|
||||||
|
delete nextComments[selectedCell];
|
||||||
delete nextFormats[selectedCell];
|
delete nextFormats[selectedCell];
|
||||||
onChange({
|
onChange({
|
||||||
...workbook,
|
...workbook,
|
||||||
sheets: workbook.sheets.map((sheet) =>
|
sheets: workbook.sheets.map((sheet) =>
|
||||||
sheet.id === activeSheet.id ? { ...sheet, cells: nextCells, hyperlinks: nextHyperlinks, cellFormats: nextFormats } : sheet
|
sheet.id === activeSheet.id ? { ...sheet, cells: nextCells, hyperlinks: nextHyperlinks, comments: nextComments, cellFormats: nextFormats } : sheet
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -856,6 +878,19 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
onBlur={(event) => updateHyperlink(selectedCell, normalizedLinkInput(event.target.value))}
|
onBlur={(event) => updateHyperlink(selectedCell, normalizedLinkInput(event.target.value))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="office-field office-field-wide">
|
||||||
|
<span>
|
||||||
|
<StickyNote size={14} />
|
||||||
|
Заметка
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className="cell-comment-field"
|
||||||
|
value={selectedComment}
|
||||||
|
aria-label="Заметка ячейки"
|
||||||
|
placeholder="Комментарий Excel"
|
||||||
|
onChange={(event) => updateComment(selectedCell, event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
{hiddenColumnCount + hiddenRowCount > 0 ? (
|
{hiddenColumnCount + hiddenRowCount > 0 ? (
|
||||||
<button className="compact-command" type="button" onClick={showAllHiddenSheetItems}>
|
<button className="compact-command" type="button" onClick={showAllHiddenSheetItems}>
|
||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
@@ -937,13 +972,14 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
const raw = activeSheet.cells[id] ?? "";
|
const raw = activeSheet.cells[id] ?? "";
|
||||||
const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw;
|
const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw;
|
||||||
const hyperlink = activeHyperlinks[id] ?? "";
|
const hyperlink = activeHyperlinks[id] ?? "";
|
||||||
|
const comment = activeComments[id] ?? "";
|
||||||
const cellFormat = activeCellFormats[id];
|
const cellFormat = activeCellFormats[id];
|
||||||
const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat);
|
const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat);
|
||||||
const isHiddenColumn = Boolean(activeHiddenColumns[columnName]);
|
const isHiddenColumn = Boolean(activeHiddenColumns[columnName]);
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={id}
|
key={id}
|
||||||
className={`${selectedCell === id ? "is-selected" : ""} ${merge ? "is-merged" : ""} ${hyperlink ? "is-linked" : ""} ${cellFormat ? "is-formatted" : ""} ${cellFormat?.wrapText ? "is-wrapped" : ""} ${cellFormat?.border ? "is-bordered" : ""} ${cellFormat?.verticalAlign ? `is-valign-${cellFormat.verticalAlign}` : ""} ${isHiddenColumn ? "is-hidden-column" : ""} ${isHiddenRow ? "is-hidden-row" : ""}`.trim()}
|
className={`${selectedCell === id ? "is-selected" : ""} ${merge ? "is-merged" : ""} ${hyperlink ? "is-linked" : ""} ${comment ? "is-commented" : ""} ${cellFormat ? "is-formatted" : ""} ${cellFormat?.wrapText ? "is-wrapped" : ""} ${cellFormat?.border ? "is-bordered" : ""} ${cellFormat?.verticalAlign ? `is-valign-${cellFormat.verticalAlign}` : ""} ${isHiddenColumn ? "is-hidden-column" : ""} ${isHiddenRow ? "is-hidden-row" : ""}`.trim()}
|
||||||
style={cellBoxStyle(cellFormat)}
|
style={cellBoxStyle(cellFormat)}
|
||||||
colSpan={merge?.columnSpan}
|
colSpan={merge?.columnSpan}
|
||||||
rowSpan={merge?.rowSpan}
|
rowSpan={merge?.rowSpan}
|
||||||
@@ -952,7 +988,7 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={selectedCell === id ? raw : display}
|
value={selectedCell === id ? raw : display}
|
||||||
aria-label={`Ячейка ${id}`}
|
aria-label={`Ячейка ${id}`}
|
||||||
title={hyperlink || undefined}
|
title={[hyperlink, comment].filter(Boolean).join("\n") || undefined}
|
||||||
style={cellInputStyle(cellFormat)}
|
style={cellInputStyle(cellFormat)}
|
||||||
onFocus={() => setSelectedCell(id)}
|
onFocus={() => setSelectedCell(id)}
|
||||||
onChange={(event) => updateCell(id, event.target.value)}
|
onChange={(event) => updateCell(id, event.target.value)}
|
||||||
@@ -961,7 +997,7 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
|
|||||||
<input
|
<input
|
||||||
value={selectedCell === id ? raw : display}
|
value={selectedCell === id ? raw : display}
|
||||||
aria-label={`Ячейка ${id}`}
|
aria-label={`Ячейка ${id}`}
|
||||||
title={hyperlink || undefined}
|
title={[hyperlink, comment].filter(Boolean).join("\n") || undefined}
|
||||||
style={cellInputStyle(cellFormat)}
|
style={cellInputStyle(cellFormat)}
|
||||||
onFocus={() => setSelectedCell(id)}
|
onFocus={() => setSelectedCell(id)}
|
||||||
onChange={(event) => updateCell(id, event.target.value)}
|
onChange={(event) => updateCell(id, event.target.value)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildChartXml,
|
buildChartXml,
|
||||||
buildCellXml,
|
buildCellXml,
|
||||||
buildAutoFilterXml,
|
buildAutoFilterXml,
|
||||||
|
buildCommentsXml,
|
||||||
buildColumnWidthsXml,
|
buildColumnWidthsXml,
|
||||||
buildHyperlinksXml,
|
buildHyperlinksXml,
|
||||||
buildMergeCellsXml,
|
buildMergeCellsXml,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
worksheetXmlToHiddenColumns,
|
worksheetXmlToHiddenColumns,
|
||||||
worksheetXmlToHiddenRows,
|
worksheetXmlToHiddenRows,
|
||||||
worksheetXmlToHyperlinks,
|
worksheetXmlToHyperlinks,
|
||||||
|
worksheetCommentsXmlToComments,
|
||||||
worksheetXmlToMergedCells,
|
worksheetXmlToMergedCells,
|
||||||
worksheetXmlToRowHeights,
|
worksheetXmlToRowHeights,
|
||||||
worksheetXmlToShowGridLines,
|
worksheetXmlToShowGridLines,
|
||||||
@@ -145,6 +147,58 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
expect(relationshipsXml).not.toContain('TargetMode="External"');
|
expect(relationshipsXml).not.toContain('TargetMode="External"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("собирает и читает legacy comments XLSX для заметок ячеек", () => {
|
||||||
|
const commentsXml = buildCommentsXml({ B2: "Проверить сумму", A1: "Комментарий\nв две строки" });
|
||||||
|
const parsedComments = worksheetCommentsXmlToComments({
|
||||||
|
comments: {
|
||||||
|
commentList: {
|
||||||
|
comment: [
|
||||||
|
{ ref: "B2", text: { t: "Проверить сумму" } },
|
||||||
|
{ ref: "A1", text: { r: [{ t: "Комментарий" }, { t: "\nв две строки" }] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commentsXml).toContain('<comment ref="A1" authorId="0"><text><t>Комментарий\nв две строки</t></text></comment>');
|
||||||
|
expect(commentsXml).toContain('<comment ref="B2" authorId="0"><text><t>Проверить сумму</t></text></comment>');
|
||||||
|
expect(parsedComments).toEqual({ A1: "Комментарий\nв две строки", B2: "Проверить сумму" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сохраняет заметки ячеек XLSX в comments, VML и relationships", async () => {
|
||||||
|
const blob = await exportXlsxBlob({
|
||||||
|
id: "workbook",
|
||||||
|
title: "Comments.xlsx",
|
||||||
|
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
activeSheet: "sheet-1",
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
id: "sheet-1",
|
||||||
|
name: "Sheet 1",
|
||||||
|
cells: { A1: "Итого" },
|
||||||
|
comments: { A1: "Проверено финансовым отделом" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
const contentTypesXml = await zip.file("[Content_Types].xml")?.async("string");
|
||||||
|
const worksheetXml = await zip.file("xl/worksheets/sheet1.xml")?.async("string");
|
||||||
|
const relationshipsXml = await zip.file("xl/worksheets/_rels/sheet1.xml.rels")?.async("string");
|
||||||
|
const commentsXml = await zip.file("xl/comments1.xml")?.async("string");
|
||||||
|
const vmlXml = await zip.file("xl/drawings/vmlDrawing1.vml")?.async("string");
|
||||||
|
const imported = await importXlsxFile(new File([blob], "Comments.xlsx", { type: blob.type }));
|
||||||
|
|
||||||
|
expect(contentTypesXml).toContain("/xl/comments1.xml");
|
||||||
|
expect(contentTypesXml).toContain('Extension="vml"');
|
||||||
|
expect(worksheetXml).toContain('<legacyDrawing r:id="rId2"/>');
|
||||||
|
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"');
|
||||||
|
expect(relationshipsXml).toContain('Target="../comments1.xml"');
|
||||||
|
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing"');
|
||||||
|
expect(commentsXml).toContain('<comment ref="A1" authorId="0">');
|
||||||
|
expect(vmlXml).toContain("<x:Row>0</x:Row><x:Column>0</x:Column>");
|
||||||
|
expect(imported.sheets[0].comments).toEqual({ A1: "Проверено финансовым отделом" });
|
||||||
|
});
|
||||||
|
|
||||||
it("сохраняет базовое форматирование ячеек XLSX в XML листа", () => {
|
it("сохраняет базовое форматирование ячеек XLSX в XML листа", () => {
|
||||||
const cellFormats = {
|
const cellFormats = {
|
||||||
A1: { bold: true, fontSize: 14, fontFamily: "Arial", textColor: "0f5fae", fillColor: "fff2cc", horizontalAlign: "center" as const },
|
A1: { bold: true, fontSize: 14, fontFamily: "Arial", textColor: "0f5fae", fillColor: "fff2cc", horizontalAlign: "center" as const },
|
||||||
|
|||||||
+168
-15
@@ -22,6 +22,7 @@ export interface ImportedExcellWorkbook {
|
|||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
|
comments?: Record<string, string>;
|
||||||
cellFormats?: Record<string, CellFormat>;
|
cellFormats?: Record<string, CellFormat>;
|
||||||
charts?: SheetChart[];
|
charts?: SheetChart[];
|
||||||
}>;
|
}>;
|
||||||
@@ -79,6 +80,7 @@ interface ParsedSheet {
|
|||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
|
comments?: Record<string, string>;
|
||||||
cellFormats?: Record<string, CellFormat>;
|
cellFormats?: Record<string, CellFormat>;
|
||||||
charts?: SheetChart[];
|
charts?: SheetChart[];
|
||||||
}
|
}
|
||||||
@@ -124,6 +126,12 @@ interface ChartPartRef {
|
|||||||
sheetName: string;
|
sheetName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CommentPartRef {
|
||||||
|
sheetIndex: number;
|
||||||
|
commentIndex: number;
|
||||||
|
comments: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
const xmlParserOptions = {
|
const xmlParserOptions = {
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
attributeNamePrefix: "",
|
attributeNamePrefix: "",
|
||||||
@@ -135,6 +143,8 @@ const xmlParserOptions = {
|
|||||||
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
||||||
const drawingRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing";
|
const drawingRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing";
|
||||||
const chartRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
|
const chartRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
|
||||||
|
const commentsRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
|
||||||
|
const vmlDrawingRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing";
|
||||||
const customNumberFormatStartId = 164;
|
const customNumberFormatStartId = 164;
|
||||||
const defaultXlsxThemeColors = [
|
const defaultXlsxThemeColors = [
|
||||||
"000000",
|
"000000",
|
||||||
@@ -237,7 +247,9 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
|
|||||||
const sheetPath = resolveWorkbookTarget(target);
|
const sheetPath = resolveWorkbookTarget(target);
|
||||||
const sheetXml = parser.parse(await readZipText(zip, sheetPath));
|
const sheetXml = parser.parse(await readZipText(zip, sheetPath));
|
||||||
const sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath);
|
const sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath);
|
||||||
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors);
|
const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath);
|
||||||
|
const commentsXml = commentsPath ? await readOptionalParsedXml(zip, parser, commentsPath) : undefined;
|
||||||
|
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors, commentsXml);
|
||||||
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 };
|
||||||
})
|
})
|
||||||
@@ -258,9 +270,10 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
|
|
||||||
const styleRegistry = createCellStyleRegistry(sheets.map((sheet) => sheet.cellFormats));
|
const styleRegistry = createCellStyleRegistry(sheets.map((sheet) => sheet.cellFormats));
|
||||||
const chartParts = chartPartRefsForSheets(sheets);
|
const chartParts = chartPartRefsForSheets(sheets);
|
||||||
|
const commentParts = commentPartRefsForSheets(sheets);
|
||||||
const activeSheetIndex = Math.max(0, sheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
|
const activeSheetIndex = Math.max(0, sheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
|
||||||
|
|
||||||
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts));
|
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts, commentParts));
|
||||||
zip.file("_rels/.rels", buildRootRelationshipsXml());
|
zip.file("_rels/.rels", buildRootRelationshipsXml());
|
||||||
zip.file("docProps/app.xml", buildAppPropertiesXml());
|
zip.file("docProps/app.xml", buildAppPropertiesXml());
|
||||||
zip.file("docProps/core.xml", buildCorePropertiesXml());
|
zip.file("docProps/core.xml", buildCorePropertiesXml());
|
||||||
@@ -280,7 +293,12 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
|
|
||||||
sheets.forEach((sheet, index) => {
|
sheets.forEach((sheet, index) => {
|
||||||
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
|
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
|
||||||
const drawingRelationshipId = sheetChartParts.length > 0 ? `rId${sortedExternalHyperlinkEntries(sheet.hyperlinks).length + 1}` : "";
|
const sheetCommentPart = commentParts.find((part) => part.sheetIndex === index);
|
||||||
|
const externalHyperlinkCount = sortedExternalHyperlinkEntries(sheet.hyperlinks).length;
|
||||||
|
const drawingRelationshipId = sheetChartParts.length > 0 ? `rId${externalHyperlinkCount + 1}` : "";
|
||||||
|
const legacyDrawingRelationshipId = sheetCommentPart
|
||||||
|
? `rId${externalHyperlinkCount + (sheetChartParts.length > 0 ? 1 : 0) + 2}`
|
||||||
|
: "";
|
||||||
zip.file(
|
zip.file(
|
||||||
`xl/worksheets/sheet${index + 1}.xml`,
|
`xl/worksheets/sheet${index + 1}.xml`,
|
||||||
buildWorksheetXml(
|
buildWorksheetXml(
|
||||||
@@ -298,12 +316,15 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
sheet.hiddenColumns,
|
sheet.hiddenColumns,
|
||||||
sheet.hiddenRows,
|
sheet.hiddenRows,
|
||||||
sheet.showGridLines,
|
sheet.showGridLines,
|
||||||
sheet.zoomScale
|
sheet.zoomScale,
|
||||||
|
legacyDrawingRelationshipId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const relationshipsXml = buildWorksheetRelationshipsXml(
|
const relationshipsXml = buildWorksheetRelationshipsXml(
|
||||||
sheet.hyperlinks,
|
sheet.hyperlinks,
|
||||||
sheetChartParts.length > 0 ? `../drawings/drawing${sheetChartParts[0].drawingIndex}.xml` : ""
|
sheetChartParts.length > 0 ? `../drawings/drawing${sheetChartParts[0].drawingIndex}.xml` : "",
|
||||||
|
sheetCommentPart ? `../comments${sheetCommentPart.commentIndex}.xml` : "",
|
||||||
|
sheetCommentPart ? `../drawings/vmlDrawing${sheetCommentPart.commentIndex}.vml` : ""
|
||||||
);
|
);
|
||||||
if (relationshipsXml) {
|
if (relationshipsXml) {
|
||||||
zip.file(`xl/worksheets/_rels/sheet${index + 1}.xml.rels`, relationshipsXml);
|
zip.file(`xl/worksheets/_rels/sheet${index + 1}.xml.rels`, relationshipsXml);
|
||||||
@@ -317,6 +338,11 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
|
|||||||
zip.file(`xl/charts/chart${part.chartIndex}.xml`, buildChartXml(part.chart, part.sheetName, part.cells));
|
zip.file(`xl/charts/chart${part.chartIndex}.xml`, buildChartXml(part.chart, part.sheetName, part.cells));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sheetCommentPart) {
|
||||||
|
zip.file(`xl/comments${sheetCommentPart.commentIndex}.xml`, buildCommentsXml(sheetCommentPart.comments));
|
||||||
|
zip.file(`xl/drawings/vmlDrawing${sheetCommentPart.commentIndex}.vml`, buildCommentsVmlDrawingXml(sheetCommentPart.comments));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return zip.generateAsync({ type: "blob", mimeType: XLSX_MIME_TYPE });
|
return zip.generateAsync({ type: "blob", mimeType: XLSX_MIME_TYPE });
|
||||||
@@ -329,7 +355,8 @@ export function parsedWorksheetToSheet(
|
|||||||
sharedStrings: string[],
|
sharedStrings: string[],
|
||||||
relationshipsXml?: unknown,
|
relationshipsXml?: unknown,
|
||||||
styleTable: Array<CellFormat | undefined> = [],
|
styleTable: Array<CellFormat | undefined> = [],
|
||||||
themeColors: string[] = defaultXlsxThemeColors
|
themeColors: string[] = defaultXlsxThemeColors,
|
||||||
|
commentsXml?: unknown
|
||||||
): ParsedSheet {
|
): ParsedSheet {
|
||||||
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
|
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
|
||||||
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
|
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
|
||||||
@@ -357,6 +384,7 @@ export function parsedWorksheetToSheet(
|
|||||||
cells: worksheetXmlToCells(worksheetXml, sharedStrings),
|
cells: worksheetXmlToCells(worksheetXml, sharedStrings),
|
||||||
mergedCells: worksheetXmlToMergedCells(worksheetXml),
|
mergedCells: worksheetXmlToMergedCells(worksheetXml),
|
||||||
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
|
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
|
||||||
|
comments: worksheetCommentsXmlToComments(commentsXml),
|
||||||
cellFormats: worksheetXmlToCellFormats(worksheetXml, styleTable),
|
cellFormats: worksheetXmlToCellFormats(worksheetXml, styleTable),
|
||||||
charts: []
|
charts: []
|
||||||
};
|
};
|
||||||
@@ -445,6 +473,22 @@ export function worksheetXmlToHyperlinks(worksheetXml: unknown, relationshipsXml
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function worksheetCommentsXmlToComments(commentsXml?: unknown) {
|
||||||
|
const comments = childRecord(commentsXml, "comments");
|
||||||
|
const commentList = childRecord(comments, "commentList");
|
||||||
|
|
||||||
|
return toArray(commentList.comment).reduce<Record<string, string>>((result, comment) => {
|
||||||
|
const record = asRecord(comment);
|
||||||
|
const address = normalizeCellAddress(String(record.ref || ""));
|
||||||
|
const text = normalizeCommentText(xmlNodeText(childRecord(record, "text")));
|
||||||
|
if (address && text) {
|
||||||
|
result[address] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
export function worksheetXmlToFrozenPane(worksheetXml: unknown): SheetFrozenPane | undefined {
|
export function worksheetXmlToFrozenPane(worksheetXml: unknown): SheetFrozenPane | undefined {
|
||||||
const worksheet = childRecord(worksheetXml, "worksheet");
|
const worksheet = childRecord(worksheetXml, "worksheet");
|
||||||
const sheetViews = childRecord(worksheet, "sheetViews");
|
const sheetViews = childRecord(worksheet, "sheetViews");
|
||||||
@@ -754,7 +798,8 @@ export function buildWorksheetXml(
|
|||||||
hiddenColumns: Record<string, boolean> = {},
|
hiddenColumns: Record<string, boolean> = {},
|
||||||
hiddenRows: Record<string, boolean> = {},
|
hiddenRows: Record<string, boolean> = {},
|
||||||
showGridLines?: boolean,
|
showGridLines?: boolean,
|
||||||
zoomScale?: number
|
zoomScale?: number,
|
||||||
|
legacyDrawingRelationshipId = ""
|
||||||
) {
|
) {
|
||||||
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
|
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
|
||||||
const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows);
|
const normalizedHiddenRows = normalizedSheetHiddenRows(hiddenRows);
|
||||||
@@ -791,12 +836,13 @@ export function buildWorksheetXml(
|
|||||||
const autoFilterXml = buildAutoFilterXml(autoFilter);
|
const autoFilterXml = buildAutoFilterXml(autoFilter);
|
||||||
const hyperlinksXml = buildHyperlinksXml(hyperlinks);
|
const hyperlinksXml = buildHyperlinksXml(hyperlinks);
|
||||||
const drawingXml = drawingRelationshipId ? `<drawing r:id="${escapeXmlAttribute(drawingRelationshipId)}"/>` : "";
|
const drawingXml = drawingRelationshipId ? `<drawing r:id="${escapeXmlAttribute(drawingRelationshipId)}"/>` : "";
|
||||||
|
const legacyDrawingXml = legacyDrawingRelationshipId ? `<legacyDrawing r:id="${escapeXmlAttribute(legacyDrawingRelationshipId)}"/>` : "";
|
||||||
const sheetPrXml = buildSheetPrXml(tabColor);
|
const sheetPrXml = buildSheetPrXml(tabColor);
|
||||||
const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale);
|
const sheetViewsXml = buildSheetViewsXml(frozenPane, showGridLines, zoomScale);
|
||||||
const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns);
|
const colsXml = buildColumnWidthsXml(columnWidths, hiddenColumns);
|
||||||
|
|
||||||
return xmlDeclaration(
|
return xmlDeclaration(
|
||||||
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}</worksheet>`
|
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}${legacyDrawingXml}</worksheet>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,22 +963,31 @@ export function buildAutoFilterXml(autoFilter?: string) {
|
|||||||
return ref ? `<autoFilter ref="${escapeXmlAttribute(ref)}"/>` : "";
|
return ref ? `<autoFilter ref="${escapeXmlAttribute(ref)}"/>` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorksheetRelationshipsXml(hyperlinks: Record<string, string> = {}, drawingTarget = "") {
|
export function buildWorksheetRelationshipsXml(hyperlinks: Record<string, string> = {}, drawingTarget = "", commentsTarget = "", vmlDrawingTarget = "") {
|
||||||
const refs = sortedExternalHyperlinkEntries(hyperlinks);
|
const refs = sortedExternalHyperlinkEntries(hyperlinks);
|
||||||
if (refs.length === 0 && !drawingTarget) {
|
if (refs.length === 0 && !drawingTarget && !commentsTarget && !vmlDrawingTarget) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const relationships = refs
|
const hyperlinkRelationships = refs
|
||||||
.map(
|
.map(
|
||||||
(entry, index) =>
|
(entry, index) =>
|
||||||
`<Relationship Id="rId${index + 1}" Type="${hyperlinkRelationshipType}" Target="${escapeXmlAttribute(entry.target)}" TargetMode="External"/>`
|
`<Relationship Id="rId${index + 1}" Type="${hyperlinkRelationshipType}" Target="${escapeXmlAttribute(entry.target)}" TargetMode="External"/>`
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
let nextRelationshipId = refs.length + 1;
|
||||||
const drawingRelationship = drawingTarget
|
const drawingRelationship = drawingTarget
|
||||||
? `<Relationship Id="rId${refs.length + 1}" Type="${drawingRelationshipType}" Target="${escapeXmlAttribute(drawingTarget)}"/>`
|
? `<Relationship Id="rId${nextRelationshipId++}" Type="${drawingRelationshipType}" Target="${escapeXmlAttribute(drawingTarget)}"/>`
|
||||||
: "";
|
: "";
|
||||||
return xmlDeclaration(`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${relationships}${drawingRelationship}</Relationships>`);
|
const commentsRelationship = commentsTarget
|
||||||
|
? `<Relationship Id="rId${nextRelationshipId++}" Type="${commentsRelationshipType}" Target="${escapeXmlAttribute(commentsTarget)}"/>`
|
||||||
|
: "";
|
||||||
|
const vmlDrawingRelationship = vmlDrawingTarget
|
||||||
|
? `<Relationship Id="rId${nextRelationshipId}" Type="${vmlDrawingRelationshipType}" Target="${escapeXmlAttribute(vmlDrawingTarget)}"/>`
|
||||||
|
: "";
|
||||||
|
return xmlDeclaration(
|
||||||
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${hyperlinkRelationships}${drawingRelationship}${commentsRelationship}${vmlDrawingRelationship}</Relationships>`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workbookSheetRefs(workbookXml: unknown): WorkbookSheetRef[] {
|
export function workbookSheetRefs(workbookXml: unknown): WorkbookSheetRef[] {
|
||||||
@@ -1028,7 +1083,7 @@ export function worksheetRelationshipTargets(relationshipsXml: unknown) {
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = []) {
|
function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = [], commentParts: CommentPartRef[] = []) {
|
||||||
const sheetOverrides = Array.from({ length: sheetCount }, (_, index) => {
|
const sheetOverrides = Array.from({ length: sheetCount }, (_, index) => {
|
||||||
return `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
|
return `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
@@ -1038,9 +1093,13 @@ function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = [
|
|||||||
const chartOverrides = chartParts
|
const chartOverrides = chartParts
|
||||||
.map((part) => `<Override PartName="/xl/charts/chart${part.chartIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>`)
|
.map((part) => `<Override PartName="/xl/charts/chart${part.chartIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
const commentsOverrides = commentParts
|
||||||
|
.map((part) => `<Override PartName="/xl/comments${part.commentIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/>`)
|
||||||
|
.join("");
|
||||||
|
const vmlDefault = commentParts.length > 0 ? '<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>' : "";
|
||||||
|
|
||||||
return xmlDeclaration(
|
return xmlDeclaration(
|
||||||
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheetOverrides}${drawingOverrides}${chartOverrides}</Types>`
|
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${vmlDefault}<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheetOverrides}${drawingOverrides}${chartOverrides}${commentsOverrides}</Types>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1122,6 +1181,63 @@ function chartPartRefsForSheets(sheets: Workbook["sheets"]): ChartPartRef[] {
|
|||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commentPartRefsForSheets(sheets: Workbook["sheets"]): CommentPartRef[] {
|
||||||
|
const refs: CommentPartRef[] = [];
|
||||||
|
let commentIndex = 1;
|
||||||
|
|
||||||
|
sheets.forEach((sheet, sheetIndex) => {
|
||||||
|
const comments = normalizedSheetComments(sheet.comments);
|
||||||
|
if (Object.keys(comments).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.push({ sheetIndex, commentIndex, comments });
|
||||||
|
commentIndex += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSheetComments(comments: Record<string, string> = {}) {
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
sortedCellTextEntries(comments).forEach(([address, comment]) => {
|
||||||
|
const text = normalizeCommentText(comment);
|
||||||
|
if (text) {
|
||||||
|
normalized[address] = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCommentsXml(comments: Record<string, string> = {}) {
|
||||||
|
const refs = sortedCellTextEntries(normalizedSheetComments(comments));
|
||||||
|
if (refs.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentXml = refs
|
||||||
|
.map(([address, text]) => `<comment ref="${escapeXmlAttribute(address)}" authorId="0"><text><t>${escapeXmlText(text)}</t></text></comment>`)
|
||||||
|
.join("");
|
||||||
|
return xmlDeclaration(
|
||||||
|
`<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>QOffice</author></authors><commentList>${commentXml}</commentList></comments>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommentsVmlDrawingXml(comments: Record<string, string> = {}) {
|
||||||
|
const refs = sortedCellTextEntries(normalizedSheetComments(comments));
|
||||||
|
const shapes = refs
|
||||||
|
.map(([address], index) => {
|
||||||
|
const column = Math.max(0, cellColumnNumber(address) - 1);
|
||||||
|
const row = Math.max(0, cellRowNumber(address) - 1);
|
||||||
|
return `<v:shape id="_x0000_s${1025 + index}" type="#_x0000_t202" style="position:absolute;margin-left:80pt;margin-top:5pt;width:144pt;height:79pt;z-index:${index + 1};visibility:hidden" fillcolor="#ffffe1" o:insetmode="auto"><v:fill color2="#ffffe1"/><v:shadow on="t" color="black" obscured="t"/><v:path o:connecttype="none"/><v:textbox style="mso-direction-alt:auto"><div style="text-align:left"/></v:textbox><x:ClientData ObjectType="Note"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>1, 15, 0, 2, 3, 15, 4, 16</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>${row}</x:Row><x:Column>${column}</x:Column></x:ClientData></v:shape>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return xmlDeclaration(
|
||||||
|
`<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shapetype id="_x0000_t202" coordsize="21600,21600" o:spt="202" path="m,l,21600r21600,l21600,xe"><v:stroke joinstyle="miter"/><v:path gradientshapeok="t" o:connecttype="rect"/></v:shapetype>${shapes}</xml>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizedSheetCharts(charts: SheetChart[] = []) {
|
function normalizedSheetCharts(charts: SheetChart[] = []) {
|
||||||
return charts
|
return charts
|
||||||
.map(normalizedChart)
|
.map(normalizedChart)
|
||||||
@@ -1820,6 +1936,11 @@ async function readOptionalRelationships(zip: ZipInstance, parser: InstanceType<
|
|||||||
return file ? parser.parse(await file.async("string")) : undefined;
|
return file ? parser.parse(await file.async("string")) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readOptionalParsedXml(zip: ZipInstance, parser: InstanceType<XmlParserConstructor>, path: string) {
|
||||||
|
const file = zip.file(path);
|
||||||
|
return file ? parser.parse(await file.async("string")) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function readZipText(zip: ZipInstance, path: string) {
|
async function readZipText(zip: ZipInstance, path: string) {
|
||||||
const file = zip.file(path);
|
const file = zip.file(path);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -1867,6 +1988,11 @@ function relationshipTargetsByType(relationshipsXml: unknown, type: string, sour
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function worksheetCommentsPath(relationshipsXml: unknown, sheetPath: string) {
|
||||||
|
const commentTargets = relationshipTargetsByType(relationshipsXml, commentsRelationshipType, sheetPath);
|
||||||
|
return Object.values(commentTargets)[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePartTarget(baseDirectory: string, target: string) {
|
function resolvePartTarget(baseDirectory: string, target: string) {
|
||||||
const normalizedTarget = target.replace(/\\/g, "/");
|
const normalizedTarget = target.replace(/\\/g, "/");
|
||||||
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${baseDirectory}/${normalizedTarget}`;
|
const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${baseDirectory}/${normalizedTarget}`;
|
||||||
@@ -2046,6 +2172,17 @@ function normalizeWhitespace(value: string) {
|
|||||||
return value.replace(/\s+/g, " ").trim();
|
return value.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCommentText(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/\r\n?/g, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.join("\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 32767);
|
||||||
|
}
|
||||||
|
|
||||||
function sortedCellEntries(cells: Record<string, string>) {
|
function sortedCellEntries(cells: Record<string, string>) {
|
||||||
return Object.entries(cells)
|
return Object.entries(cells)
|
||||||
.filter(([address, value]) => isCellAddress(address) && value !== "")
|
.filter(([address, value]) => isCellAddress(address) && value !== "")
|
||||||
@@ -2060,6 +2197,22 @@ function sortedCellEntries(cells: Record<string, string>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortedCellTextEntries(values: Record<string, string>) {
|
||||||
|
return Object.entries(values)
|
||||||
|
.map(([address, value]) => [normalizeCellAddress(address), normalizeCommentText(value)] as const)
|
||||||
|
.filter(([address, value]) => Boolean(address && value))
|
||||||
|
.filter(([address], index, entries) => entries.findIndex(([candidate]) => candidate === address) === index)
|
||||||
|
.sort(([firstAddress], [secondAddress]) => {
|
||||||
|
const firstRow = cellRowNumber(firstAddress);
|
||||||
|
const secondRow = cellRowNumber(secondAddress);
|
||||||
|
if (firstRow !== secondRow) {
|
||||||
|
return firstRow - secondRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cellColumnNumber(firstAddress) - cellColumnNumber(secondAddress);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function sortedWorksheetEntries(cells: Record<string, string>, cellFormats: Record<string, CellFormat> = {}) {
|
function sortedWorksheetEntries(cells: Record<string, string>, cellFormats: Record<string, CellFormat> = {}) {
|
||||||
const entries = new Map<string, string>();
|
const entries = new Map<string, string>();
|
||||||
|
|
||||||
|
|||||||
@@ -668,6 +668,12 @@ button {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.office-field > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.office-field input,
|
.office-field input,
|
||||||
.office-field select {
|
.office-field select {
|
||||||
min-width: 110px;
|
min-width: 110px;
|
||||||
@@ -677,6 +683,13 @@ button {
|
|||||||
min-width: 190px;
|
min-width: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-comment-field {
|
||||||
|
min-width: 220px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.office-check {
|
.office-check {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1080,6 +1093,22 @@ button {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sheet-grid td.is-commented {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-grid td.is-commented::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 1px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 9px solid #f59e0b;
|
||||||
|
border-left: 9px solid transparent;
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sheet-grid td.is-wrapped {
|
.sheet-grid td.is-wrapped {
|
||||||
height: 58px;
|
height: 58px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface Sheet {
|
|||||||
cells: Record<string, string>;
|
cells: Record<string, string>;
|
||||||
mergedCells?: MergedCellRange[];
|
mergedCells?: MergedCellRange[];
|
||||||
hyperlinks?: Record<string, string>;
|
hyperlinks?: Record<string, string>;
|
||||||
|
comments?: Record<string, string>;
|
||||||
cellFormats?: Record<string, CellFormat>;
|
cellFormats?: Record<string, CellFormat>;
|
||||||
charts?: SheetChart[];
|
charts?: SheetChart[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user