@@ -7,6 +7,8 @@ enum DocumentDocxWriterError: Error {
77}
88
99struct 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