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