Preserve QWord DOCX hyperlinks on import
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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: "." }
|
||||
]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user