Preserve QWord DOCX text colors on import

This commit is contained in:
Курнат Андрей
2026-06-01 05:39:55 +03:00
parent 9bf4dd3672
commit 04811e6580
3 changed files with 303 additions and 11 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ npm run build
## Поддержка форматов
- QWord открывает `.docx` и сохраняет `.docx`; при экспорте DOCX сохраняются заголовки, абзацы, выравнивание абзацев, списки, простые таблицы, внешние гиперссылки, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, цвет текста и подсветка.
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает явные цвета текста и подсветку из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, выравнивание абзацев, списки, простые таблицы, внешние гиперссылки, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, цвет текста и подсветка.
- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, формулы за пределами стартового диапазона A-H, базовые 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-вложения. Импорт MSG читает тему, отправителя, получателей, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы.
@@ -46,7 +46,7 @@ Desktop-меню поддерживает системные команды дл
- `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF.
- `Ctrl/Cmd+P`: печать текущей рабочей области.
Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, цвет/подсветку текста и выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, заметки докладчика, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены.
Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:color`, `w:shd` и `w:highlight` для цвета/подсветки текста DOCX, выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, заметки докладчика, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены.
## Сборка пакета
+278 -9
View File
@@ -39,7 +39,7 @@ export type WordBlock =
type MammothModule = {
convertToHtml: (
input: { arrayBuffer: ArrayBuffer },
input: { arrayBuffer?: ArrayBuffer; buffer?: ArrayBuffer },
options?: { styleMap?: string[]; convertImage?: unknown }
) => Promise<{ value: string; messages?: unknown[] }>;
images?: {
@@ -47,6 +47,28 @@ type MammothModule = {
};
};
type XmlRecord = Record<string, unknown>;
type ZipObject = {
async: (type: "string") => Promise<string>;
};
type ZipInstance = {
file: (path: string) => ZipObject | null;
};
type ZipConstructor = {
loadAsync: (data: ArrayBuffer) => Promise<ZipInstance>;
};
type XmlParserConstructor = new (options: Record<string, unknown>) => {
parse: (xml: string) => unknown;
};
type FastXmlParserModule = {
XMLParser: XmlParserConstructor;
};
type DocxModule = {
AlignmentType?: Record<string, string>;
Document: new (options: Record<string, unknown>) => unknown;
@@ -73,6 +95,14 @@ const mammothStyleMap = [
"p[style-name='Heading 1'] => h1:fresh",
"p[style-name='Heading 2'] => h2:fresh"
];
const xmlParserOptions = {
ignoreAttributes: false,
attributeNamePrefix: "",
textNodeName: "#text",
parseTagValue: false,
parseAttributeValue: false,
trimValues: false
};
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
if (!file.name.toLowerCase().endsWith(".docx")) {
@@ -81,17 +111,20 @@ export async function importDocxFile(file: File): Promise<ImportedWordDocument>
const mammoth = await loadMammoth();
const arrayBuffer = await readOfficeFileAsArrayBuffer(file);
const result = await mammoth.convertToHtml(
{ arrayBuffer },
{
styleMap: mammothStyleMap,
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
}
);
const [result, styledHtml] = await Promise.all([
mammoth.convertToHtml(
{ arrayBuffer, buffer: arrayBuffer },
{
styleMap: mammothStyleMap,
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
}
),
styledDocxHtmlFromArrayBuffer(arrayBuffer)
]);
return {
title: officeTitleFromFileName(file.name, "Документ QWord.docx"),
html: normalizeImportedDocxHtml(result.value)
html: normalizeImportedDocxHtml(styledHtml || result.value)
};
}
@@ -508,6 +541,232 @@ function normalizedCssColor(value: string) {
return namedColors[raw] ?? "";
}
async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) {
try {
const [JSZip, XMLParser] = await Promise.all([loadJsZip(), loadXmlParser()]);
const zip = await JSZip.loadAsync(arrayBuffer);
const documentXml = await zip.file("word/document.xml")?.async("string");
if (!documentXml) {
return "";
}
if (!isSimpleStyledDocxDocument(documentXml)) {
return "";
}
const parser = new XMLParser(xmlParserOptions);
const parsed = parser.parse(documentXml);
const result = parsedDocxDocumentToHtml(parsed);
return result.hasInlineColorOrHighlight ? result.html : "";
} catch {
return "";
}
}
function isSimpleStyledDocxDocument(documentXml: string) {
return !/<w:(tbl|drawing|pict|hyperlink|numPr|sdt)\b/i.test(documentXml);
}
function parsedDocxDocumentToHtml(documentXml: unknown) {
const document = childRecord(documentXml, "w:document");
const body = childRecord(document, "w:body");
let hasInlineColorOrHighlight = false;
const paragraphs = toArray(body["w:p"])
.map((paragraph) => {
const result = docxParagraphToHtml(paragraph);
hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight;
return result.html;
})
.filter(Boolean);
return {
html: paragraphs.join(""),
hasInlineColorOrHighlight
};
}
function docxParagraphToHtml(paragraph: unknown) {
const record = asRecord(paragraph);
const properties = childRecord(record, "w:pPr");
const tagName = docxParagraphTagName(properties);
const style = docxParagraphStyleAttribute(properties);
let hasInlineColorOrHighlight = false;
const runs = docxParagraphInlineRecords(record).map((run) => {
const result = docxRunToHtml(run);
hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight;
return result.html;
});
const content = runs.join("");
if (!content.trim()) {
return { html: "", hasInlineColorOrHighlight };
}
return {
html: `<${tagName}${style ? ` style="${style}"` : ""}>${content}</${tagName}>`,
hasInlineColorOrHighlight
};
}
function docxParagraphTagName(properties: XmlRecord) {
const styleValue = docxAttribute(childRecord(properties, "w:pStyle"), "w:val").toLowerCase();
if (["title", "heading1", "heading 1"].includes(styleValue)) {
return "h1";
}
if (["heading2", "heading 2"].includes(styleValue)) {
return "h2";
}
return "p";
}
function docxParagraphStyleAttribute(properties: XmlRecord) {
const alignment = docxAttribute(childRecord(properties, "w:jc"), "w:val");
const htmlAlignment = alignment === "both" ? "justify" : alignment;
return ["left", "center", "right", "justify"].includes(htmlAlignment) ? `text-align: ${htmlAlignment};` : "";
}
function docxParagraphInlineRecords(paragraph: XmlRecord) {
const directRuns = toArray(paragraph["w:r"]);
const hyperlinkRuns = toArray(paragraph["w:hyperlink"]).flatMap((hyperlink) => toArray(asRecord(hyperlink)["w:r"]));
return [...directRuns, ...hyperlinkRuns];
}
function docxRunToHtml(run: unknown) {
const record = asRecord(run);
const properties = childRecord(record, "w:rPr");
const text = docxRunText(record);
const color = docxRunColor(properties);
const highlightColor = docxRunHighlightColor(properties);
const hasInlineColorOrHighlight = Boolean(color || highlightColor);
let html = escapeHtml(text);
if (!text) {
return { html: "", hasInlineColorOrHighlight };
}
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>`;
}
const styles = [
color ? `color: #${color};` : "",
highlightColor ? `background-color: #${highlightColor};` : ""
].filter(Boolean);
if (styles.length > 0) {
html = `<span style="${styles.join(" ")}">${html}</span>`;
}
return { html, hasInlineColorOrHighlight };
}
function docxRunText(run: XmlRecord) {
const text = toArray(run["w:t"]).map(xmlNodeText).join("");
const tabs = toArray(run["w:tab"]).map(() => "\t").join("");
const breaks = toArray(run["w:br"]).map(() => "\n").join("");
return `${text}${tabs}${breaks}`;
}
function docxRunColor(properties: XmlRecord) {
const value = docxAttribute(childRecord(properties, "w:color"), "w:val");
return normalizedDocxHexColor(value);
}
function docxRunHighlightColor(properties: XmlRecord) {
const shadingFill = normalizedDocxHexColor(docxAttribute(childRecord(properties, "w:shd"), "w:fill"));
if (shadingFill) {
return shadingFill;
}
const highlight = docxAttribute(childRecord(properties, "w:highlight"), "w:val").toLowerCase();
const highlightColors: Record<string, string> = {
black: "000000",
blue: "0000FF",
cyan: "00FFFF",
darkblue: "00008B",
darkcyan: "008B8B",
darkgray: "A9A9A9",
darkgreen: "006400",
darkmagenta: "8B008B",
darkred: "8B0000",
darkyellow: "808000",
green: "00FF00",
lightgray: "D3D3D3",
magenta: "FF00FF",
red: "FF0000",
white: "FFFFFF",
yellow: "FFFF00"
};
return highlightColors[highlight] ?? "";
}
function normalizedDocxHexColor(value: string) {
const normalized = value.trim().toUpperCase();
if (!normalized || normalized === "AUTO" || normalized === "NONE") {
return "";
}
return /^[0-9A-F]{6}$/.test(normalized) ? normalized : "";
}
function hasDocxBoolean(properties: XmlRecord, key: string) {
if (!Object.prototype.hasOwnProperty.call(properties, key)) {
return false;
}
const value = docxAttribute(childRecord(properties, key), "w:val") || docxAttribute(childRecord(properties, key), "val");
return !["0", "false", "off"].includes(value.toLowerCase());
}
function childRecord(value: unknown, key: string): XmlRecord {
const child = asRecord(value)[key];
if (Array.isArray(child)) {
return asRecord(child[0]);
}
return asRecord(child);
}
function asRecord(value: unknown): XmlRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as XmlRecord) : {};
}
function toArray<T>(value: T | T[] | undefined): T[] {
if (value === undefined) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function docxAttribute(record: XmlRecord, name: string) {
const value = record[name] ?? record[name.replace(/^w:/, "")];
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
function xmlNodeText(value: unknown): string {
if (typeof value === "string" || typeof value === "number") {
return String(value);
}
return String(asRecord(value)["#text"] ?? "");
}
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function collectInlineRuns(nodes: ChildNode[], format: InlineFormat): WordInlineRun[] {
return collectInlineSegments(nodes, format).flatMap((segment) => (segment.type === "runs" ? segment.runs : []));
}
@@ -798,6 +1057,16 @@ async function loadMammoth() {
return module.default ?? module;
}
async function loadJsZip() {
const module = (await import("jszip")) as { default?: ZipConstructor } & ZipConstructor;
return module.default ?? module;
}
async function loadXmlParser() {
const module = (await import("fast-xml-parser")) as FastXmlParserModule;
return module.XMLParser;
}
async function loadDocx() {
const module = (await import("docx")) as unknown as { default?: DocxModule } & DocxModule;
return module.default ?? module;
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
describe("wordOffice DOCX import", () => {
it("imports DOCX text color and highlight from OOXML", async () => {
const blob = await exportDocxBlob(
"Color.docx",
'<p><span style="color: #0f5fae; background-color: #fff2cc;">Color fragment</span></p>'
);
const file = new File([blob], "Color.docx", { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" });
const imported = await importDocxFile(file);
expect(imported.html).toContain("color: #0F5FAE;");
expect(imported.html).toContain("background-color: #FFF2CC;");
expect(htmlToWordBlocks(imported.html)).toEqual([
{
type: "paragraph",
text: "Color fragment",
runs: [{ text: "Color fragment", color: "0F5FAE", highlightColor: "FFF2CC" }]
}
]);
});
});