Skip to content

Commit

Permalink
Oneeightyg issue17 (#33)
Browse files Browse the repository at this point in the history
* DocX can now create Word documents with styles

Introduced a new DocXConfiguration class that allows clients to control certain aspects of docx creation:
  o) The styles.xml file to be included in the docx
  o) Whether the declared font should always be output

Added two new NSAttributedString keys so that clients can indicate  that text should use Word paragraph and character styles:
  o) NSAttributedString.Key.paragraphStyleId
  o) NSAttributedString.Key.characterStyleId

The value for these attributes should be a style id that is present in the Word file.

* Allow clients to force indentation to 0

When translating NSParagraphStyle values to docx, a value of 0 is often used to indicate that no attributes should be output. This is true for paragraph spacing, line height, etc. And, for many of those, a value of 0 doesn’t really make sense.

For indentation, though, setting values to zero can be valid. We don't want to break existing clients (which might rely on this behavior), so we've introduced a constant, `zeroIndent`, which can be used to force indentation to zero, when desired.

* Handle baseline offset NSAttributedString.Key

We assume that a baseline offset of less than zero indicates subscript, and a baseline offset of more than zero indicates superscript.

* Revert "Handle baseline offset NSAttributedString.Key"

This reverts commit c790d92.

Pulling this out of issue17 so that I can file a separate PR for it.

* Revert "Allow clients to force indentation to 0"

This reverts commit f983b7a.

Pulling this out of issue17 so that I can file a separate PR for it.

* DocXConfiguration -> DocXStyleConfiguration

In addition, DocXOptions has a new property, styleConfiguration, which holds an optional DocXStyleConfiguration.

* Add new initializers for DocXStylesConfiguration

The designated initializer now takes an AEXMLDocument. And there are now two new convenience initializers, one that takes a String? and one that takes a URL?.

* additional tests for styles

Co-authored-by: Brad Andalman <[email protected]>
Co-authored-by: Morten Bertz <[email protected]>
  • Loading branch information
3 people authored Jan 13, 2023
1 parent dec3ab6 commit 9999b2e
Show file tree
Hide file tree
Showing 16 changed files with 439 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "7254c74b49cec2cb81520523ba993c671f71b066",
"revision" : "1b662e2e7a091710ad8a963263939984e2ebf527",
"version" : "0.9.14"
}
}
Expand Down
10 changes: 9 additions & 1 deletion DocX/AttributeElements.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ extension Dictionary where Key == NSAttributedString.Key{
let vertAlignElement = vertAlignElement(baselineOffset: baselineOffset) {
attributesElement.addChild(vertAlignElement)
}
// Character styles
if let characterStyleId = self[.characterStyleId] as? String {
attributesElement.addChild(characterStyleElement(styleId: characterStyleId))
}

return attributesElement
}
Expand Down Expand Up @@ -126,7 +130,6 @@ extension Dictionary where Key == NSAttributedString.Key{
outlineElement.addChild(lineCapElement)

return [colorElement,outlineElement]

}

func vertAlignElement(baselineOffset: CGFloat) -> AEXMLElement? {
Expand All @@ -147,6 +150,11 @@ extension Dictionary where Key == NSAttributedString.Key{
return nil
}
}
func characterStyleElement(styleId: String) -> AEXMLElement {
return AEXMLElement(name: "w:rStyle",
value: nil,
attributes: ["w:val": styleId])
}
}


Expand Down
15 changes: 10 additions & 5 deletions DocX/DocX.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,11 @@ struct LinkRelationship:DocumentRelationship{
}

protocol DocX{
func docXDocument(linkRelations:[DocumentRelationship])throws ->String
func docXDocument(linkRelations:[DocumentRelationship],
options:DocXOptions) throws ->String
func writeDocX(to url:URL)throws
func writeDocX(to url:URL, options:DocXOptions)throws
func writeDocX(to url:URL, options:DocXOptions) throws
func prepareLinks(linkXML:AEXMLDocument, mediaURL:URL)->[DocumentRelationship]


}

