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