diff --git a/README.md b/README.md index bdacdaf..0f806de 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, 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` пока не реализованы. diff --git a/src/io/wordOffice.ts b/src/io/wordOffice.ts index bfd2cb2..a44c07b 100644 --- a/src/io/wordOffice.ts +++ b/src/io/wordOffice.ts @@ -53,7 +53,7 @@ type MammothModule = { type XmlRecord = Record; type ZipObject = { - async: (type: "string") => Promise; + async: (type: "string" | "base64") => Promise; }; type ZipInstance = { @@ -93,6 +93,7 @@ type DocxModule = { type InlineFormat = Omit; type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock }; type DocxInlineRecord = { run: unknown; href?: string }; +type DocxImageDataByRelationshipId = Record; const mammothStyleMap = [ "p[style-name='Title'] => h1:fresh", @@ -108,6 +109,8 @@ const xmlParserOptions = { trimValues: false }; const wordHyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; +const wordImageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; +const emptyDocxRelationshipTargets = { hyperlinks: {}, images: {} }; export async function importDocxFile(file: File): Promise { if (!file.name.toLowerCase().endsWith(".docx")) { @@ -599,37 +602,45 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { const parser = new XMLParser(xmlParserOptions); const orderedParser = new XMLParser({ ...xmlParserOptions, preserveOrder: true }); 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.hasTable ? result.html : ""; + const relationshipTargets = relationshipsXml ? docxRelationshipTargetsById(parser.parse(relationshipsXml)) : emptyDocxRelationshipTargets; + const imageDataByRelationshipId = await docxImageDataByRelationshipId(zip, relationshipTargets.images); + const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), relationshipTargets.hyperlinks, imageDataByRelationshipId); + return result.hasInlineColorOrHighlight || result.hasParagraphAlignment || result.hasTable || result.hasImage ? result.html : ""; } catch { return ""; } } function isSimpleStyledDocxDocument(documentXml: string) { - return !/ = {}) { +function orderedDocxDocumentToHtml( + documentXml: unknown, + hyperlinkTargets: Record = {}, + imageDataByRelationshipId: DocxImageDataByRelationshipId = {} +) { const document = firstOrderedElement(toArray(documentXml), "w:document"); const body = firstOrderedElement(orderedElementChildren(document), "w:body"); let hasInlineColorOrHighlight = false; let hasParagraphAlignment = false; let hasTable = false; + let hasImage = false; const blocks = orderedElementChildren(body) .flatMap((block) => { const name = orderedElementName(block); if (name === "w:p") { - const result = orderedDocxParagraphToHtml(block, hyperlinkTargets); + const result = orderedDocxParagraphToHtml(block, hyperlinkTargets, imageDataByRelationshipId); hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight; hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment; + hasImage = hasImage || result.hasImage; return result.html ? [result.html] : []; } if (name === "w:tbl") { - const html = orderedDocxTableToHtml(block, hyperlinkTargets); - hasTable = hasTable || Boolean(html); - return html ? [html] : []; + const result = orderedDocxTableToHtml(block, hyperlinkTargets, imageDataByRelationshipId); + hasTable = hasTable || Boolean(result.html); + hasImage = hasImage || result.hasImage; + return result.html ? [result.html] : []; } return []; @@ -639,41 +650,58 @@ function orderedDocxDocumentToHtml(documentXml: unknown, hyperlinkTargets: Recor html: blocks.join(""), hasInlineColorOrHighlight, hasParagraphAlignment, - hasTable + hasTable, + hasImage }; } -function orderedDocxParagraphToHtml(paragraph: unknown, hyperlinkTargets: Record) { +function orderedDocxParagraphToHtml( + paragraph: unknown, + hyperlinkTargets: Record, + imageDataByRelationshipId: DocxImageDataByRelationshipId = {} +) { const properties = orderedElementToRecord(firstOrderedElement(orderedElementChildren(paragraph), "w:pPr")); const tagName = docxParagraphTagName(properties); const style = docxParagraphStyleAttribute(properties); const hasParagraphAlignment = Boolean(style); let hasInlineColorOrHighlight = false; + let hasImage = false; const runs = orderedDocxParagraphInlineRecords(paragraph, hyperlinkTargets).map((inline) => { - const result = docxRunToHtml(inline.run); + const result = docxRunToHtml(inline.run, imageDataByRelationshipId); hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight || Boolean(inline.href); + hasImage = hasImage || result.hasImage; return inline.href && result.html ? `${result.html}` : result.html; }); const content = runs.join(""); if (!content.trim()) { - return { html: "", hasInlineColorOrHighlight, hasParagraphAlignment }; + return { html: "", hasInlineColorOrHighlight, hasParagraphAlignment, hasImage }; } return { html: `<${tagName}${style ? ` style="${style}"` : ""}>${content}`, hasInlineColorOrHighlight, - hasParagraphAlignment + hasParagraphAlignment, + hasImage }; } -function orderedDocxTableToHtml(table: unknown, hyperlinkTargets: Record) { +function orderedDocxTableToHtml( + table: unknown, + hyperlinkTargets: Record, + imageDataByRelationshipId: DocxImageDataByRelationshipId = {} +) { + let hasImage = false; 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) + .map((paragraph) => { + const result = orderedDocxParagraphToHtml(paragraph, hyperlinkTargets, imageDataByRelationshipId); + hasImage = hasImage || result.hasImage; + return result.html; + }) .filter(Boolean); const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell)); return content ? `${content}` : ""; @@ -684,7 +712,7 @@ function orderedDocxTableToHtml(table: unknown, hyperlinkTargets: Record 0 ? `${rows.join("")}
` : ""; + return { html: rows.length > 0 ? `${rows.join("")}
` : "", hasImage }; } function orderedDocxCellText(cell: unknown) { @@ -729,22 +757,86 @@ function orderedDocxParagraphInlineRecords(paragraph: unknown, hyperlinkTargets: }); } -function docxHyperlinkTargetsById(relationshipsXml: unknown) { +function docxRelationshipTargetsById(relationshipsXml: unknown) { const relationships = childRecord(relationshipsXml, "Relationships"); - const targets: Record = {}; + const targets: { hyperlinks: Record; images: Record } = { hyperlinks: {}, images: {} }; toArray(relationships.Relationship).forEach((relationship) => { const record = asRecord(relationship); const id = relationshipAttribute(record, "Id"); const type = relationshipAttribute(record, "Type"); - const target = normalizedHyperlinkTarget(relationshipAttribute(record, "Target")); - if (id && type === wordHyperlinkRelationshipType && target) { - targets[id] = target; + const target = relationshipAttribute(record, "Target"); + if (!id || !target) { + return; + } + + if (type === wordHyperlinkRelationshipType) { + const normalizedTarget = normalizedHyperlinkTarget(target); + if (normalizedTarget) { + targets.hyperlinks[id] = normalizedTarget; + } + return; + } + + if (type === wordImageRelationshipType) { + targets.images[id] = resolveDocxTarget("word", target); } }); return targets; } +async function docxImageDataByRelationshipId(zip: ZipInstance, imageTargets: Record) { + const entries = await Promise.all( + Object.entries(imageTargets).map(async ([id, path]) => { + const mimeType = imageMimeTypeFromPath(path); + const data = mimeType ? await zip.file(path)?.async("base64") : ""; + return data ? ([id, `data:${mimeType};base64,${data}`] as const) : null; + }) + ); + + return entries.reduce((images, entry) => { + if (entry) { + images[entry[0]] = entry[1]; + } + + return images; + }, {}); +} + +function resolveDocxTarget(baseDirectory: string, target: string) { + const normalizedTarget = target.replace(/\\/g, "/"); + const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${baseDirectory}/${normalizedTarget}`; + const segments: string[] = []; + + rawPath.split("/").forEach((segment) => { + if (!segment || segment === ".") { + return; + } + + if (segment === "..") { + segments.pop(); + return; + } + + segments.push(segment); + }); + + return segments.join("/"); +} + +function imageMimeTypeFromPath(path: string) { + const extension = path.toLowerCase().split(/[?#]/)[0].split(".").pop() ?? ""; + const mimeTypes: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + bmp: "image/bmp" + }; + + return mimeTypes[extension] ?? ""; +} + function relationshipAttribute(record: XmlRecord, name: string) { const value = record[name] ?? record[name.toLowerCase()] ?? record[`@_${name}`] ?? record[`@_${name.toLowerCase()}`]; return typeof value === "string" || typeof value === "number" ? String(value) : ""; @@ -802,10 +894,11 @@ function appendXmlChild(record: XmlRecord, key: string, value: XmlRecord) { record[key] = Array.isArray(existing) ? [...existing, value] : [existing, value]; } -function docxRunToHtml(run: unknown) { +function docxRunToHtml(run: unknown, imageDataByRelationshipId: DocxImageDataByRelationshipId = {}) { const record = asRecord(run); const properties = childRecord(record, "w:rPr"); const text = docxRunText(record); + const imageHtml = docxRunImageHtml(record, imageDataByRelationshipId); const color = docxRunColor(properties); const highlightColor = docxRunHighlightColor(properties); const fontSize = docxRunFontSize(properties); @@ -814,35 +907,143 @@ function docxRunToHtml(run: unknown) { const hasInlineColorOrHighlight = Boolean(color || highlightColor || fontSize || fontFamily || hasStrike); let html = escapeHtml(text); - if (!text) { - return { html: "", hasInlineColorOrHighlight }; + if (!text && !imageHtml) { + return { html: "", hasInlineColorOrHighlight, hasImage: false }; } - if (hasDocxBoolean(properties, "w:b")) { - html = `${html}`; - } - if (hasDocxBoolean(properties, "w:i")) { - html = `${html}`; - } - if (hasDocxBoolean(properties, "w:u")) { - html = `${html}`; - } - if (hasStrike) { - html = `${html}`; + if (text) { + if (hasDocxBoolean(properties, "w:b")) { + html = `${html}`; + } + if (hasDocxBoolean(properties, "w:i")) { + html = `${html}`; + } + if (hasDocxBoolean(properties, "w:u")) { + html = `${html}`; + } + if (hasStrike) { + html = `${html}`; + } + + const styles = [ + color ? `color: #${color};` : "", + highlightColor ? `background-color: #${highlightColor};` : "", + fontSize ? `font-size: ${formatPointSize(fontSize)}pt;` : "", + fontFamily ? `font-family: ${cssFontFamilyValue(fontFamily)};` : "" + ].filter(Boolean); + + if (styles.length > 0) { + html = `${html}`; + } } - const styles = [ - color ? `color: #${color};` : "", - highlightColor ? `background-color: #${highlightColor};` : "", - fontSize ? `font-size: ${formatPointSize(fontSize)}pt;` : "", - fontFamily ? `font-family: ${cssFontFamilyValue(fontFamily)};` : "" + return { html: `${imageHtml}${text ? html : ""}`, hasInlineColorOrHighlight, hasImage: Boolean(imageHtml) }; +} + +function docxRunImageHtml(run: XmlRecord, imageDataByRelationshipId: DocxImageDataByRelationshipId) { + const relationshipId = firstNestedAttribute(run, ["r:embed", "embed", "@_r:embed", "@_embed"]); + const src = relationshipId ? imageDataByRelationshipId[relationshipId] : ""; + if (!src) { + return ""; + } + + const alt = firstNestedAttribute(run, ["descr", "@_descr", "title", "@_title", "name", "@_name"]); + const dimensions = docxImageDimensions(run); + const attributes = [ + `src="${escapeHtml(src)}"`, + alt ? `alt="${escapeHtml(alt)}"` : "", + dimensions.width ? `width="${dimensions.width}"` : "", + dimensions.height ? `height="${dimensions.height}"` : "" ].filter(Boolean); - if (styles.length > 0) { - html = `${html}`; + return ``; +} + +function docxImageDimensions(run: XmlRecord) { + const extent = firstNestedRecord(run, ["wp:extent", "extent"]); + const width = emuToPixels(docxNumberAttribute(extent, "cx")); + const height = emuToPixels(docxNumberAttribute(extent, "cy")); + return { + ...(width ? { width } : {}), + ...(height ? { height } : {}) + }; +} + +function firstNestedAttribute(value: unknown, names: string[]): string { + if (Array.isArray(value)) { + for (const item of value) { + const result = firstNestedAttribute(item, names); + if (result) { + return result; + } + } + + return ""; } - return { html, hasInlineColorOrHighlight }; + if (!value || typeof value !== "object") { + return ""; + } + + const record = value as XmlRecord; + for (const name of names) { + const raw = record[name]; + if (typeof raw === "string" || typeof raw === "number") { + return String(raw); + } + } + + for (const child of Object.values(record)) { + const result = firstNestedAttribute(child, names); + if (result) { + return result; + } + } + + return ""; +} + +function firstNestedRecord(value: unknown, names: string[]): XmlRecord { + if (Array.isArray(value)) { + for (const item of value) { + const result = firstNestedRecord(item, names); + if (Object.keys(result).length > 0) { + return result; + } + } + + return {}; + } + + if (!value || typeof value !== "object") { + return {}; + } + + const record = value as XmlRecord; + for (const name of names) { + const child = record[name]; + if (child && typeof child === "object" && !Array.isArray(child)) { + return child as XmlRecord; + } + } + + for (const child of Object.values(record)) { + const result = firstNestedRecord(child, names); + if (Object.keys(result).length > 0) { + return result; + } + } + + return {}; +} + +function docxNumberAttribute(record: XmlRecord, name: string) { + const value = Number.parseFloat(String(record[name] ?? record[`@_${name}`] ?? "")); + return Number.isFinite(value) && value > 0 ? value : 0; +} + +function emuToPixels(value: number) { + return value > 0 ? Math.round(value / 9525) : 0; } function docxRunText(run: XmlRecord) { diff --git a/src/io/wordOfficeImport.test.ts b/src/io/wordOfficeImport.test.ts index 40afcab..05011c2 100644 --- a/src/io/wordOfficeImport.test.ts +++ b/src/io/wordOfficeImport.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice"; +const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; +const tinyPngDataUri = `data:image/png;base64,${tinyPngBase64}`; + describe("wordOffice DOCX import", () => { it("imports DOCX text color and highlight from OOXML", async () => { const blob = await exportDocxBlob( @@ -138,4 +141,36 @@ describe("wordOffice DOCX import", () => { { type: "paragraph", text: "After table", runs: [{ text: "After table" }] } ]); }); + + it("imports styled DOCX content when the document also contains images", async () => { + const blob = await exportDocxBlob( + "Mixed.docx", + `

Color fragment

Logo
StageDone
` + ); + const file = new File([blob], "Mixed.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + const imported = await importDocxFile(file); + const blocks = htmlToWordBlocks(imported.html); + + expect(imported.html).toContain("color: #0F5FAE;"); + expect(imported.html).toContain(""); + expect(imported.html).toContain(`src="${tinyPngDataUri}"`); + expect(blocks[0]).toEqual({ + type: "paragraph", + text: "Color fragment", + runs: [{ text: "Color fragment", color: "0F5FAE" }] + }); + expect(blocks[1]).toMatchObject({ + type: "image", + src: tinyPngDataUri, + alt: "Logo", + width: 32, + height: 32 + }); + expect(blocks[2]).toEqual({ + type: "table", + rows: [["Stage", "Done"]] + }); + }); });