Preserve QWord images in styled DOCX import

This commit is contained in:
Курнат Андрей
2026-06-01 08:19:00 +03:00
parent 46e04cb9ab
commit cf3d6b0b01
3 changed files with 282 additions and 46 deletions
+1 -1
View File
@@ -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 для дат, процентов, денежных и дробных значений. - 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` пока не реализованы.
+246 -45
View File
@@ -53,7 +53,7 @@ type MammothModule = {
type XmlRecord = Record<string, unknown>; type XmlRecord = Record<string, unknown>;
type ZipObject = { type ZipObject = {
async: (type: "string") => Promise<string>; async: (type: "string" | "base64") => Promise<string>;
}; };
type ZipInstance = { type ZipInstance = {
@@ -93,6 +93,7 @@ type DocxModule = {
type InlineFormat = Omit<WordInlineRun, "text">; type InlineFormat = Omit<WordInlineRun, "text">;
type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock }; type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock };
type DocxInlineRecord = { run: unknown; href?: string }; type DocxInlineRecord = { run: unknown; href?: string };
type DocxImageDataByRelationshipId = Record<string, string>;
const mammothStyleMap = [ const mammothStyleMap = [
"p[style-name='Title'] => h1:fresh", "p[style-name='Title'] => h1:fresh",
@@ -108,6 +109,8 @@ const xmlParserOptions = {
trimValues: false trimValues: false
}; };
const wordHyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; 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<ImportedWordDocument> { export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
if (!file.name.toLowerCase().endsWith(".docx")) { if (!file.name.toLowerCase().endsWith(".docx")) {
@@ -599,37 +602,45 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) {
const parser = new XMLParser(xmlParserOptions); const parser = new XMLParser(xmlParserOptions);
const orderedParser = new XMLParser({ ...xmlParserOptions, preserveOrder: true }); const orderedParser = new XMLParser({ ...xmlParserOptions, preserveOrder: true });
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 relationshipTargets = relationshipsXml ? docxRelationshipTargetsById(parser.parse(relationshipsXml)) : emptyDocxRelationshipTargets;
const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), hyperlinkTargets); const imageDataByRelationshipId = await docxImageDataByRelationshipId(zip, relationshipTargets.images);
return result.hasInlineColorOrHighlight || result.hasParagraphAlignment || result.hasTable ? result.html : ""; const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), relationshipTargets.hyperlinks, imageDataByRelationshipId);
return result.hasInlineColorOrHighlight || result.hasParagraphAlignment || result.hasTable || result.hasImage ? result.html : "";
} catch { } catch {
return ""; return "";
} }
} }
function isSimpleStyledDocxDocument(documentXml: string) { function isSimpleStyledDocxDocument(documentXml: string) {
return !/<w:(drawing|pict|numPr|sdt)\b/i.test(documentXml); return !/<w:(pict|numPr|sdt)\b/i.test(documentXml);
} }
function orderedDocxDocumentToHtml(documentXml: unknown, hyperlinkTargets: Record<string, string> = {}) { function orderedDocxDocumentToHtml(
documentXml: unknown,
hyperlinkTargets: Record<string, string> = {},
imageDataByRelationshipId: DocxImageDataByRelationshipId = {}
) {
const document = firstOrderedElement(toArray(documentXml), "w:document"); const document = firstOrderedElement(toArray(documentXml), "w:document");
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;
let hasTable = false; let hasTable = false;
let hasImage = false;
const blocks = orderedElementChildren(body) const blocks = orderedElementChildren(body)
.flatMap((block) => { .flatMap((block) => {
const name = orderedElementName(block); const name = orderedElementName(block);
if (name === "w:p") { if (name === "w:p") {
const result = orderedDocxParagraphToHtml(block, hyperlinkTargets); const result = orderedDocxParagraphToHtml(block, hyperlinkTargets, imageDataByRelationshipId);
hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight; hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight;
hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment; hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment;
hasImage = hasImage || result.hasImage;
return result.html ? [result.html] : []; return result.html ? [result.html] : [];
} }
if (name === "w:tbl") { if (name === "w:tbl") {
const html = orderedDocxTableToHtml(block, hyperlinkTargets); const result = orderedDocxTableToHtml(block, hyperlinkTargets, imageDataByRelationshipId);
hasTable = hasTable || Boolean(html); hasTable = hasTable || Boolean(result.html);
return html ? [html] : []; hasImage = hasImage || result.hasImage;
return result.html ? [result.html] : [];
} }
return []; return [];
@@ -639,41 +650,58 @@ function orderedDocxDocumentToHtml(documentXml: unknown, hyperlinkTargets: Recor
html: blocks.join(""), html: blocks.join(""),
hasInlineColorOrHighlight, hasInlineColorOrHighlight,
hasParagraphAlignment, hasParagraphAlignment,
hasTable hasTable,
hasImage
}; };
} }
function orderedDocxParagraphToHtml(paragraph: unknown, hyperlinkTargets: Record<string, string>) { function orderedDocxParagraphToHtml(
paragraph: unknown,
hyperlinkTargets: Record<string, string>,
imageDataByRelationshipId: DocxImageDataByRelationshipId = {}
) {
const properties = orderedElementToRecord(firstOrderedElement(orderedElementChildren(paragraph), "w:pPr")); const properties = orderedElementToRecord(firstOrderedElement(orderedElementChildren(paragraph), "w:pPr"));
const tagName = docxParagraphTagName(properties); const tagName = docxParagraphTagName(properties);
const style = docxParagraphStyleAttribute(properties); const style = docxParagraphStyleAttribute(properties);
const hasParagraphAlignment = Boolean(style); const hasParagraphAlignment = Boolean(style);
let hasInlineColorOrHighlight = false; let hasInlineColorOrHighlight = false;
let hasImage = false;
const runs = orderedDocxParagraphInlineRecords(paragraph, hyperlinkTargets).map((inline) => { 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); hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight || Boolean(inline.href);
hasImage = hasImage || result.hasImage;
return inline.href && result.html ? `<a href="${escapeHtml(inline.href)}">${result.html}</a>` : result.html; return inline.href && result.html ? `<a href="${escapeHtml(inline.href)}">${result.html}</a>` : result.html;
}); });
const content = runs.join(""); const content = runs.join("");
if (!content.trim()) { if (!content.trim()) {
return { html: "", hasInlineColorOrHighlight, hasParagraphAlignment }; return { html: "", hasInlineColorOrHighlight, hasParagraphAlignment, hasImage };
} }
return { return {
html: `<${tagName}${style ? ` style="${style}"` : ""}>${content}</${tagName}>`, html: `<${tagName}${style ? ` style="${style}"` : ""}>${content}</${tagName}>`,
hasInlineColorOrHighlight, hasInlineColorOrHighlight,
hasParagraphAlignment hasParagraphAlignment,
hasImage
}; };
} }
function orderedDocxTableToHtml(table: unknown, hyperlinkTargets: Record<string, string>) { function orderedDocxTableToHtml(
table: unknown,
hyperlinkTargets: Record<string, string>,
imageDataByRelationshipId: DocxImageDataByRelationshipId = {}
) {
let hasImage = false;
const rows = orderedDirectElements(table, "w:tr") const rows = orderedDirectElements(table, "w:tr")
.map((row) => { .map((row) => {
const cells = orderedDirectElements(row, "w:tc") const cells = orderedDirectElements(row, "w:tc")
.map((cell) => { .map((cell) => {
const paragraphs = orderedDirectElements(cell, "w:p") 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); .filter(Boolean);
const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell)); const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell));
return content ? `<td>${content}</td>` : "<td></td>"; return content ? `<td>${content}</td>` : "<td></td>";
@@ -684,7 +712,7 @@ function orderedDocxTableToHtml(table: unknown, hyperlinkTargets: Record<string,
}) })
.filter(Boolean); .filter(Boolean);
return rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : ""; return { html: rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : "", hasImage };
} }
function orderedDocxCellText(cell: unknown) { 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 relationships = childRecord(relationshipsXml, "Relationships");
const targets: Record<string, string> = {}; const targets: { hyperlinks: Record<string, string>; images: Record<string, string> } = { hyperlinks: {}, images: {} };
toArray(relationships.Relationship).forEach((relationship) => { toArray(relationships.Relationship).forEach((relationship) => {
const record = asRecord(relationship); const record = asRecord(relationship);
const id = relationshipAttribute(record, "Id"); const id = relationshipAttribute(record, "Id");
const type = relationshipAttribute(record, "Type"); const type = relationshipAttribute(record, "Type");
const target = normalizedHyperlinkTarget(relationshipAttribute(record, "Target")); const target = relationshipAttribute(record, "Target");
if (id && type === wordHyperlinkRelationshipType && target) { if (!id || !target) {
targets[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; return targets;
} }
async function docxImageDataByRelationshipId(zip: ZipInstance, imageTargets: Record<string, string>) {
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<DocxImageDataByRelationshipId>((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<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp"
};
return mimeTypes[extension] ?? "";
}
function relationshipAttribute(record: XmlRecord, name: string) { function relationshipAttribute(record: XmlRecord, name: string) {
const value = record[name] ?? record[name.toLowerCase()] ?? record[`@_${name}`] ?? record[`@_${name.toLowerCase()}`]; const value = record[name] ?? record[name.toLowerCase()] ?? record[`@_${name}`] ?? record[`@_${name.toLowerCase()}`];
return typeof value === "string" || typeof value === "number" ? String(value) : ""; 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]; record[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
} }
function docxRunToHtml(run: unknown) { function docxRunToHtml(run: unknown, imageDataByRelationshipId: DocxImageDataByRelationshipId = {}) {
const record = asRecord(run); const record = asRecord(run);
const properties = childRecord(record, "w:rPr"); const properties = childRecord(record, "w:rPr");
const text = docxRunText(record); const text = docxRunText(record);
const imageHtml = docxRunImageHtml(record, imageDataByRelationshipId);
const color = docxRunColor(properties); const color = docxRunColor(properties);
const highlightColor = docxRunHighlightColor(properties); const highlightColor = docxRunHighlightColor(properties);
const fontSize = docxRunFontSize(properties); const fontSize = docxRunFontSize(properties);
@@ -814,35 +907,143 @@ function docxRunToHtml(run: unknown) {
const hasInlineColorOrHighlight = Boolean(color || highlightColor || fontSize || fontFamily || hasStrike); const hasInlineColorOrHighlight = Boolean(color || highlightColor || fontSize || fontFamily || hasStrike);
let html = escapeHtml(text); let html = escapeHtml(text);
if (!text) { if (!text && !imageHtml) {
return { html: "", hasInlineColorOrHighlight }; return { html: "", hasInlineColorOrHighlight, hasImage: false };
} }
if (hasDocxBoolean(properties, "w:b")) { if (text) {
html = `<strong>${html}</strong>`; if (hasDocxBoolean(properties, "w:b")) {
} html = `<strong>${html}</strong>`;
if (hasDocxBoolean(properties, "w:i")) { }
html = `<em>${html}</em>`; if (hasDocxBoolean(properties, "w:i")) {
} html = `<em>${html}</em>`;
if (hasDocxBoolean(properties, "w:u")) { }
html = `<u>${html}</u>`; if (hasDocxBoolean(properties, "w:u")) {
} html = `<u>${html}</u>`;
if (hasStrike) { }
html = `<s>${html}</s>`; if (hasStrike) {
html = `<s>${html}</s>`;
}
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 = `<span style="${styles.join(" ")}">${html}</span>`;
}
} }
const styles = [ return { html: `${imageHtml}${text ? html : ""}`, hasInlineColorOrHighlight, hasImage: Boolean(imageHtml) };
color ? `color: #${color};` : "", }
highlightColor ? `background-color: #${highlightColor};` : "",
fontSize ? `font-size: ${formatPointSize(fontSize)}pt;` : "", function docxRunImageHtml(run: XmlRecord, imageDataByRelationshipId: DocxImageDataByRelationshipId) {
fontFamily ? `font-family: ${cssFontFamilyValue(fontFamily)};` : "" 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); ].filter(Boolean);
if (styles.length > 0) { return `<img ${attributes.join(" ")}>`;
html = `<span style="${styles.join(" ")}">${html}</span>`; }
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) { function docxRunText(run: XmlRecord) {
+35
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice"; import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
const tinyPngDataUri = `data:image/png;base64,${tinyPngBase64}`;
describe("wordOffice DOCX import", () => { describe("wordOffice DOCX import", () => {
it("imports DOCX text color and highlight from OOXML", async () => { it("imports DOCX text color and highlight from OOXML", async () => {
const blob = await exportDocxBlob( const blob = await exportDocxBlob(
@@ -138,4 +141,36 @@ describe("wordOffice DOCX import", () => {
{ type: "paragraph", text: "After table", runs: [{ text: "After table" }] } { 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",
`<p><span style="color: #0f5fae;">Color fragment</span></p><img src="${tinyPngDataUri}" alt="Logo" width="32" height="32"><table><tr><td>Stage</td><td>Done</td></tr></table>`
);
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("<table>");
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"]]
});
});
}); });