diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 00b4c88c..fa93bfe2 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -267,6 +267,7 @@ class nwLabels: BUILD_FMT = { nwBuildFmt.ODT: QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)"), nwBuildFmt.FODT: QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)"), + nwBuildFmt.DOCX: QT_TRANSLATE_NOOP("Constant", "Microsoft Word Document (.docx)"), nwBuildFmt.HTML: QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)"), nwBuildFmt.NWD: QT_TRANSLATE_NOOP("Constant", "novelWriter Markup (.txt)"), nwBuildFmt.STD_MD: QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)"), @@ -278,6 +279,7 @@ class nwLabels: BUILD_EXT = { nwBuildFmt.ODT: ".odt", nwBuildFmt.FODT: ".fodt", + nwBuildFmt.DOCX: ".docx", nwBuildFmt.HTML: ".html", nwBuildFmt.NWD: ".txt", nwBuildFmt.STD_MD: ".md", diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 732b2d76..562ae052 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -37,6 +37,7 @@ from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.enum import nwBuildFmt from novelwriter.error import formatException, logException +from novelwriter.formats.todocx import ToDocX from novelwriter.formats.tohtml import ToHtml from novelwriter.formats.tokenizer import Tokenizer from novelwriter.formats.tomarkdown import ToMarkdown @@ -185,6 +186,13 @@ class NWBuildDocument: if self._build.getBool("format.replaceTabs"): makeObj.replaceTabs(nSpaces=4, spaceChar=" ") + elif bFormat == nwBuildFmt.DOCX: + makeObj = ToDocX(self._project) + filtered = self._setupBuild(makeObj) + makeObj.initDocument() + + yield from self._iterBuild(makeObj, filtered) + elif bFormat == nwBuildFmt.PDF: makeObj = ToQTextDocument(self._project) filtered = self._setupBuild(makeObj) diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 55bcbfc6..75081d36 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -180,13 +180,14 @@ class nwBuildFmt(Enum): ODT = 0 FODT = 1 - HTML = 2 - NWD = 3 - STD_MD = 4 - EXT_MD = 5 - PDF = 6 - J_HTML = 7 - J_NWD = 8 + DOCX = 2 + HTML = 3 + NWD = 4 + STD_MD = 5 + EXT_MD = 6 + PDF = 7 + J_HTML = 8 + J_NWD = 9 class nwStatusShape(Enum): diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py new file mode 100644 index 00000000..69d020e6 --- /dev/null +++ b/novelwriter/formats/todocx.py @@ -0,0 +1,216 @@ +""" +novelWriter – DOCX Text Converter +================================= + +File History: +Created: 2024-10-15 [2.6b1] ToRaw + +This file is a part of novelWriter +Copyright 2018–2024, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET + +from datetime import datetime +from pathlib import Path +from zipfile import ZipFile + +from novelwriter import __version__ +from novelwriter.common import xmlIndent +from novelwriter.core.project import NWProject +from novelwriter.formats.tokenizer import Tokenizer + +logger = logging.getLogger(__name__) + +# Types and Relationships +WORD_BASE = "application/vnd.openxmlformats-officedocument" +RELS_TYPE = "application/vnd.openxmlformats-package.relationships+xml" +REL_CORE = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" +REL_APP = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" +REL_DOC = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" + +# Main XML NameSpaces +PROPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" +TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types" +RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +XML_NS = { + "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + "dc": "http://purl.org/dc/elements/1.1/", + "dcterms": "http://purl.org/dc/terms/", + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", +} +for ns, uri in XML_NS.items(): + ET.register_namespace(ns, uri) + + +def _mkTag(ns: str, tag: str) -> str: + """Assemble namespace and tag name.""" + if uri := XML_NS.get(ns, ""): + return f"{{{uri}}}{tag}" + logger.warning("Missing xml namespace '%s'", ns) + return tag + + +def _addSingle( + parent: ET.Element, + tag: str, + text: str | int | None = None, + attrib: dict | None = None +) -> None: + """Add a single value to a parent element.""" + xSub = ET.SubElement(parent, tag, attrib=attrib or {}) + if text is not None: + xSub.text = str(text) + return + + +class ToDocX(Tokenizer): + """Core: DocX Document Writer + + Extend the Tokenizer class to writer DocX Document files. + """ + + def __init__(self, project: NWProject) -> None: + super().__init__(project) + + # XML + self._dDoc = ET.Element("") # document.xml + + self._xBody = ET.Element("") # Text body + + # Internal + self._dLanguage = "en-GB" + + return + + ## + # Setters + ## + + def setLanguage(self, language: str | None) -> None: + """Set language for the document.""" + if language: + self._dLanguage = language.replace("_", "-") + return + + ## + # Class Methods + ## + + def initDocument(self) -> None: + """Initialises the DocX document structure.""" + + self._dDoc = ET.Element(_mkTag("w", "document")) + self._xBody = ET.SubElement(self._dDoc, _mkTag("w", "body")) + + return + + def doConvert(self) -> None: + """Convert the list of text tokens into XML elements.""" + self._result = "" # Not used, but cleared just in case + return + + def saveDocument(self, path: Path) -> None: + """Save the data to a .docx file.""" + timeStamp = datetime.now().isoformat(sep="T", timespec="seconds") + + # .rels + dRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS}) + _addSingle(dRels, "Relationship", attrib={ + "Id": "rId1", "Type": REL_CORE, "Target": "docProps/core.xml", + }) + _addSingle(dRels, "Relationship", attrib={ + "Id": "rId2", "Type": REL_APP, "Target": "docProps/app.xml", + }) + _addSingle(dRels, "Relationship", attrib={ + "Id": "rId3", "Type": REL_DOC, "Target": "word/document.xml", + }) + + # core.xml + dCore = ET.Element("coreProperties") + tsAttr = {_mkTag("xsi", "type"): "dcterms:W3CDTF"} + _addSingle(dCore, _mkTag("dcterms", "created"), timeStamp, attrib=tsAttr) + _addSingle(dCore, _mkTag("dcterms", "modified"), timeStamp, attrib=tsAttr) + _addSingle(dCore, _mkTag("dc", "creator"), self._project.data.author) + _addSingle(dCore, _mkTag("dc", "title"), self._project.data.name) + _addSingle(dCore, _mkTag("dc", "creator"), self._project.data.author) + _addSingle(dCore, _mkTag("dc", "language"), self._dLanguage) + _addSingle(dCore, _mkTag("cp", "revision"), str(self._project.data.saveCount)) + _addSingle(dCore, _mkTag("cp", "lastModifiedBy"), self._project.data.author) + + # app.xml + dApp = ET.Element("Properties", attrib={"xmlns": PROPS_NS}) + _addSingle(dApp, "TotalTime", self._project.data.editTime // 60) + _addSingle(dApp, "Application", f"novelWriter/{__version__}") + if count := self._counts.get("allWords"): + _addSingle(dApp, "Words", count) + if count := self._counts.get("textWordChars"): + _addSingle(dApp, "Characters", count) + if count := self._counts.get("textChars"): + _addSingle(dApp, "CharactersWithSpaces", count) + if count := self._counts.get("paragraphCount"): + _addSingle(dApp, "Paragraphs", count) + + # document.xml.rels + dDRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS}) + + # [Content_Types].xml + dCont = ET.Element("Types", attrib={"xmlns": TYPES_NS}) + _addSingle(dCont, "Default", attrib={ + "Extension": "xml", "ContentType": "application/xml", + }) + _addSingle(dCont, "Default", attrib={ + "Extension": "rels", "ContentType": RELS_TYPE, + }) + _addSingle(dCont, "Override", attrib={ + "PartName": "/_rels/.rels", + "ContentType": RELS_TYPE, + }) + _addSingle(dCont, "Override", attrib={ + "PartName": "/docProps/core.xml", + "ContentType": f"{WORD_BASE}.extended-properties+xml", + }) + _addSingle(dCont, "Override", attrib={ + "PartName": "/docProps/app.xml", + "ContentType": "application/vnd.openxmlformats-package.core-properties+xml", + }) + _addSingle(dCont, "Override", attrib={ + "PartName": "/word/_rels/document.xml.rels", + "ContentType": RELS_TYPE, + }) + _addSingle(dCont, "Override", attrib={ + "PartName": "/word/document.xml", + "ContentType": f"{WORD_BASE}.wordprocessingml.document.main+xml", + }) + + def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None: + with zipObj.open(name, mode="w") as fObj: + xml = ET.ElementTree(xObj) + xmlIndent(xml) + xml.write(fObj, encoding="utf-8", xml_declaration=True) + + with ZipFile(path, mode="w") as outZip: + xmlToZip("_rels/.rels", dRels, outZip) + xmlToZip("docProps/core.xml", dCore, outZip) + xmlToZip("docProps/app.xml", dApp, outZip) + xmlToZip("word/_rels/document.xml.rels", dDRels, outZip) + xmlToZip("word/document.xml", self._dDoc, outZip) + xmlToZip("[Content_Types].xml", dCont, outZip) + + return