diff --git a/README.md b/README.md index 3d6cf71..a19c3c4 100644 --- a/README.md +++ b/README.md @@ -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, базовые 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` пока не реализованы. diff --git a/src/io/wordOffice.ts b/src/io/wordOffice.ts index 9c8da87..968cfe7 100644 --- a/src/io/wordOffice.ts +++ b/src/io/wordOffice.ts @@ -92,6 +92,7 @@ type DocxModule = { type InlineFormat = Omit; type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock }; +type DocxInlineRecord = { run: unknown; href?: string }; const mammothStyleMap = [ "p[style-name='Title'] => h1:fresh", @@ -106,6 +107,7 @@ const xmlParserOptions = { parseAttributeValue: false, trimValues: false }; +const wordHyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; export async function importDocxFile(file: File): Promise { if (!file.name.toLowerCase().endsWith(".docx")) { @@ -595,8 +597,10 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { } const parser = new XMLParser(xmlParserOptions); - const parsed = parser.parse(documentXml); - const result = parsedDocxDocumentToHtml(parsed); + 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.html : ""; } catch { return ""; @@ -604,17 +608,17 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { } function isSimpleStyledDocxDocument(documentXml: string) { - return !/ = {}) { + const document = firstOrderedElement(toArray(documentXml), "w:document"); + const body = firstOrderedElement(orderedElementChildren(document), "w:body"); let hasInlineColorOrHighlight = false; let hasParagraphAlignment = false; - const paragraphs = toArray(body["w:p"]) + const paragraphs = orderedDirectElements(body, "w:p") .map((paragraph) => { - const result = docxParagraphToHtml(paragraph); + const result = orderedDocxParagraphToHtml(paragraph, hyperlinkTargets); hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight; hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment; return result.html; @@ -628,17 +632,16 @@ function parsedDocxDocumentToHtml(documentXml: unknown) { }; } -function docxParagraphToHtml(paragraph: unknown) { - const record = asRecord(paragraph); - const properties = childRecord(record, "w:pPr"); +function orderedDocxParagraphToHtml(paragraph: unknown, hyperlinkTargets: Record) { + const properties = orderedElementToRecord(firstOrderedElement(orderedElementChildren(paragraph), "w:pPr")); const tagName = docxParagraphTagName(properties); const style = docxParagraphStyleAttribute(properties); const hasParagraphAlignment = Boolean(style); let hasInlineColorOrHighlight = false; - const runs = docxParagraphInlineRecords(record).map((run) => { - const result = docxRunToHtml(run); - hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight; - return result.html; + const runs = orderedDocxParagraphInlineRecords(paragraph, hyperlinkTargets).map((inline) => { + const result = docxRunToHtml(inline.run); + hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight || Boolean(inline.href); + return inline.href && result.html ? `${result.html}` : result.html; }); const content = runs.join(""); @@ -671,10 +674,93 @@ function docxParagraphStyleAttribute(properties: XmlRecord) { 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 orderedDocxParagraphInlineRecords(paragraph: unknown, hyperlinkTargets: Record) { + return orderedElementChildren(paragraph).flatMap((child) => { + const name = orderedElementName(child); + if (name === "w:r") { + return [{ run: orderedElementToRecord(child) }]; + } + if (name !== "w:hyperlink") { + return []; + } + + const relationshipId = docxAttribute(orderedElementAttributes(child), "r:id") || docxAttribute(orderedElementAttributes(child), "id"); + const href = normalizedHyperlinkTarget(hyperlinkTargets[relationshipId]); + return orderedDirectElements(child, "w:r").map((run) => ({ run: orderedElementToRecord(run), ...(href ? { href } : {}) })); + }); +} + +function docxHyperlinkTargetsById(relationshipsXml: unknown) { + const relationships = childRecord(relationshipsXml, "Relationships"); + const targets: Record = {}; + 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; + } + }); + + return targets; +} + +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) : ""; +} + +function firstOrderedElement(nodes: unknown[], name: string) { + return nodes.find((node) => orderedElementName(node) === name); +} + +function orderedDirectElements(parent: unknown, name: string) { + return orderedElementChildren(parent).filter((node) => orderedElementName(node) === name); +} + +function orderedElementName(node: unknown) { + return Object.keys(asRecord(node)).find((key) => key !== ":@") ?? ""; +} + +function orderedElementChildren(node: unknown): unknown[] { + const name = orderedElementName(node); + return name ? toArray(asRecord(node)[name]) : []; +} + +function orderedElementAttributes(node: unknown): XmlRecord { + return asRecord(asRecord(node)[":@"]); +} + +function orderedElementToRecord(node: unknown): XmlRecord { + const record: XmlRecord = { ...orderedElementAttributes(node) }; + + orderedElementChildren(node).forEach((child) => { + const childObject = asRecord(child); + if (Object.prototype.hasOwnProperty.call(childObject, "#text")) { + record["#text"] = `${record["#text"] ?? ""}${xmlNodeText(childObject["#text"])}`; + return; + } + + const childName = orderedElementName(child); + if (!childName) { + return; + } + + appendXmlChild(record, childName, orderedElementToRecord(child)); + }); + + return record; +} + +function appendXmlChild(record: XmlRecord, key: string, value: XmlRecord) { + const existing = record[key]; + if (existing === undefined) { + record[key] = value; + return; + } + + record[key] = Array.isArray(existing) ? [...existing, value] : [existing, value]; } function docxRunToHtml(run: unknown) { diff --git a/src/io/wordOfficeImport.test.ts b/src/io/wordOfficeImport.test.ts index 131e7a3..866fdd7 100644 --- a/src/io/wordOfficeImport.test.ts +++ b/src/io/wordOfficeImport.test.ts @@ -91,4 +91,25 @@ describe("wordOffice DOCX import", () => { } ]); }); + + it("imports DOCX external hyperlinks from OOXML relationships", async () => { + const blob = await exportDocxBlob("Hyperlink.docx", '

Open report.

'); + const file = new File([blob], "Hyperlink.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + const imported = await importDocxFile(file); + + expect(imported.html).toContain(''); + expect(htmlToWordBlocks(imported.html)).toEqual([ + { + type: "paragraph", + text: "Open report.", + runs: [ + { text: "Open " }, + { text: "report", bold: true, href: "https://example.com/report" }, + { text: "." } + ] + } + ]); + }); });