novelWriter 2.5.3__py3-none-any.whl → 2.6b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/METADATA +1 -1
  2. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/RECORD +80 -60
  3. novelwriter/__init__.py +49 -10
  4. novelwriter/assets/i18n/project_en_GB.json +1 -0
  5. novelwriter/assets/icons/typicons_dark/icons.conf +8 -0
  6. novelwriter/assets/icons/typicons_dark/mixed_copy.svg +4 -0
  7. novelwriter/assets/icons/typicons_dark/mixed_margin-bottom.svg +6 -0
  8. novelwriter/assets/icons/typicons_dark/mixed_margin-left.svg +6 -0
  9. novelwriter/assets/icons/typicons_dark/mixed_margin-right.svg +6 -0
  10. novelwriter/assets/icons/typicons_dark/mixed_margin-top.svg +6 -0
  11. novelwriter/assets/icons/typicons_dark/mixed_size-height.svg +6 -0
  12. novelwriter/assets/icons/typicons_dark/mixed_size-width.svg +6 -0
  13. novelwriter/assets/icons/typicons_dark/nw_toolbar.svg +5 -0
  14. novelwriter/assets/icons/typicons_light/icons.conf +8 -0
  15. novelwriter/assets/icons/typicons_light/mixed_copy.svg +4 -0
  16. novelwriter/assets/icons/typicons_light/mixed_margin-bottom.svg +6 -0
  17. novelwriter/assets/icons/typicons_light/mixed_margin-left.svg +6 -0
  18. novelwriter/assets/icons/typicons_light/mixed_margin-right.svg +6 -0
  19. novelwriter/assets/icons/typicons_light/mixed_margin-top.svg +6 -0
  20. novelwriter/assets/icons/typicons_light/mixed_size-height.svg +6 -0
  21. novelwriter/assets/icons/typicons_light/mixed_size-width.svg +6 -0
  22. novelwriter/assets/icons/typicons_light/nw_toolbar.svg +5 -0
  23. novelwriter/assets/manual.pdf +0 -0
  24. novelwriter/assets/sample.zip +0 -0
  25. novelwriter/common.py +100 -2
  26. novelwriter/config.py +25 -15
  27. novelwriter/constants.py +168 -60
  28. novelwriter/core/buildsettings.py +66 -39
  29. novelwriter/core/coretools.py +145 -147
  30. novelwriter/core/docbuild.py +132 -170
  31. novelwriter/core/index.py +38 -37
  32. novelwriter/core/item.py +41 -8
  33. novelwriter/core/itemmodel.py +518 -0
  34. novelwriter/core/options.py +4 -1
  35. novelwriter/core/project.py +67 -89
  36. novelwriter/core/spellcheck.py +9 -14
  37. novelwriter/core/status.py +7 -5
  38. novelwriter/core/tree.py +268 -287
  39. novelwriter/dialogs/docmerge.py +7 -17
  40. novelwriter/dialogs/preferences.py +46 -33
  41. novelwriter/dialogs/projectsettings.py +5 -5
  42. novelwriter/enum.py +36 -23
  43. novelwriter/extensions/configlayout.py +27 -12
  44. novelwriter/extensions/modified.py +13 -1
  45. novelwriter/extensions/pagedsidebar.py +5 -5
  46. novelwriter/formats/shared.py +155 -0
  47. novelwriter/formats/todocx.py +1191 -0
  48. novelwriter/formats/tohtml.py +451 -0
  49. novelwriter/{core → formats}/tokenizer.py +487 -491
  50. novelwriter/formats/tomarkdown.py +217 -0
  51. novelwriter/{core → formats}/toodt.py +311 -432
  52. novelwriter/formats/toqdoc.py +484 -0
  53. novelwriter/formats/toraw.py +91 -0
  54. novelwriter/gui/doceditor.py +342 -284
  55. novelwriter/gui/dochighlight.py +96 -84
  56. novelwriter/gui/docviewer.py +88 -31
  57. novelwriter/gui/docviewerpanel.py +17 -25
  58. novelwriter/gui/editordocument.py +17 -2
  59. novelwriter/gui/itemdetails.py +25 -28
  60. novelwriter/gui/mainmenu.py +129 -63
  61. novelwriter/gui/noveltree.py +45 -47
  62. novelwriter/gui/outline.py +196 -249
  63. novelwriter/gui/projtree.py +594 -1241
  64. novelwriter/gui/search.py +9 -10
  65. novelwriter/gui/sidebar.py +7 -6
  66. novelwriter/gui/theme.py +10 -5
  67. novelwriter/guimain.py +100 -196
  68. novelwriter/shared.py +66 -27
  69. novelwriter/text/counting.py +2 -0
  70. novelwriter/text/patterns.py +168 -60
  71. novelwriter/tools/manusbuild.py +14 -12
  72. novelwriter/tools/manuscript.py +120 -78
  73. novelwriter/tools/manussettings.py +424 -291
  74. novelwriter/tools/welcome.py +4 -4
  75. novelwriter/tools/writingstats.py +3 -3
  76. novelwriter/types.py +23 -7
  77. novelwriter/core/tohtml.py +0 -530
  78. novelwriter/core/tomarkdown.py +0 -252
  79. novelwriter/core/toqdoc.py +0 -419
  80. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/LICENSE.md +0 -0
  81. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/WHEEL +0 -0
  82. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/entry_points.txt +0 -0
  83. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1191 @@
