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 для дат, процентов, денежных и дробных значений.
|
- 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
@@ -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) {
|
||||||
|
|||||||
@@ -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"]]
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user