Preserve QWord DOCX text colors on import
This commit is contained in:
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user