1
+ """
2
+ novelWriter – DocX Text Converter
3
+ =================================
4
+
5
+ File History:
6
+ Created: 2024-10-18 [2.6b1] ToDocX
7
+ Created: 2024-10-18 [2.6b1] DocXParagraph
8
+
9
+ This file is a part of novelWriter
10
+ Copyright 2018–2024, Veronica Berglyd Olsen
11
+
12
+ This program is free software: you can redistribute it and/or modify
13
+ it under the terms of the GNU General Public License as published by
14
+ the Free Software Foundation, either version 3 of the License, or
15
+ (at your option) any later version.
16
+
17
+ This program is distributed in the hope that it will be useful, but
18
+ WITHOUT ANY WARRANTY; without even the implied warranty of
19
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
20
+ General Public License for more details.
21
+
22
+ You should have received a copy of the GNU General Public License
23
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import re
29
+ import xml.etree.ElementTree as ET
30
+
31
+ from datetime import datetime
32
+ from pathlib import Path
33
+ from typing import NamedTuple
34
+ from zipfile import ZIP_DEFLATED, ZipFile
35
+
36
+ from PyQt5.QtCore import QMargins, QSize
37
+ from PyQt5.QtGui import QColor
38
+
39
+ from novelwriter import __version__
40
+ from novelwriter.common import firstFloat, xmlElement, xmlSubElem
41
+ from novelwriter.constants import nwHeadFmt, nwStyles
42
+ from novelwriter.core.project import NWProject
43
+ from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt
44
+ from novelwriter.formats.tokenizer import Tokenizer
45
+ from novelwriter.types import QtHexRgb
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ # RegEx
50
+ RX_TEXT = re.compile(r"([\n\t])", re.UNICODE)
51
+
52
+ # Types and Relationships
53
+ OOXML_SCM = "http://schemas.openxmlformats.org"
54
+ WORD_BASE = "application/vnd.openxmlformats-officedocument.wordprocessingml"
55
+ RELS_TYPE = "application/vnd.openxmlformats-package.relationships+xml"
56
+ RELS_BASE = f"{OOXML_SCM}/officeDocument/2006/relationships"
57
+
58
+ # Main XML NameSpaces
59
+ XML_NS = {
60
+ "cp": f"{OOXML_SCM}/package/2006/metadata/core-properties",
61
+ "dc": "http://purl.org/dc/elements/1.1/",
62
+ "dcterms": "http://purl.org/dc/terms/",
63
+ "r": RELS_BASE,
64
+ "w": f"{OOXML_SCM}/wordprocessingml/2006/main",
65
+ "xml": "http://www.w3.org/XML/1998/namespace",
66
+ "xsi": "http://www.w3.org/2001/XMLSchema-instance",
67
+ }
68
+ for ns, uri in XML_NS.items():
69
+ ET.register_namespace(ns, uri)
70
+
71
+
72
+ def _wTag(tag: str) -> str:
73
+ """Assemble namespace and tag name for standard w namespace."""
74
+ return f"{{{OOXML_SCM}/wordprocessingml/2006/main}}{tag}"
75
+
76
+
77
+ def _mkTag(ns: str, tag: str) -> str:
78
+ """Assemble namespace and tag name."""
79
+ if uri := XML_NS.get(ns, ""):
80
+ return f"{{{uri}}}{tag}"
81
+ logger.warning("Missing xml namespace '%s'", ns)
82
+ return tag
83
+
84
+
85
+ def _docXCol(color: QColor) -> str:
86
+ """Format a QColor as the DocX accepted value."""
87
+ return color.name(QtHexRgb).lstrip("#")
88
+
89
+
90
+ def _wText(parent: ET.Element, text: str) -> ET.Element:
91
+ """Create a text element and add the preserve flag if necessary."""
92
+ attrib = {}
93
+ if len(text) > len(text.strip()):
94
+ attrib[_mkTag("xml", "space")] = "preserve"
95
+ return xmlSubElem(parent, _wTag("t"), text, attrib=attrib)
96
+
97
+
98
+ def _mmToSz(value: float) -> int:
99
+ """Convert millimetres to internal margin size units"""
100
+ return int(value*20.0*72.0/25.4)
101
+
102
+
103
+ # Cached
104
+ W_VAL = _wTag("val")
105
+
106
+ # Formatting Codes
107
+ X_BLD = 0x001 # Bold format
108
+ X_ITA = 0x002 # Italic format
109
+ X_DEL = 0x004 # Strikethrough format
110
+ X_UND = 0x008 # Underline format
111
+ X_MRK = 0x010 # Marked format
112
+ X_SUP = 0x020 # Superscript
113
+ X_SUB = 0x040 # Subscript
114
+ X_COL = 0x080 # Coloured text
115
+ X_HRF = 0x100 # Link
116
+
117
+ # Formatting Masks
118
+ M_BLD = ~X_BLD
119
+ M_ITA = ~X_ITA
120
+ M_DEL = ~X_DEL
121
+ M_UND = ~X_UND
122
+ M_MRK = ~X_MRK
123
+ M_SUP = ~X_SUP
124
+ M_SUB = ~X_SUB
125
+ M_COL = ~X_COL
126
+ M_HRF = ~X_HRF
127
+
128
+ # DocX Styles
129
+ S_NORM = "Normal"
130
+ S_TITLE = "Title"
131
+ S_HEAD1 = "Heading1"
132
+ S_HEAD2 = "Heading2"
133
+ S_HEAD3 = "Heading3"
134
+ S_HEAD4 = "Heading4"
135
+ S_SEP = "Separator"
136
+ S_META = "MetaText"
137
+ S_HEAD = "Header"
138
+ S_FNOTE = "FootnoteText"
139
+
140
+
141
+ class DocXXmlRel(NamedTuple):
142
+
143
+ rId: str
144
+ relType: str
145
+ targetMode: str | None = None
146
+
147
+
148
+ class DocXXmlFile(NamedTuple):
149
+
150
+ xml: ET.Element
151
+ path: str
152
+ contentType: str
153
+
154
+
155
+ class DocXParStyle(NamedTuple):
156
+
157
+ name: str
158
+ styleId: str
159
+ size: float
160
+ basedOn: str | None = None
161
+ nextStyle: str | None = None
162
+ before: float | None = None
163
+ after: float | None = None
164
+ left: float | None = None
165
+ line: float | None = None
166
+ indentFirst: float | None = None
167
+ align: str | None = None
168
+ default: bool = False
169
+ level: int | None = None
170
+ color: str | None = None
171
+ bold: bool = False
172
+
173
+
174
+ class ToDocX(Tokenizer):
175
+ """Core: DocX Document Writer
176
+
177
+ Extend the Tokenizer class to writer DocX Document files.
178
+ """
179
+
180
+ def __init__(self, project: NWProject) -> None:
181
+ super().__init__(project)
182
+
183
+ # Properties
184
+ self._headerFormat = ""
185
+ self._pageOffset = 0
186
+
187
+ # Internal
188
+ self._fontFamily = "Liberation Serif"
189
+ self._fontSize = 12.0
190
+ self._pageSize = QSize(_mmToSz(210.0), _mmToSz(297.0))
191
+ self._pageMargins = QMargins(_mmToSz(20.0), _mmToSz(20.0), _mmToSz(20.0), _mmToSz(20.0))
192
+
193
+ # Data Variables
194
+ self._pars: list[DocXParagraph] = []
195
+ self._rels: dict[str, DocXXmlRel] = {}
196
+ self._files: dict[str, DocXXmlFile] = {}
197
+ self._styles: dict[str, DocXParStyle] = {}
198
+ self._usedNotes: dict[str, int] = {}
199
+ self._usedFields: list[tuple[ET.Element, str]] = []
200
+
201
+ return
202
+
203
+ ##
204
+ # Setters
205
+ ##
206
+
207
+ def setPageLayout(
208
+ self, width: float, height: float, top: float, bottom: float, left: float, right: float
209
+ ) -> None:
210
+ """Set the document page size and margins in millimetres."""
211
+ self._pageSize = QSize(_mmToSz(width), _mmToSz(height))
212
+ self._pageMargins = QMargins(_mmToSz(left), _mmToSz(top), _mmToSz(right), _mmToSz(bottom))
213
+ return
214
+
215
+ def setHeaderFormat(self, format: str, offset: int) -> None:
216
+ """Set the document header format."""
217
+ self._headerFormat = format.strip()
218
+ self._pageOffset = offset
219
+ return
220
+
221
+ ##
222
+ # Class Methods
223
+ ##
224
+
225
+ def initDocument(self) -> None:
226
+ """Initialises the DocX document structure."""
227
+ super().initDocument()
228
+ self._fontFamily = self._textFont.family()
229
+ self._fontSize = self._textFont.pointSizeF()
230
+ self._generateStyles()
231
+ return
232
+
233
+ def doConvert(self) -> None:
234
+ """Convert the list of text tokens into XML elements."""
235
+ bIndent = self._fontSize * self._blockIndent
236
+
237
+ for tType, _, tText, tFormat, tStyle in self._blocks:
238
+
239
+ # Create Paragraph
240
+ par = DocXParagraph()
241
+ self._pars.append(par)
242
+
243
+ # Styles
244
+ if tStyle & BlockFmt.LEFT:
245
+ par.setAlignment("left")
246
+ elif tStyle & BlockFmt.RIGHT:
247
+ par.setAlignment("right")
248
+ elif tStyle & BlockFmt.CENTRE:
249
+ par.setAlignment("center")
250
+ elif tStyle & BlockFmt.JUSTIFY:
251
+ par.setAlignment("both")
252
+
253
+ if tStyle & BlockFmt.PBB:
254
+ par.setPageBreakBefore(True)
255
+ if tStyle & BlockFmt.PBA:
256
+ par.setPageBreakAfter(True)
257
+
258
+ if tStyle & BlockFmt.Z_BTM:
259
+ par.setMarginBottom(0.0)
260
+ if tStyle & BlockFmt.Z_TOP:
261
+ par.setMarginTop(0.0)
262
+
263
+ if tStyle & BlockFmt.IND_T:
264
+ par.setIndentFirst(True)
265
+ if tStyle & BlockFmt.IND_L:
266
+ par.setMarginLeft(bIndent)
267
+ if tStyle & BlockFmt.IND_R:
268
+ par.setMarginRight(bIndent)
269
+
270
+ # Process Text Types
271
+ if tType == BlockTyp.TEXT:
272
+ self._processFragments(par, S_NORM, tText, tFormat)
273
+
274
+ elif tType == BlockTyp.TITLE:
275
+ self._processFragments(par, S_TITLE, tText, tFormat)
276
+
277
+ elif tType == BlockTyp.HEAD1:
278
+ self._processFragments(par, S_HEAD1, tText, tFormat)
279
+
280
+ elif tType == BlockTyp.HEAD2:
281
+ self._processFragments(par, S_HEAD2, tText, tFormat)
282
+
283
+ elif tType == BlockTyp.HEAD3:
284
+ self._processFragments(par, S_HEAD3, tText, tFormat)
285
+
286
+ elif tType == BlockTyp.HEAD4:
287
+ self._processFragments(par, S_HEAD4, tText, tFormat)
288
+
289
+ elif tType == BlockTyp.SEP:
290
+ self._processFragments(par, S_SEP, tText)
291
+
292
+ elif tType == BlockTyp.SKIP:
293
+ self._processFragments(par, S_NORM, "")
294
+
295
+ elif tType == BlockTyp.COMMENT:
296
+ self._processFragments(par, S_META, tText, tFormat)
297
+
298
+ elif tType == BlockTyp.KEYWORD:
299
+ self._processFragments(par, S_META, tText, tFormat)
300
+
301
+ return
302
+
303
+ def closeDocument(self) -> None:
304
+ """Generate all the XML."""
305
+ self._coreXml()
306
+ self._appXml()
307
+ self._stylesXml()
308
+ self._fontTableXml()
309
+
310
+ fId = None
311
+ dId = None
312
+ if self._headerFormat:
313
+ dId = self._defaultHeaderXml()
314
+ fId = self._firstHeaderXml()
315
+
316
+ self._documentXml(fId, dId)
317
+ self._settingsXml()
318
+ if self._usedNotes:
319
+ self._footnotesXml()
320
+
321
+ return
322
+
323
+ def saveDocument(self, path: Path) -> None:
324
+ """Save the data to a .docx file."""
325
+ # Content Lists
326
+ cExts: list[tuple[str, str]] = []
327
+ cExts.append(("xml", "application/xml"))
328
+ cExts.append(("rels", RELS_TYPE))
329
+
330
+ cDocs: list[tuple[str, str]] = []
331
+ cDocs.append(("/_rels/.rels", RELS_TYPE))
332
+ cDocs.append(("/word/_rels/document.xml.rels", RELS_TYPE))
333
+
334
+ # Relationships XML
335
+ rRels = xmlElement("Relationships", attrib={
336
+ "xmlns": f"{OOXML_SCM}/package/2006/relationships"
337
+ })
338
+ wRels = xmlElement("Relationships", attrib={
339
+ "xmlns": f"{OOXML_SCM}/package/2006/relationships"
340
+ })
341
+ for name, rel in self._rels.items():
342
+ isRoot = name in ("core.xml", "app.xml", "document.xml")
343
+ if xml := self._files.get(name):
344
+ target = f"{xml.path}/{name}" if isRoot else name
345
+ cDocs.append((f"/{xml.path}/{name}", xml.contentType))
346
+ else:
347
+ target = name
348
+ attrib = {"Id": rel.rId, "Type": rel.relType, "Target": target}
349
+ if rel.targetMode:
350
+ attrib["TargetMode"] = rel.targetMode
351
+ xmlSubElem(rRels if isRoot else wRels, "Relationship", attrib=attrib)
352
+
353
+ # Content Types XML
354
+ dTypes = xmlElement("Types", attrib={
355
+ "xmlns": f"{OOXML_SCM}/package/2006/content-types"
356
+ })
357
+ for name, content in cExts:
358
+ xmlSubElem(dTypes, "Default", attrib={"Extension": name, "ContentType": content})
359
+ for name, content in cDocs:
360
+ xmlSubElem(dTypes, "Override", attrib={"PartName": name, "ContentType": content})
361
+
362
+ def xmlToZip(name: str, root: ET.Element, zipObj: ZipFile) -> None:
363
+ zipObj.writestr(name, ET.tostring(root, encoding="utf-8", xml_declaration=True))
364
+
365
+ with ZipFile(path, mode="w", compression=ZIP_DEFLATED, compresslevel=3) as outZip:
366
+ xmlToZip("_rels/.rels", rRels, outZip)
367
+ xmlToZip("word/_rels/document.xml.rels", wRels, outZip)
368
+ for name, rel in self._files.items():
369
+ xmlToZip(f"{rel.path}/{name}", rel.xml, outZip)
370
+ xmlToZip("[Content_Types].xml", dTypes, outZip)
371
+
372
+ return
373
+
374
+ ##
375
+ # Internal Functions
376
+ ##
377
+
378
+ def _processFragments(
379
+ self, par: DocXParagraph, pStyle: str, text: str, tFmt: T_Formats | None = None
380
+ ) -> None:
381
+ """Apply formatting tags to text."""
382
+ par.setStyle(self._styles.get(pStyle))
383
+ xFmt = 0x00
384
+ xNode = None
385
+ fStart = 0
386
+ fLink = ""
387
+ fClass = ""
388
+ for fPos, fFmt, fData in tFmt or []:
389
+
390
+ if xNode is not None:
391
+ par.addContent(xNode)
392
+ xNode = None
393
+
394
+ if temp := text[fStart:fPos]:
395
+ par.addContent(self._textRunToXml(temp, xFmt, fClass, fLink))
396
+
397
+ if fFmt == TextFmt.B_B:
398
+ xFmt |= X_BLD
399
+ elif fFmt == TextFmt.B_E:
400
+ xFmt &= M_BLD
401
+ elif fFmt == TextFmt.I_B:
402
+ xFmt |= X_ITA
403
+ elif fFmt == TextFmt.I_E:
404
+ xFmt &= M_ITA
405
+ elif fFmt == TextFmt.D_B:
406
+ xFmt |= X_DEL
407
+ elif fFmt == TextFmt.D_E:
408
+ xFmt &= M_DEL
409
+ elif fFmt == TextFmt.U_B:
410
+ xFmt |= X_UND
411
+ elif fFmt == TextFmt.U_E:
412
+ xFmt &= M_UND
413
+ elif fFmt == TextFmt.M_B:
414
+ xFmt |= X_MRK
415
+ elif fFmt == TextFmt.M_E:
416
+ xFmt &= M_MRK
417
+ elif fFmt == TextFmt.SUP_B:
418
+ xFmt |= X_SUP
419
+ elif fFmt == TextFmt.SUP_E:
420
+ xFmt &= M_SUP
421
+ elif fFmt == TextFmt.SUB_B:
422
+ xFmt |= X_SUB
423
+ elif fFmt == TextFmt.SUB_E:
424
+ xFmt &= M_SUB
425
+ elif fFmt == TextFmt.COL_B:
426
+ xFmt |= X_COL
427
+ fClass = fData
428
+ elif fFmt == TextFmt.COL_E:
429
+ xFmt &= M_COL
430
+ fClass = ""
431
+ elif fFmt == TextFmt.HRF_B:
432
+ xFmt |= X_HRF
433
+ fLink = fData
434
+ elif fFmt == TextFmt.HRF_E:
435
+ xFmt &= M_HRF
436
+ fLink = ""
437
+ elif fFmt == TextFmt.FNOTE:
438
+ xNode = self._generateFootnote(fData)
439
+ elif fFmt == TextFmt.FIELD:
440
+ xNode = self._generateField(fData, xFmt)
441
+ elif fFmt == TextFmt.STRIP:
442
+ pass
443
+
444
+ # Move pos for next pass
445
+ fStart = fPos
446
+
447
+ if xNode is not None:
448
+ par.addContent(xNode)
449
+
450
+ if temp := text[fStart:]:
451
+ par.addContent(self._textRunToXml(temp, xFmt, fClass, fLink))
452
+
453
+ return
454
+
455
+ def _textRunToXml(self, text: str | None, fmt: int, fClass: str, fLink: str) -> ET.Element:
456
+ """Encode the text run into XML."""
457
+ xR = xmlElement(_wTag("r"))
458
+ rPr = xmlSubElem(xR, _wTag("rPr"))
459
+ if fmt & X_BLD:
460
+ xmlSubElem(rPr, _wTag("b"))
461
+ if fmt & X_ITA:
462
+ xmlSubElem(rPr, _wTag("i"))
463
+ if fmt & X_UND:
464
+ xmlSubElem(rPr, _wTag("u"), attrib={W_VAL: "single"})
465
+ if fmt & X_MRK:
466
+ xmlSubElem(rPr, _wTag("shd"), attrib={
467
+ _wTag("fill"): _docXCol(self._theme.highlight), W_VAL: "clear",
468
+ })
469
+ if fmt & X_DEL:
470
+ xmlSubElem(rPr, _wTag("strike"))
471
+ if fmt & X_SUP:
472
+ xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "superscript"})
473
+ if fmt & X_SUB:
474
+ xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "subscript"})
475
+ if fmt & X_COL and (color := self._classes.get(fClass)):
476
+ xmlSubElem(rPr, _wTag("color"), attrib={W_VAL: _docXCol(color)})
477
+
478
+ if isinstance(text, str):
479
+ for segment in RX_TEXT.split(text):
480
+ if segment == "\n":
481
+ xmlSubElem(xR, _wTag("br"))
482
+ elif segment == "\t":
483
+ xmlSubElem(xR, _wTag("tab"))
484
+ elif segment:
485
+ _wText(xR, segment)
486
+
487
+ if fmt & X_HRF and fLink:
488
+ xmlSubElem(rPr, _wTag("rStyle"), attrib={W_VAL: "InternetLink"})
489
+ xH = xmlElement(_wTag("hyperlink"), attrib={
490
+ _mkTag("r", "id"): self._appendExternalRel(fLink),
491
+ })
492
+ xH.append(xR)
493
+ return xH
494
+
495
+ return xR
496
+
497
+ ##
498
+ # DocX Content
499
+ ##
500
+
501
+ def _generateFootnote(self, key: str) -> ET.Element | None:
502
+ """Generate a footnote XML object."""
503
+ if key in self._footnotes:
504
+ idx = len(self._usedNotes) + 1
505
+ xR = xmlElement(_wTag("r"))
506
+ rPr = xmlSubElem(xR, _wTag("rPr"))
507
+ xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "superscript"})
508
+ xmlSubElem(xR, _wTag("footnoteReference"), attrib={_wTag("id"): str(idx)})
509
+ self._usedNotes[key] = idx
510
+ return xR
511
+ return None
512
+
513
+ def _generateField(self, key: str, fmt: int) -> ET.Element | None:
514
+ """Generate a data field XML object."""
515
+ if key and (field := key.partition(":")[2]):
516
+ xR = self._textRunToXml(None, fmt, "", "")
517
+ xT = _wText(xR, "0")
518
+ self._usedFields.append((xT, field))
519
+ return xR
520
+ return None
521
+
522
+ def _generateStyles(self) -> None:
523
+ """Generate usable styles."""
524
+ styles: list[DocXParStyle] = []
525
+
526
+ hScale = self._scaleHeads
527
+ hColor = _docXCol(self._theme.head) if self._colorHeads else None
528
+ fSz = self._fontSize
529
+
530
+ # Add Normal Style
531
+ styles.append(DocXParStyle(
532
+ name="Normal",
533
+ styleId=S_NORM,
534
+ size=fSz,
535
+ default=True,
536
+ before=fSz * self._marginText[0],
537
+ after=fSz * self._marginText[1],
538
+ line=fSz * self._lineHeight,
539
+ indentFirst=fSz * self._firstWidth,
540
+ align=self._defaultAlign,
541
+ ))
542
+
543
+ # Add Title
544
+ styles.append(DocXParStyle(
545
+ name="Title",
546
+ styleId=S_TITLE,
547
+ size=(nwStyles.H_SIZES[0] * fSz) if hScale else fSz,
548
+ basedOn=S_NORM,
549
+ nextStyle=S_NORM,
550
+ before=fSz * self._marginTitle[0],
551
+ after=fSz * self._marginTitle[1],
552
+ line=fSz * self._lineHeight,
553
+ level=0,
554
+ bold=self._boldHeads,
555
+ ))
556
+
557
+ # Add Heading 1
558
+ styles.append(DocXParStyle(
559
+ name="Heading 1",
560
+ styleId=S_HEAD1,
561
+ size=(nwStyles.H_SIZES[1] * fSz) if hScale else fSz,
562
+ basedOn=S_NORM,
563
+ nextStyle=S_NORM,
564
+ before=fSz * self._marginHead1[0],
565
+ after=fSz * self._marginHead1[1],
566
+ line=fSz * self._lineHeight,
567
+ level=0,
568
+ color=hColor,
569
+ bold=self._boldHeads,
570
+ ))
571
+
572
+ # Add Heading 2
573
+ styles.append(DocXParStyle(
574
+ name="Heading 2",
575
+ styleId=S_HEAD2,
576
+ size=(nwStyles.H_SIZES[2] * fSz) if hScale else fSz,
577
+ basedOn=S_NORM,
578
+ nextStyle=S_NORM,
579
+ before=fSz * self._marginHead2[0],
580
+ after=fSz * self._marginHead2[1],
581
+ line=fSz * self._lineHeight,
582
+ level=1,
583
+ color=hColor,
584
+ bold=self._boldHeads,
585
+ ))
586
+
587
+ # Add Heading 3
588
+ styles.append(DocXParStyle(
589
+ name="Heading 3",
590
+ styleId=S_HEAD3,
591
+ size=(nwStyles.H_SIZES[3] * fSz) if hScale else fSz,
592
+ basedOn=S_NORM,
593
+ nextStyle=S_NORM,
594
+ before=fSz * self._marginHead3[0],
595
+ after=fSz * self._marginHead3[1],
596
+ line=fSz * self._lineHeight,
597
+ level=1,
598
+ color=hColor,
599
+ bold=self._boldHeads,
600
+ ))
601
+
602
+ # Add Heading 4
603
+ styles.append(DocXParStyle(
604
+ name="Heading 4",
605
+ styleId=S_HEAD4,
606
+ size=(nwStyles.H_SIZES[4] * fSz) if hScale else fSz,
607
+ basedOn=S_NORM,
608
+ nextStyle=S_NORM,
609
+ before=fSz * self._marginHead4[0],
610
+ after=fSz * self._marginHead4[1],
611
+ line=fSz * self._lineHeight,
612
+ level=1,
613
+ color=hColor,
614
+ bold=self._boldHeads,
615
+ ))
616
+
617
+ # Add Separator
618
+ styles.append(DocXParStyle(
619
+ name="Separator",
620
+ styleId=S_SEP,
621
+ size=fSz,
622
+ basedOn=S_NORM,
623
+ nextStyle=S_NORM,
624
+ before=fSz * self._marginSep[0],
625
+ after=fSz * self._marginSep[1],
626
+ line=fSz * self._lineHeight,
627
+ align="center",
628
+ ))
629
+
630
+ # Add Text Meta Style
631
+ styles.append(DocXParStyle(
632
+ name="Meta Text",
633
+ styleId=S_META,
634
+ size=fSz,
635
+ basedOn=S_NORM,
636
+ nextStyle=S_NORM,
637
+ before=fSz * self._marginMeta[0],
638
+ after=fSz * self._marginMeta[1],
639
+ line=fSz * self._lineHeight,
640
+ ))
641
+
642
+ # Header
643
+ styles.append(DocXParStyle(
644
+ name="Header",
645
+ styleId=S_HEAD,
646
+ size=fSz,
647
+ basedOn=S_NORM,
648
+ align="right",
649
+ ))
650
+
651
+ # Footnote
652
+ styles.append(DocXParStyle(
653
+ name="Footnote Text",
654
+ styleId=S_FNOTE,
655
+ size=nwStyles.T_SMALL * fSz,
656
+ basedOn=S_NORM,
657
+ before=0.0,
658
+ after=fSz * self._marginFoot[1],
659
+ left=fSz * self._marginFoot[0],
660
+ line=fSz * self._lineHeight,
661
+ ))
662
+
663
+ # Add to Cache
664
+ for style in styles:
665
+ self._styles[style.styleId] = style
666
+
667
+ return
668
+
669
+ def _nextRelId(self) -> str:
670
+ """Generate the next unique rId."""
671
+ return f"rId{len(self._rels) + 1}"
672
+
673
+ def _appendExternalRel(self, target: str) -> str:
674
+ """Append external rel to the registry."""
675
+ if rel := self._rels.get(target):
676
+ return rel.rId
677
+ rId = self._nextRelId()
678
+ self._rels[target] = DocXXmlRel(
679
+ rId=rId,
680
+ relType=f"{RELS_BASE}/hyperlink",
681
+ targetMode="External"
682
+ )
683
+ return rId
684
+
685
+ def _appXml(self) -> str:
686
+ """Populate app.xml."""
687
+ rId = self._nextRelId()
688
+ xRoot = xmlElement("Properties", attrib={
689
+ "xmlns": f"{OOXML_SCM}/officeDocument/2006/extended-properties"
690
+ })
691
+ self._rels["app.xml"] = DocXXmlRel(
692
+ rId=rId,
693
+ relType=f"{RELS_BASE}/extended-properties",
694
+ )
695
+ self._files["app.xml"] = DocXXmlFile(
696
+ xml=xRoot,
697
+ path="docProps",
698
+ contentType="application/vnd.openxmlformats-officedocument.extended-properties+xml",
699
+ )
700
+
701
+ xmlSubElem(xRoot, "TotalTime", self._project.data.editTime // 60)
702
+ xmlSubElem(xRoot, "Application", f"novelWriter/{__version__}")
703
+ if count := self._counts.get("allWords"):
704
+ xmlSubElem(xRoot, "Words", count)
705
+ if count := self._counts.get("textWordChars"):
706
+ xmlSubElem(xRoot, "Characters", count)
707
+ if count := self._counts.get("textChars"):
708
+ xmlSubElem(xRoot, "CharactersWithSpaces", count)
709
+ if count := self._counts.get("paragraphCount"):
710
+ xmlSubElem(xRoot, "Paragraphs", count)
711
+
712
+ return rId
713
+
714
+ def _coreXml(self) -> str:
715
+ """Populate app.xml."""
716
+ rId = self._nextRelId()
717
+ xRoot = xmlElement(_mkTag("cp", "coreProperties"))
718
+ self._rels["core.xml"] = DocXXmlRel(
719
+ rId=rId,
720
+ relType=f"{OOXML_SCM}/package/2006/relationships/metadata/core-properties",
721
+ )
722
+ self._files["core.xml"] = DocXXmlFile(
723
+ xml=xRoot,
724
+ path="docProps",
725
+ contentType="application/vnd.openxmlformats-package.core-properties+xml",
726
+ )
727
+
728
+ timeStamp = datetime.now().isoformat(sep="T", timespec="seconds")
729
+ tsAttr = {_mkTag("xsi", "type"): "dcterms:W3CDTF"}
730
+ xmlSubElem(xRoot, _mkTag("dcterms", "created"), timeStamp, attrib=tsAttr)
731
+ xmlSubElem(xRoot, _mkTag("dcterms", "modified"), timeStamp, attrib=tsAttr)
732
+ xmlSubElem(xRoot, _mkTag("dc", "creator"), self._project.data.author)
733
+ xmlSubElem(xRoot, _mkTag("dc", "title"), self._project.data.name)
734
+ xmlSubElem(xRoot, _mkTag("dc", "language"), self._dLocale.name())
735
+ xmlSubElem(xRoot, _mkTag("cp", "revision"), str(self._project.data.saveCount))
736
+ xmlSubElem(xRoot, _mkTag("cp", "lastModifiedBy"), self._project.data.author)
737
+
738
+ return rId
739
+
740
+ def _stylesXml(self) -> str:
741
+ """Populate styles.xml."""
742
+ rId = self._nextRelId()
743
+ xRoot = xmlElement(_wTag("styles"))
744
+ self._rels["styles.xml"] = DocXXmlRel(
745
+ rId=rId,
746
+ relType=f"{RELS_BASE}/styles",
747
+ )
748
+ self._files["styles.xml"] = DocXXmlFile(
749
+ xml=xRoot,
750
+ path="word",
751
+ contentType=f"{WORD_BASE}.styles+xml",
752
+ )
753
+
754
+ # Default Style
755
+ xStyl = xmlSubElem(xRoot, _wTag("docDefaults"))
756
+ xRDef = xmlSubElem(xStyl, _wTag("rPrDefault"))
757
+ xPDef = xmlSubElem(xStyl, _wTag("pPrDefault"))
758
+ xRPr = xmlSubElem(xRDef, _wTag("rPr"))
759
+ xPPr = xmlSubElem(xPDef, _wTag("pPr"))
760
+
761
+ size = str(int(2.0 * self._fontSize))
762
+ line = str(int(20.0 * self._lineHeight * self._fontSize))
763
+
764
+ xmlSubElem(xRPr, _wTag("rFonts"), attrib={
765
+ _wTag("ascii"): self._fontFamily,
766
+ _wTag("hAnsi"): self._fontFamily,
767
+ _wTag("cs"): self._fontFamily,
768
+ })
769
+ xmlSubElem(xRPr, _wTag("sz"), attrib={W_VAL: size})
770
+ xmlSubElem(xRPr, _wTag("szCs"), attrib={W_VAL: size})
771
+ xmlSubElem(xRPr, _wTag("lang"), attrib={W_VAL: self._dLocale.name()})
772
+ xmlSubElem(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
773
+
774
+ # Paragraph Styles
775
+ for style in self._styles.values():
776
+ sAttr = {}
777
+ sAttr[_wTag("type")] = "paragraph"
778
+ sAttr[_wTag("styleId")] = style.styleId
779
+ if style.default:
780
+ sAttr[_wTag("default")] = "1"
781
+
782
+ size = firstFloat(style.size, self._fontSize)
783
+
784
+ xStyl = xmlSubElem(xRoot, _wTag("style"), attrib=sAttr)
785
+ xmlSubElem(xStyl, _wTag("name"), attrib={W_VAL: style.name})
786
+ if style.basedOn:
787
+ xmlSubElem(xStyl, _wTag("basedOn"), attrib={W_VAL: style.basedOn})
788
+ if style.nextStyle:
789
+ xmlSubElem(xStyl, _wTag("next"), attrib={W_VAL: style.nextStyle})
790
+
791
+ # pPr Node
792
+ pPr = xmlSubElem(xStyl, _wTag("pPr"))
793
+ xmlSubElem(pPr, _wTag("spacing"), attrib={
794
+ _wTag("before"): str(int(20.0 * firstFloat(style.before))),
795
+ _wTag("after"): str(int(20.0 * firstFloat(style.after))),
796
+ _wTag("line"): str(int(20.0 * firstFloat(style.line, size))),
797
+ _wTag("lineRule"): "auto",
798
+ })
799
+ if style.left is not None:
800
+ xmlSubElem(pPr, _wTag("ind"), attrib={
801
+ _wTag("left"): str(int(20.0 * style.left)),
802
+ })
803
+ if style.align:
804
+ xmlSubElem(pPr, _wTag("jc"), attrib={W_VAL: style.align})
805
+ if style.level is not None:
806
+ xmlSubElem(pPr, _wTag("outlineLvl"), attrib={W_VAL: str(style.level)})
807
+
808
+ # rPr Node
809
+ rPr = xmlSubElem(xStyl, _wTag("rPr"))
810
+ if style.bold:
811
+ xmlSubElem(rPr, _wTag("b"))
812
+ if style.color:
813
+ xmlSubElem(rPr, _wTag("color"), attrib={W_VAL: style.color})
814
+ xmlSubElem(rPr, _wTag("sz"), attrib={W_VAL: str(int(2.0 * size))})
815
+ xmlSubElem(rPr, _wTag("szCs"), attrib={W_VAL: str(int(2.0 * size))})
816
+
817
+ # Character Style
818
+ xStyl = xmlSubElem(xRoot, _wTag("style"), attrib={
819
+ _wTag("type"): "character",
820
+ _wTag("styleId"): "InternetLink"
821
+ })
822
+ xmlSubElem(xStyl, _wTag("name"), attrib={W_VAL: "Hyperlink"})
823
+ rPr = xmlSubElem(xStyl, _wTag("rPr"))
824
+ xmlSubElem(rPr, _wTag("color"), attrib={W_VAL: _docXCol(self._theme.link)})
825
+ xmlSubElem(rPr, _wTag("u"), attrib={W_VAL: "single"})
826
+
827
+ return rId
828
+
829
+ def _defaultHeaderXml(self) -> str:
830
+ """Populate header1.xml."""
831
+ rId = self._nextRelId()
832
+ xRoot = xmlElement(_wTag("hdr"))
833
+ self._rels["header1.xml"] = DocXXmlRel(
834
+ rId=rId,
835
+ relType=f"{RELS_BASE}/header",
836
+ )
837
+ self._files["header1.xml"] = DocXXmlFile(
838
+ xml=xRoot,
839
+ path="word",
840
+ contentType=f"{WORD_BASE}.header+xml",
841
+ )
842
+
843
+ xP = xmlSubElem(xRoot, _wTag("p"))
844
+ xPPr = xmlSubElem(xP, _wTag("pPr"))
845
+ xmlSubElem(xPPr, _wTag("pStyle"), attrib={W_VAL: S_HEAD})
846
+ xmlSubElem(xPPr, _wTag("jc"), attrib={W_VAL: "right"})
847
+ xmlSubElem(xPPr, _wTag("rPr"))
848
+
849
+ pre, page, post = self._headerFormat.partition(nwHeadFmt.DOC_PAGE)
850
+ pre = pre.replace(nwHeadFmt.DOC_PROJECT, self._project.data.name)
851
+ pre = pre.replace(nwHeadFmt.DOC_AUTHOR, self._project.data.author)
852
+ post = post.replace(nwHeadFmt.DOC_PROJECT, self._project.data.name)
853
+ post = post.replace(nwHeadFmt.DOC_AUTHOR, self._project.data.author)
854
+
855
+ wFldCT = _wTag("fldCharType")
856
+ if pre:
857
+ xR = xmlSubElem(xP, _wTag("r"))
858
+ _wText(xR, pre)
859
+ if page:
860
+ xR = xmlSubElem(xP, _wTag("r"))
861
+ xmlSubElem(xR, _wTag("fldChar"), attrib={wFldCT: "begin"})
862
+ xR = xmlSubElem(xP, _wTag("r"))
863
+ xmlSubElem(xR, _wTag("instrText"), "PAGE", attrib={_mkTag("xml", "space"): "preserve"})
864
+ xR = xmlSubElem(xP, _wTag("r"))
865
+ xmlSubElem(xR, _wTag("fldChar"), attrib={wFldCT: "separate"})
866
+ xR = xmlSubElem(xP, _wTag("r"))
867
+ xmlSubElem(xR, _wTag("fldChar"), attrib={wFldCT: "end"})
868
+ if post:
869
+ xR = xmlSubElem(xP, _wTag("r"))
870
+ _wText(xR, post)
871
+
872
+ return rId
873
+
874
+ def _firstHeaderXml(self) -> str:
875
+ """Populate header2.xml."""
876
+ rId = self._nextRelId()
877
+ xRoot = xmlElement(_wTag("hdr"))
878
+ self._rels["header2.xml"] = DocXXmlRel(
879
+ rId=rId,
880
+ relType=f"{RELS_BASE}/header",
881
+ )
882
+ self._files["header2.xml"] = DocXXmlFile(
883
+ xml=xRoot,
884
+ path="word",
885
+ contentType=f"{WORD_BASE}.header+xml",
886
+ )
887
+
888
+ xP = xmlSubElem(xRoot, _wTag("p"))
889
+ xPPr = xmlSubElem(xP, _wTag("pPr"))
890
+ xmlSubElem(xPPr, _wTag("pStyle"), attrib={W_VAL: S_HEAD})
891
+ xmlSubElem(xPPr, _wTag("jc"), attrib={W_VAL: "right"})
892
+ xmlSubElem(xPPr, _wTag("rPr"))
893
+
894
+ xR = xmlSubElem(xP, _wTag("r"))
895
+ xmlSubElem(xR, _wTag("rPr"))
896
+
897
+ return rId
898
+
899
+ def _documentXml(self, hFirst: str | None, hDefault: str | None) -> str:
900
+ """Populate document.xml."""
901
+ rId = self._nextRelId()
902
+ xRoot = xmlElement(_wTag("document"))
903
+ xBody = xmlSubElem(xRoot, _wTag("body"))
904
+ self._rels["document.xml"] = DocXXmlRel(
905
+ rId=rId,
906
+ relType=f"{RELS_BASE}/officeDocument",
907
+ )
908
+ self._files["document.xml"] = DocXXmlFile(
909
+ xml=xRoot,
910
+ path="word",
911
+ contentType=f"{WORD_BASE}.document.main+xml",
912
+ )
913
+
914
+ # Map all Page Break Before to After where possible
915
+ pars: list[DocXParagraph] = []
916
+ for i, par in enumerate(self._pars):
917
+ if i > 0 and par.pageBreakBefore:
918
+ prev = self._pars[i-1]
919
+ par.setPageBreakBefore(False)
920
+ prev.setPageBreakAfter(True)
921
+
922
+ pars.append(par)
923
+
924
+ # Replace fields if there are stats available
925
+ if self._usedFields and self._counts:
926
+ for xField, field in self._usedFields:
927
+ if (value := self._counts.get(field)) is not None:
928
+ xField.text = self._formatInt(value)
929
+
930
+ # Write Paragraphs
931
+ for par in pars:
932
+ par.toXml(xBody)
933
+
934
+ # Write Settings
935
+ xSect = xmlSubElem(xBody, _wTag("sectPr"))
936
+ if hFirst and hDefault:
937
+ xmlSubElem(xSect, _wTag("headerReference"), attrib={
938
+ _wTag("type"): "first", _mkTag("r", "id"): hFirst,
939
+ })
940
+ xmlSubElem(xSect, _wTag("headerReference"), attrib={
941
+ _wTag("type"): "default", _mkTag("r", "id"): hDefault,
942
+ })
943
+
944
+ xFn = xmlSubElem(xSect, _wTag("footnotePr"))
945
+ xmlSubElem(xFn, _wTag("numFmt"), attrib={
946
+ W_VAL: "decimal",
947
+ })
948
+
949
+ xmlSubElem(xSect, _wTag("pgSz"), attrib={
950
+ _wTag("w"): str(self._pageSize.width()),
951
+ _wTag("h"): str(self._pageSize.height()),
952
+ _wTag("orient"): "portrait",
953
+ })
954
+ xmlSubElem(xSect, _wTag("pgMar"), attrib={
955
+ _wTag("top"): str(self._pageMargins.top()),
956
+ _wTag("right"): str(self._pageMargins.right()),
957
+ _wTag("bottom"): str(self._pageMargins.bottom()),
958
+ _wTag("left"): str(self._pageMargins.left()),
959
+ _wTag("header"): str(self._pageMargins.top() - int(35.0*self._fontSize)),
960
+ _wTag("footer"): "0",
961
+ _wTag("gutter"): "0",
962
+ })
963
+ xmlSubElem(xSect, _wTag("pgNumType"), attrib={
964
+ _wTag("start"): str(1 - self._pageOffset),
965
+ _wTag("fmt"): "decimal",
966
+ })
967
+ xmlSubElem(xSect, _wTag("titlePg"))
968
+
969
+ return rId
970
+
971
+ def _footnotesXml(self) -> str:
972
+ """Populate footnotes.xml."""
973
+ rId = self._nextRelId()
974
+ xRoot = xmlElement(_wTag("footnotes"))
975
+ self._rels["footnotes.xml"] = DocXXmlRel(
976
+ rId=rId,
977
+ relType=f"{RELS_BASE}/footnotes",
978
+ )
979
+ self._files["footnotes.xml"] = DocXXmlFile(
980
+ xml=xRoot,
981
+ path="word",
982
+ contentType=f"{WORD_BASE}.footnotes+xml",
983
+ )
984
+
985
+ for key, idx in self._usedNotes.items():
986
+ par = DocXParagraph()
987
+ par.setIsFootnote(True)
988
+ if content := self._footnotes.get(key):
989
+ self._processFragments(par, S_FNOTE, content[0], content[1])
990
+ par.toXml(xmlSubElem(xRoot, _wTag("footnote"), attrib={_wTag("id"): str(idx)}))
991
+
992
+ return rId
993
+
994
+ def _fontTableXml(self) -> str:
995
+ """Populate fontTable.xml."""
996
+ rId = self._nextRelId()
997
+ xRoot = xmlElement(_wTag("fonts"))
998
+ self._rels["fontTable.xml"] = DocXXmlRel(
999
+ rId=rId,
1000
+ relType=f"{RELS_BASE}/fontTable",
1001
+ )
1002
+ self._files["fontTable.xml"] = DocXXmlFile(
1003
+ xml=xRoot,
1004
+ path="word",
1005
+ contentType=f"{WORD_BASE}.fontTable+xml",
1006
+ )
1007
+
1008
+ xFont = xmlSubElem(xRoot, _wTag("font"), attrib={
1009
+ _wTag("name"): self._textFont.family(),
1010
+ })
1011
+ xmlSubElem(xFont, _wTag("pitch"), attrib={
1012
+ W_VAL: "fixed" if self._textFont.fixedPitch() else "variable",
1013
+ })
1014
+
1015
+ return rId
1016
+
1017
+ def _settingsXml(self) -> str:
1018
+ """Populate settings.xml."""
1019
+ rId = self._nextRelId()
1020
+ xRoot = xmlElement(_wTag("settings"))
1021
+ self._rels["settings.xml"] = DocXXmlRel(
1022
+ rId=rId,
1023
+ relType=f"{RELS_BASE}/settings",
1024
+ )
1025
+ self._files["settings.xml"] = DocXXmlFile(
1026
+ xml=xRoot,
1027
+ path="word",
1028
+ contentType=f"{WORD_BASE}.settings+xml",
1029
+ )
1030
+
1031
+ xSet = xmlSubElem(xRoot, _wTag("footnotePr"))
1032
+ xmlSubElem(xSet, _wTag("numFmt"), attrib={W_VAL: "decimal"})
1033
+
1034
+ xSet = xmlSubElem(xRoot, _wTag("compat"))
1035
+ xmlSubElem(xSet, _wTag("compatSetting"), attrib={
1036
+ _wTag("name"): "compatibilityMode",
1037
+ _wTag("uri"): "http://schemas.microsoft.com/office/word",
1038
+ W_VAL: "12",
1039
+ })
1040
+
1041
+ if self._counts:
1042
+ xVars = xmlSubElem(xRoot, _wTag("docVars"))
1043
+ for key, value in self._counts.items():
1044
+ xmlSubElem(xVars, _wTag("docVar"), attrib={
1045
+ _wTag("name"): f"Manuscript{key[:1].upper()}{key[1:]}",
1046
+ W_VAL: str(value),
1047
+ })
1048
+
1049
+ return rId
1050
+
1051
+
1052
+ class DocXParagraph:
1053
+
1054
+ __slots__ = (
1055
+ "_content", "_style", "_textAlign",
1056
+ "_topMargin", "_bottomMargin", "_leftMargin", "_rightMargin",
1057
+ "_indentFirst", "_breakBefore", "_breakAfter", "_footnoteRef",
1058
+ )
1059
+
1060
+ def __init__(self) -> None:
1061
+ self._content: list[ET.Element] = []
1062
+ self._style: DocXParStyle | None = None
1063
+ self._textAlign: str | None = None
1064
+ self._topMargin: float | None = None
1065
+ self._bottomMargin: float | None = None
1066
+ self._leftMargin: float | None = None
1067
+ self._rightMargin: float | None = None
1068
+ self._indentFirst = False
1069
+ self._breakBefore = False
1070
+ self._breakAfter = False
1071
+ self._footnoteRef = False
1072
+ return
1073
+
1074
+ ##
1075
+ # Properties
1076
+ ##
1077
+
1078
+ @property
1079
+ def pageBreakBefore(self) -> bool:
1080
+ """Has page break before."""
1081
+ return self._breakBefore
1082
+
1083
+ ##
1084
+ # Setters
1085
+ ##
1086
+
1087
+ def setStyle(self, style: DocXParStyle | None) -> None:
1088
+ """Set the paragraph style."""
1089
+ self._style = style
1090
+ return
1091
+
1092
+ def setAlignment(self, value: str) -> None:
1093
+ """Set paragraph alignment."""
1094
+ if value in ("left", "center", "right", "both"):
1095
+ self._textAlign = value
1096
+ return
1097
+
1098
+ def setMarginTop(self, value: float) -> None:
1099
+ """Set margin above in pt."""
1100
+ self._topMargin = value
1101
+ return
1102
+
1103
+ def setMarginBottom(self, value: float) -> None:
1104
+ """Set margin below in pt."""
1105
+ self._bottomMargin = value
1106
+ return
1107
+
1108
+ def setMarginLeft(self, value: float) -> None:
1109
+ """Set margin left in pt."""
1110
+ self._leftMargin = value
1111
+ return
1112
+
1113
+ def setMarginRight(self, value: float) -> None:
1114
+ """Set margin right in pt."""
1115
+ self._rightMargin = value
1116
+ return
1117
+
1118
+ def setIndentFirst(self, state: bool) -> None:
1119
+ """Set first line indent."""
1120
+ self._indentFirst = state
1121
+ return
1122
+
1123
+ def setPageBreakBefore(self, state: bool) -> None:
1124
+ """Set page break before flag."""
1125
+ self._breakBefore = state
1126
+ return
1127
+
1128
+ def setPageBreakAfter(self, state: bool) -> None:
1129
+ """Set page break after flag."""
1130
+ self._breakAfter = state
1131
+ return
1132
+
1133
+ def setIsFootnote(self, state: bool) -> None:
1134
+ """Set is footnote flag."""
1135
+ self._footnoteRef = state
1136
+ return
1137
+
1138
+ ##
1139
+ # Methods
1140
+ ##
1141
+
1142
+ def addContent(self, run: ET.Element) -> None:
1143
+ """Add a run segment to the paragraph."""
1144
+ self._content.append(run)
1145
+ return
1146
+
1147
+ def toXml(self, body: ET.Element) -> None:
1148
+ """Called after all content is set."""
1149
+ if style := self._style:
1150
+ xP = xmlSubElem(body, _wTag("p"))
1151
+
1152
+ # Values
1153
+ indent = {}
1154
+ if self._indentFirst and style.indentFirst is not None:
1155
+ indent[_wTag("firstLine")] = str(int(20.0 * style.indentFirst))
1156
+ if self._leftMargin is not None:
1157
+ indent[_wTag("left")] = str(int(20.0 * self._leftMargin))
1158
+ if self._rightMargin is not None:
1159
+ indent[_wTag("right")] = str(int(20.0 * self._rightMargin))
1160
+
1161
+ # Paragraph
1162
+ pPr = xmlSubElem(xP, _wTag("pPr"))
1163
+ xmlSubElem(pPr, _wTag("pStyle"), attrib={W_VAL: style.styleId})
1164
+ if self._topMargin is not None or self._bottomMargin is not None:
1165
+ xmlSubElem(pPr, _wTag("spacing"), attrib={
1166
+ _wTag("before"): str(int(20.0 * firstFloat(self._topMargin, style.before))),
1167
+ _wTag("after"): str(int(20.0 * firstFloat(self._bottomMargin, style.after))),
1168
+ _wTag("line"): str(int(20.0 * firstFloat(style.line, style.size))),
1169
+ _wTag("lineRule"): "auto",
1170
+ })
1171
+ if indent:
1172
+ xmlSubElem(pPr, _wTag("ind"), attrib=indent)
1173
+ if self._textAlign:
1174
+ xmlSubElem(pPr, _wTag("jc"), attrib={W_VAL: self._textAlign})
1175
+
1176
+ # Text
1177
+ if self._footnoteRef:
1178
+ xR = xmlSubElem(xP, _wTag("r"))
1179
+ rPr = xmlSubElem(xR, _wTag("rPr"))
1180
+ xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "superscript"})
1181
+ xmlSubElem(xR, _wTag("footnoteRef"))
1182
+ if self._breakBefore:
1183
+ xR = xmlSubElem(xP, _wTag("r"))
1184
+ xmlSubElem(xR, _wTag("br"), attrib={_wTag("type"): "page"})
1185
+ for xR in self._content:
1186
+ xP.append(xR)
1187
+ if self._breakAfter:
1188
+ xR = xmlSubElem(xP, _wTag("r"))
1189
+ xmlSubElem(xR, _wTag("br"), attrib={_wTag("type"): "page"})
1190
+
1191
+ return