public let docXUTIType="org.openxmlformats.wordprocessingml.document"
Expand All @@ -48,7 +47,13 @@ public extension NSAttributedString.Key{
```
will result in a page break after *some string*
*/
static let breakType = NSAttributedString.Key.init("com.telethon.docx.attributedstringkey.break")
static let breakType = NSAttributedString.Key("com.telethon.docx.attributedstringkey.break")

/// A custom attribute that specifies the styleId to use for an entire paragraph
static let paragraphStyleId = NSAttributedString.Key("com.telethon.docx.attributedstringkey.paragraphStyleId")

/// A custom attribute that specifies the styleId to use for characters
static let characterStyleId = NSAttributedString.Key("com.telethon.docx.attributedstringkey.characterStyleId")
}

/// Encapsulates different break types in a document.
Expand Down
11 changes: 10 additions & 1 deletion DocX/DocXOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import Foundation
import AEXML


/// Options to specify metadata to be saved with the document
/// Metadata and output settings for docx creation
public struct DocXOptions{

// Metadata
//

/// The author of the document. Defaults to 'DocX'. This value is also used to set the `lastModifiedBy` value.
public var author: String="DocX"

Expand All @@ -33,6 +36,12 @@ public struct DocXOptions{
/// The modification date of the document. Defaults to now.
public var modifiedDate: Date = Date()

// Output settings
//

/// An optional configuration object for style output
public var styleConfiguration: DocXStyleConfiguration?


public init(){}

Expand Down
81 changes: 81 additions & 0 deletions DocX/DocXStyleConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// DocXStyleConfiguration.swift
//
//
// Created by Brad Andalman on 2023/1/2.
//

import Foundation
import AEXML

/// Configuration parameters that control docx styling.
public struct DocXStyleConfiguration {
/// The styles XML document to include
public let stylesXMLDocument: AEXMLDocument?

/// Should the font family be specified explicitly?
/// This can come in handy when a client prefers that a font specified
/// by a Word style (including the default "Normal" style) should be used.
public let outputFontFamily: Bool

/// Designated initializer that takes an AEXMLDocument for the `styles.xml` file
public init(stylesXMLDocument: AEXMLDocument?, outputFontFamily: Bool = true) {
self.stylesXMLDocument = stylesXMLDocument
self.outputFontFamily = outputFontFamily
}

/// Convenience initializer that takes a URL to the `styles.xml` file
public init(stylesXMLURL: URL? = nil, outputFontFamily: Bool = true) throws {
let xmlDocument: AEXMLDocument?
if let xmlURL = stylesXMLURL {
let xmlData = try Data(contentsOf: xmlURL)
xmlDocument = try AEXMLDocument(xml: xmlData,
options: DocXStyleConfiguration.xmlOptions)
} else {
xmlDocument = nil
}

self.init(stylesXMLDocument: xmlDocument, outputFontFamily: outputFontFamily)
}

/// Convenience initializer that takes a string for the `styles.xml` file
public init(stylesXMLString: String? = nil, outputFontFamily: Bool = true) throws {
let xmlDocument: AEXMLDocument?
if let xmlString = stylesXMLString {
xmlDocument = try AEXMLDocument(xml: xmlString,
options: DocXStyleConfiguration.xmlOptions)
} else {
xmlDocument = nil
}

self.init(stylesXMLDocument: xmlDocument, outputFontFamily: outputFontFamily)
}

/// The paragraph styles that were found in the `styles.xml` file during initialization. Use with `NSAttributedString.Key.paragraphId`.
public var availableParagraphStyles:[String]?{
return self.stylesXMLDocument?.root.children.filter({element in
element.name == "w:style" && element.attributes["w:type"] == "paragraph"
}).compactMap({$0.attributes["w:styleId"]})
}

/// The character styles that were found in the `styles.xml` file during initialization. Use with `NSAttributedString.Key.characterId`.
public var availableCharacterStyles:[String]?{
return self.stylesXMLDocument?.root.children.filter({element in
element.name == "w:style" && element.attributes["w:type"] == "character"
}).compactMap({$0.attributes["w:styleId"]})
}

/// Returns the AEXML options used to create an AEXMLDocument
private static var xmlOptions: AEXMLOptions = {
var options = AEXMLOptions()
options.parserSettings.shouldTrimWhitespace=false
options.documentHeader.standalone="yes"

// Enable escaping so that reserved characters, like < & >, don't
// result in an invalid docx file
// See: https://github.com/shinjukunian/DocX/issues/24
options.escape = true

return options
}()
}
5 changes: 4 additions & 1 deletion DocX/DocXWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ public class DocXWriter{
/// - pages: an array of NSAttributedStrings. A page break fill be inserted after each page.
/// - url: The destination of the resulting .docx, e.g. ```myfile.docx```
/// - options: an optional instance of `DocXOptions`. This allows you to specify metadata for the document.
/// - configuration: an optional instance of `DocXConfiguration` that allows you to control the docx output.
/// - Throws: Throws errors for I/O.
public class func write(pages:[NSAttributedString], to url:URL, options:DocXOptions = DocXOptions()) throws{
public class func write(pages:[NSAttributedString],
to url:URL,
options:DocXOptions = DocXOptions()) throws {
guard let first=pages.first else {return}
let result=NSMutableAttributedString(attributedString: first)
let pageSeperator=NSAttributedString(string: "\r", attributes: [.breakType:BreakType.page])
Expand Down
56 changes: 36 additions & 20 deletions DocX/DocXWriting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,31 +42,56 @@ extension DocX where Self : NSAttributedString{
}


func buildParagraphs(paragraphRanges:[ParagraphRange], linkRelations:[DocumentRelationship])->[AEXMLElement]{
func buildParagraphs(paragraphRanges:[ParagraphRange],
linkRelations:[DocumentRelationship],
options:DocXOptions) -> [AEXMLElement]{
return paragraphRanges.map({range in
let paragraph=ParagraphElement(string: self, range: range, linkRelations: linkRelations)
let paragraph=ParagraphElement(string: self,
range: range,
linkRelations: linkRelations,
options: options)
return paragraph
})
}

func docXDocument(linkRelations:[DocumentRelationship] = [DocumentRelationship]())throws ->String{
var options=AEXMLOptions()
options.documentHeader.standalone="yes"
func docXDocument(linkRelations:[DocumentRelationship] = [DocumentRelationship](),
options:DocXOptions = DocXOptions())throws ->String{
var xmlOptions=AEXMLOptions()
xmlOptions.documentHeader.standalone="yes"

// Enable escaping so that reserved characters, like < & >, don't
// result in an invalid docx file
// See: https://github.com/shinjukunian/DocX/issues/18
options.escape = true
xmlOptions.escape = true

options.lineSeparator="\n"
xmlOptions.lineSeparator="\n"
let root=DocumentRoot()
let document=AEXMLDocument(root: root, options: options)
let document=AEXMLDocument(root: root, options: xmlOptions)
let body=AEXMLElement(name: "w:body")
root.addChild(body)
body.addChildren(self.buildParagraphs(paragraphRanges: self.paragraphRanges, linkRelations: linkRelations))
body.addChildren(self.buildParagraphs(paragraphRanges: self.paragraphRanges,
linkRelations: linkRelations,
options: options))
body.addChild(pageDef)
return document.xmlCompact
}

func lastRelationshipIdIndex(linkXML: AEXMLDocument) -> Int {
let relationships=linkXML["Relationships"]
let presentIds=relationships.children.map({$0.attributes}).compactMap({$0["Id"]}).sorted(by: {s1, s2 in
return s1.compare(s2, options: [.numeric], range: nil, locale: nil) == .orderedAscending
})

let lastIdIDX:Int
if let lastID=presentIds.last?.trimmingCharacters(in: .letters){
lastIdIDX=Int(lastID) ?? 0
}
else{
lastIdIDX=0
}

return lastIdIDX
}

func prepareLinks(linkXML: AEXMLDocument, mediaURL:URL) -> [DocumentRelationship] {
var linkURLS=[URL]()
Expand All @@ -79,18 +104,8 @@ extension DocX where Self : NSAttributedString{
}
})
guard linkURLS.count > 0 else {return imageRelationships}
let relationships=linkXML["Relationships"]
let presentIds=relationships.children.map({$0.attributes}).compactMap({$0["Id"]}).sorted(by: {s1, s2 in
return s1.compare(s2, options: [.numeric], range: nil, locale: nil) == .orderedAscending
})

let lastIdIDX:Int
if let lastID=presentIds.last?.trimmingCharacters(in: .letters){
lastIdIDX=Int(lastID) ?? 0
}
else{
lastIdIDX=0
}
let lastIdIDX = lastRelationshipIdIndex(linkXML: linkXML)

let linkRelationShips=linkURLS.enumerated().map({(arg)->LinkRelationship in
let (idx, url) = arg
Expand All @@ -99,6 +114,7 @@ extension DocX where Self : NSAttributedString{
return relationShip
})

let relationships=linkXML["Relationships"]
relationships.addChildren(linkRelationShips.map({$0.element}))

return linkRelationShips + imageRelationships
Expand Down
4 changes: 3 additions & 1 deletion DocX/NSAttributedString+DocX.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ extension NSAttributedString:DocX{
/// - Parameters:
/// - url: the destination URL, e.g. ```myfolder/mydocument.docx```
/// - options: an optional instance of `DocXOptions`. This allows you to specify metadata for the document.
/// - configuration: an optional instance of `DocXConfiguration` that allows you to control the docx output.
/// - Throws: Throws for I/O related errors
public func writeDocX(to url: URL, options:DocXOptions = DocXOptions()) throws{
public func writeDocX(to url: URL,
options: DocXOptions = DocXOptions()) throws{
try self.writeDocX_builtin(to: url, options: options)
}
}
Expand Down
39 changes: 38 additions & 1 deletion DocX/NSAttributedString+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
//

import Foundation
import AEXML

#if canImport(Cocoa)
import Cocoa
#elseif canImport(UIKit)
Expand All @@ -17,6 +19,17 @@ extension NSAttributedString{
struct ParagraphRange{
let range: NSRange
let breakType: BreakType
let styleId: String?

var styleElement: AEXMLElement? {
if let styleId = styleId {
return AEXMLElement(name: "w:pStyle",
value: nil,
attributes: ["w:val": styleId])
} else {
return nil
}
}
}


Expand Down Expand Up @@ -51,8 +64,32 @@ extension NSAttributedString{
breakType = .wrap
}

// Determine whether a `paragraphStyleId` is specified for the *entire*
// paragraph. If it isn't, then we won't apply the style at all.
//
let paragraphStyleId: String?
var longestEffectiveRange = NSRange()
// If the paragraph doesn't have any text (i.e. it's a blank line),
// we still may want to apply a paragraph style. If that's the case,
// our range will be the `enclosingRange` (the paragraph break);
// otherwise, just use the substring range.
let paragraphStyleRange = (substringRange.length == 0) ? enclosingRange : substringRange
if let styleId = self.attribute(.paragraphStyleId,
at: paragraphStyleRange.location,
longestEffectiveRange: &longestEffectiveRange,
in: paragraphStyleRange) as? String,
longestEffectiveRange == paragraphStyleRange {
paragraphStyleId = styleId
} else {
// Either no `paragraphStyleId` was set, or it doesn't apply
// to an entire paragraph
paragraphStyleId = nil
}

// Create a ParagraphRange and add it to our list
let paragraphRange = ParagraphRange(range: substringRange, breakType: breakType)
let paragraphRange = ParagraphRange(range: substringRange,
breakType: breakType,
styleId: paragraphStyleId)
ranges.append(paragraphRange)
}
return ranges
Expand Down
Loading

0 comments on commit 9999b2e

Please sign in to comment.