Skip to content

Commit 5499732

Browse files
committed
Fixes
1 parent ca0bc12 commit 5499732

1 file changed

Lines changed: 81 additions & 17 deletions

File tree

singularity/DocumentExport/DocumentDocxWriter.swift

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ enum DocumentDocxWriterError: Error {
77
}
88

99
struct DocumentDocxWriter {
10+
private let wordNamespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
11+
1012
func write(envelope: DocumentContentEnvelope, templateURL: URL, outputURL: URL, headingStyle: String, bodyStyle: String, tableStyle: String) throws {
1113
let fm = FileManager.default
1214
guard fm.fileExists(atPath: templateURL.path) else { throw DocumentDocxWriterError.templateMissing }
@@ -15,31 +17,93 @@ struct DocumentDocxWriter {
1517
try fm.createDirectory(at: unzipDir, withIntermediateDirectories: true)
1618
try unzip(docx: templateURL, to: unzipDir)
1719
let documentPath = unzipDir.appendingPathComponent("word/document.xml")
18-
guard let data = try? Data(contentsOf: documentPath), var documentXML = String(data: data, encoding: .utf8) else {
20+
guard let data = try? Data(contentsOf: documentPath), let documentXML = String(data: data, encoding: .utf8) else {
1921
throw DocumentDocxWriterError.invalidDocument
2022
}
23+
let updatedXML = processDocumentXML(documentXML, envelope: envelope, headingStyle: headingStyle, bodyStyle: bodyStyle, tableStyle: tableStyle)
24+
try updatedXML.data(using: .utf8)?.write(to: documentPath)
25+
try zipFolder(at: unzipDir, to: workRoot.appendingPathComponent("output.docx"))
26+
let finalURL = outputURL
27+
let parent = finalURL.deletingLastPathComponent()
28+
try fm.createDirectory(at: parent, withIntermediateDirectories: true)
29+
if fm.fileExists(atPath: finalURL.path) {
30+
try fm.removeItem(at: finalURL)
31+
}
32+
try fm.moveItem(at: workRoot.appendingPathComponent("output.docx"), to: finalURL)
33+
try? fm.removeItem(at: workRoot)
34+
}
35+
36+
private func processDocumentXML(_ xmlString: String, envelope: DocumentContentEnvelope, headingStyle: String, bodyStyle: String, tableStyle: String) -> String {
37+
guard let document = try? XMLDocument(xmlString: xmlString, options: [.nodePreserveAll]) else {
38+
return legacyProcessDocumentXML(xmlString, envelope: envelope, headingStyle: headingStyle, bodyStyle: bodyStyle, tableStyle: tableStyle)
39+
}
40+
var unmatched: [(replacement: String, key: String)] = []
41+
for section in envelope.sections {
42+
let replacement = renderSection(section, headingStyle: headingStyle, bodyStyle: bodyStyle, tableStyle: tableStyle)
43+
if !replacePlaceholder(in: document, key: section.definition.key, replacement: replacement) {
44+
unmatched.append((replacement, section.definition.key))
45+
}
46+
}
47+
if !unmatched.isEmpty,
48+
let body = (try? document.nodes(forXPath: "//*[local-name()='body']"))?.first as? XMLElement {
49+
for entry in unmatched {
50+
if let nodes = try? nodes(from: entry.replacement) {
51+
for node in nodes {
52+
body.addChild(node)
53+
}
54+
}
55+
removePlaceholderText(key: entry.key, in: document)
56+
}
57+
}
58+
return document.xmlString(options: [.nodePreserveAll])
59+
}
60+
61+
private func legacyProcessDocumentXML(_ xmlString: String, envelope: DocumentContentEnvelope, headingStyle: String, bodyStyle: String, tableStyle: String) -> String {
62+
var documentXML = xmlString
2163
var appendQueue: [String] = []
2264
for section in envelope.sections {
23-
let replacement = xml(for: section, headingStyle: headingStyle, bodyStyle: bodyStyle, tableStyle: tableStyle)
65+
let replacement = renderSection(section, headingStyle: headingStyle, bodyStyle: bodyStyle, tableStyle: tableStyle)
2466
if !replacePlaceholder(key: section.definition.key, in: &documentXML, replacement: replacement) {
2567
removePlaceholderOccurrences(key: section.definition.key, in: &documentXML)
2668
appendQueue.append(replacement)
2769
}
2870
}
29-
if !appendQueue.isEmpty {
30-
guard let range = documentXML.range(of: "</w:body>") else { throw DocumentDocxWriterError.invalidDocument }
71+
if !appendQueue.isEmpty, let range = documentXML.range(of: "</w:body>") {
3172
documentXML.replaceSubrange(range, with: appendQueue.joined() + "</w:body>")
3273
}
33-
try documentXML.data(using: .utf8)?.write(to: documentPath)
34-
try zipFolder(at: unzipDir, to: workRoot.appendingPathComponent("output.docx"))
35-
let finalURL = outputURL
36-
let parent = finalURL.deletingLastPathComponent()
37-
try fm.createDirectory(at: parent, withIntermediateDirectories: true)
38-
if fm.fileExists(atPath: finalURL.path) {
39-
try fm.removeItem(at: finalURL)
74+
return documentXML
75+
}
76+
77+
private func replacePlaceholder(in document: XMLDocument, key: String, replacement: String) -> Bool {
78+
let xpath = "//*[local-name()='p' and contains(., '{{\(key)}}')]"
79+
guard let matches = try? document.nodes(forXPath: xpath), !matches.isEmpty else { return false }
80+
for case let element as XMLElement in matches {
81+
guard let parent = element.parent as? XMLElement else { continue }
82+
let insertionIndex = parent.children?.firstIndex(of: element) ?? parent.childCount
83+
parent.removeChild(at: insertionIndex)
84+
if let nodes = try? nodes(from: replacement) {
85+
for (offset, node) in nodes.enumerated() {
86+
parent.insertChild(node, at: insertionIndex + offset)
87+
}
88+
}
89+
}
90+
return true
91+
}
92+
93+
private func nodes(from fragment: String) throws -> [XMLNode] {
94+
let wrapped = "<root xmlns:w=\"\(wordNamespace)\">\(fragment)</root>"
95+
let doc = try XMLDocument(xmlString: wrapped, options: [.nodePreserveAll])
96+
guard let children = doc.rootElement()?.children else { return [] }
97+
return children.compactMap { $0.copy() as? XMLNode }
98+
}
99+
100+
private func removePlaceholderText(key: String, in document: XMLDocument) {
101+
guard let textNodes = try? document.nodes(forXPath: "//*[local-name()='t']") else { return }
102+
let token = "{{\(key)}}"
103+
for case let node as XMLNode in textNodes {
104+
guard let text = node.stringValue, text.contains(token) else { continue }
105+
node.stringValue = text.replacingOccurrences(of: token, with: "")
40106
}
41-
try fm.moveItem(at: workRoot.appendingPathComponent("output.docx"), to: finalURL)
42-
try? fm.removeItem(at: workRoot)
43107
}
44108

45109
private func headingStyleFor(section: DocumentSection, defaultHeadingStyle: String, bodyStyle: String) -> String {
@@ -86,7 +150,7 @@ struct DocumentDocxWriter {
86150
private func paragraphXML(text: String, style: String) -> String {
87151
let escaped = escape(text)
88152
return """
89-
<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
153+
<w:p xmlns:w="\(wordNamespace)">
90154
<w:pPr>
91155
<w:pStyle w:val="\(style)"/>
92156
</w:pPr>
@@ -108,7 +172,7 @@ struct DocumentDocxWriter {
108172
return "<w:tr>\(cells)</w:tr>"
109173
}.joined()
110174
return """
111-
<w:tbl xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
175+
<w:tbl xmlns:w="\(wordNamespace)">
112176
<w:tblPr>
113177
<w:tblStyle w:val="\(style)"/>
114178
<w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="1" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/>
@@ -126,7 +190,7 @@ struct DocumentDocxWriter {
126190
let shading = isHeader ? "<w:shd w:val=\"clear\" w:color=\"auto\" w:fill=\"D9D9D9\"/>" : ""
127191
let bold = isHeader ? "<w:b/>" : ""
128192
return """
129-
<w:tc>
193+
<w:tc xmlns:w="\(wordNamespace)">
130194
<w:tcPr>
131195
<w:tcW w:w="5000" w:type="dxa"/>
132196
\(shading)
@@ -154,7 +218,7 @@ struct DocumentDocxWriter {
154218
return escaped
155219
}
156220

157-
private func xml(for section: DocumentSection, headingStyle: String, bodyStyle: String, tableStyle: String) -> String {
221+
private func renderSection(_ section: DocumentSection, headingStyle: String, bodyStyle: String, tableStyle: String) -> String {
158222
switch section.value {
159223
case .text(let value):
160224
return paragraphsXML(text: value, style: section.definition.style ?? headingStyleFor(section: section, defaultHeadingStyle: headingStyle, bodyStyle: bodyStyle))

0 commit comments

Comments
 (0)