From 04811e6580d3a4a1395cf7a0a4cf720e0603682b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Mon, 1 Jun 2026 05:39:55 +0300 Subject: [PATCH] Preserve QWord DOCX text colors on import --- README.md | 4 +- src/io/wordOffice.ts | 287 +++++++++++++++++++++++++++++++- src/io/wordOfficeImport.test.ts | 23 +++ 3 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 src/io/wordOfficeImport.test.ts diff --git a/README.md b/README.md index 6370e26..c4344ba 100644 --- a/README.md +++ b/README.md @@ -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 в текущем инкременте не заявлены. ## Сборка пакета diff --git a/src/io/wordOffice.ts b/src/io/wordOffice.ts index c1407e4..a07a455 100644 --- a/src/io/wordOffice.ts +++ b/src/io/wordOffice.ts @@ -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; + +type ZipObject = { + async: (type: "string") => Promise; +}; + +type ZipInstance = { + file: (path: string) => ZipObject | null; +}; + +type ZipConstructor = { + loadAsync: (data: ArrayBuffer) => Promise; +}; + +type XmlParserConstructor = new (options: Record) => { + parse: (xml: string) => unknown; +}; + +type FastXmlParserModule = { + XMLParser: XmlParserConstructor; +}; + type DocxModule = { AlignmentType?: Record; Document: new (options: Record) => 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 { if (!file.name.toLowerCase().endsWith(".docx")) { @@ -81,17 +111,20 @@ export async function importDocxFile(file: File): Promise 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 !/ { + 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}`, + 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 = `${html}`; + } + if (hasDocxBoolean(properties, "w:i")) { + html = `${html}`; + } + if (hasDocxBoolean(properties, "w:u")) { + html = `${html}`; + } + + const styles = [ + color ? `color: #${color};` : "", + highlightColor ? `background-color: #${highlightColor};` : "" + ].filter(Boolean); + + if (styles.length > 0) { + html = `${html}`; + } + + 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 = { + 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(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, """); +} + 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; diff --git a/src/io/wordOfficeImport.test.ts b/src/io/wordOfficeImport.test.ts new file mode 100644 index 0000000..efa7bf8 --- /dev/null +++ b/src/io/wordOfficeImport.test.ts @@ -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", + '

Color fragment

' + ); + 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" }] + } + ]); + }); +});