Translate QExcell shared formulas on import
This commit is contained in:
@@ -32,7 +32,7 @@ npm run build
|
|||||||
## Поддержка форматов
|
## Поддержка форматов
|
||||||
|
|
||||||
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, внешние гиперссылки, простые таблицы, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, выравнивание абзацев, списки, простые таблицы, внешние гиперссылки, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта.
|
- 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 слайда, сохраняет заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка.
|
- 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` пока не реализованы.
|
- 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+Shift+E`: экспортировать текущую рабочую область в PDF.
|
||||||
- `Ctrl/Cmd+P`: печать текущей рабочей области.
|
- `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 в текущем инкременте не заявлены.
|
||||||
|
|
||||||
## Сборка пакета
|
## Сборка пакета
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,37 @@ describe("excellOffice OOXML helpers", () => {
|
|||||||
expect(xlsxCellToText({ r: "C2", f: { "#text": "A2*2" }, v: "8" }, [])).toBe("=A2*2");
|
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", () => {
|
it("читает shared string, inline string, число и boolean", () => {
|
||||||
expect(xlsxCellToText({ r: "A1", t: "s", v: "1" }, ["Первый", "Второй"])).toBe("Второй");
|
expect(xlsxCellToText({ r: "A1", t: "s", v: "1" }, ["Первый", "Второй"])).toBe("Второй");
|
||||||
expect(xlsxCellToText({ r: "A2", t: "inlineStr", is: { t: "Текст" } }, [])).toBe("Текст");
|
expect(xlsxCellToText({ r: "A2", t: "inlineStr", is: { t: "Текст" } }, [])).toBe("Текст");
|
||||||
|
|||||||
+59
-6
@@ -64,6 +64,13 @@ interface WorksheetCellRecord {
|
|||||||
cell: XmlRecord;
|
cell: XmlRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SharedFormulaDefinition {
|
||||||
|
formula: string;
|
||||||
|
baseAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharedFormulaMap = Record<string, string | SharedFormulaDefinition>;
|
||||||
|
|
||||||
export interface CellStyleRegistry {
|
export interface CellStyleRegistry {
|
||||||
formats: CellFormat[];
|
formats: CellFormat[];
|
||||||
idsByKey: Record<string, number>;
|
idsByKey: Record<string, number>;
|
||||||
@@ -223,20 +230,20 @@ export function parsedWorksheetToSheet(
|
|||||||
|
|
||||||
export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[]) {
|
export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[]) {
|
||||||
const cells: Record<string, string> = {};
|
const cells: Record<string, string> = {};
|
||||||
const sharedFormulas: Record<string, string> = {};
|
const sharedFormulas: SharedFormulaMap = {};
|
||||||
const parsedCells = worksheetCellRecords(worksheetXml);
|
const parsedCells = worksheetCellRecords(worksheetXml);
|
||||||
|
|
||||||
parsedCells.forEach(({ cell }) => {
|
parsedCells.forEach(({ address, cell }) => {
|
||||||
const formulaRecord = asRecord(cell.f);
|
const formulaRecord = asRecord(cell.f);
|
||||||
const sharedFormulaId = sharedFormulaKey(formulaRecord);
|
const sharedFormulaId = sharedFormulaKey(formulaRecord);
|
||||||
const formulaText = formulaNodeText(cell.f);
|
const formulaText = formulaNodeText(cell.f);
|
||||||
if (sharedFormulaId && formulaText) {
|
if (sharedFormulaId && formulaText) {
|
||||||
sharedFormulas[sharedFormulaId] = formulaText;
|
sharedFormulas[sharedFormulaId] = { formula: formulaText, baseAddress: address };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
parsedCells.forEach(({ address, cell }) => {
|
parsedCells.forEach(({ address, cell }) => {
|
||||||
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas);
|
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
|
||||||
|
|
||||||
if (text !== "") {
|
if (text !== "") {
|
||||||
cells[address] = text;
|
cells[address] = text;
|
||||||
@@ -435,7 +442,7 @@ function chartRangeFromFormula(formula: string, sheetName: string) {
|
|||||||
return normalizeChartRange(withoutSheet.replace(/\$/g, ""));
|
return normalizeChartRange(withoutSheet.replace(/\$/g, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function xlsxCellToText(cell: XmlRecord, sharedStrings: string[], sharedFormulas: Record<string, string> = {}) {
|
export function xlsxCellToText(cell: XmlRecord, sharedStrings: string[], sharedFormulas: SharedFormulaMap = {}, address = "") {
|
||||||
const formula = formulaNodeText(cell.f);
|
const formula = formulaNodeText(cell.f);
|
||||||
if (formula) {
|
if (formula) {
|
||||||
return formula.startsWith("=") ? formula : `=${formula}`;
|
return formula.startsWith("=") ? formula : `=${formula}`;
|
||||||
@@ -443,7 +450,12 @@ export function xlsxCellToText(cell: XmlRecord, sharedStrings: string[], sharedF
|
|||||||
|
|
||||||
const sharedFormulaId = sharedFormulaKey(asRecord(cell.f));
|
const sharedFormulaId = sharedFormulaKey(asRecord(cell.f));
|
||||||
if (sharedFormulaId && sharedFormulas[sharedFormulaId]) {
|
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 : "";
|
const type = typeof cell.t === "string" ? cell.t : "";
|
||||||
@@ -1271,6 +1283,47 @@ function normalizeCellAddress(address: string) {
|
|||||||
return isCellAddress(trimmed) ? trimmed : "";
|
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) {
|
function normalizeChartRange(range: string) {
|
||||||
const raw = range.trim().replace(/\$/g, "").toUpperCase();
|
const raw = range.trim().replace(/\$/g, "").toUpperCase();
|
||||||
const [start, end = start] = raw.split(":");
|
const [start, end = start] = raw.split(":");
|
||||||
|
|||||||
Reference in New Issue
Block a user