diff --git a/README.md b/README.md index 09b3084..2207ef2 100644 --- a/README.md +++ b/README.md @@ -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 для дат, процентов, денежных и дробных значений. - 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` пока не реализованы. diff --git a/src/io/wordOffice.ts b/src/io/wordOffice.ts index 968cfe7..bfd2cb2 100644 --- a/src/io/wordOffice.ts +++ b/src/io/wordOffice.ts @@ -601,14 +601,14 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { const relationshipsXml = await zip.file("word/_rels/document.xml.rels")?.async("string"); const hyperlinkTargets = relationshipsXml ? docxHyperlinkTargetsById(parser.parse(relationshipsXml)) : {}; const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), hyperlinkTargets); - return result.hasInlineColorOrHighlight || result.hasParagraphAlignment ? result.html : ""; + return result.hasInlineColorOrHighlight || result.hasParagraphAlignment || result.hasTable ? result.html : ""; } catch { return ""; } } function isSimpleStyledDocxDocument(documentXml: string) { - return !/ = {}) { @@ -616,19 +616,30 @@ function orderedDocxDocumentToHtml(documentXml: unknown, hyperlinkTargets: Recor const body = firstOrderedElement(orderedElementChildren(document), "w:body"); let hasInlineColorOrHighlight = false; let hasParagraphAlignment = false; - const paragraphs = orderedDirectElements(body, "w:p") - .map((paragraph) => { - const result = orderedDocxParagraphToHtml(paragraph, hyperlinkTargets); - hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight; - hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment; - return result.html; - }) - .filter(Boolean); + let hasTable = false; + const blocks = orderedElementChildren(body) + .flatMap((block) => { + const name = orderedElementName(block); + if (name === "w:p") { + const result = orderedDocxParagraphToHtml(block, hyperlinkTargets); + hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight; + 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 { - html: paragraphs.join(""), + html: blocks.join(""), hasInlineColorOrHighlight, - hasParagraphAlignment + hasParagraphAlignment, + hasTable }; } @@ -656,6 +667,34 @@ function orderedDocxParagraphToHtml(paragraph: unknown, hyperlinkTargets: Record }; } +function orderedDocxTableToHtml(table: unknown, hyperlinkTargets: Record) { + 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 ? `${content}` : ""; + }) + .join(""); + + return cells ? `${cells}` : ""; + }) + .filter(Boolean); + + return rows.length > 0 ? `${rows.join("")}
` : ""; +} + +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) { const styleValue = docxAttribute(childRecord(properties, "w:pStyle"), "w:val").toLowerCase(); if (["title", "heading1", "heading 1"].includes(styleValue)) { diff --git a/src/io/wordOfficeImport.test.ts b/src/io/wordOfficeImport.test.ts index 866fdd7..40afcab 100644 --- a/src/io/wordOfficeImport.test.ts +++ b/src/io/wordOfficeImport.test.ts @@ -112,4 +112,30 @@ describe("wordOffice DOCX import", () => { } ]); }); + + it("imports simple DOCX tables from OOXML", async () => { + const blob = await exportDocxBlob( + "Table.docx", + "

Before table

StageOwner
PlanAnna

After table

" + ); + const file = new File([blob], "Table.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + const imported = await importDocxFile(file); + + expect(imported.html).toContain(""); + expect(imported.html).toContain(""); + expect(imported.html).toContain(""); + 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" }] } + ]); + }); });

Stage

Anna