Preserve QExcell cell notes in XLSX

This commit is contained in:
Курнат Андрей
2026-06-02 05:50:30 +03:00
parent 57cce9ea96
commit 22d2903f10
7 changed files with 300 additions and 24 deletions
+54
View File
@@ -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
View File
@@ -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>();