Preserve QWord DOCX tables on import
This commit is contained in:
@@ -31,7 +31,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, базовые 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` пока не реализованы.
|
||||||
|
|||||||
+51
-12
@@ -601,14 +601,14 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) {
|
|||||||
const relationshipsXml = await zip.file("word/_rels/document.xml.rels")?.async("string");
|
const relationshipsXml = await zip.file("word/_rels/document.xml.rels")?.async("string");
|
||||||
const hyperlinkTargets = relationshipsXml ? docxHyperlinkTargetsById(parser.parse(relationshipsXml)) : {};
|
const hyperlinkTargets = relationshipsXml ? docxHyperlinkTargetsById(parser.parse(relationshipsXml)) : {};
|
||||||
const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), hyperlinkTargets);
|
const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), hyperlinkTargets);
|
||||||
return result.hasInlineColorOrHighlight || result.hasParagraphAlignment ? result.html : "";
|
return result.hasInlineColorOrHighlight || result.hasParagraphAlignment || result.hasTable ? result.html : "";
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSimpleStyledDocxDocument(documentXml: string) {
|
function isSimpleStyledDocxDocument(documentXml: string) {
|
||||||
return !/<w:(tbl|drawing|pict|numPr|sdt)\b/i.test(documentXml);
|
return !/<w:(drawing|pict|numPr|sdt)\b/i.test(documentXml);
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderedDocxDocumentToHtml(documentXml: unknown, hyperlinkTargets: Record<string, string> = {}) {
|
function orderedDocxDocumentToHtml(documentXml: unknown, hyperlinkTargets: Record<string, string> = {}) {
|
||||||
@@ -616,19 +616,30 @@ function orderedDocxDocumentToHtml(documentXml: unknown, hyperlinkTargets: Recor
|
|||||||
const body = firstOrderedElement(orderedElementChildren(document), "w:body");
|
const body = firstOrderedElement(orderedElementChildren(document), "w:body");
|
||||||
let hasInlineColorOrHighlight = false;
|
let hasInlineColorOrHighlight = false;
|
||||||
let hasParagraphAlignment = false;
|
let hasParagraphAlignment = false;
|
||||||
const paragraphs = orderedDirectElements(body, "w:p")
|
let hasTable = false;
|
||||||
.map((paragraph) => {
|
const blocks = orderedElementChildren(body)
|
||||||
const result = orderedDocxParagraphToHtml(paragraph, hyperlinkTargets);
|
.flatMap((block) => {
|
||||||
hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight;
|
const name = orderedElementName(block);
|
||||||
hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment;
|
if (name === "w:p") {
|
||||||
return result.html;
|
const result = orderedDocxParagraphToHtml(block, hyperlinkTargets);
|
||||||
})
|
hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight;
|
||||||
.filter(Boolean);
|
hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment;
|
||||||
|
return result.html ? [result.html] : [];
|
||||||
|
}
|
||||||
|
if (name === "w:tbl") {
|
||||||
|
const html = orderedDocxTableToHtml(block, hyperlinkTargets);
|
||||||
|
hasTable = hasTable || Boolean(html);
|
||||||
|
return html ? [html] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: paragraphs.join(""),
|
html: blocks.join(""),
|
||||||
hasInlineColorOrHighlight,
|
hasInlineColorOrHighlight,
|
||||||
hasParagraphAlignment
|
hasParagraphAlignment,
|
||||||
|
hasTable
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +667,34 @@ function orderedDocxParagraphToHtml(paragraph: unknown, hyperlinkTargets: Record
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderedDocxTableToHtml(table: unknown, hyperlinkTargets: Record<string, string>) {
|
||||||
|
const rows = orderedDirectElements(table, "w:tr")
|
||||||
|
.map((row) => {
|
||||||
|
const cells = orderedDirectElements(row, "w:tc")
|
||||||
|
.map((cell) => {
|
||||||
|
const paragraphs = orderedDirectElements(cell, "w:p")
|
||||||
|
.map((paragraph) => orderedDocxParagraphToHtml(paragraph, hyperlinkTargets).html)
|
||||||
|
.filter(Boolean);
|
||||||
|
const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell));
|
||||||
|
return content ? `<td>${content}</td>` : "<td></td>";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return cells ? `<tr>${cells}</tr>` : "";
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedDocxCellText(cell: unknown) {
|
||||||
|
return orderedDirectElements(cell, "w:p")
|
||||||
|
.flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {}))
|
||||||
|
.map((inline) => docxRunText(asRecord(inline.run)))
|
||||||
|
.join(" ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function docxParagraphTagName(properties: XmlRecord) {
|
function docxParagraphTagName(properties: XmlRecord) {
|
||||||
const styleValue = docxAttribute(childRecord(properties, "w:pStyle"), "w:val").toLowerCase();
|
const styleValue = docxAttribute(childRecord(properties, "w:pStyle"), "w:val").toLowerCase();
|
||||||
if (["title", "heading1", "heading 1"].includes(styleValue)) {
|
if (["title", "heading1", "heading 1"].includes(styleValue)) {
|
||||||
|
|||||||
@@ -112,4 +112,30 @@ describe("wordOffice DOCX import", () => {
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports simple DOCX tables from OOXML", async () => {
|
||||||
|
const blob = await exportDocxBlob(
|
||||||
|
"Table.docx",
|
||||||
|
"<p>Before table</p><table><tr><th>Stage</th><th>Owner</th></tr><tr><td>Plan</td><td>Anna</td></tr></table><p>After table</p>"
|
||||||
|
);
|
||||||
|
const file = new File([blob], "Table.docx", {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
});
|
||||||
|
const imported = await importDocxFile(file);
|
||||||
|
|
||||||
|
expect(imported.html).toContain("<table>");
|
||||||
|
expect(imported.html).toContain("<td><p>Stage</p></td>");
|
||||||
|
expect(imported.html).toContain("<td><p>Anna</p></td>");
|
||||||
|
expect(htmlToWordBlocks(imported.html)).toEqual([
|
||||||
|
{ type: "paragraph", text: "Before table", runs: [{ text: "Before table" }] },
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
rows: [
|
||||||
|
["Stage", "Owner"],
|
||||||
|
["Plan", "Anna"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: "paragraph", text: "After table", runs: [{ text: "After table" }] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user