Preserve QWord images in styled DOCX 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, 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` пока не реализованы.
|
||||
|
||||
+246
-45
@@ -53,7 +53,7 @@ type MammothModule = {
|
||||
type XmlRecord = Record<string, unknown>;
|
||||
|
||||
type ZipObject = {
|
||||
async: (type: "string") => Promise<string>;
|
||||
async: (type: "string" | "base64") => Promise<string>;
|
||||
};
|
||||
|
||||
type ZipInstance = {
|
||||
@@ -93,6 +93,7 @@ type DocxModule = {
|
||||
type InlineFormat = Omit<WordInlineRun, "text">;
|
||||
type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock };
|
||||
type DocxInlineRecord = { run: unknown; href?: string };
|
||||
type DocxImageDataByRelationshipId = Record<string, string>;
|
||||
|
||||
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<ImportedWordDocument> {
|
||||
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 !/<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 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<string, string>) {
|
||||
function orderedDocxParagraphToHtml(
|
||||
paragraph: unknown,
|
||||
hyperlinkTargets: Record<string, string>,
|
||||
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 ? `<a href="${escapeHtml(inline.href)}">${result.html}</a>` : 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}</${tagName}>`,
|
||||
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")
|
||||
.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 ? `<td>${content}</td>` : "<td></td>";
|
||||
@@ -684,7 +712,7 @@ function orderedDocxTableToHtml(table: unknown, hyperlinkTargets: Record<string,
|
||||
})
|
||||
.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) {
|
||||
@@ -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<string, string> = {};
|
||||
const targets: { hyperlinks: Record<string, string>; images: Record<string, string> } = { 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<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) {
|
||||
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 = `<strong>${html}</strong>`;
|
||||
}
|
||||
if (hasDocxBoolean(properties, "w:i")) {
|
||||
html = `<em>${html}</em>`;
|
||||
}
|
||||
if (hasDocxBoolean(properties, "w:u")) {
|
||||
html = `<u>${html}</u>`;
|
||||
}
|
||||
if (hasStrike) {
|
||||
html = `<s>${html}</s>`;
|
||||
if (text) {
|
||||
if (hasDocxBoolean(properties, "w:b")) {
|
||||
html = `<strong>${html}</strong>`;
|
||||
}
|
||||
if (hasDocxBoolean(properties, "w:i")) {
|
||||
html = `<em>${html}</em>`;
|
||||
}
|
||||
if (hasDocxBoolean(properties, "w:u")) {
|
||||
html = `<u>${html}</u>`;
|
||||
}
|
||||
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 = [
|
||||
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 = `<span style="${styles.join(" ")}">${html}</span>`;
|
||||
return `<img ${attributes.join(" ")}>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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",
|
||||
`<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"]]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user