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 для дат, процентов, денежных и дробных значений.
- 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
View File
@@ -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) {
+35
View File
@@ -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"]]
});
});
});