From 66406c2c0cccfb51cbfc772fa87a6c22a00d3331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Mon, 1 Jun 2026 08:09:23 +0300 Subject: [PATCH] Translate QExcell shared formulas on import --- README.md | 4 +-- src/io/excellOffice.test.ts | 31 ++++++++++++++++++ src/io/excellOffice.ts | 65 +++++++++++++++++++++++++++++++++---- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2207ef2..fac3864 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ npm run build ## Поддержка форматов - QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, внешние гиперссылки, простые таблицы, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, выравнивание абзацев, списки, простые таблицы, внешние гиперссылки, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта. -- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, формулы за пределами стартового диапазона A-H, базовые merged cells, внешние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, цвет текста, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений. +- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, цвет текста, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений. - QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, читает заметки докладчика через relationships слайда, сохраняет заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка. - QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable, MIME-вложения, `To`, `Cc` и `Bcc`. Импорт MSG читает тему, отправителя, получателей `To/Cc/Bcc`, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно адресовать через `To/Cc/Bcc` и сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы. @@ -46,7 +46,7 @@ Desktop-меню поддерживает системные команды дл - `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF. - `Ctrl/Cmd+P`: печать текущей рабочей области. -Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:color`, `w:shd`, `w:highlight`, `w:sz`, `w:rFonts` и `w:strike` для цвета/подсветки/размера/семейства/зачеркивания текста DOCX, выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, цвета текста, размеры и семейства шрифта, зачеркивание ячеек, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, пользовательский фон слайдов PPTX, цвет, размер и семейство шрифта текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc`, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены. +Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:color`, `w:shd`, `w:highlight`, `w:sz`, `w:rFonts` и `w:strike` для цвета/подсветки/размера/семейства/зачеркивания текста DOCX, выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, shared formulas XLSX с относительными ссылками, базовые merged cells, базовые стили, цвета текста, размеры и семейства шрифта, зачеркивание ячеек, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, пользовательский фон слайдов PPTX, цвет, размер и семейство шрифта текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc`, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены. ## Сборка пакета diff --git a/src/io/excellOffice.test.ts b/src/io/excellOffice.test.ts index 98bf724..b5aee66 100644 --- a/src/io/excellOffice.test.ts +++ b/src/io/excellOffice.test.ts @@ -28,6 +28,37 @@ describe("excellOffice OOXML helpers", () => { expect(xlsxCellToText({ r: "C2", f: { "#text": "A2*2" }, v: "8" }, [])).toBe("=A2*2"); }); + it("сдвигает относительные ссылки XLSX shared formulas при импорте", () => { + const cells = worksheetXmlToCells( + { + worksheet: { + sheetData: { + row: [ + { + r: "1", + c: [{ r: "C1", f: { t: "shared", si: "0", ref: "C1:D2", "#text": "A1+B$1+$A1+$B$2+'A1 Sheet'!A1&\"A1\"" }, v: "0" }] + }, + { + r: "2", + c: [ + { r: "C2", f: { t: "shared", si: "0" }, v: "0" }, + { r: "D2", f: { t: "shared", si: "0" }, v: "0" } + ] + } + ] + } + } + }, + [] + ); + + expect(cells).toEqual({ + C1: "=A1+B$1+$A1+$B$2+'A1 Sheet'!A1&\"A1\"", + C2: "=A2+B$1+$A2+$B$2+'A1 Sheet'!A2&\"A1\"", + D2: "=B2+C$1+$A2+$B$2+'A1 Sheet'!B2&\"A1\"" + }); + }); + it("читает shared string, inline string, число и boolean", () => { expect(xlsxCellToText({ r: "A1", t: "s", v: "1" }, ["Первый", "Второй"])).toBe("Второй"); expect(xlsxCellToText({ r: "A2", t: "inlineStr", is: { t: "Текст" } }, [])).toBe("Текст"); diff --git a/src/io/excellOffice.ts b/src/io/excellOffice.ts index 04d2ba5..38af7c0 100644 --- a/src/io/excellOffice.ts +++ b/src/io/excellOffice.ts @@ -64,6 +64,13 @@ interface WorksheetCellRecord { cell: XmlRecord; } +interface SharedFormulaDefinition { + formula: string; + baseAddress: string; +} + +type SharedFormulaMap = Record; + export interface CellStyleRegistry { formats: CellFormat[]; idsByKey: Record; @@ -223,20 +230,20 @@ export function parsedWorksheetToSheet( export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[]) { const cells: Record = {}; - const sharedFormulas: Record = {}; + const sharedFormulas: SharedFormulaMap = {}; const parsedCells = worksheetCellRecords(worksheetXml); - parsedCells.forEach(({ cell }) => { + parsedCells.forEach(({ address, cell }) => { const formulaRecord = asRecord(cell.f); const sharedFormulaId = sharedFormulaKey(formulaRecord); const formulaText = formulaNodeText(cell.f); if (sharedFormulaId && formulaText) { - sharedFormulas[sharedFormulaId] = formulaText; + sharedFormulas[sharedFormulaId] = { formula: formulaText, baseAddress: address }; } }); parsedCells.forEach(({ address, cell }) => { - const text = xlsxCellToText(cell, sharedStrings, sharedFormulas); + const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address); if (text !== "") { cells[address] = text; @@ -435,7 +442,7 @@ function chartRangeFromFormula(formula: string, sheetName: string) { return normalizeChartRange(withoutSheet.replace(/\$/g, "")); } -export function xlsxCellToText(cell: XmlRecord, sharedStrings: string[], sharedFormulas: Record = {}) { +export function xlsxCellToText(cell: XmlRecord, sharedStrings: string[], sharedFormulas: SharedFormulaMap = {}, address = "") { const formula = formulaNodeText(cell.f); if (formula) { return formula.startsWith("=") ? formula : `=${formula}`; @@ -443,7 +450,12 @@ export function xlsxCellToText(cell: XmlRecord, sharedStrings: string[], sharedF const sharedFormulaId = sharedFormulaKey(asRecord(cell.f)); if (sharedFormulaId && sharedFormulas[sharedFormulaId]) { - return `=${sharedFormulas[sharedFormulaId]}`; + const sharedFormula = sharedFormulas[sharedFormulaId]; + const formulaText = + typeof sharedFormula === "string" + ? sharedFormula + : translateSharedFormula(sharedFormula.formula, sharedFormula.baseAddress, normalizeCellAddress(address || String(cell.r || ""))); + return formulaText.startsWith("=") ? formulaText : `=${formulaText}`; } const type = typeof cell.t === "string" ? cell.t : ""; @@ -1271,6 +1283,47 @@ function normalizeCellAddress(address: string) { return isCellAddress(trimmed) ? trimmed : ""; } +function translateSharedFormula(formula: string, baseAddress: string, targetAddress: string) { + const normalizedBase = normalizeCellAddress(baseAddress); + const normalizedTarget = normalizeCellAddress(targetAddress); + if (!normalizedBase || !normalizedTarget || normalizedBase === normalizedTarget) { + return formula; + } + + const columnOffset = cellColumnNumber(normalizedTarget) - cellColumnNumber(normalizedBase); + const rowOffset = cellRowNumber(normalizedTarget) - cellRowNumber(normalizedBase); + + return formula + .split(/("[^"]*(?:""[^"]*)*"|'[^']*(?:''[^']*)*')/g) + .map((segment, index) => (index % 2 === 1 ? segment : translateFormulaReferences(segment, columnOffset, rowOffset))) + .join(""); +} + +function translateFormulaReferences(formula: string, columnOffset: number, rowOffset: number) { + return formula.replace(/(^|[^A-Z0-9_])(\$?[A-Z]{1,3}\$?[1-9][0-9]*)(?![A-Z0-9_])/gi, (match, prefix: string, reference: string) => { + const translated = translateFormulaReference(reference, columnOffset, rowOffset); + return translated ? `${prefix}${translated}` : match; + }); +} + +function translateFormulaReference(reference: string, columnOffset: number, rowOffset: number) { + const match = /^(\$?)([A-Z]{1,3})(\$?)([1-9][0-9]*)$/i.exec(reference); + if (!match) { + return ""; + } + + const [, columnLock, columnNameValue, rowLock, rowValue] = match; + const columnNumber = cellColumnNumber(`${columnNameValue}1`); + const rowNumber = Number(rowValue); + const translatedColumn = columnLock ? columnNumber : columnNumber + columnOffset; + const translatedRow = rowLock ? rowNumber : rowNumber + rowOffset; + if (translatedColumn < 1 || translatedRow < 1) { + return reference; + } + + return `${columnLock}${columnName(translatedColumn)}${rowLock}${translatedRow}`; +} + function normalizeChartRange(range: string) { const raw = range.trim().replace(/\$/g, "").toUpperCase(); const [start, end = start] = raw.split(":");