Preserve QWord DOCX hyperlinks on import

This commit is contained in:
Курнат Андрей
2026-06-01 07:50:01 +03:00
parent 2bb78dae74
commit 27eae3afac
3 changed files with 127 additions and 20 deletions
+1 -1
View File
@@ -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` пока не реализованы.
+105 -19
View File
@@ -92,6 +92,7 @@ type DocxModule = {
type InlineFormat = Omit<WordInlineRun, "text">;
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<ImportedWordDocument> {
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 !/<w:(tbl|drawing|pict|hyperlink|numPr|sdt)\b/i.test(documentXml);
return !/<w:(tbl|drawing|pict|numPr|sdt)\b/i.test(documentXml);
}
function parsedDocxDocumentToHtml(documentXml: unknown) {
const document = childRecord(documentXml, "w:document");
const body = childRecord(document, "w:body");
function orderedDocxDocumentToHtml(documentXml: unknown, hyperlinkTargets: Record<string, string> = {}) {
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<string, string>) {
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 ? `<a href="${escapeHtml(inline.href)}">${result.html}</a>` : 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<string, string>) {
return orderedElementChildren(paragraph).flatMap<DocxInlineRecord>((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<string, string> = {};
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) {
+21
View File
@@ -91,4 +91,25 @@ describe("wordOffice DOCX import", () => {
}
]);
});
it("imports DOCX external hyperlinks from OOXML relationships", async () => {
const blob = await exportDocxBlob("Hyperlink.docx", '<p>Open <a href="https://example.com/report"><strong>report</strong></a>.</p>');
const file = new File([blob], "Hyperlink.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
const imported = await importDocxFile(file);
expect(imported.html).toContain('<a href="https://example.com/report">');
expect(htmlToWordBlocks(imported.html)).toEqual([
{
type: "paragraph",
text: "Open report.",
runs: [
{ text: "Open " },
{ text: "report", bold: true, href: "https://example.com/report" },
{ text: "." }
]
}
]);
});
});