diff --git a/.gitignore b/.gitignore
index 66c21d61..3d1b337e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ __pycache__
/sample/meta
*.bak
*.lock
+ToC.txt
# PyTest
/prof/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed99680c..d37eee96 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,112 @@
# novelWriter Changelog
+## Version 1.2 RC 1 [2021-03-02]
+
+### Release Notes
+
+This is the release candidate of 1.2. The release is intended for testing of new features and to
+sort out potential bugs before the full release. Please take this into account when working on your
+live projects.
+
+#### The Build Novel Project Tool
+
+The main changes for version 1.2 are to the Build Novel Project tool. The Open Document export, as
+well as the Markdown export, is now handled entirely by code written for novelWriter. Previously,
+these export features depended on the underlying Qt library's save routines connected to the
+preview document shown in the build dialog. Using this method of export both meant that the content
+of the document was dependent on the preview being generated first, and it also meant that the
+exported document had limited support for novelWriter-specific features and custom formatting. The
+new export class should generate a much better result, especially for the Open Document formats.
+The Open Document standard is supported by Open Office, Libre Office, Google Docs, Microsoft Word,
+and probably a number of other applications too. The Markdown export hasn't changed a lot, but
+should be a slight improvement on the previous export feature.
+
+These changes to the build tool also imply that the saving process is now independent of the
+content of the preview window, meaning you don't have to rebuild the preview before saving, which
+was previously the case. To make this more consistent, the PDF export option has been moved to the
+print button as it is actually a print-to-file feature under the hood, not technically a proper PDF
+export format. It is exactly the same as printing to file from the print preview dialog.
+
+In addition to the changes to the export features, the Build Novel Project tool now also has
+controls for line height, which applies to all rich text export formats, and the option to replace
+unicode characters with appropriate html entities for html export.
+
+#### Document Layout Automation
+
+Among other changes in this release are a few improvements to the process of creating and changing
+documents. When a new document is first created, the header is generated from the assigned label
+and layout.
+
+In addition, for some document layouts, when the user changes the header level of the first header
+of the document, the document layout setting is updated accordingly. This should reduce the need
+for the user to maintain two ways of assigning the role of a given document. This automation only
+applies to combinations of header level and current document layouts where there is no ambiguity.
+For instance, changing the header level in a "Scene" document from level 3 to 2 changes the
+document layout automatically to "Chapter". But changing the first header of a "Book" layout
+document from 1 to 2 does not change the document's layout as the "Book" layout is a generic
+document layout and it's perfectly reasonable for its first header to be a chapter header.
+
+Keep in mind that novelWriter treats documents with layout "Book", "Chapter" and "Scene" exactly
+the same during exports. The distinction is only meant as a way to indicate the purpose of a
+document in the project tree. This new automation is meant to assist in keeping this information up
+to date. The other layouts do have an effect on formatting during export, and are generally left
+alone.
+
+#### The Session Timer and Idle Time
+
+Another change that has been requested by a couple of users is to have the session timer in the
+status bar stop counting when the user is inactive (idle). This feature is optional, and can be
+controlled from Preferences. The definition of "idle" in this context is either that the user is
+active in a different application than novelWriter (loss of focus) or that the user has not made
+any changes to the current document for a given amount of time. The time threshold is by default
+five minutes, but can be altered in Preferences.
+
+In addition, the idle time is also recorded in the session log, and can be viewed in the Writing
+Statistics dialog and exported with the rest of the information. The idle time is recorder in the
+logs regardless of whether the status bar clock takes idle time into consideration or not. So even
+if you turn off the idle time switch in Preferences, the other idle time setting still affects the
+writing stats log entries.
+
+#### Other Changes
+
+The user dictionary of the project, where words added to the dictionary from the document editor
+go, can now be viewed and edited with a new "Project Word List" tool in the "Tools" menu.
+
+A small additional feature added is also the ability to undo the last move of an item in the
+project tree. The keyboard shortcut for this is `Ctrl+Shift+Z`, or it can be accessed from the
+menu. The feature can only undo the last move, but it includes both documents moved to trash, moves
+by up/down keypress or menu entries, and drag and drop moves.
+
+Lastly, a new keyword has been added to mark characters in the story. The new keyword is intended
+to tag a character as the focus character for a chapter or scene. This is useful for stories where
+the point-of-view character and the focus character are different.
+
+_These Release Notes also include the changes from 1.2 Beta 1._
+
+### Detailed Changelog
+
+**Bugfixes**
+
+* If a tag or reference keyword was mistyped, it would still be indexed and put into the index.
+ This caused the index to be deemed invalid on the next loading of the project, triggering a
+ rebuild. A check has been added to the code parsing the lines starting with `@` to ensure only
+ valid keywords are written into the index. Issue #688. PR #690.
+
+**New Features**
+
+* Added a tool to edit the project's user dictionary. This is the dictionary where the "Add Word to
+ Dictionary" actions from the document editor go. The dialog tool allows for listing, removing and
+ adding words to this dictionary. Issue #665. PR #669.
+
+**User Interface**
+
+* Pressing the `F2` key when in the document editor will open the Item Editor for the open document
+ instead of the one selected in the project tree. PR #664.
+* The shortcut for deleting an item in the project tree has been changed from `Ctrl+Del` to
+ `Ctrl+Shift+Del`. This change was also backported to version 1.1.1. Resolves #629. PR #664.
+
+----
+
## Version 1.1.1 [2021-02-21]
### Release Notes
@@ -53,6 +160,72 @@ but is still lacking in functionality compared to Enchant.
----
+## Version 1.2 Beta 1 [2021-02-11]
+
+### Detailed Changelog
+
+**New Features**
+
+* A full Open Document exporter has been written and added for the Build Novel Project tool. This
+ replaces the previously used Qt Save to ODT which just saved the content of the preview window to
+ an ODT file. The Qt pathway was very limited and didn't generate proper formatting classes. The
+ new export class can generate both full `.odt` and flat XML `.fodt` files. The formatting support
+ of this exporter is equivalent or better than the HTML5 exporter, which was previously the best
+ supported option. Solves issue #611. PRs #607, #652, #660, and #654.
+* A full Markdown exporter has been written and added for the Build Novel Project tool. This too
+ replaces a Qt feature that was used to save the content of the preview window into a markdown
+ file. The exporter allows for both standard markdown and GitHub flavour markdown. The only
+ relevant difference being that the latter allows strikethrough text. Issue #617. PR #650.
+* The Build Novel Project tool now has a "Line height" property that is applied to the preview, and
+ to the HTML5 and Open Document export formats. Discussion #653. Issue #654. PR #660.
+* The Build Novel Project tool now has an option to convert Unicode characters to HTML entities on
+ export to HTML5. Previously, some symbols were converted while others were not. This option
+ provides a more consistent "all or nothing" option. PR #660.
+* The last file or folder move in the project tree can now be undone from the Project menu or by
+ pressing `Ctrl+Shift+Z`. PR #632.
+* When a document header level is altered in a novel file of layout type Scene, Chapter,
+ Unnumbered, or Partition, and that document is saved, the document's layout setting as seen in
+ the project tree is updated to reflect the new level of that heading. This helps reduce the
+ duplication of effort by the user to keep this information in sync. See discussion #613. This is
+ an acceptable solution to #614. Issue #618. PR #620.
+* The session timer now records the amount of time the user is idle. Idle is defined to be when the
+ application window does not have focus, and when the user hasn't made any changes to the document
+ open in the editor for a specified amount of time. The default is 5 minutes. The idle time is
+ recorded to the session log and can be shown in Writing Statistics. Optionally, the status bar
+ session timer can also be set to pause when the user is considered idle. Issues #606 and #651.
+ PRs #656 and #661.
+* It is now possible to tag a character as the focus character for a give section of text. This is
+ useful for cases where the point-of-view character differs from the main character of the story,
+ or the part of the story. Issue #605. PR #662.
+
+**User Interface**
+
+* A document's class and layout is now displayed next to its status or importance in the document
+ editor footer bar. PR #628.
+* When a new document is created, the header of the file is automatically generated based on the
+ document's tree label, and the level determined by the selected layout. Issue #530. PR #628.
+* On the Build Novel Project GUI, the PDF option has been moved from the "Save As" button to the
+ "Print" button. This more accurately reflects what it actually does: print the content of the
+ preview to a PDF using the printer pathway. This also means that all remaining items on the "Save
+ As" dropdown list now can be executed regardless of the content of the preview window. They all
+ run their own separate build process. This resolves #611. PR #650.
+* Export to plain text has been dropped. PR #617.
+
+**Code Improvements**
+
+* The index class now scans every element of the loaded index cache before accepting it. This means
+ that any unrecognised content will trigger a full re-indexing. This is particularly useful when
+ opening an index that was saved by a later release. Every entry that requires a lookup is checked
+ to avoid potential key errors for instance. All values are also checked for correct data type.
+ The new check is extensive, but still fast enough that it only adds a few milliseconds to the
+ startup time. PR #619.
+
+**Code Maintenance**
+
+* Cleaned up some redundant code after PR #637. PR #638.
+
+----
+
## Version 1.1 [2021-02-07]
### Release Notes
@@ -87,7 +260,7 @@ _These Release Notes also include the changes from 1.1 RC 1._
**Bugfixes**
* A `None` check in the details panel below the project tree was missing, resulting in an
- occasional error message being printed to the logging output. The error was otherwise handles, so
+ occasional error message being printed to the logging output. The error was otherwise handled, so
this is mainly a fix to prevent the error message. PR #639.
**User Interface**
diff --git a/docs/source/conf.py b/docs/source/conf.py
index f7d8978a..ba3c964d 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -25,9 +25,9 @@ copyright = "2018–2021, Veronica Berglyd Olsen"
author = "Veronica Berglyd Olsen"
# The short X.Y version
-version = "1.1.1"
+version = "1.2"
# The full version, including alpha/beta/rc tags
-release = "1.1.1"
+release = "1.2-rc1"
# -- General configuration ---------------------------------------------------
diff --git a/docs/source/int_introduction.rst b/docs/source/int_introduction.rst
index 4e827724..8bc8b3ad 100644
--- a/docs/source/int_introduction.rst
+++ b/docs/source/int_introduction.rst
@@ -93,13 +93,11 @@ Project Export
==============
The project can at any time be exported to a range of different formats through the
-:guilabel:`Build Novel Project` tool. Natively, novelWriter supports export to plain text file,
-HTML document, novelWriter flavoured markdown, standard markdown (requires Qt 5.14), and to a basic
-Open Document format.
+:guilabel:`Build Novel Project` tool. Natively, novelWriter supports export to Open Document,
+HTML5, and various flavours of Markdown.
-In addition, printing and printing to PDF is also possible. The best supported export format is
-HTML, which can be imported or converted by a number of other tools like Pandoc, or simply imported
-into Libre Office Writer and similar word processors.
+The HTML5 export format is suitable for convertion by a number of other tools like Pandoc, or for
+importing into word processors. In addition, printing and printing to PDF is also possible.
It is also possible to export the content of the project to a JSON file. This is useful if you want
to write your own processing script in for instance Python as the entire novel can be read into a
diff --git a/docs/source/int_started.rst b/docs/source/int_started.rst
index 4feb9c8c..6c2b596c 100644
--- a/docs/source/int_started.rst
+++ b/docs/source/int_started.rst
@@ -44,10 +44,9 @@ The following Python packages are needed to run novelWriter:
* ``pyenchant`` – needed for efficient spell checking (optional).
PyQt/Qt should be at least 5.2.1, but ideally 5.10 or higher for nearly all features to work.
-Exporting to standard Markdown, for instance, requires PyQt/Qt 5.14. Searching using regular
-expressions requires 5.3, and for full Unicode support, 5.13. There is no known minimum version
-requirement for package ``lxml``, but the code was originally written with 4.2, which is therefore
-set as the minimum. It may work on lower versions. You have to test it.
+Searching using regular expressions requires 5.3, and for full Unicode support, 5.13. There is no
+known minimum version requirement for package ``lxml``, but the code was originally written with
+4.2, which is therefore set as the minimum. It may work on lower versions. You have to test it.
Optionally, a package can be installed to interface with the Enchant spell checking libaries, but
this isn't strictly required. If no external spell checking library is available, novelWriter falls
diff --git a/docs/source/usage_export.rst b/docs/source/usage_export.rst
index 76ad3237..b9968e6b 100644
--- a/docs/source/usage_export.rst
+++ b/docs/source/usage_export.rst
@@ -102,6 +102,16 @@ document has a checkmark after the status icon in the :guilabel:`Flags` column.
override these settings.
+.. _a_export_print:
+
+Printing
+========
+
+The print button allows you to print the content in the preview window. You can either print to one
+of your system's printers, or print directly to file. You can also print to file from the regular
+print dialog. The direct to file option is just a shortcut.
+
+
.. _a_export_formats:
Export Formats
@@ -110,21 +120,13 @@ Export Formats
Currently, six formats are supported for exporting.
OpenDocument Format
- This produces an open document ``.odt`` file. The document produced has very little formatting,
- and may require further editing afterwards. For a better formatted office document, you may get
- a better result by exporting to HTML and then importing that HTML document into your office word
- processor. They are generally good at importing HTML documents.
-
-PDF Format
- The PDF export is just a shortcut for print-to-file. For a better PDF result, you may instead
- want to export to HTML and use a word processor to convert the HTML document to PDF.
+ The Build tool can produce either an ``.odt`` file, of a ``.fodt`` file. The latter is just a
+ flat version of the document format as a single XML file.
novelWriter HTML
The HTML export format writes a single ``.htm`` file with minimal style formatting. The exported
HTML document is suitable for further processing by document conversion tools like Pandoc, for
- importing in word processors, or for printing from browser. It is generally the best formatted
- export option and supports all features of novelWriter since it is entirely generated by the
- application and doesn't depend on Qt library features.
+ importing in word processors, or for printing from browser.
novelWriter Markdown
This is simply a concatenation of the project documents selected by the filters. The documents
@@ -132,12 +134,10 @@ novelWriter Markdown
included if they are selected. This is a useful format for exporting the project for later
import back into novelWriter.
-Standard Markdown
- If you have Qt 5.14 or higher, the option to export to plain markdown is available. This feature
- uses Qt's own markdown export feature.
-
-Plain Text
- The plain text export format writes a simple ``.txt`` file without any formatting at all.
+Standard/GitHub Markdown
+ The Markdown export format comes in both Standard and GitHub flavour. The *only* difference in
+ terms of novelWriter functionality is the support of strikethrough text, which is not supported
+ by the Standard flavour, but *is* supported by the GitHub flavour.
.. _a_export_options:
diff --git a/docs/source/usage_interface.rst b/docs/source/usage_interface.rst
index 3c40c3cf..e9d9c4ae 100644
--- a/docs/source/usage_interface.rst
+++ b/docs/source/usage_interface.rst
@@ -377,6 +377,10 @@ Most features are available as keyboard shortcuts. These are as follows:
":kbd:`Ctrl`:kbd:`Z`", "Undo latest changes."
":kbd:`Ctrl`:kbd:`F7`", "Toggle spell checking."
":kbd:`Ctrl`:kbd:`F10`", "Toggle automatic updating of project outline."
+ ":kbd:`Ctrl`:kbd:`Up`", "Move item one step up in the project tree."
+ ":kbd:`Ctrl`:kbd:`Down`", "Move item one step down in the project tree."
+ ":kbd:`Ctrl`:kbd:`Del`", "Delete next word in editor."
+ ":kbd:`Ctrl`:kbd:`Backspace`", "Delete previous word in editor."
":kbd:`Ctrl`:kbd:`'`", "Wrap selected text, or word under cursor, in single quotes."
":kbd:`Ctrl`:kbd:`""`", "Wrap selected text, or word under cursor, in double quotes."
":kbd:`Ctrl`:kbd:`Enter`", "Open the tag or reference under the cursor in the Viewer."
@@ -391,9 +395,7 @@ Most features are available as keyboard shortcuts. These are as follows:
":kbd:`Ctrl`:kbd:`Shift`:kbd:`R`", "Close the document viewer."
":kbd:`Ctrl`:kbd:`Shift`:kbd:`S`", "Save the current project."
":kbd:`Ctrl`:kbd:`Shift`:kbd:`W`", "Close the current project."
- ":kbd:`Ctrl`:kbd:`Shift`:kbd:`Z`", "Alternative sequence for redo last undo."
- ":kbd:`Ctrl`:kbd:`Shift`:kbd:`Up`", "Move item one step up in the project tree."
- ":kbd:`Ctrl`:kbd:`Shift`:kbd:`Down`", "Move item one step down in the project tree."
+ ":kbd:`Ctrl`:kbd:`Shift`:kbd:`Z`", "Undo move of project tree item."
":kbd:`Ctrl`:kbd:`Shift`:kbd:`Del`", "If in the project tree, move a document to trash, or delete a folder."
":kbd:`F1`", "Open the documentation. This will either open the Qt Assistant, if available, or send you to the documentation website."
":kbd:`F2`", "If in the project tree, edit a document or folder settings. (Same as :kbd:`Ctrl`:kbd:`E`)"
@@ -453,6 +455,7 @@ a key or key combination for the inserted content.
":kbd:`Ctrl`:kbd:`K`, :kbd:`Ctrl`:kbd:`D`", "Insert a division sign."
":kbd:`Ctrl`:kbd:`K`, :kbd:`G`", "Insert a ``@tag`` keyword."
":kbd:`Ctrl`:kbd:`K`, :kbd:`V`", "Insert a ``@pov`` keyword."
+ ":kbd:`Ctrl`:kbd:`K`, :kbd:`F`", "Insert a ``@focus`` keyword."
":kbd:`Ctrl`:kbd:`K`, :kbd:`C`", "Insert a ``@char`` keyword."
":kbd:`Ctrl`:kbd:`K`, :kbd:`P`", "Insert a ``@plot`` keyword."
":kbd:`Ctrl`:kbd:`K`, :kbd:`T`", "Insert a ``@time`` keyword."
diff --git a/docs/source/usage_projects.rst b/docs/source/usage_projects.rst
index e00dd234..60266f16 100644
--- a/docs/source/usage_projects.rst
+++ b/docs/source/usage_projects.rst
@@ -51,8 +51,9 @@ no restrictions are enforced by the application. You can use them however you wa
:guilabel:`Characters`
Character notes go in this root folder. These are especially important if one wants to use the
Outline view to see which character appears where, and which part of the story is told from a
- specific character's point-of-view. Tags in this folder can be references using the ``@pov``
- keyword for point-of-view characters, or the ``@char`` keyword for other characters.
+ specific character's point-of-view or focusing on a particular character's storyline. Tags in
+ this folder can be referenced using the ``@pov`` keyword for point-of-view characters,
+ ``@focus`` for a focus character, or the ``@char`` keyword for any other characters.
:guilabel:`Locations`
The locations folder is for various scene locations that you want to track. Tags in this folder
diff --git a/docs/source/usage_structure.rst b/docs/source/usage_structure.rst
index 8bfe81d5..5a903efb 100644
--- a/docs/source/usage_structure.rst
+++ b/docs/source/usage_structure.rst
@@ -103,28 +103,33 @@ allow multiple values.
The point-of-view character for the current section. The target must be a note tag in the
:guilabel:`Character` type root folder.
+``@focus``
+ The character that has the focus for the current section. This can be used in cases where the
+ focus is not a point-of-view character. The target must be a note tag in the
+ :guilabel:`Character` type root folder.
+
``@char``
- Other characters in the current section. The target must be a note tag in a
+ Other characters in the current section. The target must be a note tag in the
:guilabel:`Character` type root folder. This should not include the point-of-view character.
``@plot``
- The plot or subplot advanced in the current section. The target must be a note tag in a
+ The plot or subplot advanced in the current section. The target must be a note tag in the
:guilabel:`Plot` type root folder.
``@time``
- The timelines touched by the current section. The target must be a note tag in a
+ The timelines touched by the current section. The target must be a note tag in the
:guilabel:`Timeline` type root folder.
``@location``
- The location the current section takes place in. The target must be a note tag in a
+ The location the current section takes place in. The target must be a note tag in the
:guilabel:`Locations` type root folder.
``@object``
- Objects present in the current section. The target must be a note tag in an :guilabel:`Object`
+ Objects present in the current section. The target must be a note tag in the :guilabel:`Object`
type root folder.
``@entity``
- Entities present in the current section. The target must be a note tag in an
+ Entities present in the current section. The target must be a note tag in the
:guilabel:`Entities` type root folder.
``@custom``
diff --git a/nw/__init__.py b/nw/__init__.py
index f3464613..b64fce04 100644
--- a/nw/__init__.py
+++ b/nw/__init__.py
@@ -31,7 +31,7 @@ import logging
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QApplication, QErrorMessage
-from nw.error import exceptionHandler
+from nw.error import exceptionHandler, logException
from nw.config import Config
##
@@ -62,9 +62,9 @@ __license__ = "GPLv3"
__author__ = "Veronica Berglyd Olsen"
__maintainer__ = "Veronica Berglyd Olsen"
__email__ = "code@vkbo.net"
-__version__ = "1.1.1"
-__hexversion__ = "0x010101f0"
-__date__ = "2021-02-21"
+__version__ = "1.2rc1"
+__hexversion__ = "0x010200c1"
+__date__ = "2021-03-02"
__status__ = "Stable"
__domain__ = "novelwriter.io"
__url__ = "https://novelwriter.io"
@@ -264,9 +264,9 @@ def main(sysArgs=None):
bundle = NSBundle.mainBundle()
info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
info["CFBundleName"] = "novelWriter"
- except ImportError as e:
+ except ImportError:
logger.error("Failed to set application name")
- logger.error(str(e))
+ logException()
# Import GUI (after dependency checks), and launch
from nw.guimain import GuiMain
diff --git a/nw/assets/icons/fallback/status_idle-dark.svg b/nw/assets/icons/fallback/status_idle-dark.svg
new file mode 100644
index 00000000..b0480586
--- /dev/null
+++ b/nw/assets/icons/fallback/status_idle-dark.svg
@@ -0,0 +1,31 @@
+
+
diff --git a/nw/assets/icons/fallback/status_idle.svg b/nw/assets/icons/fallback/status_idle.svg
new file mode 100644
index 00000000..deafb10d
--- /dev/null
+++ b/nw/assets/icons/fallback/status_idle.svg
@@ -0,0 +1,31 @@
+
+
diff --git a/nw/assets/text/release_notes.htm b/nw/assets/text/release_notes.htm
index 4db3412f..a519dc2f 100644
--- a/nw/assets/text/release_notes.htm
+++ b/nw/assets/text/release_notes.htm
@@ -2,48 +2,75 @@
-
Release Notes for 1.1.1
-
Released on 21 February 2021
-
This patch makes a couple of minor improvements to the GUI. The keyboard shortcut for deleting
-entries in the project tree has been changed from "Ctrl+Del" to "Ctrl+Shift+Del" to free up that
-shortcut for the document editor. It is now possible to use the shortcut for deleting the word in
-front of the cursor, a common and useful feature of many text editors.
-
The way the negative word count filter option works on the Writing Statistics tool has been
-changed to be more intuitive. Enabling this filter now appears to remove all negative entries
-without altering the other entries. Previously, the removed negative counts would be included in
-the following entries to make it consistent with the total word count. In addition, writing
-sessions shorter than five minutes, and with no change in the word count, are no longer recorded in
-the session log file.
-
Other changes include improving the speed of the internal spell checker, used when the Enchant
-spell check library isn't available. The internal spell checker is no longer significantly slower,
-but is still lacking in functionality compared to Enchant.
+
Release Notes for 1.2 RC 1
+
Released on 2 March 2021
+
This is the release candidate of 1.2. The release is intended for testing of new features and to
+sort out potential bugs before the full release. Please take this into account when working on your
+live projects.
+
+
The Build Novel Project Tool
+
The main changes for version 1.2 are to the Build Novel Project tool. The Open Document export,
+as well as the Markdown export, is now handled entirely by code written for novelWriter.
+Previously, these export features depended on the underlying Qt library's save routines connected
+to the preview document shown in the build dialog. Using this method of export both meant that the
+content of the document was dependent on the preview being generated first, and it also meant that
+the exported document had limited support for novelWriter-specific features and custom formatting.
+The new export class should generate a much better result, especially for the Open Document
+formats. The Open Document standard is supported by Open Office, Libre Office, Google Docs,
+Microsoft Word, and probably a number of other applications too. The Markdown export hasn't changed
+a lot, but should be a slight improvement on the previous export feature.
+
These changes to the build tool also imply that the saving process is now independent of the
+content of the preview window, meaning you don't have to rebuild the preview before saving, which
+was previously the case. To make this more consistent, the PDF export option has been moved to the
+print button as it is actually a print-to-file feature under the hood, not technically a proper PDF
+export format. It is exactly the same as printing to file from the print preview dialog.
+
In addition to the changes to the export features, the Build Novel Project tool now also has
+controls for line height, which applies to all rich text export formats, and the option to replace
+unicode characters with appropriate html entities for html export.
+
+
Document Layout Automation
+
Among other changes in this release are a few improvements to the process of creating and
+changing documents. When a new document is first created, the header is generated from the assigned
+label and layout.
+
In addition, for some document layouts, when the user changes the header level of the first
+header of the document, the document layout setting is updated accordingly. This should reduce the
+need for the user to maintain two ways of assigning the role of a given document. This automation
+only applies to combinations of header level and current document layouts where there is no
+ambiguity. For instance, changing the header level in a "Scene" document from level 3 to 2 changes
+the document layout automatically to "Chapter". But changing the first header of a "Book" layout
+document from 1 to 2 does not change the document's layout as the "Book" layout is a generic
+document layout and it's perfectly reasonable for its first header to be a chapter header.
+
Keep in mind that novelWriter treats documents with layout "Book", "Chapter" and "Scene" exactly
+the same during exports. The distinction is only meant as a way to indicate the purpose of a
+document in the project tree. This new automation is meant to assist in keeping this information up
+to date. The other layouts do have an effect on formatting during export, and are generally left
+alone.
+
+
The Session Timer and Idle Time
+
Another change that has been requested by a couple of users is to have the session timer in the
+status bar stop counting when the user is inactive (idle). This feature is optional, and can be
+controlled from Preferences. The definition of "idle" in this context is either that the user is
+active in a different application than novelWriter (loss of focus) or that the user has not made
+any changes to the current document for a given amount of time. The time threshold is by default
+five minutes, but can be altered in Preferences.
+
In addition, the idle time is also recorded in the session log, and can be viewed in the Writing
+Statistics dialog and exported with the rest of the information. The idle time is recorder in the
+logs regardless of whether the status bar clock takes idle time into consideration or not. So even
+if you turn off the idle time switch in Preferences, the other idle time setting still affects the
+writing stats log entries.
+
+
Other Changes
+
The user dictionary of the project, where words added to the dictionary from the document editor
+go, can now be viewed and edited with a new "Project Word List" tool in the "Tools" menu.
+
A small additional feature added is also the ability to undo the last move of an item in the
+project tree. The keyboard shortcut for this is `Ctrl+Shift+Z`, or it can be accessed from the
+menu. The feature can only undo the last move, but it includes both documents moved to trash, moves
+by up/down keypress or menu entries, and drag and drop moves.
+
Lastly, a new keyword has been added to mark characters in the story. The new keyword is
+intended to tag a character as the focus character for a chapter or scene. This is useful for
+stories where the point-of-view character and the focus character are different.
The main change in this release is the addition of a new tab to the project tree on the left
-side of the main window. The regular project tree is now on a tab named "Project", while a new tab
-named "Novel" displays a simpler version of the information on the main "Outline" page. It lists
-all the headers of the novel part of the project, as well as the word count and point-of-view
-character of each section. This is an alternative way to navigate the novel part of the project.
-The various tree views are now also kept better in sync when the user selects various documents and
-headers.
-
In addition, a new information dialog named "Project Details" has been added. It replaces the
-"Details" tab in "Project Settings", and adds more information about the novel part of the project.
-In particular, a "Table of Contents" in the "Contents" tab displays a summary of the main parts
-and chapters of the project, their total word counts, and an estimated page count. This was made in
-response to users asking for ways to estimate the total page count of the project. The page count
-is estimated based on a words per page setting, which can be changed on the dialog window.
-
Since the tabs below the project tree now add some extra room on the GUI, some convenient
-buttons have been added in the same area, with direct access to "Project Details", "Writing
-Statistics" and "Project Setting".
-
A few other minor changes have been made as well. The Preferences dialog has been improved with
-clearer categories and hopefully better help text. Some new options have been added too. They allow
-syntax highlighting of multi-paragraph quotes. The highlighter can now optionally accept quotes to
-be left "hanging", that is, no closing quote in the same paragraph.
This patch makes a couple of minor improvements to the GUI. The keyboard shortcut for deleting
+entries in the project tree has been changed from "Ctrl+Del" to "Ctrl+Shift+Del" to free up that
+shortcut for the document editor. It is now possible to use the shortcut for deleting the word in
+front of the cursor, a common and useful feature of many text editors.
+
The way the negative word count filter option works on the Writing Statistics tool has been
+changed to be more intuitive. Enabling this filter now appears to remove all negative entries
+without altering the other entries. Previously, the removed negative counts would be included in
+the following entries to make it consistent with the total word count. In addition, writing
+sessions shorter than five minutes, and with no change in the word count, are no longer recorded in
+the session log file.
+
Other changes include improving the speed of the internal spell checker, used when the Enchant
+spell check library isn't available. The internal spell checker is no longer significantly slower,
+but is still lacking in functionality compared to Enchant.
The main change in this release is the addition of a new tab to the project tree on the left
+side of the main window. The regular project tree is now on a tab named "Project", while a new tab
+named "Novel" displays a simpler version of the information on the main "Outline" page. It lists
+all the headers of the novel part of the project, as well as the word count and point-of-view
+character of each section. This is an alternative way to navigate the novel part of the project.
+The various tree views are now also kept better in sync when the user selects various documents and
+headers.
+
In addition, a new information dialog named "Project Details" has been added. It replaces the
+"Details" tab in "Project Settings", and adds more information about the novel part of the project.
+In particular, a "Table of Contents" in the "Contents" tab displays a summary of the main parts
+and chapters of the project, their total word counts, and an estimated page count. This was made in
+response to users asking for ways to estimate the total page count of the project. The page count
+is estimated based on a words per page setting, which can be changed on the dialog window.
+
Since the tabs below the project tree now add some extra room on the GUI, some convenient
+buttons have been added in the same area, with direct access to "Project Details", "Writing
+Statistics" and "Project Setting".
+
A few other minor changes have been made as well. The Preferences dialog has been improved with
+clearer categories and hopefully better help text. Some new options have been added too. They allow
+syntax highlighting of multi-paragraph quotes. The highlighter can now optionally accept quotes to
+be left "hanging", that is, no closing quote in the same paragraph.
+
+
+
diff --git a/nw/common.py b/nw/common.py
index 11546c88..826f7370 100644
--- a/nw/common.py
+++ b/nw/common.py
@@ -30,7 +30,9 @@ from datetime import datetime
from PyQt5.QtWidgets import qApp
-from nw.constants import nwConst, nwUnicode
+from nw.constants import (
+ nwConst, nwUnicode, nwItemClass, nwItemType, nwItemLayout
+)
logger = logging.getLogger(__name__)
@@ -103,11 +105,39 @@ def isHandle(theString):
return False
if len(theString) != 13:
return False
- invalidChar = False
for c in theString:
if c not in "0123456789abcdef":
- invalidChar = True
- return not invalidChar
+ return False
+ return True
+
+def isTitleTag(theString):
+ """Check if a string is a valid title string.
+ """
+ if not isinstance(theString, str):
+ return False
+ if len(theString) != 7:
+ return False
+ if not theString.startswith("T"):
+ return False
+ for c in theString[1:]:
+ if c not in "0123456789":
+ return False
+ return True
+
+def isItemClass(theString):
+ """Check if an item is a calid nwItemClass identifier.
+ """
+ return theString in nwItemClass.__members__
+
+def isItemType(theString):
+ """Check if an item is a calid nwItemType identifier.
+ """
+ return theString in nwItemType.__members__
+
+def isItemLayout(theString):
+ """Check if an item is a calid nwItemLayout identifier.
+ """
+ return theString in nwItemLayout.__members__
def hexToInt(value, default=0):
"""Convert a hex string to an integer.
diff --git a/nw/config.py b/nw/config.py
index 4084394f..58229ea5 100644
--- a/nw/config.py
+++ b/nw/config.py
@@ -38,6 +38,7 @@ from PyQt5.QtCore import QT_VERSION_STR, QStandardPaths, QSysInfo
from nw.constants import nwConst, nwFiles, nwUnicode
from nw.common import splitVersionNumber, formatTimeStamp
+from nw.error import logException
logger = logging.getLogger(__name__)
@@ -143,6 +144,9 @@ class Config:
self.allowOpenDQuote = True # Allow open-ended double quotes
self.highlightEmph = True # Add colour to text emphasis
+ self.stopWhenIdle = True # Stop the status bar clock when the user is idle
+ self.userIdleTime = 300 # Time of inactivity to consider user idle
+
## User-Selected Symbols
self.fmtApostrophe = nwUnicode.U_RSQUO
self.fmtSingleQuotes = [nwUnicode.U_LSQUO, nwUnicode.U_RSQUO]
@@ -296,7 +300,7 @@ class Config:
os.mkdir(self.confPath)
except Exception as e:
logger.error("Could not create folder: %s" % self.confPath)
- logger.error(str(e))
+ logException()
self.hasError = True
self.errData.append("Could not create folder: %s" % self.confPath)
self.errData.append(str(e))
@@ -319,7 +323,7 @@ class Config:
os.mkdir(self.dataPath)
except Exception as e:
logger.error("Could not create folder: %s" % self.dataPath)
- logger.error(str(e))
+ logException()
self.hasError = True
self.errData.append("Could not create folder: %s" % self.dataPath)
self.errData.append(str(e))
@@ -364,7 +368,7 @@ class Config:
cnfParse.read_file(inFile)
except Exception as e:
logger.error("Could not load config file")
- logger.error(str(e))
+ logException()
self.hasError = True
self.errData.append("Could not load config file")
self.errData.append(str(e))
@@ -531,6 +535,12 @@ class Config:
self.highlightEmph = self._parseLine(
cnfParse, cnfSec, "highlightemph", self.CNF_BOOL, self.highlightEmph
)
+ self.stopWhenIdle = self._parseLine(
+ cnfParse, cnfSec, "stopwhenidle", self.CNF_BOOL, self.stopWhenIdle
+ )
+ self.userIdleTime = self._parseLine(
+ cnfParse, cnfSec, "useridletime", self.CNF_INT, self.userIdleTime
+ )
## Backup
cnfSec = "Backup"
@@ -671,6 +681,8 @@ class Config:
cnfParse.set(cnfSec, "allowopensquote", str(self.allowOpenSQuote))
cnfParse.set(cnfSec, "allowopendquote", str(self.allowOpenDQuote))
cnfParse.set(cnfSec, "highlightemph", str(self.highlightEmph))
+ cnfParse.set(cnfSec, "stopwhenidle", str(self.stopWhenIdle))
+ cnfParse.set(cnfSec, "useridletime", str(self.userIdleTime))
## Backup
cnfSec = "Backup"
@@ -705,7 +717,7 @@ class Config:
self.confChanged = False
except Exception as e:
logger.error("Could not save config file")
- logger.error(str(e))
+ logException()
self.hasError = True
self.errData.append("Could not save config file")
self.errData.append(str(e))
@@ -981,9 +993,9 @@ class Config:
return self._unpackList(
cnfParse.get(cnfSec, cnfName), cnfDefault, self.CNF_S_LST
)
- except ValueError as e:
+ except ValueError:
logger.error("Failed to load value from config file.")
- logger.error(str(e))
+ logException()
return cnfDefault
return cnfDefault
diff --git a/nw/constants/__init__.py b/nw/constants/__init__.py
index b58b56c3..1a6748a1 100644
--- a/nw/constants/__init__.py
+++ b/nw/constants/__init__.py
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
from nw.constants.iso import isoLanguage, isoCountry
from nw.constants.constants import (
- nwConst, nwLists, nwRegEx, nwFiles, nwKeyWords, nwLabels, nwQuotes, nwUnicode
+ nwConst, nwLists, nwRegEx, nwFiles, nwKeyWords, nwLabels, nwQuotes,
+ nwUnicode, nwHtmlUnicode
)
from nw.constants.enum import (
nwAlert, nwDocAction, nwItemClass, nwItemLayout, nwItemType, nwOutline,
@@ -19,6 +20,7 @@ __all__ = [
"nwLabels",
"nwQuotes",
"nwUnicode",
+ "nwHtmlUnicode",
"nwAlert",
"nwDocAction",
"nwItemClass",
diff --git a/nw/constants/constants.py b/nw/constants/constants.py
index 9d9bd4d1..94c66244 100644
--- a/nw/constants/constants.py
+++ b/nw/constants/constants.py
@@ -55,6 +55,9 @@ class nwLists():
# Item classes where the full list of novel layouts are allowed
CLS_NOVEL = {nwItemClass.NOVEL, nwItemClass.ARCHIVE}
+ # Item classes which do not require items to have same class
+ FREE_CLASS = {nwItemClass.ARCHIVE, nwItemClass.TRASH}
+
# END Class nwLists
class nwRegEx():
@@ -83,6 +86,7 @@ class nwKeyWords:
TAG_KEY = "@tag"
POV_KEY = "@pov"
+ FOCUS_KEY = "@focus"
CHAR_KEY = "@char"
PLOT_KEY = "@plot"
TIME_KEY = "@time"
@@ -93,14 +97,15 @@ class nwKeyWords:
# Set of Valid Keys
VALID_KEYS = {
- TAG_KEY, POV_KEY, CHAR_KEY, PLOT_KEY, TIME_KEY,
+ TAG_KEY, POV_KEY, FOCUS_KEY, CHAR_KEY, PLOT_KEY, TIME_KEY,
WORLD_KEY, OBJECT_KEY, ENTITY_KEY, CUSTOM_KEY
}
# Map from Keys to Item Class
KEY_CLASS = {
- CHAR_KEY : nwItemClass.CHARACTER,
POV_KEY : nwItemClass.CHARACTER,
+ FOCUS_KEY : nwItemClass.CHARACTER,
+ CHAR_KEY : nwItemClass.CHARACTER,
PLOT_KEY : nwItemClass.PLOT,
TIME_KEY : nwItemClass.TIMELINE,
WORLD_KEY : nwItemClass.WORLD,
@@ -177,6 +182,7 @@ class nwLabels():
KEY_NAME = {
nwKeyWords.TAG_KEY : "Tag",
nwKeyWords.POV_KEY : "Point of View",
+ nwKeyWords.FOCUS_KEY : "Focus",
nwKeyWords.CHAR_KEY : "Characters",
nwKeyWords.PLOT_KEY : "Plot",
nwKeyWords.TIME_KEY : "Timeline",
@@ -194,6 +200,7 @@ class nwLabels():
nwOutline.WCOUNT : "Words",
nwOutline.PCOUNT : "Pars",
nwOutline.POV : "POV",
+ nwOutline.FOCUS : "Focus",
nwOutline.CHAR : KEY_NAME[nwKeyWords.CHAR_KEY],
nwOutline.PLOT : KEY_NAME[nwKeyWords.PLOT_KEY],
nwOutline.TIME : KEY_NAME[nwKeyWords.TIME_KEY],
@@ -262,7 +269,7 @@ class nwUnicode:
U_LCQUO = "\u300c" # Left corner bracket
U_RCQUO = "\u300d" # Right corner bracket
U_LWCQUO = "\u300e" # Left white corner bracket
- U_RECQUO = "\u300f" # Right white corner bracket
+ U_RWCQUO = "\u300f" # Right white corner bracket
## Punctuation
U_FGDASH = "\u2012" # Figure dash
@@ -328,7 +335,7 @@ class nwUnicode:
H_LCQUO = "「"
H_RCQUO = "」"
H_LWCQUO = "『"
- H_LWCQUO = "『"
+ H_RWCQUO = "』"
## Punctuation
H_FGDASH = "‒"
@@ -371,3 +378,60 @@ class nwUnicode:
H_LTRIS = "◂"
# END Class nwUnicode
+
+class nwHtmlUnicode():
+
+ U_TO_H = {
+ ## Quotes
+ nwUnicode.U_QUOT : nwUnicode.H_QUOT,
+ nwUnicode.U_APOS : nwUnicode.H_APOS,
+ nwUnicode.U_LAQUO : nwUnicode.H_LAQUO,
+ nwUnicode.U_RAQUO : nwUnicode.H_RAQUO,
+ nwUnicode.U_LSQUO : nwUnicode.H_LSQUO,
+ nwUnicode.U_RSQUO : nwUnicode.H_RSQUO,
+ nwUnicode.U_SBQUO : nwUnicode.H_SBQUO,
+ nwUnicode.U_SUQUO : nwUnicode.H_SUQUO,
+ nwUnicode.U_LDQUO : nwUnicode.H_LDQUO,
+ nwUnicode.U_RDQUO : nwUnicode.H_RDQUO,
+ nwUnicode.U_BDQUO : nwUnicode.H_BDQUO,
+ nwUnicode.U_UDQUO : nwUnicode.H_UDQUO,
+ nwUnicode.U_LSAQUO : nwUnicode.H_LSAQUO,
+ nwUnicode.U_RSAQUO : nwUnicode.H_RSAQUO,
+ nwUnicode.U_BDRQUO : nwUnicode.H_BDRQUO,
+ nwUnicode.U_LCQUO : nwUnicode.H_LCQUO,
+ nwUnicode.U_RCQUO : nwUnicode.H_RCQUO,
+ nwUnicode.U_LWCQUO : nwUnicode.H_LWCQUO,
+ nwUnicode.U_RWCQUO : nwUnicode.H_RWCQUO,
+
+ ## Punctuation
+ nwUnicode.U_FGDASH : nwUnicode.H_FGDASH,
+ nwUnicode.U_ENDASH : nwUnicode.H_ENDASH,
+ nwUnicode.U_EMDASH : nwUnicode.H_EMDASH,
+ nwUnicode.U_HBAR : nwUnicode.H_HBAR,
+ nwUnicode.U_HELLIP : nwUnicode.H_HELLIP,
+ nwUnicode.U_MAPOSS : nwUnicode.H_MAPOSS,
+ nwUnicode.U_PRIME : nwUnicode.H_PRIME,
+ nwUnicode.U_DPRIME : nwUnicode.H_DPRIME,
+
+ ## Spaces
+ nwUnicode.U_NBSP : nwUnicode.H_NBSP,
+ nwUnicode.U_THSP : nwUnicode.H_THSP,
+ nwUnicode.U_THNBSP : nwUnicode.H_THNBSP,
+ nwUnicode.U_ENSP : nwUnicode.H_ENSP,
+ nwUnicode.U_EMSP : nwUnicode.H_EMSP,
+
+ ## Symbols
+ nwUnicode.U_CHECK : nwUnicode.H_CHECK,
+ nwUnicode.U_CROSS : nwUnicode.H_CROSS,
+ nwUnicode.U_BULL : nwUnicode.H_BULL,
+ nwUnicode.U_TRBULL : nwUnicode.H_TRBULL,
+ nwUnicode.U_HYBULL : nwUnicode.H_HYBULL,
+ nwUnicode.U_FLOWER : nwUnicode.H_FLOWER,
+ nwUnicode.U_PERMIL : nwUnicode.H_PERMIL,
+ nwUnicode.U_DEGREE : nwUnicode.H_DEGREE,
+ nwUnicode.U_MINUS : nwUnicode.H_MINUS,
+ nwUnicode.U_TIMES : nwUnicode.H_TIMES,
+ nwUnicode.U_DIVIDE : nwUnicode.H_DIVIDE,
+ }
+
+# END Class nwHtmlUnicode
diff --git a/nw/constants/enum.py b/nw/constants/enum.py
index 3e343f14..539c0ae0 100644
--- a/nw/constants/enum.py
+++ b/nw/constants/enum.py
@@ -127,13 +127,14 @@ class nwOutline(Enum):
WCOUNT = 5
PCOUNT = 6
POV = 7
- CHAR = 8
- PLOT = 9
- TIME = 10
- WORLD = 11
- OBJECT = 12
- ENTITY = 13
- CUSTOM = 14
- SYNOP = 15
+ FOCUS = 8
+ CHAR = 9
+ PLOT = 10
+ TIME = 11
+ WORLD = 12
+ OBJECT = 13
+ ENTITY = 14
+ CUSTOM = 15
+ SYNOP = 16
# END Enum nwOutline
diff --git a/nw/core/__init__.py b/nw/core/__init__.py
index f5140ebc..61046eec 100644
--- a/nw/core/__init__.py
+++ b/nw/core/__init__.py
@@ -5,6 +5,8 @@ from nw.core.index import NWIndex
from nw.core.project import NWProject
from nw.core.spellcheck import NWSpellCheck, NWSpellEnchant, NWSpellSimple
from nw.core.tohtml import ToHtml
+from nw.core.toodt import ToOdt
+from nw.core.tomd import ToMarkdown
from nw.core.tools import countWords, numberToRoman, numberToWord
__all__ = [
@@ -18,4 +20,6 @@ __all__ = [
"NWSpellEnchant",
"NWSpellSimple",
"ToHtml",
+ "ToOdt",
+ "ToMarkdown",
]
diff --git a/nw/core/index.py b/nw/core/index.py
index 0932b34a..2a3a142a 100644
--- a/nw/core/index.py
+++ b/nw/core/index.py
@@ -36,11 +36,13 @@ from nw.constants import (
)
from nw.core.document import NWDoc
from nw.core.tools import countWords
+from nw.common import isHandle, isTitleTag, isItemClass, isItemLayout
logger = logging.getLogger(__name__)
class NWIndex():
+ H_VALID = ("H0", "H1", "H2", "H3", "H4")
H_LEVEL = {"H0": 0, "H1": 1, "H2": 2, "H3": 3, "H4": 4}
def __init__(self, theProject, theParent):
@@ -152,9 +154,14 @@ class NWIndex():
try:
with open(indexFile, mode="r", encoding="utf8") as inFile:
theData = json.load(inFile)
- except Exception as e:
+ except Exception:
logger.error("Failed to load index file")
- logger.error(str(e))
+ nw.logException()
+ self.indexBroken = True
+ self.theParent.makeAlert(
+ "Could not load cached index file. Rebuilding index.",
+ nwAlert.WARN
+ )
return False
self._tagIndex = theData.get("tagIndex", {})
@@ -188,9 +195,9 @@ class NWIndex():
"noteIndex" : self._noteIndex,
"textCounts" : self._textCounts,
}, outFile, indent=2)
- except Exception as e:
+ except Exception:
logger.error("Failed to save index file")
- logger.error(str(e))
+ nw.logException()
return False
return True
@@ -200,37 +207,25 @@ class NWIndex():
elements it should.
"""
logger.debug("Checking index")
- self.indexBroken = False
+ tStart = time()
try:
- for tTag in self._tagIndex:
- if len(self._tagIndex[tTag]) != 4:
- self.indexBroken = True
-
- for tHandle in self._refIndex:
- for sTitle in self._refIndex[tHandle]:
- for tEntry in self._refIndex[tHandle][sTitle]["tags"]:
- if len(tEntry) != 3:
- self.indexBroken = True
-
- for tHandle in self._novelIndex:
- for sLine in self._novelIndex[tHandle]:
- if len(self._novelIndex[tHandle][sLine].keys()) != 8:
- self.indexBroken = True
-
- for tHandle in self._noteIndex:
- for sLine in self._noteIndex[tHandle]:
- if len(self._noteIndex[tHandle][sLine].keys()) != 8:
- self.indexBroken = True
-
- for tHandle in self._textCounts:
- if len(self._textCounts[tHandle]) != 3:
- self.indexBroken = True
+ self._checkTagIndex()
+ self._checkRefIndex()
+ self._checkNovelNoteIndex("novelIndex")
+ self._checkNovelNoteIndex("noteIndex")
+ self._checkTextCounts()
+ self.indexBroken = False
except Exception:
+ logger.error("Error while checking index")
+ nw.logException()
self.indexBroken = True
+ tEnd = time()
+ logger.debug("Index check took %.3f ms" % ((tEnd - tStart)*1000))
logger.debug("Index check complete")
+
if self.indexBroken:
self.clearIndex()
self.theParent.makeAlert(
@@ -317,7 +312,7 @@ class NWIndex():
if nChar == 0:
continue
- if aLine.startswith(r"#"):
+ if aLine.startswith("#"):
isTitle = self._indexTitle(tHandle, isNovel, aLine, nLine, itemLayout)
if isTitle and nLine > 0:
if nTitle > 0:
@@ -325,11 +320,10 @@ class NWIndex():
self._indexWordCounts(tHandle, isNovel, lastText, nTitle)
nTitle = nLine
- elif aLine.startswith(r"@"):
- self._indexNoteRef(tHandle, aLine, nLine, nTitle)
- self._indexTag(tHandle, aLine, nLine, nTitle, itemClass)
+ elif aLine.startswith("@"):
+ self._indexKeyword(tHandle, aLine, nLine, nTitle, itemClass)
- elif aLine.startswith(r"%"):
+ elif aLine.startswith("%"):
if nTitle > 0:
toCheck = aLine[1:].lstrip()
synTag = toCheck[:9].lower()
@@ -468,41 +462,28 @@ class NWIndex():
self._noteIndex[tHandle][sTitle]["updated"] = round(time())
return
- def _indexNoteRef(self, tHandle, aLine, nLine, nTitle):
+ def _indexKeyword(self, tHandle, aLine, nLine, nTitle, itemClass):
"""Validate and save the information about a reference to a tag
in another file.
"""
isValid, theBits, _ = self.scanThis(aLine)
- if not isValid or len(theBits) == 0:
- return False
-
- sTitle = "T%06d" % nTitle
- if sTitle not in self._refIndex[tHandle]:
- return False
-
- if theBits[0] == nwKeyWords.TAG_KEY:
- return False
+ if not isValid or len(theBits) < 2:
+ logger.warning("Skipping keyword with %d value(s) in %s" % (len(theBits), tHandle))
+ return
if theBits[0] not in nwKeyWords.VALID_KEYS:
- return False
-
- for aVal in theBits[1:]:
- self._refIndex[tHandle][sTitle]["tags"].append([nLine, theBits[0], aVal])
-
- return True
-
- def _indexTag(self, tHandle, aLine, nLine, nTitle, itemClass):
- """Validate and save the information from a tag.
- """
- isValid, theBits, thePos = self.scanThis(aLine)
- if not isValid or len(theBits) != 2:
- return False
+ logger.warning("Skipping invalid keyword '%s' in %s" % (theBits[0], tHandle))
+ return
+ sTitle = "T%06d" % nTitle
if theBits[0] == nwKeyWords.TAG_KEY:
- sTitle = "T%06d" % nTitle
self._tagIndex[theBits[1]] = [nLine, tHandle, itemClass.name, sTitle]
- return True
+ elif sTitle in self._refIndex[tHandle]:
+ for aVal in theBits[1:]:
+ self._refIndex[tHandle][sTitle]["tags"].append([nLine, theBits[0], aVal])
+
+ return
##
# Check @ Lines
@@ -722,7 +703,7 @@ class NWIndex():
for refTitle in self._refIndex[tHandle]:
for aTag in self._refIndex[tHandle][refTitle].get("tags", []):
if len(aTag) == 3 and (sTitle is None or sTitle == refTitle):
- if aTag[1] in theRefs: # Future-compatible. Check can be removed in 1.2.
+ if aTag[1] in theRefs:
theRefs[aTag[1]].append(aTag[2])
return theRefs
@@ -784,4 +765,143 @@ class NWIndex():
return theHandles
+ ##
+ # Index Checkers
+ ##
+
+ def _checkTagIndex(self):
+ """Scan the tag index for errors.
+ Waring: This function raises exceptions.
+ """
+ for tTag in self._tagIndex:
+ if not isinstance(tTag, str):
+ raise KeyError("tagIndex key is not a string")
+
+ tEntry = self._tagIndex[tTag]
+ if len(tEntry) != 4:
+ raise IndexError("tagIndex[a] expected 4 values")
+ if not isinstance(tEntry[0], int):
+ raise ValueError("tagIndex[a][0] is not an integer")
+ if not isHandle(tEntry[1]):
+ raise ValueError("tagIndex[a][1] is not a handle")
+ if not isItemClass(tEntry[2]):
+ raise ValueError("tagIndex[a][2] is not an nwItemClass")
+ if not isTitleTag(tEntry[3]):
+ raise ValueError("tagIndex[a][3] is not a title tag")
+
+ return
+
+ def _checkRefIndex(self):
+ """Scan the reference index for errors.
+ Waring: This function raises exceptions.
+ """
+ for tHandle in self._refIndex:
+ if not isHandle(tHandle):
+ raise KeyError("refIndex key is not a handle")
+
+ hEntry = self._refIndex[tHandle]
+ for sTitle in hEntry:
+ if not isTitleTag(sTitle):
+ raise KeyError("refIndex[a] key is not a title tag")
+
+ sEntry = hEntry[sTitle]
+ if "tags" not in sEntry:
+ raise KeyError("refIndex[a][b] has no 'tag' key")
+ for tEntry in sEntry["tags"]:
+ if len(tEntry) != 3:
+ raise IndexError("refIndex[a][b][tags][i] expected 3 values")
+ if not isinstance(tEntry[0], int):
+ raise ValueError("refIndex[a][b][tags][i][0] is not an integer")
+ if not tEntry[1] in nwKeyWords.VALID_KEYS:
+ raise ValueError("refIndex[a][b][tags][i][1] is not a keyword")
+ if not isinstance(tEntry[2], str):
+ raise ValueError("refIndex[a][b][tags][i][2] is not a string")
+
+ if "updated" not in sEntry:
+ raise KeyError("refIndex[a][b] has no 'updated' key")
+ if not isinstance(sEntry["updated"], int):
+ raise ValueError("%refIndex[a][b][updated] is not an integer")
+
+ return
+
+ def _checkNovelNoteIndex(self, idxName):
+ """Scan the novel or note index for errors.
+ Waring: This function raises exceptions.
+ """
+ if idxName == "novelIndex":
+ theIndex = self._novelIndex
+ elif idxName == "noteIndex":
+ theIndex = self._noteIndex
+ else:
+ raise IndexError("Unknown index %s" % idxName)
+
+ for tHandle in theIndex:
+ if not isHandle(tHandle):
+ raise KeyError("%s key is not a handle" % idxName)
+
+ hEntry = theIndex[tHandle]
+ for sTitle in theIndex[tHandle]:
+ if not isTitleTag(sTitle):
+ raise KeyError("%s[a] key is not a title tag" % idxName)
+
+ sEntry = hEntry[sTitle]
+ if len(sEntry) != 8:
+ raise IndexError("%s[a][b] expected 8 values" % idxName)
+
+ if "level" not in sEntry:
+ raise KeyError("%s[a][b] has no 'level' key" % idxName)
+ if "title" not in sEntry:
+ raise KeyError("%s[a][b] has no 'title' key" % idxName)
+ if "layout" not in sEntry:
+ raise KeyError("%s[a][b] has no 'layout' key" % idxName)
+ if "synopsis" not in sEntry:
+ raise KeyError("%s[a][b] has no 'synopsis' key" % idxName)
+ if "cCount" not in sEntry:
+ raise KeyError("%s[a][b] has no 'cCount' key" % idxName)
+ if "wCount" not in sEntry:
+ raise KeyError("%s[a][b] has no 'wCount' key" % idxName)
+ if "pCount" not in sEntry:
+ raise KeyError("%s[a][b] has no 'pCount' key" % idxName)
+ if "updated" not in sEntry:
+ raise KeyError("%s[a][b] has no 'updated' key" % idxName)
+
+ if not sEntry["level"] in self.H_VALID:
+ raise ValueError("%s[a][b][level] is not a header level" % idxName)
+ if not isinstance(sEntry["title"], str):
+ raise ValueError("%s[a][b][title] is not a string" % idxName)
+ if not isItemLayout(sEntry["layout"]):
+ raise ValueError("%s[a][b][layout] is not an nwItemLayout" % idxName)
+ if not isinstance(sEntry["synopsis"], str):
+ raise ValueError("%s[a][b][synopsis] is not a string" % idxName)
+ if not isinstance(sEntry["cCount"], int):
+ raise ValueError("%s[a][b][cCount] is not an integer" % idxName)
+ if not isinstance(sEntry["wCount"], int):
+ raise ValueError("%s[a][b][wCount] is not an integer" % idxName)
+ if not isinstance(sEntry["pCount"], int):
+ raise ValueError("%s[a][b][pCount] is not an integer" % idxName)
+ if not isinstance(sEntry["updated"], int):
+ raise ValueError("%s[a][b][updated] is not an integer" % idxName)
+
+ return
+
+ def _checkTextCounts(self):
+ """Scan the text counts index for errors.
+ Waring: This function raises exceptions.
+ """
+ for tHandle in self._textCounts:
+ if not isHandle(tHandle):
+ raise KeyError("textCounts key is not a handle")
+
+ tEntry = self._textCounts[tHandle]
+ if len(tEntry) != 3:
+ raise IndexError("textCounts[a] expected 3 values")
+ if not isinstance(tEntry[0], int):
+ raise ValueError("textCounts[a][0] is not an integer")
+ if not isinstance(tEntry[1], int):
+ raise ValueError("textCounts[a][1] is not an integer")
+ if not isinstance(tEntry[2], int):
+ raise ValueError("textCounts[a][2] is not an integer")
+
+ return
+
# END Class NWIndex
diff --git a/nw/core/item.py b/nw/core/item.py
index e2a5a4e8..62460e7e 100644
--- a/nw/core/item.py
+++ b/nw/core/item.py
@@ -28,8 +28,10 @@ import logging
from lxml import etree
-from nw.common import checkInt, isHandle
from nw.constants import nwItemType, nwItemClass, nwItemLayout
+from nw.common import (
+ checkInt, isHandle, isItemClass, isItemLayout, isItemType
+)
logger = logging.getLogger(__name__)
@@ -201,7 +203,7 @@ class NWItem():
"""
if isinstance(theType, nwItemType):
self.itemType = theType
- elif theType in nwItemType.__members__:
+ elif isItemType(theType):
self.itemType = nwItemType[theType]
else:
logger.error("Unrecognised item type '%s'" % theType)
@@ -214,7 +216,7 @@ class NWItem():
"""
if isinstance(theClass, nwItemClass):
self.itemClass = theClass
- elif theClass in nwItemClass.__members__:
+ elif isItemClass(theClass):
self.itemClass = nwItemClass[theClass]
else:
logger.error("Unrecognised item class '%s'" % theClass)
@@ -227,7 +229,7 @@ class NWItem():
"""
if isinstance(theLayout, nwItemLayout):
self.itemLayout = theLayout
- elif theLayout in nwItemLayout.__members__:
+ elif isItemLayout(theLayout):
self.itemLayout = nwItemLayout[theLayout]
else:
logger.error("Unrecognised item layout '%s'" % theLayout)
diff --git a/nw/core/options.py b/nw/core/options.py
index 36da3f32..51fba3b6 100644
--- a/nw/core/options.py
+++ b/nw/core/options.py
@@ -25,6 +25,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
+import nw
import logging
import json
import os
@@ -46,6 +47,7 @@ class OptionState():
"widthCol0",
"widthCol1",
"widthCol2",
+ "widthCol3",
"sortCol",
"sortOrder",
"incNovel",
@@ -53,6 +55,7 @@ class OptionState():
"hideZeros",
"hideNegative",
"groupByDay",
+ "showIdleTime",
"histMax",
},
"GuiDocSplit": {
@@ -70,12 +73,14 @@ class OptionState():
"excludeBody",
"textFont",
"textSize",
+ "lineHeight",
"noStyling",
"incSynopsis",
"incComments",
"incKeywords",
"incBodyText",
"replaceTabs",
+ "replaceUCode",
},
"GuiOutline": {
"headerOrder",
@@ -99,6 +104,10 @@ class OptionState():
"countFrom",
"clearDouble",
},
+ "GuiWordList": {
+ "winWidth",
+ "winHeight",
+ }
}
return
@@ -121,9 +130,9 @@ class OptionState():
try:
with open(stateFile, mode="r", encoding="utf8") as inFile:
theState = json.load(inFile)
- except Exception as e:
+ except Exception:
logger.error("Failed to load GUI options file")
- logger.error(str(e))
+ nw.logException()
return False
# Filter out unused variables
@@ -148,9 +157,9 @@ class OptionState():
try:
with open(stateFile, mode="w+", encoding="utf8") as outFile:
json.dump(self.theState, outFile, indent=2)
- except Exception as e:
+ except Exception:
logger.error("Failed to save GUI options file")
- logger.error(str(e))
+ nw.logException()
return False
return True
diff --git a/nw/core/project.py b/nw/core/project.py
index f06969c8..abee563b 100644
--- a/nw/core/project.py
+++ b/nw/core/project.py
@@ -726,12 +726,12 @@ class NWProject():
return True
- def closeProject(self):
+ def closeProject(self, idleTime=0):
"""Close the current project and clear all meta data.
"""
self.optState.saveSettings()
self.projTree.writeToCFile()
- self._appendSessionStats()
+ self._appendSessionStats(idleTime)
self._clearLockFile()
self.clearProject()
self.lockedBy = None
@@ -1219,9 +1219,9 @@ class NWProject():
if len(theLines) != 4:
return ["ERROR"]
- except Exception as e:
+ except Exception:
logger.error("Failed to read project lockfile")
- logger.error(str(e))
+ nw.logException()
return ["ERROR"]
return theLines
@@ -1240,9 +1240,9 @@ class NWProject():
outFile.write("%s\n" % self.mainConf.kernelVer)
outFile.write("%d\n" % time())
- except Exception as e:
+ except Exception:
logger.error("Failed to write project lockfile")
- logger.error(str(e))
+ nw.logException()
return False
return True
@@ -1257,9 +1257,9 @@ class NWProject():
if os.path.isfile(lockFile):
try:
os.unlink(lockFile)
- except Exception as e:
+ except Exception:
logger.error("Failed to remove project lockfile")
- logger.error(str(e))
+ nw.logException()
return False
return True
@@ -1389,7 +1389,7 @@ class NWProject():
return True
- def _appendSessionStats(self):
+ def _appendSessionStats(self, idleTime):
"""Append session statistics to the sessions log file.
"""
if not self.ensureFolderStructure():
@@ -1413,20 +1413,21 @@ class NWProject():
# It's a new file, so add a header
if self.lastWCount > 0:
outFile.write("# Offset %d\n" % self.lastWCount)
- outFile.write("# %-17s %-19s %8s %8s\n" % (
- "Start Time", "End Time", "Novel", "Notes"
+ outFile.write("# %-17s %-19s %8s %8s %8s\n" % (
+ "Start Time", "End Time", "Novel", "Notes", "Idle"
))
- outFile.write("%-19s %-19s %8d %8d\n" % (
+ outFile.write("%-19s %-19s %8d %8d %8d\n" % (
formatTimeStamp(self.projOpened),
formatTimeStamp(nowTime),
self.novelWCount,
self.notesWCount,
+ int(idleTime),
))
- except Exception as e:
+ except Exception:
logger.error("Failed to write session stats file")
- logger.error(str(e))
+ nw.logException()
return False
return True
@@ -1462,17 +1463,19 @@ class NWProject():
os.rename(theFile, newPath)
logger.info("Moved file: %s" % theFile)
logger.info("New location: %s" % newPath)
- except Exception as e:
- logger.error(str(e))
+ except Exception:
errList.append("Could not move: %s" % theFile)
+ logger.error("Could not move: %s" % theFile)
+ nw.logException()
elif len(dataItem) == 21 and dataItem.endswith("_main.bak"):
try:
os.unlink(theFile)
logger.info("Deleted file: %s" % theFile)
- except Exception as e:
- logger.error(str(e))
+ except Exception:
errList.append("Could not delete: %s" % theFile)
+ logger.error("Could not delete: %s" % theFile)
+ nw.logException()
else:
theErr = self._moveUnknownItem(theData, dataItem)
@@ -1484,9 +1487,10 @@ class NWProject():
try:
os.rmdir(theData)
logger.info("Removed folder: %s" % theFolder)
- except Exception as e:
- logger.error(str(e))
+ except Exception:
errList.append("Failed to remove: %s" % theFolder)
+ logger.error("Failed to remove: %s" % theFolder)
+ nw.logException()
return errList
@@ -1504,8 +1508,9 @@ class NWProject():
try:
os.rename(theSrc, theDst)
logger.info("Moved to junk: %s" % theSrc)
- except Exception as e:
- logger.error(str(e))
+ except Exception:
+ logger.error("Could not move item %s to junk." % theSrc)
+ nw.logException()
return "Could not move item %s to junk." % theSrc
return ""
@@ -1538,8 +1543,9 @@ class NWProject():
logger.info("Deleting: %s" % rmFile)
try:
os.unlink(rmFile)
- except Exception as e:
- logger.error(str(e))
+ except Exception:
+ logger.error("Could not delete: %s" % rmFile)
+ nw.logException()
return False
return True
diff --git a/nw/core/spellcheck.py b/nw/core/spellcheck.py
index caa8156b..9d22afcd 100644
--- a/nw/core/spellcheck.py
+++ b/nw/core/spellcheck.py
@@ -72,9 +72,9 @@ class NWSpellCheck():
with open(self.projectDict, mode="a+", encoding="utf-8") as outFile:
outFile.write("%s\n" % newWord)
self.projDict.append(newWord)
- except Exception as e:
+ except Exception:
logger.error("Failed to add word to project word list %s" % str(self.projectDict))
- logger.error(str(e))
+ nw.logException()
return False
return True
return False
@@ -123,9 +123,10 @@ class NWSpellCheck():
if len(theLine) > 0 and theLine not in self.projDict:
self.projDict.append(theLine)
logger.debug("Project word list contains %d words" % len(self.projDict))
- except Exception as e:
+
+ except Exception:
logger.error("Failed to load project word list")
- logger.error(str(e))
+ nw.logException()
return False
return True
@@ -141,6 +142,7 @@ class NWSpellEnchant(NWSpellCheck):
def __init__(self):
NWSpellCheck.__init__(self)
logger.debug("Enchant spell checking activated")
+ self.theBroker = None
return
def setLanguage(self, theLang, projectDict=None):
@@ -150,9 +152,15 @@ class NWSpellEnchant(NWSpellCheck):
"""
try:
import enchant
- self.theDict = enchant.Dict(theLang)
+ if self.theBroker is not None:
+ logger.verbose("Deleting old pyenchant broker")
+ del self.theBroker
+
+ self.theBroker = enchant.Broker()
+ self.theDict = self.theBroker.request_dict(theLang)
self.spellLanguage = theLang
logger.debug("Enchant spell checking for language %s loaded" % theLang)
+
except Exception:
logger.error("Failed to load enchant spell checking for language %s" % theLang)
self.theDict = NWSpellEnchantDummy()
@@ -201,9 +209,9 @@ class NWSpellEnchant(NWSpellCheck):
try:
spTag = self.theDict.tag
spName = self.theDict.provider.name
- except Exception as e:
+ except Exception:
logger.error("Failed to extract information about the dictionary")
- logger.error(str(e))
+ nw.logException()
spTag = ""
spName = ""
@@ -263,9 +271,9 @@ class NWSpellSimple(NWSpellCheck):
logger.debug("Dictionary contains %d words" % len(self.theWords))
self.spellLanguage = theLang
- except Exception as e:
+ except Exception:
logger.error("Failed to load spell check word list for language %s" % theLang)
- logger.error(str(e))
+ nw.logException()
self.spellLanguage = None
self._readProjectDictionary(projectDict)
diff --git a/nw/core/tohtml.py b/nw/core/tohtml.py
index 46c7d8b6..67ebcc0f 100644
--- a/nw/core/tohtml.py
+++ b/nw/core/tohtml.py
@@ -25,10 +25,9 @@ along with this program. If not, see .
"""
import logging
-import re
from nw.core.tokenizer import Tokenizer
-from nw.constants import nwUnicode, nwLabels, nwKeyWords
+from nw.constants import nwLabels, nwKeyWords, nwHtmlUnicode
logger = logging.getLogger(__name__)
@@ -41,25 +40,13 @@ class ToHtml(Tokenizer):
def __init__(self, theProject, theParent):
Tokenizer.__init__(self, theProject, theParent)
- self.genMode = self.M_EXPORT
+ self.genMode = self.M_EXPORT
self.cssStyles = True
+ self.fullHTML = []
- self.repDict = {
- "<" : "<",
- ">" : ">",
- "&" : "&",
- nwUnicode.U_ENDASH : nwUnicode.H_ENDASH,
- nwUnicode.U_EMDASH : nwUnicode.H_EMDASH,
- nwUnicode.U_HELLIP : nwUnicode.H_HELLIP,
- nwUnicode.U_NBSP : nwUnicode.H_NBSP,
- nwUnicode.U_THSP : nwUnicode.H_THSP,
- nwUnicode.U_THNBSP : nwUnicode.H_THNBSP,
- nwUnicode.U_MAPOSS : nwUnicode.H_RSQUO,
- }
- self.revDict = {}
- self.reReplace = []
- self.reReverse = []
- self._buildRegEx()
+ # Internals
+ self._trMap = {}
+ self.setReplaceUnicode(False)
return
@@ -86,31 +73,38 @@ class ToHtml(Tokenizer):
self.cssStyles = cssStyles
return
+ def setReplaceUnicode(self, doReplace):
+ """Set the translation map to either minimal or full unicode to
+ html entities replacement.
+ """
+ # Control characters must always be replaced
+ self._trMap = str.maketrans({
+ "<" : "<",
+ ">" : ">",
+ "&" : "&",
+ })
+
+ if doReplace:
+ # Extend to all relevant Unicode characters
+ self._trMap.update(str.maketrans(nwHtmlUnicode.U_TO_H))
+
+ return
+
##
# Class Methods
##
- def doAutoReplace(self):
+ def getFullResultSize(self):
+ """Return the size of the full HTML result.
+ """
+ return sum([len(x) for x in self.fullHTML])
+
+ def doPreProcessing(self):
"""Extend the auto-replace to also properly encode some unicode
characters into their respective HTML entities.
"""
- Tokenizer.doAutoReplace(self)
- self.theText = self.reReplace.sub(
- lambda x: self.repDict[x.group(0)], self.theText
- )
- return
-
- def doPostProcessing(self):
- """Reverse the html entities replacement on the markdown text.
- Otherwise, all the &something; bits will also be in there.
- """
- Tokenizer.doPostProcessing(self)
- if self.genMode == self.M_PREVIEW:
- # Doesn't matter for preview as we don't use the markdown
- return
- self.theMarkdown = self.reReverse.sub(
- lambda x: self.revDict[x.group(0)], self.theMarkdown
- )
+ Tokenizer.doPreProcessing(self)
+ self.theText = self.theText.translate(self._trMap)
return
def doConvert(self):
@@ -158,6 +152,7 @@ class ToHtml(Tokenizer):
parStyle = None
tmpResult = []
hasHardBreak = False
+
for tType, tLine, tText, tFormat, tStyle in self.theTokens:
# Styles
@@ -165,24 +160,27 @@ class ToHtml(Tokenizer):
if tStyle is not None and self.cssStyles:
if tStyle & self.A_LEFT:
aStyle.append("text-align: left;")
- if tStyle & self.A_RIGHT:
+ elif tStyle & self.A_RIGHT:
aStyle.append("text-align: right;")
- if tStyle & self.A_CENTRE:
+ elif tStyle & self.A_CENTRE:
aStyle.append("text-align: center;")
- if tStyle & self.A_JUSTIFY:
+ elif tStyle & self.A_JUSTIFY:
aStyle.append("text-align: justify;")
+
if tStyle & self.A_PBB:
aStyle.append("page-break-before: always;")
- if tStyle & self.A_PBB_AV:
- aStyle.append("page-break-before: avoid;")
- if tStyle & self.A_PBB_NO:
- aStyle.append("page-break-before: never;")
+ elif tStyle & self.A_PBB_AUT:
+ aStyle.append("page-break-before: auto;")
+
if tStyle & self.A_PBA:
aStyle.append("page-break-after: always;")
- if tStyle & self.A_PBA_AV:
- aStyle.append("page-break-after: avoid;")
- if tStyle & self.A_PBA_NO:
- aStyle.append("page-break-after: never;")
+ elif tStyle & self.A_PBA_AUT:
+ aStyle.append("page-break-after: auto;")
+
+ if tStyle & self.A_Z_BTMMRG:
+ aStyle.append("margin-bottom: 0;")
+ if tStyle & self.A_Z_TOPMRG:
+ aStyle.append("margin-top: 0;")
if len(aStyle) > 0:
hStyle = " style='%s'" % (" ".join(aStyle))
@@ -240,12 +238,12 @@ class ToHtml(Tokenizer):
if parStyle is None:
parStyle = hStyle
for xPos, xLen, xFmt in reversed(tFormat):
- tTemp = tTemp[:xPos]+htmlTags[xFmt]+tTemp[xPos+xLen:]
+ tTemp = tTemp[:xPos] + htmlTags[xFmt] + tTemp[xPos+xLen:]
if tText.endswith(" "):
- thisPar.append(tTemp.rstrip()+" ")
+ thisPar.append(tTemp.rstrip() + " ")
hasHardBreak = True
else:
- thisPar.append(tTemp.rstrip()+" ")
+ thisPar.append(tTemp.rstrip() + " ")
elif tType == self.T_SYNOPSIS and self.doSynopsis:
tmpResult.append(self._formatSynopsis(tText))
@@ -254,11 +252,60 @@ class ToHtml(Tokenizer):
tmpResult.append(self._formatComments(tText))
elif tType == self.T_KEYWORD and self.doKeywords:
- tmpResult.append(self._formatKeywords(tText))
+ tTemp = "
\n" % retText
-
- def _buildRegEx(self):
- """Build the regular expressions
- """
- self.revDict = dict(map(reversed, self.repDict.items()))
- self.reReplace = re.compile(
- "|".join([re.escape(k) for k in self.repDict.keys()]), flags=re.DOTALL
- )
- self.reReverse = re.compile(
- "|".join([re.escape(k) for k in self.revDict.keys()]), flags=re.DOTALL
- )
- return
+ return retText
# END Class ToHtml
diff --git a/nw/core/tokenizer.py b/nw/core/tokenizer.py
index 6fa7a87a..3e0474ae 100644
--- a/nw/core/tokenizer.py
+++ b/nw/core/tokenizer.py
@@ -32,7 +32,7 @@ from PyQt5.QtCore import QRegularExpression
from nw.core.document import NWDoc
from nw.core.tools import numberToWord, numberToRoman
-from nw.constants import nwConst, nwItemLayout, nwItemType, nwRegEx
+from nw.constants import nwConst, nwUnicode, nwItemLayout, nwItemType, nwRegEx
logger = logging.getLogger(__name__)
@@ -67,11 +67,11 @@ class Tokenizer():
A_CENTRE = 0x0004 # Centred
A_JUSTIFY = 0x0008 # Justified
A_PBB = 0x0010 # Page break before always
- A_PBB_AV = 0x0020 # Page break before avoid
- A_PBB_NO = 0x0040 # Page break before never
- A_PBA = 0x0080 # Page break after always
- A_PBA_AV = 0x0100 # Page break after avoid
- A_PBA_NO = 0x0200 # Page break after avoid
+ A_PBB_AUT = 0x0020 # Page break before auto
+ A_PBA = 0x0040 # Page break after always
+ A_PBA_AUT = 0x0080 # Page break after auto
+ A_Z_TOPMRG = 0x0100 # Zero top margin
+ A_Z_BTMMRG = 0x0200 # Zero bottom margin
def __init__(self, theProject, theParent):
@@ -79,20 +79,36 @@ class Tokenizer():
self.theParent = theParent
# Data Variables
- self.theText = None # The raw text to be tokenized
+ self.theText = "" # The raw text to be tokenized
self.theHandle = None # The handle associated with the text
self.theItem = None # The NWItem associated with the handle
- self.theTokens = None # The list of the processed tokens
- self.theResult = None # The result text after conversion
- self.theMarkdown = None # The result text in novelWriter markdown
+ self.theTokens = [] # The list of the processed tokens
+ self.theResult = "" # The result of the last document
+
+ self.keepMarkdown = False # Whether to keep the markdown text
+ self.theMarkdown = [] # The result novelWriter markdown of all documents
# User Settings
- self.doBodyText = True # Include body text
- self.doSynopsis = False # Also process synopsis comments
- self.doComments = False # Also process comments
- self.doKeywords = False # Also process keywords like tags and references
- self.doJustify = False # Justify text
+ self.textFont = "Serif" # Output text font
+ self.textSize = 11 # Output text size
+ self.textFixed = False # Fixed width text
+ self.lineHeight = 1.15 # Line height
+ self.doJustify = False # Justify text
+ self.doBodyText = True # Include body text
+ self.doSynopsis = False # Also process synopsis comments
+ self.doComments = False # Also process comments
+ self.doKeywords = False # Also process keywords like tags and references
+ ## Title Margins
+ self.marginTitle = (1.000, 0.500)
+ self.marginHead1 = (1.000, 0.500)
+ self.marginHead2 = (0.834, 0.500)
+ self.marginHead3 = (0.584, 0.500)
+ self.marginHead4 = (0.584, 0.500)
+ self.marginText = (0.000, 0.584)
+ self.marginMeta = (0.000, 0.584)
+
+ ## Title Formats
self.fmtTitle = "%title%" # Formatting for titles
self.fmtChapter = "%title%" # Formatting for numbered chapters
self.fmtUnNum = "%title%" # Formatting for unnumbered chapters
@@ -153,6 +169,48 @@ class Tokenizer():
self.hideSection = hideSection
return
+ def setFont(self, textFont, textSize, textFixed=False):
+ self.textFont = textFont
+ self.textSize = round(int(textSize))
+ self.textFixed = textFixed
+ return
+
+ def setLineHeight(self, lineHeight):
+ self.lineHeight = float(lineHeight)
+ return
+
+ def setJustify(self, doJustify):
+ self.doJustify = doJustify
+ return
+
+ def setTitleMargins(self, mUpper, mLower):
+ self.marginTitle = (float(mUpper), float(mLower))
+ return
+
+ def setHead1Margins(self, mUpper, mLower):
+ self.marginHead1 = (float(mUpper), float(mLower))
+ return
+
+ def setHead2Margins(self, mUpper, mLower):
+ self.marginHead2 = (float(mUpper), float(mLower))
+ return
+
+ def setHead3Margins(self, mUpper, mLower):
+ self.marginHead3 = (float(mUpper), float(mLower))
+ return
+
+ def setHead4Margins(self, mUpper, mLower):
+ self.marginHead4 = (float(mUpper), float(mLower))
+ return
+
+ def setTextMargins(self, mUpper, mLower):
+ self.marginText = (float(mUpper), float(mLower))
+ return
+
+ def setMetaMargins(self, mUpper, mLower):
+ self.marginMeta = (float(mUpper), float(mLower))
+ return
+
def setLinkHeaders(self, linkHeaders):
self.linkHeaders = linkHeaders
return
@@ -173,8 +231,8 @@ class Tokenizer():
self.doKeywords = doKeywords
return
- def setJustify(self, doJustify):
- self.doJustify = doJustify
+ def setKeepMarkdown(self, keepMarkdown):
+ self.keepMarkdown = keepMarkdown
return
##
@@ -196,7 +254,8 @@ class Tokenizer():
self.theTokens.append((
self.T_TITLE, 0, theTitle, None, self.A_PBB | self.A_CENTRE
))
- self.theMarkdown = "# %s\n\n" % theTitle
+ if self.keepMarkdown:
+ self.theMarkdown.append("# %s\n\n" % theTitle)
return True
@@ -238,26 +297,10 @@ class Tokenizer():
return True
- def getResult(self):
- """Return the result from the conversion.
- """
- return self.theResult
-
- def getResultSize(self):
- """Return the size of the result from the conversion.
- """
- if self.theResult is None:
- return 0
- return len(self.theResult)
-
- def getFilteredMarkdown(self):
- """Return the novelWriter markdown after the filters have been applied.
- """
- return self.theMarkdown
-
- def doAutoReplace(self):
- """Run through the user's auto-replace dictionary.
+ def doPreProcessing(self):
+ """Reun trough the various replace doctionaries.
"""
+ # Process the user's auto-replace dictionary
if len(self.theProject.autoReplace) > 0:
repDict = {}
for aKey, aVal in self.theProject.autoReplace.items():
@@ -265,6 +308,10 @@ class Tokenizer():
xRep = re.compile("|".join([re.escape(k) for k in repDict.keys()]), flags=re.DOTALL)
self.theText = xRep.sub(lambda x: repDict[x.group(0)], self.theText)
+ # Process the character translation map
+ trDict = {nwUnicode.U_MAPOSS: nwUnicode.U_RSQUO}
+ self.theText = self.theText.translate(str.maketrans(trDict))
+
return
def doPostProcessing(self):
@@ -307,7 +354,6 @@ class Tokenizer():
]
self.theTokens = []
- self.theMarkdown = ""
tmpMarkdown = []
nLine = 0
for aLine in self.theText.splitlines():
@@ -316,88 +362,61 @@ class Tokenizer():
# Tag lines starting with specific characters
if len(aLine.strip()) == 0:
self.theTokens.append((
- self.T_EMPTY,
- nLine,
- "",
- None,
- self.A_NONE
+ self.T_EMPTY, nLine, "", None, self.A_NONE
))
- tmpMarkdown.append("\n")
+ if self.keepMarkdown:
+ tmpMarkdown.append("\n")
elif aLine[0] == "%":
cLine = aLine[1:].lstrip()
synTag = cLine[:9].lower()
if synTag == "synopsis:":
self.theTokens.append((
- self.T_SYNOPSIS,
- nLine,
- cLine[9:].strip(),
- None,
- self.A_NONE
+ self.T_SYNOPSIS, nLine, cLine[9:].strip(), None, self.A_NONE
))
- if self.doSynopsis:
+ if self.doSynopsis and self.keepMarkdown:
tmpMarkdown.append("%s\n" % aLine)
else:
self.theTokens.append((
- self.T_COMMENT,
- nLine,
- aLine[1:].strip(),
- None,
- self.A_NONE
+ self.T_COMMENT, nLine, aLine[1:].strip(), None, self.A_NONE
))
- if self.doComments:
+ if self.doComments and self.keepMarkdown:
tmpMarkdown.append("%s\n" % aLine)
elif aLine[0] == "@":
self.theTokens.append((
- self.T_KEYWORD,
- nLine,
- aLine[1:].strip(),
- None,
- self.A_NONE
+ self.T_KEYWORD, nLine, aLine[1:].strip(), None, self.A_NONE
))
- if self.doKeywords:
+ if self.doKeywords and self.keepMarkdown:
tmpMarkdown.append("%s\n" % aLine)
elif aLine[:2] == "# ":
self.theTokens.append((
- self.T_HEAD1,
- nLine,
- aLine[2:].strip(),
- None,
- self.A_NONE
+ self.T_HEAD1, nLine, aLine[2:].strip(), None, self.A_NONE
))
- tmpMarkdown.append("%s\n" % aLine)
+ if self.keepMarkdown:
+ tmpMarkdown.append("%s\n" % aLine)
elif aLine[:3] == "## ":
self.theTokens.append((
- self.T_HEAD2,
- nLine,
- aLine[3:].strip(),
- None,
- self.A_NONE
+ self.T_HEAD2, nLine, aLine[3:].strip(), None, self.A_NONE
))
- tmpMarkdown.append("%s\n" % aLine)
+ if self.keepMarkdown:
+ tmpMarkdown.append("%s\n" % aLine)
elif aLine[:4] == "### ":
self.theTokens.append((
- self.T_HEAD3,
- nLine,
- aLine[4:].strip(),
- None,
- self.A_NONE
+ self.T_HEAD3, nLine, aLine[4:].strip(), None, self.A_NONE
))
- tmpMarkdown.append("%s\n" % aLine)
+ if self.keepMarkdown:
+ tmpMarkdown.append("%s\n" % aLine)
elif aLine[:5] == "#### ":
self.theTokens.append((
- self.T_HEAD4,
- nLine,
- aLine[5:].strip(),
- None,
- self.A_NONE
+ self.T_HEAD4, nLine, aLine[5:].strip(), None, self.A_NONE
))
- tmpMarkdown.append("%s\n" % aLine)
+ if self.keepMarkdown:
+ tmpMarkdown.append("%s\n" % aLine)
else:
if not self.doBodyText:
@@ -420,26 +439,44 @@ class Tokenizer():
# sorted by position
fmtPos = sorted(fmtPos, key=itemgetter(0))
self.theTokens.append((
- self.T_TEXT,
- nLine,
- aLine,
- fmtPos,
- self.A_NONE
+ self.T_TEXT, nLine, aLine, fmtPos, self.A_NONE
))
- tmpMarkdown.append("%s\n" % aLine)
+ if self.keepMarkdown:
+ tmpMarkdown.append("%s\n" % aLine)
# Always add an empty line at the end
self.theTokens.append((
- self.T_EMPTY,
- nLine,
- "",
- None,
- self.A_NONE
+ self.T_EMPTY, nLine, "", None, self.A_NONE
))
- tmpMarkdown.append("\n")
+ if self.keepMarkdown:
+ tmpMarkdown.append("\n")
- self.theMarkdown = "".join(tmpMarkdown)
- tmpMarkdown = []
+ if self.keepMarkdown:
+ self.theMarkdown.append("".join(tmpMarkdown))
+
+ # Second Pass
+ # ===========
+ # Some items need a second pass
+
+ pToken = (self.T_EMPTY, 0, "", None, self.A_NONE)
+ nToken = (self.T_EMPTY, 0, "", None, self.A_NONE)
+ tCount = len(self.theTokens)
+ for n, tToken in enumerate(self.theTokens):
+
+ if n > 0:
+ pToken = self.theTokens[n-1]
+ if n < tCount - 1:
+ nToken = self.theTokens[n+1]
+
+ if tToken[0] == self.T_KEYWORD:
+ aStyle = tToken[4]
+ if pToken[0] == self.T_KEYWORD:
+ aStyle |= self.A_Z_TOPMRG
+ if nToken[0] == self.T_KEYWORD:
+ aStyle |= self.A_Z_BTMMRG
+ self.theTokens[n] = (
+ tToken[0], tToken[1], tToken[2], tToken[3], aStyle
+ )
return
@@ -454,9 +491,7 @@ class Tokenizer():
# For novel files, we need to handle chapter numbering, scene
# numbering, and scene breaks
if self.isNovel:
- for n in range(len(self.theTokens)):
-
- tToken = self.theTokens[n]
+ for n, tToken in enumerate(self.theTokens):
# In case we see text before a scene, we reset the flag
if tToken[0] == self.T_TEXT:
@@ -468,11 +503,7 @@ class Tokenizer():
tTemp = self._formatHeading(self.fmtTitle, tToken[2])
self.theTokens[n] = (
- tToken[0],
- tToken[1],
- tTemp,
- None,
- self.A_NONE
+ tToken[0], tToken[1], tTemp, None, self.A_NONE
)
elif tToken[0] == self.T_HEAD2:
@@ -490,11 +521,7 @@ class Tokenizer():
# Format the chapter header
self.theTokens[n] = (
- tToken[0],
- tToken[1],
- tTemp,
- None,
- self.A_PBB
+ tToken[0], tToken[1], tTemp, None, self.A_PBB
)
# Set scene variables
@@ -511,53 +538,29 @@ class Tokenizer():
tTemp = self._formatHeading(self.fmtScene, tToken[2])
if tTemp == "" and self.hideScene:
self.theTokens[n] = (
- self.T_EMPTY,
- tToken[1],
- "",
- None,
- self.A_NONE
+ self.T_EMPTY, tToken[1], "", None, self.A_NONE
)
elif tTemp == "" and not self.hideScene:
if self.firstScene:
self.theTokens[n] = (
- self.T_EMPTY,
- tToken[1],
- "",
- None,
- self.A_NONE
+ self.T_EMPTY, tToken[1], "", None, self.A_NONE
)
else:
self.theTokens[n] = (
- self.T_SKIP,
- tToken[1],
- "",
- None,
- self.A_NONE
+ self.T_SKIP, tToken[1], "", None, self.A_NONE
)
elif tTemp == self.fmtScene:
if self.firstScene:
self.theTokens[n] = (
- self.T_EMPTY,
- tToken[1],
- "",
- None,
- self.A_NONE
+ self.T_EMPTY, tToken[1], "", None, self.A_NONE
)
else:
self.theTokens[n] = (
- self.T_SEP,
- tToken[1],
- tTemp,
- None,
- self.A_CENTRE
+ self.T_SEP, tToken[1], tTemp, None, self.A_CENTRE
)
else:
self.theTokens[n] = (
- tToken[0],
- tToken[1],
- tTemp,
- None,
- self.A_NONE
+ tToken[0], tToken[1], tTemp, None, self.A_NONE
)
# Definitely no longer the first scene
@@ -570,35 +573,19 @@ class Tokenizer():
tTemp = self._formatHeading(self.fmtSection, tToken[2])
if tTemp == "" and self.hideSection:
self.theTokens[n] = (
- self.T_EMPTY,
- tToken[1],
- "",
- None,
- self.A_NONE
+ self.T_EMPTY, tToken[1], "", None, self.A_NONE
)
elif tTemp == "" and not self.hideSection:
self.theTokens[n] = (
- self.T_SKIP,
- tToken[1],
- "",
- None,
- self.A_NONE
+ self.T_SKIP, tToken[1], "", None, self.A_NONE
)
elif tTemp == self.fmtSection:
self.theTokens[n] = (
- self.T_SEP,
- tToken[1],
- tTemp,
- None,
- self.A_CENTRE
+ self.T_SEP, tToken[1], tTemp, None, self.A_CENTRE
)
else:
self.theTokens[n] = (
- tToken[0],
- tToken[1],
- tTemp,
- None,
- self.A_NONE
+ tToken[0], tToken[1], tTemp, None, self.A_NONE
)
# For title page and partitions, we need to centre all text.
@@ -609,28 +596,18 @@ class Tokenizer():
for n, tToken in enumerate(self.theTokens):
if tToken[0] == self.T_HEAD1:
if self.isTitle:
+ aStyle = self.A_PBB_AUT | self.A_CENTRE
self.theTokens[n] = (
- self.T_TITLE,
- tToken[1],
- tToken[2],
- tToken[3],
- self.A_PBB_NO | self.A_CENTRE
+ self.T_TITLE, tToken[1], tToken[2], tToken[3], aStyle
)
else:
+ aStyle = self.A_PBB | self.A_CENTRE
self.theTokens[n] = (
- tToken[0],
- tToken[1],
- tToken[2],
- tToken[3],
- self.A_PBB | self.A_CENTRE
+ tToken[0], tToken[1], tToken[2], tToken[3], aStyle
)
else:
self.theTokens[n] = (
- tToken[0],
- tToken[1],
- tToken[2],
- tToken[3],
- self.A_CENTRE
+ tToken[0], tToken[1], tToken[2], tToken[3], self.A_CENTRE
)
# Add a page break after the last entry
@@ -638,11 +615,7 @@ class Tokenizer():
if n >= 0:
tToken = self.theTokens[n]
self.theTokens[n] = (
- tToken[0],
- tToken[1],
- tToken[2],
- tToken[3],
- tToken[4] | self.A_PBA
+ tToken[0], tToken[1], tToken[2], tToken[3], tToken[4] | self.A_PBA
)
# A single page is always left-aligned and starts on a fresh
@@ -651,23 +624,23 @@ class Tokenizer():
for n, tToken in enumerate(self.theTokens):
if n == 0:
self.theTokens[n] = (
- tToken[0],
- tToken[1],
- tToken[2],
- tToken[3],
- self.A_LEFT | self.A_PBB
+ tToken[0], tToken[1], tToken[2], tToken[3], self.A_LEFT | self.A_PBB
)
else:
self.theTokens[n] = (
- tToken[0],
- tToken[1],
- tToken[2],
- tToken[3],
- self.A_LEFT
+ tToken[0], tToken[1], tToken[2], tToken[3], self.A_LEFT
)
return True
+ def saveRawMarkdown(self, savePath):
+ """Save the data to a plain text file.
+ """
+ with open(savePath, mode="w", encoding="utf8") as outFile:
+ for nwdPage in self.theMarkdown:
+ outFile.write(nwdPage)
+ return
+
##
# Internal Functions
##
diff --git a/nw/core/tomd.py b/nw/core/tomd.py
new file mode 100644
index 00000000..fe6da8fa
--- /dev/null
+++ b/nw/core/tomd.py
@@ -0,0 +1,197 @@
+# -*- coding: utf-8 -*-
+"""
+novelWriter – Markdown Text Converter
+=====================================
+Extends the Tokenizer class to generate Makrdown output
+
+File History:
+Created: 2021-02-06 [1.2a0]
+
+This file is a part of novelWriter
+Copyright 2018–2021, 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 .
+"""
+
+import logging
+
+from nw.core.tokenizer import Tokenizer
+from nw.constants import nwLabels
+
+logger = logging.getLogger(__name__)
+
+class ToMarkdown(Tokenizer):
+
+ M_STD = 0 # Standard Markdown
+ M_GH = 1 # GitHub Markdown
+
+ def __init__(self, theProject, theParent):
+ Tokenizer.__init__(self, theProject, theParent)
+
+ self.genMode = self.M_STD
+ self.fullMD = []
+
+ return
+
+ ##
+ # Setters
+ ##
+
+ def setStandardMarkdown(self):
+ self.genMode = self.M_STD
+ return
+
+ def setGitHubMarkdown(self):
+ self.genMode = self.M_GH
+ return
+
+ ##
+ # Class Methods
+ ##
+
+ def getFullResultSize(self):
+ """Return the size of the full Markdown result.
+ """
+ return sum([len(x) for x in self.fullMD])
+
+ def doConvert(self):
+ """Convert the list of text tokens into a HTML document saved
+ to theResult.
+ """
+ if self.genMode == self.M_STD:
+ # Standard
+ mdTags = {
+ self.FMT_B_B : "**", self.FMT_B_E : "**",
+ self.FMT_I_B : "_", self.FMT_I_E : "_",
+ self.FMT_D_B : "", self.FMT_D_E : "",
+ }
+ else:
+ # GitHub and novelWriter
+ mdTags = {
+ self.FMT_B_B : "**", self.FMT_B_E : "**",
+ self.FMT_I_B : "_", self.FMT_I_E : "_",
+ self.FMT_D_B : "~~", self.FMT_D_E : "~~",
+ }
+
+ self.theResult = ""
+
+ thisPar = []
+ tmpResult = []
+
+ for tType, tLine, tText, tFormat, tStyle in self.theTokens:
+
+ # Process Text Type
+ if tType == self.T_EMPTY:
+ if len(thisPar) > 0:
+ tTemp = "".join(thisPar)
+ tmpResult.append("%s\n\n" % tTemp.rstrip(" "))
+ thisPar = []
+
+ elif tType == self.T_TITLE:
+ tHead = tText.replace(r"\\", "\n")
+ tmpResult.append("# %s\n\n" % tHead)
+
+ elif tType == self.T_HEAD1:
+ tHead = tText.replace(r"\\", "\n")
+ tmpResult.append("# %s\n\n" % tHead)
+
+ elif tType == self.T_HEAD2:
+ tHead = tText.replace(r"\\", "\n")
+ tmpResult.append("## %s\n\n" % tHead)
+
+ elif tType == self.T_HEAD3:
+ tHead = tText.replace(r"\\", "\n")
+ tmpResult.append("### %s\n\n" % tHead)
+
+ elif tType == self.T_HEAD4:
+ tHead = tText.replace(r"\\", "\n")
+ tmpResult.append("#### %s\n\n" % tHead)
+
+ elif tType == self.T_SEP:
+ tmpResult.append("%s\n\n" % tText)
+
+ elif tType == self.T_SKIP:
+ tmpResult.append("\n\n\n")
+
+ elif tType == self.T_TEXT:
+ tTemp = tText
+ for xPos, xLen, xFmt in reversed(tFormat):
+ tTemp = tTemp[:xPos] + mdTags[xFmt] + tTemp[xPos+xLen:]
+ if tText.endswith(" "):
+ thisPar.append(tTemp.rstrip() + " \n")
+ else:
+ thisPar.append(tTemp.rstrip() + " ")
+
+ elif tType == self.T_SYNOPSIS and self.doSynopsis:
+ tmpResult.append("**Synopsis:** %s\n\n" % tText)
+
+ elif tType == self.T_COMMENT and self.doComments:
+ tmpResult.append("**Comment:** %s\n\n" % tText)
+
+ elif tType == self.T_KEYWORD and self.doKeywords:
+ tmpResult.append(self._formatKeywords(tText, tStyle))
+
+ self.theResult = "".join(tmpResult)
+ tmpResult = []
+
+ self.fullMD.append(self.theResult)
+
+ return
+
+ def saveMarkdown(self, savePath):
+ """Save the data to a plain text file.
+ """
+ with open(savePath, mode="w", encoding="utf8") as outFile:
+ theText = "".join(self.fullMD)
+ outFile.write(theText)
+
+ return
+
+ def replaceTabs(self, nSpaces=8, spaceChar=" "):
+ """Replace tabs with spaces.
+ """
+ fullMD = []
+ eightSpace = spaceChar*nSpaces
+ for aPage in self.fullMD:
+ fullMD.append(aPage.replace("\t", eightSpace))
+
+ self.fullMD = fullMD
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _formatKeywords(self, tText, tStyle):
+ """Apply Markdown formatting to keywords.
+ """
+ isValid, theBits, thePos = self.theParent.theIndex.scanThis("@"+tText)
+ if not isValid or not theBits:
+ return ""
+
+ retText = ""
+ if theBits[0] in nwLabels.KEY_NAME:
+ retText += "**%s:** " % nwLabels.KEY_NAME[theBits[0]]
+
+ if len(theBits) > 1:
+ retText += ", ".join(theBits[1:])
+
+ if tStyle & self.A_Z_BTMMRG:
+ retText += " \n"
+ else:
+ retText += "\n\n"
+
+ return retText
+
+# END Class ToMarkdown
diff --git a/nw/core/toodt.py b/nw/core/toodt.py
new file mode 100644
index 00000000..b9cffc3c
--- /dev/null
+++ b/nw/core/toodt.py
@@ -0,0 +1,1285 @@
+# -*- coding: utf-8 -*-
+"""
+novelWriter – ODT Text Converter
+================================
+Extends the Tokenizer class to generate ODT and FODT files
+
+File History:
+Created: 2021-01-26 [1.2a0]
+
+This file is a part of novelWriter
+Copyright 2018–2021, 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 .
+"""
+
+import nw
+import logging
+
+from lxml import etree
+from hashlib import sha256
+from datetime import datetime
+from zipfile import ZipFile
+
+from nw.core.tokenizer import Tokenizer
+from nw.constants import nwLabels, nwKeyWords
+
+logger = logging.getLogger(__name__)
+
+# Main XML NameSpaces
+XML_NS = {
+ "office" : "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
+ "style" : "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
+ "loext" : "urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0",
+ "text" : "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
+ "meta" : "urn:oasis:names:tc:opendocument:xmlns:meta:1.0",
+ "fo" : "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
+}
+
+# Mimetype and Version
+X_MIME = "application/vnd.oasis.opendocument.text"
+X_VERS = "1.2"
+
+# Text Formatting Tags
+TAG_BR = "{%s}line-break" % XML_NS["text"]
+TAG_TAB = "{%s}tab" % XML_NS["text"]
+TAG_SPAN = "{%s}span" % XML_NS["text"]
+TAG_STNM = "{%s}style-name" % XML_NS["text"]
+
+class ToOdt(Tokenizer):
+
+ X_BLD = 0x01 # Bold format
+ X_ITA = 0x02 # Italic format
+ X_DEL = 0x04 # Strikethrough format
+ X_BRK = 0x08 # Line break
+ X_TAB = 0x10 # Tab
+
+ def __init__(self, theProject, theParent, isFlat):
+ Tokenizer.__init__(self, theProject, theParent)
+
+ self.mainConf = nw.CONFIG
+
+ self._isFlat = isFlat # Flat: .fodt, otherwise .odt
+
+ self._dFlat = None # FODT file XML root
+ self._dCont = None # ODT content.xml root
+ self._dMeta = None # ODT meta.xml root
+ self._dStyl = None # ODT styles.xml root
+
+ self._xMeta = None # Office meta root
+ self._xStyl = None # Office styles root
+ self._xAuto = None # Office auto-styles root
+ self._xMast = None # Office master-styles root
+ self._xBody = None # Office body root
+ self._xText = None # Office text root
+
+ self._xAut2 = None # Page layout auto-styles for ODT file
+
+ self._mainPara = {} # User-accessible paragraph styles
+ self._autoPara = {} # Auto-generated paragraph styles
+ self._autoText = {} # Auto-generated text styles
+
+ # Properties
+ self.textFont = "Liberation Serif"
+ self.textSize = 12
+ self.textFixed = False
+ self.colourHead = False
+ self.addHeader = True
+ self.headerText = ""
+
+ # Internal
+ self._fontFamily = "'Liberation Sans'"
+ self._fontPitch = "variable"
+ self._fSizeTitle = "30pt"
+ self._fSizeHead1 = "24pt"
+ self._fSizeHead2 = "20pt"
+ self._fSizeHead3 = "16pt"
+ self._fSizeHead4 = "14pt"
+ self._fSizeHead = "14pt"
+ self._fSizeText = "12pt"
+ self._lineHeight = "115%"
+ self._textAlign = "left"
+ self._dLanguage = "en"
+ self._dCountry = "GB"
+
+ ## Text Margings in Units of em
+ self._mTopTitle = "0.423cm"
+ self._mTopHead1 = "0.423cm"
+ self._mTopHead2 = "0.353cm"
+ self._mTopHead3 = "0.247cm"
+ self._mTopHead4 = "0.247cm"
+ self._mTopHead = "0.423cm"
+ self._mTopText = "0.000cm"
+ self._mTopMeta = "0.000cm"
+
+ self._mBotTitle = "0.212cm"
+ self._mBotHead1 = "0.212cm"
+ self._mBotHead2 = "0.212cm"
+ self._mBotHead3 = "0.212cm"
+ self._mBotHead4 = "0.212cm"
+ self._mBotHead = "0.212cm"
+ self._mBotText = "0.247cm"
+ self._mBotMeta = "0.106cm"
+
+ ## Document Margins
+ self._mDocTop = "2.000cm"
+ self._mDocBtm = "2.000cm"
+ self._mDocLeft = "2.000cm"
+ self._mDocRight = "2.000cm"
+
+ ## Colour
+ self._colHead12 = None
+ self._opaHead12 = None
+ self._colHead34 = None
+ self._opaHead34 = None
+ self._colMetaTx = None
+ self._opaMetaTx = None
+
+ return
+
+ ##
+ # Setters
+ ##
+
+ def setLanguage(self, theLang):
+ """Set language for the document.
+ """
+ if theLang is None:
+ return False
+
+ langBits = theLang.split("_")
+ self._dLanguage = langBits[0]
+ if len(langBits) > 1:
+ self._dCountry = langBits[1]
+
+ return True
+
+ def setColourHeaders(self, doColour):
+ """Enable/disable coloured headings and comments.
+ """
+ self.colourHead = doColour
+ return
+
+ ##
+ # Class Methods
+ ##
+
+ def initDocument(self):
+ """Initialises a new open document XML tree.
+ """
+ # Initialise Variables
+ # ====================
+
+ self._fontFamily = self.textFont
+ if len(self.textFont.split()) > 1:
+ self._fontFamily = f"'{self.textFont}'"
+ self._fontPitch = "fixed" if self.textFixed else "variable"
+
+ self._fSizeTitle = f"{round(2.50 * self.textSize):d}pt"
+ self._fSizeHead1 = f"{round(2.00 * self.textSize):d}pt"
+ self._fSizeHead2 = f"{round(1.60 * self.textSize):d}pt"
+ self._fSizeHead3 = f"{round(1.30 * self.textSize):d}pt"
+ self._fSizeHead4 = f"{round(1.15 * self.textSize):d}pt"
+ self._fSizeHead = f"{round(1.15 * self.textSize):d}pt"
+ self._fSizeText = f"{self.textSize:d}pt"
+
+ mScale = self.lineHeight/1.15
+
+ self._mTopTitle = self._emToCm(mScale * self.marginTitle[0])
+ self._mTopHead1 = self._emToCm(mScale * self.marginHead1[0])
+ self._mTopHead2 = self._emToCm(mScale * self.marginHead2[0])
+ self._mTopHead3 = self._emToCm(mScale * self.marginHead3[0])
+ self._mTopHead4 = self._emToCm(mScale * self.marginHead4[0])
+ self._mTopHead = self._emToCm(mScale * self.marginHead4[0])
+ self._mTopText = self._emToCm(mScale * self.marginText[0])
+ self._mTopMeta = self._emToCm(mScale * self.marginMeta[0])
+
+ self._mBotTitle = self._emToCm(mScale * self.marginTitle[1])
+ self._mBotHead1 = self._emToCm(mScale * self.marginHead1[1])
+ self._mBotHead2 = self._emToCm(mScale * self.marginHead2[1])
+ self._mBotHead3 = self._emToCm(mScale * self.marginHead3[1])
+ self._mBotHead4 = self._emToCm(mScale * self.marginHead4[1])
+ self._mBotHead = self._emToCm(mScale * self.marginHead4[1])
+ self._mBotText = self._emToCm(mScale * self.marginText[1])
+ self._mBotMeta = self._emToCm(mScale * self.marginMeta[1])
+
+ if self.colourHead:
+ self._colHead12 = "#2a6099"
+ self._opaHead12 = "100%"
+ self._colHead34 = "#444444"
+ self._opaHead34 = "100%"
+ self._colMetaTx = "#813709"
+ self._opaMetaTx = "100%"
+
+ self._lineHeight = f"{round(100 * self.lineHeight):d}%"
+ self._textAlign = "justify" if self.doJustify else "left"
+
+ # Document Header
+ # ===============
+
+ if self.headerText == "":
+ theTitle = self.theProject.bookTitle
+ theAuth = self.theProject.getAuthors()
+ self.headerText = f"{theTitle} / {theAuth} /"
+
+ # Create Roots
+ # ============
+
+ tAttr = {}
+ tAttr[_mkTag("office", "version")] = X_VERS
+
+ fAttr = {}
+ fAttr[_mkTag("style", "name")] = self.textFont
+ fAttr[_mkTag("style", "font-pitch")] = self._fontPitch
+
+ if self._isFlat:
+
+ # FODT File
+ # =========
+
+ tAttr[_mkTag("office", "mimetype")] = X_MIME
+
+ tFlat = _mkTag("office", "document")
+ self._dFlat = etree.Element(tFlat, attrib=tAttr, nsmap=XML_NS)
+
+ self._xMeta = etree.SubElement(self._dFlat, _mkTag("office", "meta"))
+ self._xFont = etree.SubElement(self._dFlat, _mkTag("office", "font-face-decls"))
+ self._xStyl = etree.SubElement(self._dFlat, _mkTag("office", "styles"))
+ self._xAuto = etree.SubElement(self._dFlat, _mkTag("office", "automatic-styles"))
+ self._xMast = etree.SubElement(self._dFlat, _mkTag("office", "master-styles"))
+ self._xBody = etree.SubElement(self._dFlat, _mkTag("office", "body"))
+
+ etree.SubElement(self._xFont, _mkTag("style", "font-face"), attrib=fAttr)
+
+ else:
+
+ # ODT File
+ # ========
+
+ tCont = _mkTag("office", "document-content")
+ tMeta = _mkTag("office", "document-meta")
+ tStyl = _mkTag("office", "document-styles")
+
+ # content.xml
+ self._dCont = etree.Element(tCont, attrib=tAttr, nsmap=XML_NS)
+ self._xFnt1 = etree.SubElement(self._dCont, _mkTag("office", "font-face-decls"))
+ self._xAuto = etree.SubElement(self._dCont, _mkTag("office", "automatic-styles"))
+ self._xBody = etree.SubElement(self._dCont, _mkTag("office", "body"))
+
+ # meta.xml
+ self._dMeta = etree.Element(tMeta, attrib=tAttr, nsmap=XML_NS)
+ self._xMeta = etree.SubElement(self._dMeta, _mkTag("office", "meta"))
+
+ # styles.xml
+ self._dStyl = etree.Element(tStyl, attrib=tAttr, nsmap=XML_NS)
+ self._xFnt2 = etree.SubElement(self._dStyl, _mkTag("office", "font-face-decls"))
+ self._xStyl = etree.SubElement(self._dStyl, _mkTag("office", "styles"))
+ self._xAut2 = etree.SubElement(self._dStyl, _mkTag("office", "automatic-styles"))
+ self._xMast = etree.SubElement(self._dStyl, _mkTag("office", "master-styles"))
+
+ etree.SubElement(self._xFnt1, _mkTag("style", "font-face"), attrib=fAttr)
+ etree.SubElement(self._xFnt2, _mkTag("style", "font-face"), attrib=fAttr)
+
+ # Finalise
+ # ========
+
+ self._xText = etree.SubElement(self._xBody, _mkTag("office", "text"))
+
+ # Meta Data
+ xMeta = etree.SubElement(self._xMeta, _mkTag("meta", "creation-date"))
+ xMeta.text = datetime.now().isoformat()
+
+ xMeta = etree.SubElement(self._xMeta, _mkTag("meta", "generator"))
+ xMeta.text = f"novelWriter/{nw.__version__}"
+
+ self._pageStyles()
+ self._defaultStyles()
+ self._useableStyles()
+ self._writeHeader()
+
+ return
+
+ def doConvert(self):
+ """Convert the list of text tokens into XML elements.
+ """
+ self.theResult = "" # Not used, but cleared just in case
+
+ odtTags = {
+ self.FMT_B_B : "_B", # Bold open format
+ self.FMT_B_E : "b_", # Bold close format
+ self.FMT_I_B : "I", # Italic open format
+ self.FMT_I_E : "i", # Italic close format
+ self.FMT_D_B : "_S", # Strikethrough open format
+ self.FMT_D_E : "s_", # Strikethrough close format
+ }
+
+ thisPar = []
+ thisFmt = []
+ parStyle = None
+ hasHardBreak = False
+ for tType, tLine, tText, tFormat, tStyle in self.theTokens:
+
+ # Styles
+ oStyle = ODTParagraphStyle()
+ if tStyle is not None:
+ if tStyle & self.A_LEFT:
+ oStyle.setTextAlign("left")
+ elif tStyle & self.A_RIGHT:
+ oStyle.setTextAlign("right")
+ elif tStyle & self.A_CENTRE:
+ oStyle.setTextAlign("center")
+ elif tStyle & self.A_JUSTIFY:
+ oStyle.setTextAlign("justify")
+
+ if tStyle & self.A_PBB:
+ oStyle.setBreakBefore("page")
+ elif tStyle & self.A_PBB_AUT:
+ oStyle.setBreakBefore("auto")
+
+ if tStyle & self.A_PBA:
+ oStyle.setBreakAfter("page")
+ elif tStyle & self.A_PBA_AUT:
+ oStyle.setBreakAfter("auto")
+
+ if tStyle & self.A_Z_BTMMRG:
+ oStyle.setMarginBottom("0.000cm")
+ if tStyle & self.A_Z_TOPMRG:
+ oStyle.setMarginTop("0.000cm")
+
+ # Process Text Types
+ if tType == self.T_EMPTY:
+ if hasHardBreak and parStyle is not None:
+ if self.doJustify:
+ parStyle.setTextAlign("left")
+
+ if len(thisPar) > 0:
+ tTemp = "".join(thisPar)
+ fTemp = "".join(thisFmt)
+ tTxt = tTemp.rstrip()
+ tFmt = fTemp[:len(tTxt)]
+ self._addTextPar("Text_Body", parStyle, tTxt, theFmt=tFmt)
+
+ thisPar = []
+ thisFmt = []
+ parStyle = None
+ hasHardBreak = False
+
+ elif tType == self.T_TITLE:
+ tHead = tText.replace(r"\\", "\n")
+ self._addTextPar("Title", oStyle, tHead, isHead=True)
+
+ elif tType == self.T_HEAD1:
+ tHead = tText.replace(r"\\", "\n")
+ self._addTextPar("Heading_1", oStyle, tHead, isHead=True, oLevel="1")
+
+ elif tType == self.T_HEAD2:
+ tHead = tText.replace(r"\\", "\n")
+ self._addTextPar("Heading_2", oStyle, tHead, isHead=True, oLevel="2")
+
+ elif tType == self.T_HEAD3:
+ tHead = tText.replace(r"\\", "\n")
+ self._addTextPar("Heading_3", oStyle, tHead, isHead=True, oLevel="3")
+
+ elif tType == self.T_HEAD4:
+ tHead = tText.replace(r"\\", "\n")
+ self._addTextPar("Heading_4", oStyle, tHead, isHead=True, oLevel="4")
+
+ elif tType == self.T_SEP:
+ self._addTextPar("Text_Body", oStyle, tText)
+
+ elif tType == self.T_SKIP:
+ self._addTextPar("Text_Body", oStyle, "")
+
+ elif tType == self.T_TEXT:
+ tTemp = tText
+ if parStyle is None:
+ parStyle = oStyle
+
+ tFmt = " "*len(tTemp)
+ for xPos, xLen, xFmt in tFormat:
+ tFmt = tFmt[:xPos] + odtTags[xFmt] + tFmt[xPos+xLen:]
+
+ tTxt = tTemp.rstrip()
+ tFmt = tFmt[:len(tTxt)]
+ if tText.endswith(" "):
+ thisPar.append(tTxt + "\n")
+ thisFmt.append(tFmt + " ")
+ hasHardBreak = True
+ else:
+ thisPar.append(tTxt + " ")
+ thisFmt.append(tFmt + " ")
+
+ elif tType == self.T_SYNOPSIS and self.doSynopsis:
+ tTemp, fTemp = self._formatSynopsis(tText)
+ self._addTextPar("Text_Meta", oStyle, tTemp, theFmt=fTemp)
+
+ elif tType == self.T_COMMENT and self.doComments:
+ tTemp, fTemp = self._formatComments(tText)
+ self._addTextPar("Text_Meta", oStyle, tTemp, theFmt=fTemp)
+
+ elif tType == self.T_KEYWORD and self.doKeywords:
+ tTemp, fTemp = self._formatKeywords(tText)
+ self._addTextPar("Text_Meta", oStyle, tTemp, theFmt=fTemp)
+
+ return
+
+ def closeDocument(self):
+ """Return the serialised XML document
+ """
+ # Build the auto-generated styles
+ for styleName, styleObj in self._autoPara.values():
+ styleObj.packXML(self._xAuto, styleName)
+ for styleName, styleObj in self._autoText.values():
+ styleObj.packXML(self._xAuto, styleName)
+
+ return
+
+ def saveFlatXML(self, savePath):
+ """Save the data to an .fodt file.
+ """
+ with open(savePath, mode="wb") as outFile:
+ outFile.write(etree.tostring(
+ self._dFlat,
+ pretty_print = True,
+ encoding = "utf-8",
+ xml_declaration = True
+ ))
+ return
+
+ def saveOpenDocText(self, savePath):
+ """Save the data to an .odt file.
+ """
+ mMap = {"manifest" : "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"}
+ mMani = "{%s}manifest" % mMap["manifest"]
+ mVers = "{%s}version" % mMap["manifest"]
+ mPath = "{%s}full-path" % mMap["manifest"]
+ mType = "{%s}media-type" % mMap["manifest"]
+ mFile = "{%s}file-entry" % mMap["manifest"]
+
+ xMani = etree.Element(mMani, attrib={mVers: X_VERS}, nsmap=mMap)
+ etree.SubElement(xMani, mFile, attrib={mPath: "/", mVers: X_VERS, mType: X_MIME})
+ etree.SubElement(xMani, mFile, attrib={mPath: "settings.xml", mType: "text/xml"})
+ etree.SubElement(xMani, mFile, attrib={mPath: "content.xml", mType: "text/xml"})
+ etree.SubElement(xMani, mFile, attrib={mPath: "meta.xml", mType: "text/xml"})
+ etree.SubElement(xMani, mFile, attrib={mPath: "styles.xml", mType: "text/xml"})
+
+ sMap = {"office" : "urn:oasis:names:tc:opendocument:xmlns:office:1.0"}
+ oRoot = "{%s}document-settings" % sMap["office"]
+ oSett = "{%s}settings" % sMap["office"]
+ xSett = etree.Element(oRoot, nsmap=sMap)
+ etree.SubElement(xSett, oSett)
+
+ with ZipFile(savePath, mode="w") as outFile:
+ outFile.writestr("mimetype", X_MIME)
+ outFile.writestr("META-INF/manifest.xml", etree.tostring(
+ xMani, pretty_print=False, encoding="utf-8", xml_declaration=True
+ ))
+ outFile.writestr("settings.xml", etree.tostring(
+ xSett, pretty_print=False, encoding="utf-8", xml_declaration=True
+ ))
+ outFile.writestr("content.xml", etree.tostring(
+ self._dCont, pretty_print=False, encoding="utf-8", xml_declaration=True
+ ))
+ outFile.writestr("meta.xml", etree.tostring(
+ self._dMeta, pretty_print=False, encoding="utf-8", xml_declaration=True
+ ))
+ outFile.writestr("styles.xml", etree.tostring(
+ self._dStyl, pretty_print=False, encoding="utf-8", xml_declaration=True
+ ))
+
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _formatSynopsis(self, tText):
+ """Apply formatting to synopsis lines.
+ """
+ rTxt = "**Synopsis:** %s" % tText
+ rFmt = "_B b_ %s" % (" "*len(tText))
+ return rTxt, rFmt
+
+ def _formatComments(self, tText):
+ """Apply formatting to comments.
+ """
+ rTxt = "**Comment:** %s" % tText
+ rFmt = "_B b_ %s" % (" "*len(tText))
+ return rTxt, rFmt
+
+ def _formatKeywords(self, tText):
+ """Apply formatting to keywords.
+ """
+ isValid, theBits, thePos = self.theParent.theIndex.scanThis("@"+tText)
+ if not isValid or not theBits:
+ return ""
+
+ rTxt = ""
+ rFmt = ""
+ if theBits[0] in nwLabels.KEY_NAME:
+ tText = nwLabels.KEY_NAME[theBits[0]]
+ rTxt += "**%s:** " % tText
+ rFmt += "_B%s b_ " % (" "*len(tText))
+ if len(theBits) > 1:
+ if theBits[0] == nwKeyWords.TAG_KEY:
+ rTxt += "%s" % theBits[1]
+ rFmt += "%s" % (" "*len(theBits[1]))
+ else:
+ tTags = ", ".join(theBits[1:])
+ rTxt += tTags
+ rFmt += (" "*len(tTags))
+
+ return rTxt, rFmt
+
+ def _addTextPar(self, styleName, oStyle, theText, theFmt="", isHead=False, oLevel=None):
+ """Add a text paragraph to the text XML element.
+ """
+ tAttr = {}
+ tAttr[_mkTag("text", "style-name")] = self._paraStyle(styleName, oStyle)
+ if oLevel is not None:
+ tAttr[_mkTag("text", "outline-level")] = oLevel
+
+ pTag = "h" if isHead else "p"
+ xElem = etree.SubElement(self._xText, _mkTag("text", pTag), attrib=tAttr)
+
+ if not theText:
+ return
+
+ ##
+ # Process Formatting
+ ##
+
+ if len(theText) != len(theFmt):
+ # Genrate dummy format if there isn't any
+ theFmt = " "*len(theText)
+
+ # XML functions
+ xTail = None
+
+ def appendText(tText):
+ nonlocal xElem, xTail
+ if tText:
+ if xTail is None:
+ xElem.text = tText
+ else:
+ xTail.tail = tText
+
+ def appendSpan(tText, tFmt):
+ nonlocal xElem, xTail
+ if tText:
+ xTail = etree.SubElement(xElem, TAG_SPAN, attrib={
+ TAG_STNM: self._textStyle(tFmt)
+ })
+ xTail.text = tText
+
+ # The formatting loop
+ tTemp = ""
+ xFmt = 0x00
+ pFmt = 0x00
+
+ for i, c in enumerate(theText):
+
+ if theFmt[i] == "_":
+ continue
+ elif theFmt[i] == "B":
+ xFmt |= self.X_BLD
+ elif theFmt[i] == "b":
+ xFmt ^= self.X_BLD
+ elif theFmt[i] == "I":
+ xFmt |= self.X_ITA
+ elif theFmt[i] == "i":
+ xFmt ^= self.X_ITA
+ elif theFmt[i] == "S":
+ xFmt |= self.X_DEL
+ elif theFmt[i] == "s":
+ xFmt ^= self.X_DEL
+
+ if c == "\n":
+ xFmt |= self.X_BRK
+ c = ""
+ elif c == "\t":
+ xFmt |= self.X_TAB
+ c = ""
+
+ if theFmt[i] == " ":
+ tTemp += c
+
+ if xFmt != pFmt:
+ if pFmt == 0x00:
+ appendText(tTemp)
+ tTemp = ""
+ else:
+ appendSpan(tTemp, pFmt)
+ tTemp = ""
+
+ if xFmt & self.X_BRK:
+ xTail = etree.SubElement(xElem, TAG_BR)
+ xFmt ^= self.X_BRK
+
+ if xFmt & self.X_TAB:
+ xTail = etree.SubElement(xElem, TAG_TAB)
+ xFmt ^= self.X_TAB
+
+ pFmt = xFmt
+
+ # Save what remains in the buffer
+ appendText(tTemp)
+
+ return
+
+ def _paraStyle(self, parName, oStyle):
+ """Return a name for a style object.
+ """
+ refStyle = self._mainPara.get(parName, None)
+ if refStyle is None:
+ logger.error("Unknown paragraph style '%s'" % parName)
+ return "Standard"
+
+ if not refStyle.checkNew(oStyle):
+ return parName
+
+ oStyle.setParentStyleName(parName)
+ theID = oStyle.getID()
+ if theID in self._autoPara:
+ return self._autoPara[theID][0]
+
+ newName = "P%d" % (len(self._autoPara) + 1)
+ self._autoPara[theID] = (newName, oStyle)
+
+ return newName
+
+ def _textStyle(self, styleCode):
+ """Return a text style for a given style code.
+ """
+ if styleCode in self._autoText:
+ return self._autoText[styleCode][0]
+
+ newName = "T%d" % (len(self._autoText) + 1)
+ newStyle = ODTTextStyle()
+ if styleCode & self.X_BLD:
+ newStyle.setFontWeight("bold")
+ if styleCode & self.X_ITA:
+ newStyle.setFontStyle("italic")
+ if styleCode & self.X_DEL:
+ newStyle.setStrikeStyle("solid")
+ newStyle.setStrikeType("single")
+
+ self._autoText[styleCode] = (newName, newStyle)
+
+ return newName
+
+ def _emToCm(self, emVal):
+ """Converts an em value to centimetres.
+ """
+ return f"{emVal*2.54/72*self.textSize:.3f}cm"
+
+ ##
+ # Style Elements
+ ##
+
+ def _pageStyles(self):
+ """Set the default page style.
+ """
+ theAttr = {}
+ theAttr[_mkTag("style", "name")] = "PM1"
+ if self._isFlat:
+ xPage = etree.SubElement(self._xAuto, _mkTag("style", "page-layout"), attrib=theAttr)
+ else:
+ xPage = etree.SubElement(self._xAut2, _mkTag("style", "page-layout"), attrib=theAttr)
+
+ theAttr = {}
+ theAttr[_mkTag("fo", "margin-top")] = self._mDocTop
+ theAttr[_mkTag("fo", "margin-bottom")] = self._mDocBtm
+ theAttr[_mkTag("fo", "margin-left")] = self._mDocLeft
+ theAttr[_mkTag("fo", "margin-right")] = self._mDocRight
+ etree.SubElement(xPage, _mkTag("style", "page-layout-properties"), attrib=theAttr)
+
+ xHead = etree.SubElement(xPage, _mkTag("style", "header-style"))
+
+ theAttr = {}
+ theAttr[_mkTag("fo", "min-height")] = "0.600cm"
+ theAttr[_mkTag("fo", "margin-left")] = "0.000cm"
+ theAttr[_mkTag("fo", "margin-right")] = "0.000cm"
+ theAttr[_mkTag("fo", "margin-bottom")] = "0.500cm"
+ etree.SubElement(xHead, _mkTag("style", "header-footer-properties"), attrib=theAttr)
+
+ return
+
+ def _defaultStyles(self):
+ """Set the default styles.
+ """
+ # Add Paragraph Family Style
+ # ==========================
+
+ theAttr = {}
+ theAttr[_mkTag("style", "family")] = "paragraph"
+ xStyl = etree.SubElement(self._xStyl, _mkTag("style", "default-style"), attrib=theAttr)
+
+ theAttr = {}
+ theAttr[_mkTag("style", "line-break")] = "strict"
+ theAttr[_mkTag("style", "tab-stop-distance")] = "1.251cm"
+ theAttr[_mkTag("style", "writing-mode")] = "page"
+ etree.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=theAttr)
+
+ theAttr = {}
+ theAttr[_mkTag("style", "font-name")] = self.textFont
+ theAttr[_mkTag("fo", "font-family")] = self._fontFamily
+ theAttr[_mkTag("fo", "font-size")] = self._fSizeText
+ theAttr[_mkTag("fo", "language")] = self._dLanguage
+ theAttr[_mkTag("fo", "country")] = self._dCountry
+ etree.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=theAttr)
+
+ # Add Standard Paragraph Style
+ # ============================
+
+ theAttr = {}
+ theAttr[_mkTag("style", "name")] = "Standard"
+ theAttr[_mkTag("style", "family")] = "paragraph"
+ theAttr[_mkTag("style", "class")] = "text"
+ xStyl = etree.SubElement(self._xStyl, _mkTag("style", "style"), attrib=theAttr)
+
+ theAttr = {}
+ theAttr[_mkTag("style", "font-name")] = self.textFont
+ theAttr[_mkTag("fo", "font-family")] = self._fontFamily
+ theAttr[_mkTag("fo", "font-size")] = self._fSizeText
+ etree.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=theAttr)
+
+ # Add Default Heading Style
+ # =========================
+
+ theAttr = {}
+ theAttr[_mkTag("style", "name")] = "Heading"
+ theAttr[_mkTag("style", "family")] = "paragraph"
+ theAttr[_mkTag("style", "parent-style-name")] = "Standard"
+ theAttr[_mkTag("style", "next-style-name")] = "Text_Body"
+ theAttr[_mkTag("style", "class")] = "text"
+ xStyl = etree.SubElement(self._xStyl, _mkTag("style", "style"), attrib=theAttr)
+
+ theAttr = {}
+ theAttr[_mkTag("fo", "margin-top")] = self._mTopHead
+ theAttr[_mkTag("fo", "margin-bottom")] = self._mBotHead
+ theAttr[_mkTag("fo", "keep-with-next")] = "always"
+ etree.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=theAttr)
+
+ theAttr = {}
+ theAttr[_mkTag("style", "font-name")] = self.textFont
+ theAttr[_mkTag("fo", "font-family")] = self._fontFamily
+ theAttr[_mkTag("fo", "font-size")] = self._fSizeHead
+ etree.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=theAttr)
+
+ # Add Header and Footer Styles
+ # ============================
+ if not self.addHeader:
+ return
+
+ theAttr = {}
+ theAttr[_mkTag("style", "name")] = "Header_and_Footer"
+ theAttr[_mkTag("style", "display-name")] = "Header and Footer"
+ theAttr[_mkTag("style", "family")] = "paragraph"
+ theAttr[_mkTag("style", "parent-style-name")] = "Standard"
+ theAttr[_mkTag("style", "class")] = "extra"
+ etree.SubElement(self._xStyl, _mkTag("style", "style"), attrib=theAttr)
+
+ return
+
+ def _useableStyles(self):
+ """Set the usable styles.
+ """
+ # Add Text Body Style
+ # ===================
+
+ oStyle = ODTParagraphStyle()
+ oStyle.setDisplayName("Text Body")
+ oStyle.setParentStyleName("Standard")
+ oStyle.setClass("text")
+ oStyle.setMarginTop(self._mTopText)
+ oStyle.setMarginBottom(self._mBotText)
+ oStyle.setLineHeight(self._lineHeight)
+ oStyle.setFontName(self.textFont)
+ oStyle.setFontFamily(self._fontFamily)
+ oStyle.setFontSize(self._fSizeText)
+ oStyle.setTextAlign(self._textAlign)
+ oStyle.packXML(self._xStyl, "Text_Body")
+
+ self._mainPara["Text_Body"] = oStyle
+
+ # Add Text Meta Style
+ # ===================
+
+ oStyle = ODTParagraphStyle()
+ oStyle.setDisplayName("Text Meta")
+ oStyle.setParentStyleName("Standard")
+ oStyle.setClass("text")
+ oStyle.setMarginTop(self._mTopMeta)
+ oStyle.setMarginBottom(self._mBotMeta)
+ oStyle.setLineHeight(self._lineHeight)
+ oStyle.setFontName(self.textFont)
+ oStyle.setFontFamily(self._fontFamily)
+ oStyle.setFontSize(self._fSizeText)
+ oStyle.setColor(self._colMetaTx)
+ oStyle.setOpacity(self._opaMetaTx)
+ oStyle.packXML(self._xStyl, "Text_Meta")
+
+ self._mainPara["Text_Meta"] = oStyle
+
+ # Add Title Style
+ # ===============
+
+ oStyle = ODTParagraphStyle()
+ oStyle.setDisplayName("Title")
+ oStyle.setParentStyleName("Heading")
+ oStyle.setNextStyleName("Text_Body")
+ oStyle.setClass("chapter")
+ oStyle.setTextAlign("center")
+ oStyle.setMarginTop(self._mTopTitle)
+ oStyle.setMarginBottom(self._mBotTitle)
+ oStyle.setFontName(self.textFont)
+ oStyle.setFontFamily(self._fontFamily)
+ oStyle.setFontSize(self._fSizeTitle)
+ oStyle.setFontWeight("bold")
+ oStyle.packXML(self._xStyl, "Title")
+
+ self._mainPara["Title"] = oStyle
+
+ # Add Heading 1 Style
+ # ===================
+
+ oStyle = ODTParagraphStyle()
+ oStyle.setDisplayName("Heading 1")
+ oStyle.setParentStyleName("Heading")
+ oStyle.setNextStyleName("Text_Body")
+ oStyle.setOutlineLevel("1")
+ oStyle.setClass("text")
+ oStyle.setMarginTop(self._mTopHead1)
+ oStyle.setMarginBottom(self._mBotHead1)
+ oStyle.setFontName(self.textFont)
+ oStyle.setFontFamily(self._fontFamily)
+ oStyle.setFontSize(self._fSizeHead1)
+ oStyle.setColor(self._colHead12)
+ oStyle.setOpacity(self._opaHead12)
+ oStyle.setFontWeight("bold")
+ oStyle.packXML(self._xStyl, "Heading_1")
+
+ self._mainPara["Heading_1"] = oStyle
+
+ # Add Heading 2 Style
+ # ===================
+
+ oStyle = ODTParagraphStyle()
+ oStyle.setDisplayName("Heading 2")
+ oStyle.setParentStyleName("Heading")
+ oStyle.setNextStyleName("Text_Body")
+ oStyle.setOutlineLevel("2")
+ oStyle.setClass("text")
+ oStyle.setMarginTop(self._mTopHead2)
+ oStyle.setMarginBottom(self._mBotHead2)
+ oStyle.setFontName(self.textFont)
+ oStyle.setFontFamily(self._fontFamily)
+ oStyle.setFontSize(self._fSizeHead2)
+ oStyle.setColor(self._colHead12)
+ oStyle.setOpacity(self._opaHead12)
+ oStyle.setFontWeight("bold")
+ oStyle.packXML(self._xStyl, "Heading_2")
+
+ self._mainPara["Heading_2"] = oStyle
+
+ # Add Heading 3 Style
+ # ===================
+
+ oStyle = ODTParagraphStyle()
+ oStyle.setDisplayName("Heading 3")
+ oStyle.setParentStyleName("Heading")
+ oStyle.setNextStyleName("Text_Body")
+ oStyle.setOutlineLevel("3")
+ oStyle.setClass("text")
+ oStyle.setMarginTop(self._mTopHead3)
+ oStyle.setMarginBottom(self._mBotHead3)
+ oStyle.setFontName(self.textFont)
+ oStyle.setFontFamily(self._fontFamily)
+ oStyle.setFontSize(self._fSizeHead3)
+ oStyle.setColor(self._colHead34)
+ oStyle.setOpacity(self._opaHead34)
+ oStyle.setFontWeight("bold")
+ oStyle.packXML(self._xStyl, "Heading_3")
+
+ self._mainPara["Heading_3"] = oStyle
+
+ # Add Heading 4 Style
+ # ===================
+
+ oStyle = ODTParagraphStyle()
+ oStyle.setDisplayName("Heading 4")
+ oStyle.setParentStyleName("Heading")
+ oStyle.setNextStyleName("Text_Body")
+ oStyle.setOutlineLevel("4")
+ oStyle.setClass("text")
+ oStyle.setMarginTop(self._mTopHead4)
+ oStyle.setMarginBottom(self._mBotHead4)
+ oStyle.setFontName(self.textFont)
+ oStyle.setFontFamily(self._fontFamily)
+ oStyle.setFontSize(self._fSizeHead4)
+ oStyle.setColor(self._colHead34)
+ oStyle.setOpacity(self._opaHead34)
+ oStyle.setFontWeight("bold")
+ oStyle.packXML(self._xStyl, "Heading_4")
+
+ self._mainPara["Heading_4"] = oStyle
+
+ # Add Header Style
+ # ================
+ if not self.addHeader:
+ return
+
+ oStyle = ODTParagraphStyle()
+ oStyle.setDisplayName("Header")
+ oStyle.setParentStyleName("Header_and_Footer")
+ oStyle.setTextAlign("right")
+ oStyle.packXML(self._xStyl, "Header")
+
+ self._mainPara["Header"] = oStyle
+
+ return
+
+ def _writeHeader(self):
+ """Write the header elements.
+ """
+ if not self.addHeader:
+ return
+
+ theAttr = {}
+ theAttr[_mkTag("style", "name")] = "Standard"
+ theAttr[_mkTag("style", "page-layout-name")] = "PM1"
+ xPage = etree.SubElement(self._xMast, _mkTag("style", "master-page"), attrib=theAttr)
+
+ # Standard Page Header
+ xHead = etree.SubElement(xPage, _mkTag("style", "header"))
+ xPar = etree.SubElement(xHead, _mkTag("text", "p"), attrib={
+ _mkTag("text", "style-name"): "Header"
+ })
+ xPar.text = self.headerText.strip() + " "
+
+ xTail = etree.SubElement(xPar, _mkTag("text", "page-number"), attrib={
+ _mkTag("text", "select-page"): "current"
+ })
+ xTail.text = "2"
+
+ # First Page Header
+ xHead = etree.SubElement(xPage, _mkTag("style", "header-first"))
+ xPar = etree.SubElement(xHead, _mkTag("text", "p"), attrib={
+ _mkTag("text", "style-name"): "Header"
+ })
+
+ return
+
+# END Class ToOdt
+
+# =============================================================================================== #
+# Auto-Style Classes
+# =============================================================================================== #
+
+class ODTParagraphStyle():
+ """Wrapper class for the paragraph style setting used by the
+ exporter. Only the used settings are exposed here to keep the class
+ minimal and fast.
+ """
+ VALID_ALIGN = ["start", "center", "end", "justify", "inside", "outside", "left", "right"]
+ VALID_BREAK = ["auto", "column", "page", "even-page", "odd-page", "inherit"]
+ VALID_LEVEL = ["1", "2", "3", "4"]
+ VALID_CLASS = ["text", "chapter"]
+ VALID_WEIGHT = ["normal", "inherit", "bold"]
+
+ def __init__(self):
+
+ # Attributes
+ self._mAttr = {
+ "display-name": ["style", None],
+ "parent-style-name": ["style", None],
+ "next-style-name": ["style", None],
+ "default-outline-level": ["style", None],
+ "class": ["style", None],
+ }
+
+ # Paragraph Attributes
+ self._pAttr = {
+ "margin-top": ["fo", None],
+ "margin-bottom": ["fo", None],
+ "line-height": ["fo", None],
+ "text-align": ["fo", None],
+ "break-before": ["fo", None],
+ "break-after": ["fo", None],
+ }
+
+ # Text Attributes
+ self._tAttr = {
+ "font-name": ["style", None],
+ "font-family": ["fo", None],
+ "font-size": ["fo", None],
+ "font-weight": ["fo", None],
+ "color": ["fo", None],
+ "opacity": ["loext", None],
+ }
+
+ return
+
+ ##
+ # Attribute Setters
+ ##
+
+ def setDisplayName(self, theValue):
+ self._mAttr["display-name"][1] = str(theValue)
+ return
+
+ def setParentStyleName(self, theValue):
+ self._mAttr["parent-style-name"][1] = str(theValue)
+ return
+
+ def setNextStyleName(self, theValue):
+ self._mAttr["next-style-name"][1] = str(theValue)
+ return
+
+ def setOutlineLevel(self, theValue):
+ if theValue in self.VALID_LEVEL:
+ self._mAttr["default-outline-level"][1] = str(theValue)
+ return
+
+ def setClass(self, theValue):
+ if theValue in self.VALID_CLASS:
+ self._mAttr["class"][1] = str(theValue)
+ return
+
+ ##
+ # Paragraph Setters
+ ##
+
+ def setMarginTop(self, theValue):
+ self._pAttr["margin-top"][1] = str(theValue)
+ return
+
+ def setMarginBottom(self, theValue):
+ self._pAttr["margin-bottom"][1] = str(theValue)
+ return
+
+ def setLineHeight(self, theValue):
+ self._pAttr["line-height"][1] = str(theValue)
+ return
+
+ def setTextAlign(self, theValue):
+ if theValue in self.VALID_ALIGN:
+ self._pAttr["text-align"][1] = str(theValue)
+ return
+
+ def setBreakBefore(self, theValue):
+ if theValue in self.VALID_BREAK:
+ self._pAttr["break-before"][1] = str(theValue)
+ return
+
+ def setBreakAfter(self, theValue):
+ if theValue in self.VALID_BREAK:
+ self._pAttr["break-after"][1] = str(theValue)
+ return
+
+ ##
+ # Text Setters
+ ##
+
+ def setFontName(self, theValue):
+ self._tAttr["font-name"][1] = str(theValue)
+ return
+
+ def setFontFamily(self, theValue):
+ self._tAttr["font-family"][1] = str(theValue)
+ return
+
+ def setFontSize(self, theValue):
+ self._tAttr["font-size"][1] = str(theValue)
+ return
+
+ def setFontWeight(self, theValue):
+ if theValue in self.VALID_WEIGHT:
+ self._tAttr["font-weight"][1] = str(theValue)
+ return
+
+ def setColor(self, theValue):
+ self._tAttr["color"][1] = str(theValue)
+ return
+
+ def setOpacity(self, theValue):
+ self._tAttr["opacity"][1] = str(theValue)
+ return
+
+ ##
+ # Getters
+ ##
+
+ def getAttr(self, attrName):
+ """Look through the dictionaries for the value, and return it if
+ we can find it, If not, return None.
+ """
+ retVal = self._mAttr.get(attrName, None)
+ if retVal is not None:
+ return retVal
+
+ retVal = self._pAttr.get(attrName, None)
+ if retVal is not None:
+ return retVal
+
+ retVal = self._tAttr.get(attrName, None)
+ if retVal is not None:
+ return retVal
+
+ return None
+
+ ##
+ # Methods
+ ##
+
+ def checkNew(self, refStyle):
+ """Check if there are new settings in refStyle that differ from
+ those in the current object.
+ """
+ for aName, (aNm, aVal) in refStyle._mAttr.items():
+ if aVal is not None and aVal != self._mAttr[aName][1]:
+ return True
+ for aName, (aNm, aVal) in refStyle._pAttr.items():
+ if aVal is not None and aVal != self._pAttr[aName][1]:
+ return True
+ for aName, (aNm, aVal) in refStyle._tAttr.items():
+ if aVal is not None and aVal != self._tAttr[aName][1]:
+ return True
+ return False
+
+ def getID(self):
+ """Generate a unique ID from the settings.
+ """
+ theString = (
+ f"Paragraph:Main:{str(self._mAttr)}:"
+ f"Paragraph:Para:{str(self._pAttr)}:"
+ f"Paragraph:Text:{str(self._tAttr)}:"
+ )
+ return sha256(theString.encode()).hexdigest()
+
+ def packXML(self, xParent, xName):
+ """Pack the content into an xml element.
+ """
+ theAttr = {}
+ theAttr[_mkTag("style", "name")] = xName
+ theAttr[_mkTag("style", "family")] = "paragraph"
+ for aName, (aNm, aVal) in self._mAttr.items():
+ if aVal is not None:
+ theAttr[_mkTag(aNm, aName)] = aVal
+
+ xEntry = etree.SubElement(xParent, _mkTag("style", "style"), attrib=theAttr)
+
+ theAttr = {}
+ for aName, (aNm, aVal) in self._pAttr.items():
+ if aVal is not None:
+ theAttr[_mkTag(aNm, aName)] = aVal
+
+ if theAttr:
+ etree.SubElement(xEntry, _mkTag("style", "paragraph-properties"), attrib=theAttr)
+
+ theAttr = {}
+ for aName, (aNm, aVal) in self._tAttr.items():
+ if aVal is not None:
+ theAttr[_mkTag(aNm, aName)] = aVal
+
+ if theAttr:
+ etree.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=theAttr)
+
+ return
+
+# END Class ODTParagraphStyle
+
+class ODTTextStyle():
+ """Wrapper class for the text style setting used by the exporter.
+ Only the used settings are exposed here to keep the class minimal
+ and fast.
+ """
+ VALID_WEIGHT = ["normal", "inherit", "bold"]
+ VALID_STYLE = ["normal", "inherit", "italic"]
+ VALID_LSTYLE = ["none", "solid"]
+ VALID_LTYPE = ["none", "single", "double"]
+
+ def __init__(self):
+
+ # Text Attributes
+ self._tAttr = {
+ "font-weight": ["fo", None],
+ "font-style": ["fo", None],
+ "text-line-through-style": ["style", None],
+ "text-line-through-type": ["style", None],
+ }
+
+ return
+
+ ##
+ # Setters
+ ##
+
+ def setFontWeight(self, theValue):
+ if theValue in self.VALID_WEIGHT:
+ self._tAttr["font-weight"][1] = str(theValue)
+ return
+
+ def setFontStyle(self, theValue):
+ if theValue in self.VALID_STYLE:
+ self._tAttr["font-style"][1] = str(theValue)
+ return
+
+ def setStrikeStyle(self, theValue):
+ if theValue in self.VALID_LSTYLE:
+ self._tAttr["text-line-through-style"][1] = str(theValue)
+ return
+
+ def setStrikeType(self, theValue):
+ if theValue in self.VALID_LTYPE:
+ self._tAttr["text-line-through-type"][1] = str(theValue)
+ return
+
+ ##
+ # Methods
+ ##
+
+ def packXML(self, xParent, xName):
+ """Pack the content into an xml element.
+ """
+ theAttr = {}
+ theAttr[_mkTag("style", "name")] = xName
+ theAttr[_mkTag("style", "family")] = "text"
+ xEntry = etree.SubElement(xParent, _mkTag("style", "style"), attrib=theAttr)
+
+ theAttr = {}
+ for aName, (aNm, aVal) in self._tAttr.items():
+ if aVal is not None:
+ theAttr[_mkTag(aNm, aName)] = aVal
+
+ if theAttr:
+ etree.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=theAttr)
+
+ return
+
+# END Class ODTTextStyle
+
+# =============================================================================================== #
+# Local Functions
+# =============================================================================================== #
+
+def _mkTag(nsName, tagName):
+ """Assemble namespace and tag name.
+ """
+ theNS = XML_NS.get(nsName, "")
+ if theNS:
+ return "{%s}%s" % (theNS, tagName)
+ logger.warning("Missing xml namespace '%s'" % nsName)
+ return tagName
diff --git a/nw/core/tree.py b/nw/core/tree.py
index 36a56e5c..5fdc55ba 100644
--- a/nw/core/tree.py
+++ b/nw/core/tree.py
@@ -24,6 +24,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
+import nw
import logging
import os
@@ -33,10 +34,35 @@ from time import time
from nw.core.item import NWItem
from nw.common import checkHandle
-from nw.constants import nwFiles, nwItemType, nwItemClass, nwItemLayout, nwConst
+from nw.constants import (
+ nwFiles, nwItemType, nwItemClass, nwItemLayout, nwConst, nwLists
+)
logger = logging.getLogger(__name__)
+# Layout Translation Map
+LAYOUT_MAP = {
+ nwItemLayout.SCENE: {
+ "H1": nwItemLayout.BOOK,
+ "H2": nwItemLayout.CHAPTER,
+ },
+ nwItemLayout.CHAPTER: {
+ "H1": nwItemLayout.BOOK,
+ "H3": nwItemLayout.SCENE,
+ "H4": nwItemLayout.SCENE,
+ },
+ nwItemLayout.UNNUMBERED: {
+ "H1": nwItemLayout.BOOK,
+ "H3": nwItemLayout.SCENE,
+ "H4": nwItemLayout.SCENE,
+ },
+ nwItemLayout.PARTITION: {
+ "H2": nwItemLayout.CHAPTER,
+ "H3": nwItemLayout.SCENE,
+ "H4": nwItemLayout.SCENE,
+ },
+}
+
class NWTree():
def __init__(self, theProject):
@@ -179,8 +205,9 @@ class NWTree():
outFile.write("\n".join(tocList))
outFile.write("\n")
- except Exception as e:
- logger.error(str(e))
+ except Exception:
+ logger.error("Could not write ToC file")
+ nw.logException()
return False
return True
@@ -202,6 +229,29 @@ class NWTree():
novelWords += tItem.wordCount
return novelWords, noteWords
+ def updateItemLayout(self, tHandle, hLevel):
+ """Check if the item layout needs updating based on the header
+ given level.
+ """
+ tItem = self.__getitem__(tHandle)
+ if tItem is None:
+ return False
+ if tItem.itemClass not in nwLists.CLS_NOVEL:
+ return False
+ if hLevel not in ("H1", "H2", "H3", "H4"):
+ return False
+
+ iLayout = tItem.itemLayout
+ if iLayout in LAYOUT_MAP:
+ if hLevel in LAYOUT_MAP[iLayout]:
+ tItem.itemLayout = LAYOUT_MAP[iLayout][hLevel]
+ logger.debug("Changed layout for %s from %s to %s" % (
+ tHandle, iLayout.name, tItem.itemLayout.name
+ ))
+ return True
+
+ return False
+
##
# Tree Structure Methods
##
diff --git a/nw/error.py b/nw/error.py
index 1f879e37..998b7347 100644
--- a/nw/error.py
+++ b/nw/error.py
@@ -24,12 +24,31 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
+import sys
+import logging
+
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
qApp, QDialog, QGridLayout, QStyle, QPlainTextEdit, QLabel,
QDialogButtonBox
)
+logger = logging.getLogger(__name__)
+
+# =============================================================================================== #
+# Utility Functions
+# =============================================================================================== #
+
+def logException():
+ """Log the content of an exception message.
+ """
+ exType, exValue, _ = sys.exc_info()
+ logger.error("%s: %s" % (exType.__name__, str(exValue).strip("'")))
+
+# =============================================================================================== #
+# Error Handler
+# =============================================================================================== #
+
class NWErrorMessage(QDialog):
def __init__(self, parent):
@@ -72,7 +91,6 @@ class NWErrorMessage(QDialog):
"""Generate a message and append session data, error info and
error traceback.
"""
- import sys
from traceback import format_tb
from nw import __issuesurl__, __version__
from PyQt5.Qt import PYQT_VERSION_STR
@@ -133,15 +151,12 @@ class NWErrorMessage(QDialog):
# END Class NWErrorMessage
-
def exceptionHandler(exType, exValue, exTrace):
"""Function to catch unhandled global exceptions.
"""
- import logging
from traceback import print_tb
from PyQt5.QtWidgets import qApp
- logger = logging.getLogger(__name__)
logger.critical("%s: %s" % (exType.__name__, str(exValue)))
print_tb(exTrace)
diff --git a/nw/gui/__init__.py b/nw/gui/__init__.py
index aa7455b5..8744348f 100644
--- a/nw/gui/__init__.py
+++ b/nw/gui/__init__.py
@@ -20,6 +20,7 @@ from nw.gui.projtree import GuiProjectTree
from nw.gui.projwizard import GuiProjectWizard
from nw.gui.statusbar import GuiMainStatus
from nw.gui.theme import GuiTheme
+from nw.gui.wordlist import GuiWordList
from nw.gui.writingstats import GuiWritingStats
__all__ = [
@@ -44,5 +45,6 @@ __all__ = [
"GuiProjectTree",
"GuiProjectWizard",
"GuiTheme",
+ "GuiWordList",
"GuiWritingStats",
]
diff --git a/nw/gui/build.py b/nw/gui/build.py
index 73d20e3f..3b76248f 100644
--- a/nw/gui/build.py
+++ b/nw/gui/build.py
@@ -32,21 +32,21 @@ import os
from time import time
from datetime import datetime
-from PyQt5.QtCore import Qt, QByteArray, QTimer
+from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtPrintSupport import QPrinter, QPrintPreviewDialog
from PyQt5.QtGui import (
- QPalette, QColor, QTextDocumentWriter, QFont, QCursor
+ QPalette, QColor, QFont, QCursor, QFontInfo
)
from PyQt5.QtWidgets import (
qApp, QDialog, QVBoxLayout, QHBoxLayout, QTextBrowser, QPushButton, QLabel,
QLineEdit, QGroupBox, QGridLayout, QProgressBar, QMenu, QAction,
QFileDialog, QFontDialog, QSpinBox, QScrollArea, QSplitter, QWidget,
- QSizePolicy
+ QSizePolicy, QDoubleSpinBox
)
from nw.common import fuzzyTime, makeFileNameSafe
from nw.gui.custom import QSwitch
-from nw.core import ToHtml
+from nw.core import ToHtml, ToOdt, ToMarkdown
from nw.constants import (
nwConst, nwAlert, nwFiles, nwItemType, nwItemLayout, nwItemClass
)
@@ -55,14 +55,17 @@ logger = logging.getLogger(__name__)
class GuiBuildNovel(QDialog):
- FMT_ODT = 1
- FMT_PDF = 2
- FMT_HTM = 3
- FMT_MD = 4
- FMT_NWD = 5
- FMT_TXT = 6
- FMT_JSON_H = 7
- FMT_JSON_M = 8
+ FMT_PDF = 1 # Print to PDF
+
+ FMT_ODT = 2 # Open Document file
+ FMT_FODT = 3 # Flat Open Document file
+ FMT_HTM = 4 # HTML5
+ FMT_NWD = 5 # nW Markdown
+ FMT_MD = 6 # Standard Markdown
+ FMT_GH = 7 # GitHub Markdown
+
+ FMT_JSON_H = 8 # HTML5 wrapped in JSON
+ FMT_JSON_M = 9 # nW Markdown wrapped in JSON
def __init__(self, theParent, theProject):
QDialog.__init__(self, theParent)
@@ -78,7 +81,7 @@ class GuiBuildNovel(QDialog):
self.htmlText = [] # List of html documents
self.htmlStyle = [] # List of html styles
- self.nwdText = [] # List of markdown documents
+ self.htmlSize = 0 # Size of the html document
self.buildTime = 0 # The timestamp of the last build
self.setWindowTitle("Build Novel Project")
@@ -92,6 +95,9 @@ class GuiBuildNovel(QDialog):
self.docView = GuiBuildNovelDocView(self, self.theProject)
+ hS = self.theTheme.fontPixelSize
+ wS = 2*hS
+
# Title Formats
# =============
@@ -163,33 +169,39 @@ class GuiBuildNovel(QDialog):
self.boxTitle.addWidget(self.fmtTitle)
self.boxChapter = QHBoxLayout()
self.boxChapter.addWidget(self.fmtChapter)
- self.boxUnnumbered = QHBoxLayout()
- self.boxUnnumbered.addWidget(self.fmtUnnumbered)
+ self.boxUnnumb = QHBoxLayout()
+ self.boxUnnumb.addWidget(self.fmtUnnumbered)
self.boxScene = QHBoxLayout()
self.boxScene.addWidget(self.fmtScene)
self.boxSection = QHBoxLayout()
self.boxSection.addWidget(self.fmtSection)
- self.titleForm.addWidget(QLabel("Title"), 0, 0, 1, 1, Qt.AlignLeft)
- self.titleForm.addLayout(self.boxTitle, 0, 1, 1, 1, Qt.AlignRight)
- self.titleForm.addWidget(QLabel("Chapter"), 1, 0, 1, 1, Qt.AlignLeft)
- self.titleForm.addLayout(self.boxChapter, 1, 1, 1, 1, Qt.AlignRight)
- self.titleForm.addWidget(QLabel("Unnumbered"), 2, 0, 1, 1, Qt.AlignLeft)
- self.titleForm.addLayout(self.boxUnnumbered, 2, 1, 1, 1, Qt.AlignRight)
- self.titleForm.addWidget(QLabel("Scene"), 3, 0, 1, 1, Qt.AlignLeft)
- self.titleForm.addLayout(self.boxScene, 3, 1, 1, 1, Qt.AlignRight)
- self.titleForm.addWidget(QLabel("Section"), 4, 0, 1, 1, Qt.AlignLeft)
- self.titleForm.addLayout(self.boxSection, 4, 1, 1, 1, Qt.AlignRight)
+ titleLabel = QLabel("Title")
+ chapterLabel = QLabel("Chapter")
+ unnumbLabel = QLabel("Unnumbered")
+ sceneLabel = QLabel("Scene")
+ sectionLabel = QLabel("Section")
+
+ self.titleForm.addWidget(titleLabel, 0, 0, 1, 1, Qt.AlignLeft)
+ self.titleForm.addLayout(self.boxTitle, 0, 1, 1, 1, Qt.AlignRight)
+ self.titleForm.addWidget(chapterLabel, 1, 0, 1, 1, Qt.AlignLeft)
+ self.titleForm.addLayout(self.boxChapter, 1, 1, 1, 1, Qt.AlignRight)
+ self.titleForm.addWidget(unnumbLabel, 2, 0, 1, 1, Qt.AlignLeft)
+ self.titleForm.addLayout(self.boxUnnumb, 2, 1, 1, 1, Qt.AlignRight)
+ self.titleForm.addWidget(sceneLabel, 3, 0, 1, 1, Qt.AlignLeft)
+ self.titleForm.addLayout(self.boxScene, 3, 1, 1, 1, Qt.AlignRight)
+ self.titleForm.addWidget(sectionLabel, 4, 0, 1, 1, Qt.AlignLeft)
+ self.titleForm.addLayout(self.boxSection, 4, 1, 1, 1, Qt.AlignRight)
self.titleForm.setColumnStretch(0, 0)
self.titleForm.setColumnStretch(1, 1)
- # Text Options
- # =============
+ # Font Options
+ # ============
- self.formatGroup = QGroupBox("Formatting Options", self)
- self.formatForm = QGridLayout(self)
- self.formatGroup.setLayout(self.formatForm)
+ self.fontGroup = QGroupBox("Font Options", self)
+ self.fontForm = QGridLayout(self)
+ self.fontGroup.setLayout(self.fontForm)
## Font Family
self.textFont = QLineEdit()
@@ -203,98 +215,111 @@ class GuiBuildNovel(QDialog):
self.fontButton.clicked.connect(self._selectFont)
self.textSize = QSpinBox(self)
- self.textSize.setFixedWidth(5*self.theTheme.textNWidth)
+ self.textSize.setFixedWidth(6*self.theTheme.textNWidth)
self.textSize.setMinimum(6)
self.textSize.setMaximum(72)
self.textSize.setSingleStep(1)
- self.textSize.setToolTip(
- "The size is used for PDF and printing. Other formats have no size set."
- )
self.textSize.setValue(
self.optState.getInt("GuiBuildNovel", "textSize", self.mainConf.textSize)
)
- self.justifyText = QSwitch()
- self.justifyText.setToolTip(
- "Applies to PDF, printing, HTML, and Open Document exports."
- )
- self.justifyText.setChecked(
- self.optState.getBool("GuiBuildNovel", "justifyText", False)
- )
-
- self.noStyling = QSwitch()
- self.noStyling.setToolTip(
- "Disable all styling of the text."
- )
- self.noStyling.setChecked(
- self.optState.getBool("GuiBuildNovel", "noStyling", False)
+ self.lineHeight = QDoubleSpinBox(self)
+ self.lineHeight.setFixedWidth(6*self.theTheme.textNWidth)
+ self.lineHeight.setMinimum(0.8)
+ self.lineHeight.setMaximum(3.0)
+ self.lineHeight.setSingleStep(0.05)
+ self.lineHeight.setDecimals(2)
+ self.lineHeight.setValue(
+ self.optState.getFloat("GuiBuildNovel", "lineHeight", 1.15)
)
# Dummy box due to QGridView and QLineEdit expand bug
self.boxFont = QHBoxLayout()
self.boxFont.addWidget(self.textFont)
- self.formatForm.addWidget(QLabel("Font family"), 0, 0, 1, 1, Qt.AlignLeft)
- self.formatForm.addLayout(self.boxFont, 0, 1, 1, 1, Qt.AlignRight)
- self.formatForm.addWidget(self.fontButton, 0, 2, 1, 1, Qt.AlignRight)
- self.formatForm.addWidget(QLabel("Font size"), 1, 0, 1, 1, Qt.AlignLeft)
- self.formatForm.addWidget(self.textSize, 1, 1, 1, 2, Qt.AlignRight)
- self.formatForm.addWidget(QLabel("Justify text"), 2, 0, 1, 1, Qt.AlignLeft)
- self.formatForm.addWidget(self.justifyText, 2, 1, 1, 2, Qt.AlignRight)
- self.formatForm.addWidget(QLabel("Disable styling"), 3, 0, 1, 1, Qt.AlignLeft)
- self.formatForm.addWidget(self.noStyling, 3, 1, 1, 2, Qt.AlignRight)
+ fontFamilyLabel = QLabel("Font family")
+ fontSizeLabel = QLabel("Font size")
+ lineHeightLabel = QLabel("Line height")
+ justifyLabel = QLabel("Justify text")
+ stylingLabel = QLabel("Disable styling")
- self.formatForm.setColumnStretch(0, 0)
- self.formatForm.setColumnStretch(1, 1)
- self.formatForm.setColumnStretch(2, 0)
+ self.fontForm.addWidget(fontFamilyLabel, 0, 0, 1, 1, Qt.AlignLeft)
+ self.fontForm.addLayout(self.boxFont, 0, 1, 1, 1, Qt.AlignRight)
+ self.fontForm.addWidget(self.fontButton, 0, 2, 1, 1, Qt.AlignRight)
+ self.fontForm.addWidget(fontSizeLabel, 1, 0, 1, 1, Qt.AlignLeft)
+ self.fontForm.addWidget(self.textSize, 1, 1, 1, 2, Qt.AlignRight)
+ self.fontForm.addWidget(lineHeightLabel, 2, 0, 1, 1, Qt.AlignLeft)
+ self.fontForm.addWidget(self.lineHeight, 2, 1, 1, 2, Qt.AlignRight)
- # Include Switches
- # ================
+ self.fontForm.setColumnStretch(0, 0)
+ self.fontForm.setColumnStretch(1, 1)
+ self.fontForm.setColumnStretch(2, 0)
- self.textGroup = QGroupBox("Text Options", self)
+ # Styling Options
+ # ===============
+
+ self.styleGroup = QGroupBox("Styling Options", self)
+ self.styleForm = QGridLayout(self)
+ self.styleGroup.setLayout(self.styleForm)
+
+ self.justifyText = QSwitch(width=wS, height=hS)
+ self.justifyText.setChecked(
+ self.optState.getBool("GuiBuildNovel", "justifyText", False)
+ )
+
+ self.noStyling = QSwitch(width=wS, height=hS)
+ self.noStyling.setChecked(
+ self.optState.getBool("GuiBuildNovel", "noStyling", False)
+ )
+
+ self.styleForm.addWidget(justifyLabel, 1, 0, 1, 1, Qt.AlignLeft)
+ self.styleForm.addWidget(self.justifyText, 1, 1, 1, 2, Qt.AlignRight)
+ self.styleForm.addWidget(stylingLabel, 2, 0, 1, 1, Qt.AlignLeft)
+ self.styleForm.addWidget(self.noStyling, 2, 1, 1, 2, Qt.AlignRight)
+
+ self.styleForm.setColumnStretch(0, 0)
+ self.styleForm.setColumnStretch(1, 1)
+
+ # Include Options
+ # ===============
+
+ self.textGroup = QGroupBox("Include Options", self)
self.textForm = QGridLayout(self)
self.textGroup.setLayout(self.textForm)
- self.includeSynopsis = QSwitch()
- self.includeSynopsis.setToolTip(
- "Include synopsis comments in the output."
- )
+ self.includeSynopsis = QSwitch(width=wS, height=hS)
self.includeSynopsis.setChecked(
self.optState.getBool("GuiBuildNovel", "incSynopsis", False)
)
- self.includeComments = QSwitch()
- self.includeComments.setToolTip(
- "Include plain comments in the output."
- )
+ self.includeComments = QSwitch(width=wS, height=hS)
self.includeComments.setChecked(
self.optState.getBool("GuiBuildNovel", "incComments", False)
)
- self.includeKeywords = QSwitch()
- self.includeKeywords.setToolTip(
- "Include meta keywords (tags, references) in the output."
- )
+ self.includeKeywords = QSwitch(width=wS, height=hS)
self.includeKeywords.setChecked(
self.optState.getBool("GuiBuildNovel", "incKeywords", False)
)
- self.includeBody = QSwitch()
- self.includeBody.setToolTip(
- "Include body text in the output."
- )
+ self.includeBody = QSwitch(width=wS, height=hS)
self.includeBody.setChecked(
self.optState.getBool("GuiBuildNovel", "incBodyText", True)
)
- self.textForm.addWidget(QLabel("Include synopsis"), 0, 0, 1, 1, Qt.AlignLeft)
- self.textForm.addWidget(self.includeSynopsis, 0, 1, 1, 1, Qt.AlignRight)
- self.textForm.addWidget(QLabel("Include comments"), 1, 0, 1, 1, Qt.AlignLeft)
- self.textForm.addWidget(self.includeComments, 1, 1, 1, 1, Qt.AlignRight)
- self.textForm.addWidget(QLabel("Include keywords"), 2, 0, 1, 1, Qt.AlignLeft)
- self.textForm.addWidget(self.includeKeywords, 2, 1, 1, 1, Qt.AlignRight)
- self.textForm.addWidget(QLabel("Include body text"), 3, 0, 1, 1, Qt.AlignLeft)
- self.textForm.addWidget(self.includeBody, 3, 1, 1, 1, Qt.AlignRight)
+ synopsisLabel = QLabel("Include synopsis")
+ commentsLabel = QLabel("Include comments")
+ keywordsLabel = QLabel("Include keywords")
+ bodyLabel = QLabel("Include body text")
+
+ self.textForm.addWidget(synopsisLabel, 0, 0, 1, 1, Qt.AlignLeft)
+ self.textForm.addWidget(self.includeSynopsis, 0, 1, 1, 1, Qt.AlignRight)
+ self.textForm.addWidget(commentsLabel, 1, 0, 1, 1, Qt.AlignLeft)
+ self.textForm.addWidget(self.includeComments, 1, 1, 1, 1, Qt.AlignRight)
+ self.textForm.addWidget(keywordsLabel, 2, 0, 1, 1, Qt.AlignLeft)
+ self.textForm.addWidget(self.includeKeywords, 2, 1, 1, 1, Qt.AlignRight)
+ self.textForm.addWidget(bodyLabel, 3, 0, 1, 1, Qt.AlignLeft)
+ self.textForm.addWidget(self.includeBody, 3, 1, 1, 1, Qt.AlignRight)
self.textForm.setColumnStretch(0, 1)
self.textForm.setColumnStretch(1, 0)
@@ -306,7 +331,7 @@ class GuiBuildNovel(QDialog):
self.fileForm = QGridLayout(self)
self.fileGroup.setLayout(self.fileForm)
- self.novelFiles = QSwitch()
+ self.novelFiles = QSwitch(width=wS, height=hS)
self.novelFiles.setToolTip(
"Include files with layouts 'Book', 'Page', 'Partition', "
"'Chapter', 'Unnumbered', and 'Scene'."
@@ -315,13 +340,13 @@ class GuiBuildNovel(QDialog):
self.optState.getBool("GuiBuildNovel", "addNovel", True)
)
- self.noteFiles = QSwitch()
+ self.noteFiles = QSwitch(width=wS, height=hS)
self.noteFiles.setToolTip("Include files with layout 'Note'.")
self.noteFiles.setChecked(
self.optState.getBool("GuiBuildNovel", "addNotes", False)
)
- self.ignoreFlag = QSwitch()
+ self.ignoreFlag = QSwitch(width=wS, height=hS)
self.ignoreFlag.setToolTip(
"Ignore the 'Include when building project' setting and include "
"all files in the output."
@@ -330,12 +355,16 @@ class GuiBuildNovel(QDialog):
self.optState.getBool("GuiBuildNovel", "ignoreFlag", False)
)
- self.fileForm.addWidget(QLabel("Include novel files"), 0, 0, 1, 1, Qt.AlignLeft)
- self.fileForm.addWidget(self.novelFiles, 0, 1, 1, 1, Qt.AlignRight)
- self.fileForm.addWidget(QLabel("Include note files"), 1, 0, 1, 1, Qt.AlignLeft)
- self.fileForm.addWidget(self.noteFiles, 1, 1, 1, 1, Qt.AlignRight)
- self.fileForm.addWidget(QLabel("Ignore export flag"), 2, 0, 1, 1, Qt.AlignLeft)
- self.fileForm.addWidget(self.ignoreFlag, 2, 1, 1, 1, Qt.AlignRight)
+ novelLabel = QLabel("Include novel files")
+ notesLabel = QLabel("Include note files")
+ exportLabel = QLabel("Ignore export flag")
+
+ self.fileForm.addWidget(novelLabel, 0, 0, 1, 1, Qt.AlignLeft)
+ self.fileForm.addWidget(self.novelFiles, 0, 1, 1, 1, Qt.AlignRight)
+ self.fileForm.addWidget(notesLabel, 1, 0, 1, 1, Qt.AlignLeft)
+ self.fileForm.addWidget(self.noteFiles, 1, 1, 1, 1, Qt.AlignRight)
+ self.fileForm.addWidget(exportLabel, 2, 0, 1, 1, Qt.AlignLeft)
+ self.fileForm.addWidget(self.ignoreFlag, 2, 1, 1, 1, Qt.AlignRight)
self.fileForm.setColumnStretch(0, 1)
self.fileForm.setColumnStretch(1, 0)
@@ -347,16 +376,23 @@ class GuiBuildNovel(QDialog):
self.exportForm = QGridLayout(self)
self.exportGroup.setLayout(self.exportForm)
- self.replaceTabs = QSwitch()
- self.replaceTabs.setToolTip(
- "Replace all tabs with eight spaces."
- )
+ self.replaceTabs = QSwitch(width=wS, height=hS)
self.replaceTabs.setChecked(
self.optState.getBool("GuiBuildNovel", "replaceTabs", False)
)
- self.exportForm.addWidget(QLabel("Replace tabs with spaces"), 0, 0, 1, 1, Qt.AlignLeft)
- self.exportForm.addWidget(self.replaceTabs, 0, 1, 1, 1, Qt.AlignRight)
+ self.replaceUCode = QSwitch(width=wS, height=hS)
+ self.replaceUCode.setChecked(
+ self.optState.getBool("GuiBuildNovel", "replaceUCode", False)
+ )
+
+ tabsLabel = QLabel("Replace tabs with spaces")
+ uCodeLabel = QLabel("Replace Unicode in HTML")
+
+ self.exportForm.addWidget(tabsLabel, 0, 0, 1, 1, Qt.AlignLeft)
+ self.exportForm.addWidget(self.replaceTabs, 0, 1, 1, 1, Qt.AlignRight)
+ self.exportForm.addWidget(uCodeLabel, 1, 0, 1, 1, Qt.AlignLeft)
+ self.exportForm.addWidget(self.replaceUCode, 1, 1, 1, 1, Qt.AlignRight)
self.exportForm.setColumnStretch(0, 1)
self.exportForm.setColumnStretch(1, 0)
@@ -364,10 +400,9 @@ class GuiBuildNovel(QDialog):
# Build Button
# ============
- self.buildProgress = QProgressBar()
self.buildProgress = QProgressBar()
- self.buildNovel = QPushButton("Build Project")
+ self.buildNovel = QPushButton("Build Preview")
self.buildNovel.clicked.connect(self._buildPreview)
# Action Buttons
@@ -375,20 +410,33 @@ class GuiBuildNovel(QDialog):
self.buttonBox = QHBoxLayout()
- self.btnPrint = QPushButton("Print")
- self.btnPrint.clicked.connect(self._printDocument)
+ # Printing
+
+ self.printMenu = QMenu(self)
+ self.btnPrint = QPushButton("Print")
+ self.btnPrint.setMenu(self.printMenu)
+
+ self.printSend = QAction("Print Preview", self)
+ self.printSend.triggered.connect(self._printDocument)
+ self.printMenu.addAction(self.printSend)
+
+ self.printFile = QAction("Print to PDF", self)
+ self.printFile.triggered.connect(lambda: self._saveDocument(self.FMT_PDF))
+ self.printMenu.addAction(self.printFile)
+
+ # Saving to File
- self.btnSave = QPushButton("Save As")
self.saveMenu = QMenu(self)
+ self.btnSave = QPushButton("Save As")
self.btnSave.setMenu(self.saveMenu)
self.saveODT = QAction("Open Document (.odt)", self)
self.saveODT.triggered.connect(lambda: self._saveDocument(self.FMT_ODT))
self.saveMenu.addAction(self.saveODT)
- self.savePDF = QAction("Portable Document Format (.pdf)", self)
- self.savePDF.triggered.connect(lambda: self._saveDocument(self.FMT_PDF))
- self.saveMenu.addAction(self.savePDF)
+ self.saveFODT = QAction("Flat Open Document (.fodt)", self)
+ self.saveFODT.triggered.connect(lambda: self._saveDocument(self.FMT_FODT))
+ self.saveMenu.addAction(self.saveFODT)
self.saveHTM = QAction("novelWriter HTML (.htm)", self)
self.saveHTM.triggered.connect(lambda: self._saveDocument(self.FMT_HTM))
@@ -398,14 +446,13 @@ class GuiBuildNovel(QDialog):
self.saveNWD.triggered.connect(lambda: self._saveDocument(self.FMT_NWD))
self.saveMenu.addAction(self.saveNWD)
- if self.mainConf.verQtValue >= 51400:
- self.saveMD = QAction("Markdown (.md)", self)
- self.saveMD.triggered.connect(lambda: self._saveDocument(self.FMT_MD))
- self.saveMenu.addAction(self.saveMD)
+ self.saveMD = QAction("Standard Markdown (.md)", self)
+ self.saveMD.triggered.connect(lambda: self._saveDocument(self.FMT_MD))
+ self.saveMenu.addAction(self.saveMD)
- self.saveTXT = QAction("Plain Text (.txt)", self)
- self.saveTXT.triggered.connect(lambda: self._saveDocument(self.FMT_TXT))
- self.saveMenu.addAction(self.saveTXT)
+ self.saveGH = QAction("GitHub Markdown (.md)", self)
+ self.saveGH.triggered.connect(lambda: self._saveDocument(self.FMT_GH))
+ self.saveMenu.addAction(self.saveGH)
self.saveJsonH = QAction("JSON + novelWriter HTML (.json)", self)
self.saveJsonH.triggered.connect(lambda: self._saveDocument(self.FMT_JSON_H))
@@ -435,7 +482,8 @@ class GuiBuildNovel(QDialog):
# The Tool Box
self.toolsBox = QVBoxLayout()
self.toolsBox.addWidget(self.titleGroup)
- self.toolsBox.addWidget(self.formatGroup)
+ self.toolsBox.addWidget(self.fontGroup)
+ self.toolsBox.addWidget(self.styleGroup)
self.toolsBox.addWidget(self.textGroup)
self.toolsBox.addWidget(self.fileGroup)
self.toolsBox.addWidget(self.exportGroup)
@@ -521,34 +569,83 @@ class GuiBuildNovel(QDialog):
self.docView.setText(
"Failed to generate preview. The result is too big."
)
- self._enableQtSave(False)
else:
self.htmlText = []
self.htmlStyle = []
- self.nwdText = []
self.buildTime = 0
return False
return True
##
- # Slots
+ # Slots and Related
##
def _buildPreview(self):
"""Build a preview of the project in the document viewer.
"""
+ # Get Settings
+ justifyText = self.justifyText.isChecked()
+ noStyling = self.noStyling.isChecked()
+ textFont = self.textFont.text()
+ textSize = self.textSize.value()
+ replaceTabs = self.replaceTabs.isChecked()
+
+ self.htmlText = []
+ self.htmlStyle = []
+ self.htmlSize = 0
+
+ # Build Preview
+ # =============
+
+ makeHtml = ToHtml(self.theProject, self.theParent)
+ self._doBuild(makeHtml, isPreview=True)
+ if replaceTabs:
+ makeHtml.replaceTabs()
+
+ self.htmlText = makeHtml.fullHTML
+ self.htmlStyle = makeHtml.getStyleSheet()
+ self.htmlSize = makeHtml.getFullResultSize()
+ self.buildTime = int(time())
+
+ # Load Preview
+ # ============
+
+ self.docView.setTextFont(textFont, textSize)
+ self.docView.setJustify(justifyText)
+ if noStyling:
+ self.docView.clearStyleSheet()
+ else:
+ self.docView.setStyleSheet(self.htmlStyle)
+
+ if self.htmlSize < nwConst.MAX_BUILDSIZE:
+ self.docView.setContent(self.htmlText, self.buildTime)
+ else:
+ self.docView.setText(
+ "Failed to generate preview. The result is too big."
+ )
+
+ self._saveCache()
+
+ return
+
+ def _doBuild(self, bldObj, isPreview=False, doConvert=True):
+ """Rund the build with a specific build object.
+ """
+ tStart = int(time())
+
# Get Settings
fmtTitle = self.fmtTitle.text().strip()
fmtChapter = self.fmtChapter.text().strip()
fmtUnnumbered = self.fmtUnnumbered.text().strip()
fmtScene = self.fmtScene.text().strip()
fmtSection = self.fmtSection.text().strip()
- justifyText = self.justifyText.isChecked()
- noStyling = self.noStyling.isChecked()
textFont = self.textFont.text()
textSize = self.textSize.value()
+ lineHeight = self.lineHeight.value()
+ justifyText = self.justifyText.isChecked()
+ noStyling = self.noStyling.isChecked()
incSynopsis = self.includeSynopsis.isChecked()
incComments = self.includeComments.isChecked()
incKeywords = self.includeKeywords.isChecked()
@@ -556,20 +653,37 @@ class GuiBuildNovel(QDialog):
noteFiles = self.noteFiles.isChecked()
ignoreFlag = self.ignoreFlag.isChecked()
includeBody = self.includeBody.isChecked()
- replaceTabs = self.replaceTabs.isChecked()
+ replaceUCode = self.replaceUCode.isChecked()
- makeHtml = ToHtml(self.theProject, self.theParent)
- makeHtml.setTitleFormat(fmtTitle)
- makeHtml.setChapterFormat(fmtChapter)
- makeHtml.setUnNumberedFormat(fmtUnnumbered)
- makeHtml.setSceneFormat(fmtScene, fmtScene == "")
- makeHtml.setSectionFormat(fmtSection, fmtSection == "")
- makeHtml.setBodyText(includeBody)
- makeHtml.setSynopsis(incSynopsis)
- makeHtml.setComments(incComments)
- makeHtml.setKeywords(incKeywords)
- makeHtml.setJustify(justifyText)
- makeHtml.setStyles(not noStyling)
+ # Get font information
+ fontInfo = QFontInfo(QFont(textFont, textSize))
+ textFixed = fontInfo.fixedPitch()
+
+ isHtml = isinstance(bldObj, ToHtml)
+ isOdt = isinstance(bldObj, ToOdt)
+
+ bldObj.setTitleFormat(fmtTitle)
+ bldObj.setChapterFormat(fmtChapter)
+ bldObj.setUnNumberedFormat(fmtUnnumbered)
+ bldObj.setSceneFormat(fmtScene, fmtScene == "")
+ bldObj.setSectionFormat(fmtSection, fmtSection == "")
+
+ bldObj.setFont(textFont, textSize, textFixed)
+ bldObj.setJustify(justifyText)
+ bldObj.setLineHeight(lineHeight)
+
+ bldObj.setSynopsis(incSynopsis)
+ bldObj.setComments(incComments)
+ bldObj.setKeywords(incKeywords)
+ bldObj.setBodyText(includeBody)
+
+ if isHtml:
+ bldObj.setStyles(not noStyling)
+ bldObj.setReplaceUnicode(replaceUCode)
+
+ if isOdt:
+ bldObj.setColourHeaders(not noStyling)
+ bldObj.initDocument()
# Make sure the project and document is up to date
self.theParent.treeView.flushTreeOrder()
@@ -578,14 +692,6 @@ class GuiBuildNovel(QDialog):
self.buildProgress.setMaximum(len(self.theProject.projTree))
self.buildProgress.setValue(0)
- tStart = int(time())
-
- self.htmlText = []
- self.htmlStyle = []
- self.nwdText = []
-
- htmlSize = 0
-
for nItt, tItem in enumerate(self.theProject.projTree):
noteRoot = noteFiles
@@ -596,76 +702,44 @@ class GuiBuildNovel(QDialog):
try:
if noteRoot:
# Add headers for root folders of notes
- makeHtml.addRootHeading(tItem.itemHandle)
- makeHtml.doConvert()
- self.htmlText.append(makeHtml.getResult())
- self.nwdText.append(makeHtml.getFilteredMarkdown())
- htmlSize += makeHtml.getResultSize()
+ bldObj.addRootHeading(tItem.itemHandle)
+ if doConvert:
+ bldObj.doConvert()
elif self._checkInclude(tItem, noteFiles, novelFiles, ignoreFlag):
- makeHtml.setText(tItem.itemHandle)
- makeHtml.doAutoReplace()
- makeHtml.tokenizeText()
- makeHtml.doHeaders()
- makeHtml.doConvert()
- makeHtml.doPostProcessing()
- self.htmlText.append(makeHtml.getResult())
- self.nwdText.append(makeHtml.getFilteredMarkdown())
- htmlSize += makeHtml.getResultSize()
+ bldObj.setText(tItem.itemHandle)
+ bldObj.doPreProcessing()
+ bldObj.tokenizeText()
+ bldObj.doHeaders()
+ if doConvert:
+ bldObj.doConvert()
+ bldObj.doPostProcessing()
+
+ except Exception:
+ logger.error("Failed to build document '%s'" % tItem.itemHandle)
+ nw.logException()
+ if isPreview:
+ self.docView.setText((
+ "Failed to generate preview. "
+ "Document with title '%s' could not be parsed."
+ ) % tItem.itemName)
- except Exception as e:
- logger.error("Failed to generate html of document '%s'" % tItem.itemHandle)
- logger.error(str(e))
- self.docView.setText((
- "Failed to generate preview. "
- "Document with title '%s' could not be parsed."
- ) % tItem.itemName)
return False
# Update progress bar, also for skipped items
self.buildProgress.setValue(nItt+1)
- if makeHtml.errData:
+ if isOdt:
+ bldObj.closeDocument()
+
+ tEnd = int(time())
+ logger.debug("Built project in %.3f ms" % (1000*(tEnd - tStart)))
+
+ if bldObj.errData:
self.theParent.makeAlert((
"There were problems when building the project:"
" - %s"
- ) % " - ".join(makeHtml.errData), nwAlert.ERROR)
-
- if replaceTabs:
- htmlText = []
- eightSpace = " "*8
- for aLine in self.htmlText:
- htmlText.append(aLine.replace("\t", eightSpace))
- self.htmlText = htmlText
-
- nwdText = []
- for aLine in self.nwdText:
- nwdText.append(aLine.replace("\t", " "))
- self.nwdText = nwdText
-
- tEnd = int(time())
- logger.debug("Built project in %.3f ms" % (1000*(tEnd-tStart)))
- self.htmlStyle = makeHtml.getStyleSheet()
- self.buildTime = tEnd
-
- # Load the preview document with the html data
- self.docView.setTextFont(textFont, textSize)
- self.docView.setJustify(justifyText)
- if noStyling:
- self.docView.clearStyleSheet()
- else:
- self.docView.setStyleSheet(self.htmlStyle)
-
- if htmlSize < nwConst.MAX_BUILDSIZE:
- self.docView.setContent(self.htmlText, self.buildTime)
- self._enableQtSave(True)
- else:
- self.docView.setText(
- "Failed to generate preview. The result is too big."
- )
- self._enableQtSave(False)
-
- self._saveCache()
+ ) % " - ".join(bldObj.errData), nwAlert.ERROR)
return
@@ -709,62 +783,59 @@ class GuiBuildNovel(QDialog):
return True
- def _saveDocument(self, theFormat):
+ def _saveDocument(self, theFmt):
"""Save the document to various formats.
"""
- byteFmt = QByteArray()
+ replaceTabs = self.replaceTabs.isChecked()
+
fileExt = ""
textFmt = ""
- outTool = ""
- # Create the settings
- if theFormat == self.FMT_ODT:
- byteFmt.append("odf")
+ # Settings
+ # ========
+
+ if theFmt == self.FMT_ODT:
fileExt = "odt"
textFmt = "Open Document"
- outTool = "Qt"
- elif theFormat == self.FMT_PDF:
- fileExt = "pdf"
- textFmt = "PDF"
- outTool = "QtPrint"
+ elif theFmt == self.FMT_FODT:
+ fileExt = "fodt"
+ textFmt = "Flat Open Document"
- elif theFormat == self.FMT_HTM:
+ elif theFmt == self.FMT_HTM:
fileExt = "htm"
textFmt = "Plain HTML"
- outTool = "NW"
- elif theFormat == self.FMT_MD:
- byteFmt.append("markdown")
- fileExt = "md"
- textFmt = "Markdown"
- outTool = "Qt"
-
- elif theFormat == self.FMT_NWD:
+ elif theFmt == self.FMT_NWD:
fileExt = "nwd"
- textFmt = "%s Markdown" % nw.__package__
- outTool = "NW"
+ textFmt = "novelWriter Markdown"
- elif theFormat == self.FMT_TXT:
- byteFmt.append("plaintext")
- fileExt = "txt"
- textFmt = "Plain Text"
- outTool = "Qt"
+ elif theFmt == self.FMT_MD:
+ fileExt = "md"
+ textFmt = "Standard Markdown"
- elif theFormat == self.FMT_JSON_H:
+ elif theFmt == self.FMT_GH:
+ fileExt = "md"
+ textFmt = "GitHub Markdown"
+
+ elif theFmt == self.FMT_JSON_H:
fileExt = "json"
- textFmt = "JSON + %s HTML" % nw.__package__
- outTool = "NW"
+ textFmt = "JSON + novelWriter HTML"
- elif theFormat == self.FMT_JSON_M:
+ elif theFmt == self.FMT_JSON_M:
fileExt = "json"
- textFmt = "JSON + %s Markdown" % nw.__package__
- outTool = "NW"
+ textFmt = "JSON + novelWriter Markdown"
+
+ elif theFmt == self.FMT_PDF:
+ fileExt = "pdf"
+ textFmt = "PDF"
else:
return False
- # Generate the file name
+ # Generate File Name
+ # ==================
+
if fileExt:
cleanName = makeFileNameSafe(self.theProject.projName)
@@ -787,87 +858,118 @@ class GuiBuildNovel(QDialog):
else:
return False
- # Do the actual writing
- wSuccess = False
+ # Build and Write
+ # ===============
+
errMsg = ""
- if outTool == "Qt":
- docWriter = QTextDocumentWriter()
- docWriter.setFileName(savePath)
- docWriter.setFormat(byteFmt)
- wSuccess = docWriter.write(self.docView.qDocument)
+ wSuccess = False
- elif outTool == "NW":
+ if theFmt == self.FMT_ODT:
+ makeOdt = ToOdt(self.theProject, self.theParent, isFlat=False)
+ self._doBuild(makeOdt)
try:
- with open(savePath, mode="w", encoding="utf8") as outFile:
- if theFormat == self.FMT_HTM:
- # Write novelWriter HTML data
- theStyle = self.htmlStyle.copy()
- theStyle.append(r"article {width: 800px; margin: 40px auto;}")
- bodyText = "".join(self.htmlText)
- bodyText = bodyText.replace("\t", " ")
-
- theHtml = (
- "\n"
- "\n"
- "\n"
- "\n"
- "{projTitle:s}\n"
- "\n"
- "\n"
- "\n"
- "\n"
- "{bodyText:s}\n"
- "\n"
- "\n"
- "\n"
- ).format(
- projTitle = self.theProject.projName,
- htmlStyle = "\n".join(theStyle),
- bodyText = bodyText,
- )
- outFile.write(theHtml)
-
- elif theFormat == self.FMT_NWD:
- # Write novelWriter markdown data
- for aLine in self.nwdText:
- outFile.write(aLine)
-
- elif theFormat == self.FMT_JSON_H or theFormat == self.FMT_JSON_M:
- jsonData = {
- "meta" : {
- "workingTitle" : self.theProject.projName,
- "novelTitle" : self.theProject.bookTitle,
- "authors" : self.theProject.bookAuthors,
- "buildTime" : self.buildTime,
- }
- }
-
- if theFormat == self.FMT_JSON_H:
- theBody = []
- for htmlPage in self.htmlText:
- theBody.append(htmlPage.rstrip("\n").split("\n"))
- jsonData["text"] = {
- "css" : self.htmlStyle,
- "html" : theBody,
- }
- elif theFormat == self.FMT_JSON_M:
- theBody = []
- for nwdPage in self.nwdText:
- theBody.append(nwdPage.split("\n"))
- jsonData["text"] = {
- "nwd" : theBody,
- }
-
- outFile.write(json.dumps(jsonData, indent=2))
-
+ makeOdt.saveOpenDocText(savePath)
wSuccess = True
-
except Exception as e:
errMsg = str(e)
- elif outTool == "QtPrint" and theFormat == self.FMT_PDF:
+ elif theFmt == self.FMT_FODT:
+ makeOdt = ToOdt(self.theProject, self.theParent, isFlat=True)
+ self._doBuild(makeOdt)
+ try:
+ makeOdt.saveFlatXML(savePath)
+ wSuccess = True
+ except Exception as e:
+ errMsg = str(e)
+
+ elif theFmt == self.FMT_HTM:
+ makeHtml = ToHtml(self.theProject, self.theParent)
+ self._doBuild(makeHtml)
+ if replaceTabs:
+ makeHtml.replaceTabs()
+
+ try:
+ makeHtml.saveHTML5(savePath)
+ wSuccess = True
+ except Exception as e:
+ errMsg = str(e)
+
+ elif theFmt == self.FMT_NWD:
+ makeNwd = ToMarkdown(self.theProject, self.theParent)
+ makeNwd.setKeepMarkdown(True)
+ self._doBuild(makeNwd, doConvert=False)
+ if replaceTabs:
+ makeNwd.replaceTabs(spaceChar=" ")
+
+ try:
+ makeNwd.saveRawMarkdown(savePath)
+ wSuccess = True
+ except Exception as e:
+ errMsg = str(e)
+
+ elif theFmt in (self.FMT_MD, self.FMT_GH):
+ makeMd = ToMarkdown(self.theProject, self.theParent)
+ if theFmt == self.FMT_GH:
+ makeMd.setGitHubMarkdown()
+ else:
+ makeMd.setStandardMarkdown()
+
+ self._doBuild(makeMd)
+ if replaceTabs:
+ makeMd.replaceTabs(nSpaces=4, spaceChar=" ")
+
+ try:
+ makeMd.saveMarkdown(savePath)
+ wSuccess = True
+ except Exception as e:
+ errMsg = str(e)
+
+ elif theFmt == self.FMT_JSON_H or theFmt == self.FMT_JSON_M:
+ jsonData = {
+ "meta" : {
+ "workingTitle" : self.theProject.projName,
+ "novelTitle" : self.theProject.bookTitle,
+ "authors" : self.theProject.bookAuthors,
+ "buildTime" : self.buildTime,
+ }
+ }
+
+ if theFmt == self.FMT_JSON_H:
+ makeHtml = ToHtml(self.theProject, self.theParent)
+ self._doBuild(makeHtml)
+ if replaceTabs:
+ makeHtml.replaceTabs()
+
+ theBody = []
+ for htmlPage in makeHtml.fullHTML:
+ theBody.append(htmlPage.rstrip("\n").split("\n"))
+ jsonData["text"] = {
+ "css" : self.htmlStyle,
+ "html" : theBody,
+ }
+
+ elif theFmt == self.FMT_JSON_M:
+ makeMd = ToHtml(self.theProject, self.theParent)
+ makeMd.setKeepMarkdown(True)
+ self._doBuild(makeMd, doConvert=False)
+ if replaceTabs:
+ makeMd.replaceTabs(spaceChar=" ")
+
+ theBody = []
+ for nwdPage in makeMd.theMarkdown:
+ theBody.append(nwdPage.split("\n"))
+ jsonData["text"] = {
+ "nwd" : theBody,
+ }
+
+ try:
+ with open(savePath, mode="w", encoding="utf8") as outFile:
+ outFile.write(json.dumps(jsonData, indent=2))
+ wSuccess = True
+ except Exception as e:
+ errMsg = str(e)
+
+ elif theFmt == self.FMT_PDF:
try:
thePrinter = QPrinter()
thePrinter.setOutputFormat(QPrinter.PdfFormat)
@@ -945,24 +1047,21 @@ class GuiBuildNovel(QDialog):
with open(buildCache, mode="r", encoding="utf8") as inFile:
theJson = inFile.read()
theData = json.loads(theJson)
- except Exception as e:
+ except Exception:
logger.error("Failed to load build cache")
- logger.error(str(e))
+ nw.logException()
return False
- if "htmlText" in theData.keys():
- self.htmlText = theData["htmlText"]
- dataCount += 1
+ if "buildTime" in theData.keys():
+ self.buildTime = theData["buildTime"]
if "htmlStyle" in theData.keys():
self.htmlStyle = theData["htmlStyle"]
dataCount += 1
- if "nwdText" in theData.keys():
- self.nwdText = theData["nwdText"]
+ if "htmlText" in theData.keys():
+ self.htmlText = theData["htmlText"]
dataCount += 1
- if "buildTime" in theData.keys():
- self.buildTime = theData["buildTime"]
- return dataCount == 3
+ return dataCount == 2
def _saveCache(self):
"""Save the current data to cache.
@@ -973,14 +1072,13 @@ class GuiBuildNovel(QDialog):
try:
with open(buildCache, mode="w+", encoding="utf8") as outFile:
outFile.write(json.dumps({
- "htmlText" : self.htmlText,
- "htmlStyle" : self.htmlStyle,
- "nwdText" : self.nwdText,
"buildTime" : self.buildTime,
+ "htmlStyle" : self.htmlStyle,
+ "htmlText" : self.htmlText,
}, indent=2))
- except Exception as e:
+ except Exception:
logger.error("Failed to save build cache")
- logger.error(str(e))
+ nw.logException()
return False
return True
@@ -1007,17 +1105,6 @@ class GuiBuildNovel(QDialog):
# Internal Functions
##
- def _enableQtSave(self, theState):
- """Set the enabled status of Save menu entries that depend on
- the QTextDocument.
- """
- self.saveODT.setEnabled(theState)
- self.savePDF.setEnabled(theState)
- self.saveTXT.setEnabled(theState)
- if self.mainConf.verQtValue >= 51400:
- self.saveMD.setEnabled(theState)
- return
-
def _saveSettings(self):
"""Save the various user settings.
"""
@@ -1032,46 +1119,47 @@ class GuiBuildNovel(QDialog):
"section" : self.fmtSection.text().strip(),
})
- winWidth = self.mainConf.rpxInt(self.width())
- winHeight = self.mainConf.rpxInt(self.height())
- justifyText = self.justifyText.isChecked()
- noStyling = self.noStyling.isChecked()
- textFont = self.textFont.text()
- textSize = self.textSize.value()
- novelFiles = self.novelFiles.isChecked()
- noteFiles = self.noteFiles.isChecked()
- ignoreFlag = self.ignoreFlag.isChecked()
- incSynopsis = self.includeSynopsis.isChecked()
- incComments = self.includeComments.isChecked()
- incKeywords = self.includeKeywords.isChecked()
- incBodyText = self.includeBody.isChecked()
- replaceTabs = self.replaceTabs.isChecked()
+ winWidth = self.mainConf.rpxInt(self.width())
+ winHeight = self.mainConf.rpxInt(self.height())
+ justifyText = self.justifyText.isChecked()
+ noStyling = self.noStyling.isChecked()
+ textFont = self.textFont.text()
+ textSize = self.textSize.value()
+ lineHeight = self.lineHeight.value()
+ novelFiles = self.novelFiles.isChecked()
+ noteFiles = self.noteFiles.isChecked()
+ ignoreFlag = self.ignoreFlag.isChecked()
+ incSynopsis = self.includeSynopsis.isChecked()
+ incComments = self.includeComments.isChecked()
+ incKeywords = self.includeKeywords.isChecked()
+ incBodyText = self.includeBody.isChecked()
+ replaceTabs = self.replaceTabs.isChecked()
+ replaceUCode = self.replaceUCode.isChecked()
mainSplit = self.mainSplit.sizes()
- if len(mainSplit) == 2:
- boxWidth = self.mainConf.rpxInt(mainSplit[0])
- docWidth = self.mainConf.rpxInt(mainSplit[1])
- else:
- boxWidth = 100
- docWidth = 100
+ boxWidth = self.mainConf.rpxInt(mainSplit[0])
+ docWidth = self.mainConf.rpxInt(mainSplit[1])
# GUI Settings
- self.optState.setValue("GuiBuildNovel", "winWidth", winWidth)
- self.optState.setValue("GuiBuildNovel", "winHeight", winHeight)
- self.optState.setValue("GuiBuildNovel", "boxWidth", boxWidth)
- self.optState.setValue("GuiBuildNovel", "docWidth", docWidth)
- self.optState.setValue("GuiBuildNovel", "justifyText", justifyText)
- self.optState.setValue("GuiBuildNovel", "noStyling", noStyling)
- self.optState.setValue("GuiBuildNovel", "textFont", textFont)
- self.optState.setValue("GuiBuildNovel", "textSize", textSize)
- self.optState.setValue("GuiBuildNovel", "addNovel", novelFiles)
- self.optState.setValue("GuiBuildNovel", "addNotes", noteFiles)
- self.optState.setValue("GuiBuildNovel", "ignoreFlag", ignoreFlag)
- self.optState.setValue("GuiBuildNovel", "incSynopsis", incSynopsis)
- self.optState.setValue("GuiBuildNovel", "incComments", incComments)
- self.optState.setValue("GuiBuildNovel", "incKeywords", incKeywords)
- self.optState.setValue("GuiBuildNovel", "incBodyText", incBodyText)
- self.optState.setValue("GuiBuildNovel", "replaceTabs", replaceTabs)
+ self.optState.setValue("GuiBuildNovel", "winWidth", winWidth)
+ self.optState.setValue("GuiBuildNovel", "winHeight", winHeight)
+ self.optState.setValue("GuiBuildNovel", "boxWidth", boxWidth)
+ self.optState.setValue("GuiBuildNovel", "docWidth", docWidth)
+ self.optState.setValue("GuiBuildNovel", "justifyText", justifyText)
+ self.optState.setValue("GuiBuildNovel", "noStyling", noStyling)
+ self.optState.setValue("GuiBuildNovel", "textFont", textFont)
+ self.optState.setValue("GuiBuildNovel", "textSize", textSize)
+ self.optState.setValue("GuiBuildNovel", "lineHeight", lineHeight)
+ self.optState.setValue("GuiBuildNovel", "addNovel", novelFiles)
+ self.optState.setValue("GuiBuildNovel", "addNotes", noteFiles)
+ self.optState.setValue("GuiBuildNovel", "ignoreFlag", ignoreFlag)
+ self.optState.setValue("GuiBuildNovel", "incSynopsis", incSynopsis)
+ self.optState.setValue("GuiBuildNovel", "incComments", incComments)
+ self.optState.setValue("GuiBuildNovel", "incKeywords", incKeywords)
+ self.optState.setValue("GuiBuildNovel", "incBodyText", incBodyText)
+ self.optState.setValue("GuiBuildNovel", "replaceTabs", replaceTabs)
+ self.optState.setValue("GuiBuildNovel", "replaceUCode", replaceUCode)
+
self.optState.saveSettings()
return
@@ -1107,7 +1195,7 @@ class GuiBuildNovelDocView(QTextBrowser):
self.qDocument.setDocumentMargin(self.mainConf.getTextMargin())
self.setPlaceholderText(
"This area will show the content of the document to be "
- "exported or printed. Press the \"Build Project\" button "
+ "exported or printed. Press the \"Build Preview\" button "
"to generate content."
)
diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py
index 9e4b8b89..abafc6fb 100644
--- a/nw/gui/doceditor.py
+++ b/nw/gui/doceditor.py
@@ -54,7 +54,7 @@ from nw.gui.dochighlight import GuiDocHighlighter
from nw.common import transferCase
from nw.constants import (
nwConst, nwAlert, nwUnicode, nwDocAction, nwDocInsert, nwItemClass,
- nwKeyWords
+ nwKeyWords, nwLabels
)
logger = logging.getLogger(__name__)
@@ -91,6 +91,7 @@ class GuiDocEditor(QTextEdit):
self.wordCount = 0 # Word count
self.paraCount = 0 # Paragraph count
self.lastEdit = 0 # Time stamp of last edit
+ self.lastActive = 0 # Time stamp of last activity
self.lastFind = None # Position of the last found search word
self.bigDoc = False # Flag for very large document size
self.doReplace = False # Switch to temporarily disable auto-replace
@@ -169,15 +170,16 @@ class GuiDocEditor(QTextEdit):
self.clear()
self.wcTimer.stop()
- self.theHandle = None
- self.charCount = 0
- self.wordCount = 0
- self.paraCount = 0
- self.lastEdit = 0
- self.lastFind = None
- self.bigDoc = False
- self.doReplace = False
- self.queuePos = None
+ self.theHandle = None
+ self.charCount = 0
+ self.wordCount = 0
+ self.paraCount = 0
+ self.lastEdit = 0
+ self.lastActive = 0
+ self.lastFind = None
+ self.bigDoc = False
+ self.doReplace = False
+ self.queuePos = None
self.setDocumentChanged(False)
self.docHeader.setTitleFromHandle(self.theHandle)
@@ -319,6 +321,7 @@ class GuiDocEditor(QTextEdit):
logger.debug("Document highlighted in %.3f ms" % (1000*(afTime-bfTime)))
self.lastEdit = time()
+ self.lastActive = time()
self._runCounter()
self.wcTimer.start()
self.theHandle = tHandle
@@ -415,11 +418,21 @@ class GuiDocEditor(QTextEdit):
self.setDocumentChanged(False)
self.theIndex.scanText(tHandle, docText)
+
if self._updateHeaders(checkLevel=True):
self.theParent.requestNovelTreeRefresh()
else:
self.theParent.novelView.updateWordCounts(tHandle)
+ hLevel = "H0"
+ if self.theHeaders:
+ hLevel = self.theHeaders[0][1]
+
+ if self.theProject.projTree.updateItemLayout(tHandle, hLevel):
+ self.theParent.treeView.setTreeItemValues(tHandle)
+ self.nwDocument.saveDocument(docText)
+ self.docFooter.updateInfo()
+
return True
def updateDocMargins(self):
@@ -647,6 +660,10 @@ class GuiDocEditor(QTextEdit):
this class when calling these actions from other classes.
"""
logger.verbose("Requesting action: %s" % theAction.name)
+ if not self.hasFocus():
+ logger.verbose("Editor does not have focus")
+ return False
+
if self.theHandle is None:
logger.error("No document open")
return False
@@ -708,6 +725,7 @@ class GuiDocEditor(QTextEdit):
return False
self._allowAutoReplace(True)
+ self.lastActive = time()
return True
@@ -825,6 +843,7 @@ class GuiDocEditor(QTextEdit):
* The undo/redo/select all sequences bypasses the docAction
pathway from the menu, so we redirect them back from here.
"""
+ self.lastActive = time()
isReturn = keyEvent.key() == Qt.Key_Return
isReturn |= keyEvent.key() == Qt.Key_Enter
if isReturn and self.docSearch.anyFocus():
@@ -1069,7 +1088,7 @@ class GuiDocEditor(QTextEdit):
logger.verbose("Word counter is busy")
return
- if time() - self.lastEdit < 5*self.wcInterval:
+ if time() - self.lastEdit < 5 * self.wcInterval:
logger.verbose("Running word counter")
self.theParent.threadPool.start(self.wCounter)
@@ -2511,8 +2530,11 @@ class GuiDocEditFooter(QWidget):
else:
iStatus = self.theProject.importItems.checkEntry(iStatus)
theIcon = self.theParent.importIcons[iStatus]
+
sIcon = theIcon.pixmap(self.sPx, self.sPx)
- sText = self.theItem.itemStatus
+ sClass = nwLabels.CLASS_NAME[self.theItem.itemClass]
+ sLayout = nwLabels.LAYOUT_NAME[self.theItem.itemLayout]
+ sText = f"{self.theItem.itemStatus} / {sClass} / {sLayout}"
self.statusIcon.setPixmap(sIcon)
self.statusText.setText(sText)
diff --git a/nw/gui/docviewer.py b/nw/gui/docviewer.py
index 55e458bb..ddc0d876 100644
--- a/nw/gui/docviewer.py
+++ b/nw/gui/docviewer.py
@@ -176,13 +176,13 @@ class GuiDocViewer(QTextBrowser):
# See issue #298
try:
aDoc.setText(tHandle)
- aDoc.doAutoReplace()
+ aDoc.doPreProcessing()
aDoc.tokenizeText()
aDoc.doConvert()
aDoc.doPostProcessing()
- except Exception as e:
+ except Exception:
logger.error("Failed to generate preview for document with handle '%s'" % tHandle)
- logger.error(str(e))
+ nw.logException()
self.setText("An error occurred while generating the preview.")
return False
diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py
index df99559d..6d6267a9 100644
--- a/nw/gui/mainmenu.py
+++ b/nw/gui/mainmenu.py
@@ -115,22 +115,25 @@ class GuiMainMenu(QMenuBar):
##
def setSpellCheck(self, theMode):
- """Set the spell check check box to theMode. This is controlled
- by the document editor class, which holds the master spell check
- flag.
+ """Forward spell check check state to its action.
"""
self.aSpellCheck.setChecked(theMode)
return
def setAutoOutline(self, theMode):
- """Set the auto outline check box to theMode. Used during
- initialisation.
+ """Forward auto outline check state to its action.
"""
self.aAutoOutline.setChecked(theMode)
return
+ def setFocusMode(self, theMode):
+ """Forward focus mode check state to its action.
+ """
+ self.aFocusMode.setChecked(theMode)
+ return
+
##
- # Menu Action
+ # Slots
##
def _toggleSpellCheck(self, isChecked=False):
@@ -164,17 +167,11 @@ class GuiMainMenu(QMenuBar):
return True
def _openWebsite(self, theUrl):
- """Open an URL in the system's default browser.
+ """Open a URL in the system's default browser.
"""
QDesktopServices.openUrl(QUrl(theUrl))
return True
- def _openIssue(self):
- """Open the issue tracker URL in the system's default browser.
- """
- QDesktopServices.openUrl(QUrl(nw.__issuesurl__))
- return True
-
##
# Menu Builders
##
@@ -263,19 +260,40 @@ class GuiMainMenu(QMenuBar):
self.projMenu.addSeparator()
# Project > Edit
- self.aEditItem = QAction("Edit Project Item", self)
- self.aEditItem.setStatusTip("Change item settings")
+ self.aEditItem = QAction("Edit Item", self)
+ self.aEditItem.setStatusTip("Change project item settings")
self.aEditItem.setShortcuts(["Ctrl+E", "F2"])
self.aEditItem.triggered.connect(lambda: self.theParent.editItem(None))
self.projMenu.addAction(self.aEditItem)
# Project > Delete
- self.aDeleteItem = QAction("Delete Project Item", self)
- self.aDeleteItem.setStatusTip("Delete selected item")
+ self.aDeleteItem = QAction("Delete Item", self)
+ self.aDeleteItem.setStatusTip("Delete selected project item")
self.aDeleteItem.setShortcut("Ctrl+Shift+Del")
self.aDeleteItem.triggered.connect(lambda: self.theParent.treeView.deleteItem(None))
self.projMenu.addAction(self.aDeleteItem)
+ # Project > Move Up
+ self.aMoveUp = QAction("Move Item Up", self)
+ self.aMoveUp.setStatusTip("Move project item up")
+ self.aMoveUp.setShortcut("Ctrl+Up")
+ self.aMoveUp.triggered.connect(lambda: self._moveTreeItem(-1))
+ self.projMenu.addAction(self.aMoveUp)
+
+ # Project > Move Down
+ self.aMoveDown = QAction("Move Item Down", self)
+ self.aMoveDown.setStatusTip("Move project item down")
+ self.aMoveDown.setShortcut("Ctrl+Down")
+ self.aMoveDown.triggered.connect(lambda: self._moveTreeItem(1))
+ self.projMenu.addAction(self.aMoveDown)
+
+ # Project > Undo Last Action
+ self.aMoveUndo = QAction("Undo Last Move", self)
+ self.aMoveUndo.setStatusTip("Undo last item move")
+ self.aMoveUndo.setShortcut("Ctrl+Shift+Z")
+ self.aMoveUndo.triggered.connect(lambda: self.theParent.treeView.undoLastMove())
+ self.projMenu.addAction(self.aMoveUndo)
+
# Project > Empty Trash
self.aEmptyTrash = QAction("Empty Trash", self)
self.aEmptyTrash.setStatusTip("Permanently delete all files in the Trash folder")
@@ -710,6 +728,7 @@ class GuiMainMenu(QMenuBar):
self.mInsKWItems = {}
self.mInsKWItems[nwKeyWords.TAG_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, G")
self.mInsKWItems[nwKeyWords.POV_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, V")
+ self.mInsKWItems[nwKeyWords.FOCUS_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, F")
self.mInsKWItems[nwKeyWords.CHAR_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, C")
self.mInsKWItems[nwKeyWords.PLOT_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, P")
self.mInsKWItems[nwKeyWords.TIME_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, T")
@@ -891,40 +910,28 @@ class GuiMainMenu(QMenuBar):
# Tools
self.toolsMenu = self.addMenu("&Tools")
- # Tools > Move Up
- self.aMoveUp = QAction("Move Tree Item Up", self)
- self.aMoveUp.setStatusTip("Move item up")
- self.aMoveUp.setShortcut("Ctrl+Shift+Up")
- self.aMoveUp.triggered.connect(lambda: self._moveTreeItem(-1))
- self.toolsMenu.addAction(self.aMoveUp)
-
- # Tools > Move Down
- self.aMoveDown = QAction("Move Tree Item Down", self)
- self.aMoveDown.setStatusTip("Move item down")
- self.aMoveDown.setShortcut("Ctrl+Shift+Down")
- self.aMoveDown.triggered.connect(lambda: self._moveTreeItem(1))
- self.toolsMenu.addAction(self.aMoveDown)
-
- # Tools > Separator
- self.toolsMenu.addSeparator()
-
- # Tools > Toggle Spell Check
+ # Tools > Check Spelling
self.aSpellCheck = QAction("Check Spelling", self)
self.aSpellCheck.setStatusTip("Toggle check spelling")
self.aSpellCheck.setCheckable(True)
self.aSpellCheck.setChecked(self.theProject.spellCheck)
- # Here we must used triggered, not toggled, to avoid recursion
- self.aSpellCheck.triggered.connect(self._toggleSpellCheck)
+ self.aSpellCheck.triggered.connect(self._toggleSpellCheck) # triggered, not toggled!
self.aSpellCheck.setShortcut("Ctrl+F7")
self.toolsMenu.addAction(self.aSpellCheck)
- # Tools > Update Spell Check
+ # Tools > Re-Run Spell Check
self.aReRunSpell = QAction("Re-Run Spell Check", self)
self.aReRunSpell.setStatusTip("Run the spell checker on current document")
self.aReRunSpell.setShortcut("F7")
self.aReRunSpell.triggered.connect(lambda: self.theParent.docEditor.spellCheckDocument())
self.toolsMenu.addAction(self.aReRunSpell)
+ # Tools > Project Word List
+ self.aEditWordList = QAction("Project Word List", self)
+ self.aEditWordList.setStatusTip("Edit the project's word list")
+ self.aEditWordList.triggered.connect(lambda: self.theParent.showProjectWordListDialog())
+ self.toolsMenu.addAction(self.aEditWordList)
+
# Tools > Separator
self.toolsMenu.addSeparator()
diff --git a/nw/gui/outline.py b/nw/gui/outline.py
index 291171cd..666c8026 100644
--- a/nw/gui/outline.py
+++ b/nw/gui/outline.py
@@ -49,6 +49,7 @@ class GuiOutline(QTreeWidget):
nwOutline.WCOUNT : 50,
nwOutline.PCOUNT : 50,
nwOutline.POV : 100,
+ nwOutline.FOCUS : 100,
nwOutline.CHAR : 100,
nwOutline.PLOT : 100,
nwOutline.TIME : 100,
@@ -68,6 +69,7 @@ class GuiOutline(QTreeWidget):
nwOutline.WCOUNT : False,
nwOutline.PCOUNT : False,
nwOutline.POV : False,
+ nwOutline.FOCUS : True,
nwOutline.CHAR : False,
nwOutline.PLOT : False,
nwOutline.TIME : True,
@@ -451,6 +453,7 @@ class GuiOutline(QTreeWidget):
theRefs = self.theIndex.getReferences(tHandle, sTitle)
newItem.setText(self.colIndex[nwOutline.POV], ", ".join(theRefs[nwKeyWords.POV_KEY]))
+ newItem.setText(self.colIndex[nwOutline.FOCUS], ", ".join(theRefs[nwKeyWords.FOCUS_KEY]))
newItem.setText(self.colIndex[nwOutline.CHAR], ", ".join(theRefs[nwKeyWords.CHAR_KEY]))
newItem.setText(self.colIndex[nwOutline.PLOT], ", ".join(theRefs[nwKeyWords.PLOT_KEY]))
newItem.setText(self.colIndex[nwOutline.TIME], ", ".join(theRefs[nwKeyWords.TIME_KEY]))
diff --git a/nw/gui/outlinedetails.py b/nw/gui/outlinedetails.py
index 94ab542e..15785ff8 100644
--- a/nw/gui/outlinedetails.py
+++ b/nw/gui/outlinedetails.py
@@ -105,6 +105,7 @@ class GuiOutlineDetails(QScrollArea):
# Tags
self.povKeyLabel = QLabel("%s" % nwLabels.KEY_NAME[nwKeyWords.POV_KEY])
+ self.focKeyLabel = QLabel("%s" % nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])
self.chrKeyLabel = QLabel("%s" % nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY])
self.pltKeyLabel = QLabel("%s" % nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])
self.timKeyLabel = QLabel("%s" % nwLabels.KEY_NAME[nwKeyWords.TIME_KEY])
@@ -114,6 +115,7 @@ class GuiOutlineDetails(QScrollArea):
self.cstKeyLabel = QLabel("%s" % nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY])
self.povKeyLWrap = QHBoxLayout()
+ self.focKeyLWrap = QHBoxLayout()
self.chrKeyLWrap = QHBoxLayout()
self.pltKeyLWrap = QHBoxLayout()
self.timKeyLWrap = QHBoxLayout()
@@ -123,6 +125,7 @@ class GuiOutlineDetails(QScrollArea):
self.cstKeyLWrap = QHBoxLayout()
self.povKeyValue = QLabel("")
+ self.focKeyValue = QLabel("")
self.chrKeyValue = QLabel("")
self.pltKeyValue = QLabel("")
self.timKeyValue = QLabel("")
@@ -132,6 +135,7 @@ class GuiOutlineDetails(QScrollArea):
self.cstKeyValue = QLabel("")
self.povKeyValue.setWordWrap(True)
+ self.focKeyValue.setWordWrap(True)
self.chrKeyValue.setWordWrap(True)
self.pltKeyValue.setWordWrap(True)
self.timKeyValue.setWordWrap(True)
@@ -141,6 +145,7 @@ class GuiOutlineDetails(QScrollArea):
self.cstKeyValue.setWordWrap(True)
self.povKeyValue.linkActivated.connect(self._tagClicked)
+ self.focKeyValue.linkActivated.connect(self._tagClicked)
self.chrKeyValue.linkActivated.connect(self._tagClicked)
self.pltKeyValue.linkActivated.connect(self._tagClicked)
self.timKeyValue.linkActivated.connect(self._tagClicked)
@@ -150,6 +155,7 @@ class GuiOutlineDetails(QScrollArea):
self.cstKeyValue.linkActivated.connect(self._tagClicked)
self.povKeyLWrap.addWidget(self.povKeyValue, 1)
+ self.focKeyLWrap.addWidget(self.focKeyValue, 1)
self.chrKeyLWrap.addWidget(self.chrKeyValue, 1)
self.pltKeyLWrap.addWidget(self.pltKeyValue, 1)
self.timKeyLWrap.addWidget(self.timKeyValue, 1)
@@ -190,23 +196,25 @@ class GuiOutlineDetails(QScrollArea):
self.tagsForm.addWidget(self.povKeyLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.povKeyLWrap, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addWidget(self.chrKeyLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addLayout(self.chrKeyLWrap, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addWidget(self.pltKeyLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addLayout(self.pltKeyLWrap, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addWidget(self.timKeyLabel, 3, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addLayout(self.timKeyLWrap, 3, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addWidget(self.wldKeyLabel, 4, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addLayout(self.wldKeyLWrap, 4, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addWidget(self.objKeyLabel, 5, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addLayout(self.objKeyLWrap, 5, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addWidget(self.entKeyLabel, 6, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addLayout(self.entKeyLWrap, 6, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addWidget(self.cstKeyLabel, 7, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
- self.tagsForm.addLayout(self.cstKeyLWrap, 7, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addWidget(self.focKeyLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addLayout(self.focKeyLWrap, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addWidget(self.chrKeyLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addLayout(self.chrKeyLWrap, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addWidget(self.pltKeyLabel, 3, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addLayout(self.pltKeyLWrap, 3, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addWidget(self.timKeyLabel, 4, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addLayout(self.timKeyLWrap, 4, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addWidget(self.wldKeyLabel, 5, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addLayout(self.wldKeyLWrap, 5, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addWidget(self.objKeyLabel, 6, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addLayout(self.objKeyLWrap, 6, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addWidget(self.entKeyLabel, 7, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addLayout(self.entKeyLWrap, 7, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addWidget(self.cstKeyLabel, 8, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
+ self.tagsForm.addLayout(self.cstKeyLWrap, 8, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.setColumnStretch(1, 1)
- self.tagsForm.setRowStretch(7, 1)
+ self.tagsForm.setRowStretch(8, 1)
self.tagsForm.setHorizontalSpacing(hSpace)
self.tagsForm.setVerticalSpacing(vSpace)
@@ -257,6 +265,7 @@ class GuiOutlineDetails(QScrollArea):
self.pCValue.setText("")
self.synopValue.setText("")
self.povKeyValue.setText("")
+ self.focKeyValue.setText("")
self.chrKeyValue.setText("")
self.pltKeyValue.setText("")
self.timKeyValue.setText("")
@@ -296,6 +305,7 @@ class GuiOutlineDetails(QScrollArea):
self.synopValue.setText(novIdx["synopsis"])
self.povKeyValue.setText(self._formatTags(theRefs, nwKeyWords.POV_KEY))
+ self.focKeyValue.setText(self._formatTags(theRefs, nwKeyWords.FOCUS_KEY))
self.chrKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CHAR_KEY))
self.pltKeyValue.setText(self._formatTags(theRefs, nwKeyWords.PLOT_KEY))
self.timKeyValue.setText(self._formatTags(theRefs, nwKeyWords.TIME_KEY))
diff --git a/nw/gui/preferences.py b/nw/gui/preferences.py
index b7be1d05..4913be25 100644
--- a/nw/gui/preferences.py
+++ b/nw/gui/preferences.py
@@ -358,6 +358,33 @@ class GuiPreferencesProjects(QWidget):
"If off, backups will run in the background."
)
+ # Session Timer
+ # =============
+ self.mainForm.addGroupLabel("Session Timer")
+
+ ## Pause when idle
+ self.stopWhenIdle = QSwitch()
+ self.stopWhenIdle.setChecked(self.mainConf.stopWhenIdle)
+ self.mainForm.addRow(
+ "Pause the session timer when not writing",
+ self.stopWhenIdle,
+ "Also pauses when the application window does not have focus."
+ )
+
+ ## Inactive time for idle
+ self.userIdleTime = QDoubleSpinBox()
+ self.userIdleTime.setMinimum(0.5)
+ self.userIdleTime.setMaximum(600.0)
+ self.userIdleTime.setSingleStep(0.5)
+ self.userIdleTime.setDecimals(1)
+ self.userIdleTime.setValue(self.mainConf.userIdleTime/60.0)
+ self.mainForm.addRow(
+ "Editor inactive time before pausing timer",
+ self.userIdleTime,
+ "User activity includes typing and changing the content.",
+ theUnit="minutes"
+ )
+
return
def saveValues(self):
@@ -372,6 +399,10 @@ class GuiPreferencesProjects(QWidget):
self.mainConf.backupOnClose = self.backupOnClose.isChecked()
self.mainConf.askBeforeBackup = self.askBeforeBackup.isChecked()
+ # Session Timer
+ self.mainConf.stopWhenIdle = self.stopWhenIdle.isChecked()
+ self.mainConf.userIdleTime = round(self.userIdleTime.value() * 60)
+
self.mainConf.confChanged = True
return
diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py
index b48b938b..eff1ae7c 100644
--- a/nw/gui/projtree.py
+++ b/nw/gui/projtree.py
@@ -33,12 +33,12 @@ from time import time
from PyQt5.QtCore import Qt, QSize, pyqtSignal
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
- qApp, QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction
+ QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction
)
from nw.core import NWDoc
from nw.constants import (
- nwLabels, nwItemType, nwItemClass, nwItemLayout, nwAlert, nwConst
+ nwLabels, nwItemType, nwItemClass, nwItemLayout, nwAlert, nwConst, nwLists
)
logger = logging.getLogger(__name__)
@@ -68,6 +68,7 @@ class GuiProjectTree(QTreeWidget):
self._treeMap = {}
self._treeChanged = False
self._timeChanged = 0
+ self._lastMove = {}
##
# Build GUI
@@ -269,10 +270,43 @@ class GuiProjectTree(QTreeWidget):
logger.error("Failed to add new item")
return False
+ # If there is no handle set, return here
+ if tHandle is None:
+ return True
+
# Add the new item to the tree
- if tHandle is not None:
- self.revealNewTreeItem(tHandle, nHandle)
- self.theParent.editItem(tHandle)
+ self.revealNewTreeItem(tHandle, nHandle)
+ self.theParent.editItem(tHandle)
+ nwItem = self.theProject.projTree[tHandle]
+
+ # If this is a folder, return here
+ if nwItem.itemType != nwItemType.FILE:
+ return True
+
+ # This is a new files, so let's add some content
+ newDoc = NWDoc(self.theProject, self.theParent)
+ curTxt = newDoc.openDocument(tHandle, showStatus=False)
+ if curTxt == "":
+ if nwItem.itemLayout == nwItemLayout.CHAPTER:
+ newText = f"## {nwItem.itemName}\n\n"
+ elif nwItem.itemLayout == nwItemLayout.UNNUMBERED:
+ newText = f"## {nwItem.itemName}\n\n"
+ elif nwItem.itemLayout == nwItemLayout.SCENE:
+ newText = f"### {nwItem.itemName}\n\n"
+ else:
+ newText = f"# {nwItem.itemName}\n\n"
+
+ # Save the text and index it
+ newDoc.saveDocument(newText)
+ self.theIndex.scanText(tHandle, newText)
+
+ # Get Word Counts
+ cC, wC, pC = self.theIndex.getCounts(tHandle)
+ nwItem.setCharCount(cC)
+ nwItem.setWordCount(wC)
+ nwItem.setParaCount(pC)
+ self.propagateCount(tHandle, wC)
+ self.projectWordCount()
return True
@@ -302,7 +336,7 @@ class GuiProjectTree(QTreeWidget):
logger.error("No project open")
return False
- if qApp.focusWidget() != self:
+ if not self.hasFocus():
return False
tHandle = self.getSelectedHandle()
@@ -328,6 +362,7 @@ class GuiProjectTree(QTreeWidget):
return False
cItem = pItem.takeChild(tIndex)
pItem.insertChild(nIndex, cItem)
+ self._recordLastMove(cItem, pItem, tIndex)
self.clearSelection()
cItem.setSelected(True)
@@ -416,7 +451,7 @@ class GuiProjectTree(QTreeWidget):
for tHandle in self.getTreeFromHandle(trashHandle):
if tHandle == trashHandle:
continue
- self.deleteItem(tHandle, True)
+ self.deleteItem(tHandle, alreadyAsked=True)
if nTrash > 0:
self._setTreeChanged(True)
@@ -484,8 +519,9 @@ class GuiProjectTree(QTreeWidget):
theDoc = NWDoc(self.theProject, self.theParent)
theDoc.deleteDocument(tHandle)
- del self.theProject.projTree[tHandle]
self.theIndex.deleteHandle(tHandle)
+ self._deleteTreeItem(tHandle)
+ self._setTreeChanged(True)
else:
# The file is not already in the trash folder, so we
@@ -508,11 +544,12 @@ class GuiProjectTree(QTreeWidget):
tIndex = trItemP.indexOfChild(trItemS)
trItemC = trItemP.takeChild(tIndex)
trItemT.addChild(trItemC)
- nwItemS.setParent(self.theProject.projTree.trashRoot())
+ self._updateItemParent(tHandle)
self.propagateCount(tHandle, wCount)
- self._setTreeChanged(True)
self.theIndex.deleteHandle(tHandle)
+ self._recordLastMove(trItemS, trItemP, tIndex)
+ self._setTreeChanged(True)
elif nwItemS.itemType == nwItemType.FOLDER:
logger.debug("User requested folder %s deleted" % tHandle)
@@ -523,7 +560,8 @@ class GuiProjectTree(QTreeWidget):
tIndex = trItemP.indexOfChild(trItemS)
if trItemS.childCount() == 0:
trItemP.takeChild(tIndex)
- del self.theProject.projTree[tHandle]
+ self._deleteTreeItem(tHandle)
+ self._setTreeChanged(True)
else:
self.makeAlert((
"Cannot delete folder. It is not empty. "
@@ -537,7 +575,7 @@ class GuiProjectTree(QTreeWidget):
tIndex = self.indexOfTopLevelItem(trItemS)
if trItemS.childCount() == 0:
self.takeTopLevelItem(tIndex)
- del self.theProject.projTree[tHandle]
+ self._deleteTreeItem(tHandle)
self.theParent.mainMenu.setAvailableRoot()
self._setTreeChanged(True)
else:
@@ -646,6 +684,52 @@ class GuiProjectTree(QTreeWidget):
logger.debug("%d items added to the project tree" % iCount)
return True
+ def undoLastMove(self):
+ """Attempt to undo the last action.
+ """
+ srcItem = self._lastMove.get("item", None)
+ dstItem = self._lastMove.get("parent", None)
+ dstIndex = self._lastMove.get("index", None)
+
+ if not self.hasFocus():
+ return False
+
+ if srcItem is None or dstItem is None or dstIndex is None:
+ logger.verbose("No tree move to undo")
+ return False
+
+ if srcItem not in self._treeMap.values():
+ logger.warning("Source item no longer exists")
+ return False
+
+ if dstItem not in self._treeMap.values():
+ logger.warning("Previous parent item no longer exists")
+ return False
+
+ dstIndex = min(max(0, dstIndex), dstItem.childCount())
+ wCount = int(srcItem.data(self.C_COUNT, Qt.UserRole))
+ sHandle = srcItem.data(self.C_NAME, Qt.UserRole)
+ dHandle = dstItem.data(self.C_NAME, Qt.UserRole)
+ logger.debug("Moving item %s back to %s, index %d" % (
+ sHandle, dHandle, dstIndex
+ ))
+
+ self.propagateCount(sHandle, 0)
+ parItem = srcItem.parent()
+ srcIndex = parItem.indexOfChild(srcItem)
+ movItem = parItem.takeChild(srcIndex)
+ dstItem.insertChild(dstIndex, movItem)
+
+ snItem = self.theProject.projTree[sHandle]
+ dnItem = self.theProject.projTree[dHandle]
+ self._postItemMove(sHandle, snItem, dnItem, wCount)
+
+ self.clearSelection()
+ movItem.setSelected(True)
+ self._lastMove = {}
+
+ return True
+
def getSelectedHandle(self):
"""Get the currently selected handle. If multiple items are
selected, return the first.
@@ -754,46 +838,30 @@ class GuiProjectTree(QTreeWidget):
self.makeAlert("The item cannot be moved to that location.", nwAlert.ERROR)
return
+ pItem = sItem.parent()
+ pIndex = 0
+ if pItem is not None:
+ pIndex = pItem.indexOfChild(sItem)
+
wCount = int(sItem.data(self.C_COUNT, Qt.UserRole))
+ isFile = snItem.itemType == nwItemType.FILE
+ isRoot = snItem.itemType == nwItemType.ROOT
+ onFile = dnItem.itemType == nwItemType.FILE
+
isSame = snItem.itemClass == dnItem.itemClass
isNone = snItem.itemClass == nwItemClass.NO_CLASS
isNote = snItem.itemLayout == nwItemLayout.NOTE
- onFile = dnItem.itemType == nwItemType.FILE
- isRoot = snItem.itemType == nwItemType.ROOT
- onFree = dnItem.itemClass == nwItemClass.ARCHIVE
- onFree |= dnItem.itemClass == nwItemClass.TRASH
- onFree &= snItem.itemType == nwItemType.FILE
- isOnTop = self.dropIndicatorPosition() == QAbstractItemView.OnItem
- if (isSame or isNone or isNote or onFree) and not (onFile and isOnTop) and not isRoot:
+ onFree = dnItem.itemClass in nwLists.FREE_CLASS and isFile
+
+ allowDrop = isSame or isNone or isNote or onFree
+ allowDrop &= not (self.dropIndicatorPosition() == QAbstractItemView.OnItem and onFile)
+
+ if allowDrop and not isRoot:
logger.debug("Drag'n'drop of item %s accepted" % sHandle)
self.propagateCount(sHandle, 0)
QTreeWidget.dropEvent(self, theEvent)
- self._updateItemParent(sHandle)
-
- # If the item does not have the same class as the target,
- # and the target is not a free root folder, update its class
- if not (isSame or onFree):
- logger.debug("Item %s class has been changed from %s to %s" % (
- sHandle,
- snItem.itemClass.name,
- dnItem.itemClass.name
- ))
- snItem.setClass(dnItem.itemClass)
- self.setTreeItemValues(sHandle)
-
- self.propagateCount(sHandle, wCount)
-
- # The items dropped into archive or trash should be removed
- # from the project index, for all other items, we rescan the
- # file to ensure the index is up to date.
- if onFree:
- self.theIndex.deleteHandle(sHandle)
- else:
- self.theIndex.reIndexHandle(sHandle)
-
- # Trigger dependent updates
- self._setTreeChanged(True)
- self._emitItemChange(sHandle)
+ self._postItemMove(sHandle, snItem, dnItem, wCount)
+ self._recordLastMove(sItem, pItem, pIndex)
else:
theEvent.ignore()
@@ -806,22 +874,63 @@ class GuiProjectTree(QTreeWidget):
# Internal Functions
##
+ def _postItemMove(self, sHandle, snItem, dnItem, wCount):
+ """Run various maintenance tasks for a moved item.
+ """
+ isFile = snItem.itemType == nwItemType.FILE
+ isSame = snItem.itemClass == dnItem.itemClass
+ onFree = dnItem.itemClass in nwLists.FREE_CLASS and isFile
+
+ self._updateItemParent(sHandle)
+
+ # If the item does not have the same class as the target,
+ # and the target is not a free root folder, update its class
+ if not (isSame or onFree):
+ logger.debug("Item %s class has been changed from %s to %s" % (
+ sHandle, snItem.itemClass.name, dnItem.itemClass.name
+ ))
+ snItem.setClass(dnItem.itemClass)
+ self.setTreeItemValues(sHandle)
+
+ self.propagateCount(sHandle, wCount)
+
+ # The items dropped into archive or trash should be removed
+ # from the project index, for all other items, we rescan the
+ # file to ensure the index is up to date.
+ if onFree:
+ self.theIndex.deleteHandle(sHandle)
+ else:
+ self.theIndex.reIndexHandle(sHandle)
+
+ # Trigger dependent updates
+ self._setTreeChanged(True)
+ self._emitItemChange(sHandle)
+
+ return
+
def _getTreeItem(self, tHandle):
"""Returns the QTreeWidgetItem of a given item handle.
"""
return self._treeMap.get(tHandle, None)
- def _scanChildren(self, theList, theItem, theIndex):
+ def _deleteTreeItem(self, tHandle):
+ """Delete a tree item from the project and the map.
+ """
+ del self.theProject.projTree[tHandle]
+ self._treeMap.pop(tHandle, None)
+ return
+
+ def _scanChildren(self, theList, tItem, tIndex):
"""This is a recursive function returning all items in a tree
starting at a given QTreeWidgetItem.
"""
- tHandle = theItem.data(self.C_NAME, Qt.UserRole)
+ tHandle = tItem.data(self.C_NAME, Qt.UserRole)
nwItem = self.theProject.projTree[tHandle]
- nwItem.setExpanded(theItem.isExpanded())
- nwItem.setOrder(theIndex)
+ nwItem.setExpanded(tItem.isExpanded())
+ nwItem.setOrder(tIndex)
theList.append(tHandle)
- for i in range(theItem.childCount()):
- self._scanChildren(theList, theItem.child(i), i)
+ for i in range(tItem.childCount()):
+ self._scanChildren(theList, tItem.child(i), i)
return theList
def _addTreeItem(self, nwItem, nHandle=None):
@@ -951,6 +1060,19 @@ class GuiProjectTree(QTreeWidget):
return
+ def _recordLastMove(self, srcItem, parItem, parIndex):
+ """Record the last action so that it can be undone.
+ """
+ prevItem = self._lastMove.get("item", None)
+ if prevItem is None or srcItem != prevItem:
+ self._lastMove = {
+ "item": srcItem,
+ "parent": parItem,
+ "index": parIndex,
+ }
+
+ return
+
# END Class GuiProjectTree
class GuiProjectTreeMenu(QMenu):
diff --git a/nw/gui/statusbar.py b/nw/gui/statusbar.py
index f17af853..441cacda 100644
--- a/nw/gui/statusbar.py
+++ b/nw/gui/statusbar.py
@@ -29,7 +29,6 @@ import logging
from time import time
-from PyQt5.QtCore import QTimer
from PyQt5.QtGui import QColor, QPainter
from PyQt5.QtWidgets import qApp, QStatusBar, QLabel, QAbstractButton
@@ -49,6 +48,7 @@ class GuiMainStatus(QStatusBar):
self.theParent = theParent
self.theTheme = theParent.theTheme
self.refTime = None
+ self.userIdle = False
colNone = QColor(*self.theTheme.statNone)
colTrue = QColor(*self.theTheme.statUnsaved)
@@ -97,9 +97,12 @@ class GuiMainStatus(QStatusBar):
## The Session Clock
### Set the mimimum width so the label doesn't rescale every second
+ self.timePixmap = self.theTheme.getPixmap("status_time", (iPx, iPx))
+ self.idlePixmap = self.theTheme.getPixmap("status_idle", (iPx, iPx))
+
self.timeIcon = QLabel()
self.timeText = QLabel("")
- self.timeIcon.setPixmap(self.theTheme.getPixmap("status_time", (iPx, iPx)))
+ self.timeIcon.setPixmap(self.timePixmap)
self.timeText.setToolTip("Session Time")
self.timeText.setMinimumWidth(self.theTheme.getTextWidth("00:00:00:"))
self.timeIcon.setContentsMargins(0, 0, 0, 0)
@@ -110,12 +113,6 @@ class GuiMainStatus(QStatusBar):
# Other Settings
self.setSizeGripEnabled(True)
- # Start the Clock
- self.sessionTimer = QTimer()
- self.sessionTimer.setInterval(1000)
- self.sessionTimer.timeout.connect(self._updateTime)
- self.sessionTimer.start()
-
logger.debug("GuiMainStatus initialisation complete")
self.clearStatus()
@@ -130,7 +127,7 @@ class GuiMainStatus(QStatusBar):
self.setStats(0, 0)
self.setProjectStatus(None)
self.setDocumentStatus(None)
- self._updateTime()
+ self.updateTime()
return True
##
@@ -182,17 +179,33 @@ class GuiMainStatus(QStatusBar):
self.statsText.setToolTip("Project word count (session change)")
return
- ##
- # Internal Functions
- ##
+ def setUserIdle(self, userIdle):
+ """Change the idle status icon.
+ """
+ if not self.mainConf.stopWhenIdle:
+ userIdle = False
- def _updateTime(self):
+ if self.userIdle != userIdle:
+ if userIdle:
+ self.timeIcon.setPixmap(self.idlePixmap)
+ else:
+ self.timeIcon.setPixmap(self.timePixmap)
+
+ self.userIdle = userIdle
+
+ return
+
+ def updateTime(self, idleTime=0.0):
"""Update the session clock.
"""
if self.refTime is None:
self.timeText.setText("00:00:00")
else:
- self.timeText.setText(formatTime(round(time() - self.refTime)))
+ if self.mainConf.stopWhenIdle:
+ sessTime = round(time() - self.refTime - idleTime)
+ else:
+ sessTime = round(time() - self.refTime)
+ self.timeText.setText(formatTime(sessTime))
return
# END Class GuiMainStatus
diff --git a/nw/gui/theme.py b/nw/gui/theme.py
index 84628cf0..9ec6018e 100644
--- a/nw/gui/theme.py
+++ b/nw/gui/theme.py
@@ -268,9 +268,9 @@ class GuiTheme:
if os.path.isfile(self.cssFile):
with open(self.cssFile, mode="r", encoding="utf8") as inFile:
cssData = inFile.read()
- except Exception as e:
+ except Exception:
logger.error("Could not load theme css file")
- logger.error(str(e))
+ nw.logException()
return False
# Config File
@@ -278,9 +278,9 @@ class GuiTheme:
try:
with open(self.confFile, mode="r", encoding="utf8") as inFile:
confParser.read_file(inFile)
- except Exception as e:
+ except Exception:
logger.error("Could not load theme settings from: %s" % self.confFile)
- logger.error(str(e))
+ nw.logException()
return False
## Main
@@ -336,9 +336,9 @@ class GuiTheme:
try:
with open(self.syntaxFile, mode="r", encoding="utf8") as inFile:
confParser.read_file(inFile)
- except Exception as e:
+ except Exception:
logger.error("Could not load syntax colours from: %s" % self.syntaxFile)
- logger.error(str(e))
+ nw.logException()
return False
## Main
@@ -539,6 +539,7 @@ class GuiIcons:
"proj_nwx" : (None, None),
"status_lang" : (None, None),
"status_time" : (None, None),
+ "status_idle" : (None, None),
"status_stats" : (None, None),
"status_lines" : (None, None),
"doc_h0" : (QStyle.SP_FileIcon, "x-office-document"),
@@ -640,9 +641,9 @@ class GuiIcons:
try:
with open(self.confFile, mode="r", encoding="utf8") as inFile:
confParser.read_file(inFile)
- except Exception as e:
+ except Exception:
logger.error("Could not load icon theme settings from: %s" % self.confFile)
- logger.error(str(e))
+ nw.logException()
return False
## Main
diff --git a/nw/gui/wordlist.py b/nw/gui/wordlist.py
new file mode 100644
index 00000000..f5b6fe80
--- /dev/null
+++ b/nw/gui/wordlist.py
@@ -0,0 +1,213 @@
+# -*- coding: utf-8 -*-
+"""
+novelWriter – GUI User Wordlist
+===============================
+Class holding the user's wordlist dialog
+
+File History:
+Created: 2021-02-12 [1.2b1]
+
+This file is a part of novelWriter
+Copyright 2018–2021, 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 .
+"""
+
+import nw
+import logging
+import os
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import (
+ QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, QListWidget,
+ QAbstractItemView, QPushButton, QLineEdit, QLabel
+)
+
+from nw.constants import nwFiles, nwAlert
+
+logger = logging.getLogger(__name__)
+
+class GuiWordList(QDialog):
+
+ def __init__(self, theParent, theProject):
+ QDialog.__init__(self, theParent)
+
+ logger.debug("Initialising GuiWordList ...")
+ self.setObjectName("GuiWordList")
+
+ self.mainConf = nw.CONFIG
+ self.theParent = theParent
+ self.theTheme = theParent.theTheme
+ self.theProject = theProject
+ self.optState = theProject.optState
+
+ self.setWindowTitle("Project Word List")
+
+ mS = self.mainConf.pxInt(250)
+ wW = self.mainConf.pxInt(320)
+ wH = self.mainConf.pxInt(340)
+
+ self.setMinimumWidth(mS)
+ self.setMinimumHeight(mS)
+ self.resize(
+ self.mainConf.pxInt(self.optState.getInt("GuiWordList", "winWidth", wW)),
+ self.mainConf.pxInt(self.optState.getInt("GuiWordList", "winHeight", wH))
+ )
+
+ # Main Widgets
+ # ============
+
+ self.headLabel = QLabel("Project Word List")
+
+ self.listBox = QListWidget()
+ self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop)
+ self.listBox.setSortingEnabled(True)
+
+ self.newEntry = QLineEdit()
+
+ self.addButton = QPushButton(self.theTheme.getIcon("add"), "")
+ self.addButton.setToolTip("Add new entry")
+ self.addButton.clicked.connect(self._doAdd)
+
+ self.delButton = QPushButton(self.theTheme.getIcon("remove"), "")
+ self.delButton.setToolTip("Delete selected entry")
+ self.delButton.clicked.connect(self._doDelete)
+
+ self.editBox = QHBoxLayout()
+ self.editBox.addWidget(self.newEntry, 1)
+ self.editBox.addWidget(self.addButton, 0)
+ self.editBox.addWidget(self.delButton, 0)
+
+ self.buttonBox = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Close)
+ self.buttonBox.accepted.connect(self._doSave)
+ self.buttonBox.rejected.connect(self._doClose)
+
+ # Assemble
+ # ========
+
+ self.outerBox = QVBoxLayout()
+ self.outerBox.addWidget(self.headLabel)
+ self.outerBox.addSpacing(self.mainConf.pxInt(8))
+ self.outerBox.addWidget(self.listBox, 1)
+ self.outerBox.addLayout(self.editBox, 0)
+ self.outerBox.addSpacing(self.mainConf.pxInt(12))
+ self.outerBox.addWidget(self.buttonBox, 0)
+
+ self.setLayout(self.outerBox)
+
+ self._loadWordList()
+
+ logger.debug("GuiWordList initialisation complete")
+
+ return
+
+ ##
+ # Slots
+ ##
+
+ def _doAdd(self):
+ """Add a new word to the word list.
+ """
+ newWord = self.newEntry.text().strip()
+ if newWord == "":
+ self.theParent.makeAlert("Cannot add a blank word.", nwAlert.ERROR)
+ return False
+
+ if self.listBox.findItems(newWord, Qt.MatchExactly):
+ self.theParent.makeAlert(
+ "The word '%s' is already in the word list." % newWord, nwAlert.ERROR
+ )
+ return False
+
+ self.listBox.addItem(newWord)
+ self.newEntry.setText("")
+
+ return True
+
+ def _doDelete(self):
+ """Delete the selected item.
+ """
+ selItem = self.listBox.selectedItems()
+ if selItem:
+ self.listBox.takeItem(self.listBox.row(selItem[0]))
+ return
+
+ def _doSave(self):
+ """Save the new word list and close.
+ """
+ self._saveGuiSettings()
+
+ dctFile = os.path.join(self.theProject.projMeta, nwFiles.PROJ_DICT)
+ tmpFile = dctFile + "~"
+
+ try:
+ with open(tmpFile, mode="w", encoding="utf8") as outFile:
+ for i in range(self.listBox.count()):
+ outFile.write(self.listBox.item(i).text() + "\n")
+
+ except Exception:
+ logger.error("Could not save new word list")
+ nw.logException()
+ self.reject()
+ return False
+
+ if os.path.isfile(dctFile):
+ os.unlink(dctFile)
+ os.rename(tmpFile, dctFile)
+ self.accept()
+
+ return True
+
+ def _doClose(self):
+ """Close without saving the word list.
+ """
+ self._saveGuiSettings()
+ self.reject()
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _loadWordList(self):
+ """Load the project's word list, if it exists.
+ """
+ self.listBox.clear()
+
+ wordList = os.path.join(self.theProject.projMeta, nwFiles.PROJ_DICT)
+ if not os.path.isfile(wordList):
+ logger.debug("No project dictionary file found")
+ return False
+
+ with open(wordList, mode="r", encoding="utf8") as inFile:
+ for inLine in inFile:
+ theWord = inLine.strip()
+ if len(theWord) == 0:
+ continue
+ self.listBox.addItem(theWord)
+
+ return True
+
+ def _saveGuiSettings(self):
+ """Save GUI settings.
+ """
+ winWidth = self.mainConf.rpxInt(self.width())
+ winHeight = self.mainConf.rpxInt(self.height())
+
+ self.optState.setValue("GuiWordList", "winWidth", winWidth)
+ self.optState.setValue("GuiWordList", "winHeight", winHeight)
+
+ return
+
+# END Class GuiWordList
diff --git a/nw/gui/writingstats.py b/nw/gui/writingstats.py
index 2b675c7c..dec1fef4 100644
--- a/nw/gui/writingstats.py
+++ b/nw/gui/writingstats.py
@@ -48,8 +48,9 @@ class GuiWritingStats(QDialog):
C_TIME = 0
C_LENGTH = 1
- C_COUNT = 2
- C_BAR = 3
+ C_IDLE = 2
+ C_COUNT = 3
+ C_BAR = 4
FMT_JSON = 0
FMT_CSV = 1
@@ -89,16 +90,21 @@ class GuiWritingStats(QDialog):
wCol2 = self.mainConf.pxInt(
self.optState.getInt("GuiWritingStats", "widthCol2", 80)
)
+ wCol3 = self.mainConf.pxInt(
+ self.optState.getInt("GuiWritingStats", "widthCol3", 80)
+ )
self.listBox = QTreeWidget()
- self.listBox.setHeaderLabels(["Session Start", "Length", "Words", "Histogram"])
+ self.listBox.setHeaderLabels(["Session Start", "Length", "Idle", "Words", "Histogram"])
self.listBox.setIndentation(0)
self.listBox.setColumnWidth(self.C_TIME, wCol0)
self.listBox.setColumnWidth(self.C_LENGTH, wCol1)
- self.listBox.setColumnWidth(self.C_COUNT, wCol2)
+ self.listBox.setColumnWidth(self.C_IDLE, wCol2)
+ self.listBox.setColumnWidth(self.C_COUNT, wCol3)
hHeader = self.listBox.headerItem()
hHeader.setTextAlignment(self.C_LENGTH, Qt.AlignRight)
+ hHeader.setTextAlignment(self.C_IDLE, Qt.AlignRight)
hHeader.setTextAlignment(self.C_COUNT, Qt.AlignRight)
sortValid = (Qt.AscendingOrder, Qt.DescendingOrder)
@@ -127,6 +133,10 @@ class GuiWritingStats(QDialog):
self.labelTotal.setFont(self.theTheme.guiFontFixed)
self.labelTotal.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
+ self.labelIdleT = QLabel(formatTime(0))
+ self.labelIdleT.setFont(self.theTheme.guiFontFixed)
+ self.labelIdleT.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
+
self.labelFilter = QLabel(formatTime(0))
self.labelFilter.setFont(self.theTheme.guiFontFixed)
self.labelFilter.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
@@ -144,16 +154,18 @@ class GuiWritingStats(QDialog):
self.totalWords.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
self.infoForm.addWidget(QLabel("Total Time:"), 0, 0)
- self.infoForm.addWidget(QLabel("Filtered Time:"), 1, 0)
- self.infoForm.addWidget(QLabel("Novel Word Count:"), 2, 0)
- self.infoForm.addWidget(QLabel("Notes Word Count:"), 3, 0)
- self.infoForm.addWidget(QLabel("Total Word Count:"), 4, 0)
+ self.infoForm.addWidget(QLabel("Idle Time:"), 1, 0)
+ self.infoForm.addWidget(QLabel("Filtered Time:"), 2, 0)
+ self.infoForm.addWidget(QLabel("Novel Word Count:"), 3, 0)
+ self.infoForm.addWidget(QLabel("Notes Word Count:"), 4, 0)
+ self.infoForm.addWidget(QLabel("Total Word Count:"), 5, 0)
self.infoForm.addWidget(self.labelTotal, 0, 1)
- self.infoForm.addWidget(self.labelFilter, 1, 1)
- self.infoForm.addWidget(self.novelWords, 2, 1)
- self.infoForm.addWidget(self.notesWords, 3, 1)
- self.infoForm.addWidget(self.totalWords, 4, 1)
- self.infoForm.setRowStretch(5, 1)
+ self.infoForm.addWidget(self.labelIdleT, 1, 1)
+ self.infoForm.addWidget(self.labelFilter, 2, 1)
+ self.infoForm.addWidget(self.novelWords, 3, 1)
+ self.infoForm.addWidget(self.notesWords, 4, 1)
+ self.infoForm.addWidget(self.totalWords, 5, 1)
+ self.infoForm.setRowStretch(6, 1)
# Filter Options
sPx = self.theTheme.baseIconSize
@@ -192,17 +204,25 @@ class GuiWritingStats(QDialog):
)
self.groupByDay.clicked.connect(self._updateListBox)
+ self.showIdleTime = QSwitch(width=2*sPx, height=sPx)
+ self.showIdleTime.setChecked(
+ self.optState.getBool("GuiWritingStats", "showIdleTime", False)
+ )
+ self.showIdleTime.clicked.connect(self._updateListBox)
+
self.filterForm.addWidget(QLabel("Count novel files"), 0, 0)
self.filterForm.addWidget(QLabel("Count note files"), 1, 0)
self.filterForm.addWidget(QLabel("Hide zero word count"), 2, 0)
self.filterForm.addWidget(QLabel("Hide negative word count"), 3, 0)
self.filterForm.addWidget(QLabel("Group entries by day"), 4, 0)
+ self.filterForm.addWidget(QLabel("Show idle time"), 5, 0)
self.filterForm.addWidget(self.incNovel, 0, 1)
self.filterForm.addWidget(self.incNotes, 1, 1)
self.filterForm.addWidget(self.hideZeros, 2, 1)
self.filterForm.addWidget(self.hideNegative, 3, 1)
self.filterForm.addWidget(self.groupByDay, 4, 1)
- self.filterForm.setRowStretch(5, 1)
+ self.filterForm.addWidget(self.showIdleTime, 5, 1)
+ self.filterForm.setRowStretch(6, 1)
# Settings
self.histMax = QSpinBox(self)
@@ -278,6 +298,7 @@ class GuiWritingStats(QDialog):
widthCol0 = self.mainConf.rpxInt(self.listBox.columnWidth(0))
widthCol1 = self.mainConf.rpxInt(self.listBox.columnWidth(1))
widthCol2 = self.mainConf.rpxInt(self.listBox.columnWidth(2))
+ widthCol3 = self.mainConf.rpxInt(self.listBox.columnWidth(3))
sortCol = self.listBox.sortColumn()
sortOrder = self.listBox.header().sortIndicatorOrder()
incNovel = self.incNovel.isChecked()
@@ -285,6 +306,7 @@ class GuiWritingStats(QDialog):
hideZeros = self.hideZeros.isChecked()
hideNegative = self.hideNegative.isChecked()
groupByDay = self.groupByDay.isChecked()
+ showIdleTime = self.showIdleTime.isChecked()
histMax = self.histMax.value()
self.optState.setValue("GuiWritingStats", "winWidth", winWidth)
@@ -292,6 +314,7 @@ class GuiWritingStats(QDialog):
self.optState.setValue("GuiWritingStats", "widthCol0", widthCol0)
self.optState.setValue("GuiWritingStats", "widthCol1", widthCol1)
self.optState.setValue("GuiWritingStats", "widthCol2", widthCol2)
+ self.optState.setValue("GuiWritingStats", "widthCol3", widthCol3)
self.optState.setValue("GuiWritingStats", "sortCol", sortCol)
self.optState.setValue("GuiWritingStats", "sortOrder", sortOrder)
self.optState.setValue("GuiWritingStats", "incNovel", incNovel)
@@ -299,6 +322,7 @@ class GuiWritingStats(QDialog):
self.optState.setValue("GuiWritingStats", "hideZeros", hideZeros)
self.optState.setValue("GuiWritingStats", "hideNegative", hideNegative)
self.optState.setValue("GuiWritingStats", "groupByDay", groupByDay)
+ self.optState.setValue("GuiWritingStats", "showIdleTime", showIdleTime)
self.optState.setValue("GuiWritingStats", "histMax", histMax)
self.optState.saveSettings()
@@ -347,23 +371,25 @@ class GuiWritingStats(QDialog):
with open(savePath, mode="w", encoding="utf8") as outFile:
if dataFmt == self.FMT_JSON:
jsonData = []
- for _, sD, tT, wD, wA, wB in self.filterData:
+ for _, sD, tT, wD, wA, wB, tI in self.filterData:
jsonData.append({
"date": sD,
"length": tT,
"newWords": wD,
"novelWords": wA,
"noteWords": wB,
+ "idleTime": tI,
})
json.dump(jsonData, outFile, indent=2)
wSuccess = True
if dataFmt == self.FMT_CSV:
outFile.write(
- '"Date","Length (sec)","Words Changed","Novel Words","Note Words"\n'
+ '"Date","Length (sec)","Words Changed",'
+ '"Novel Words","Note Words","Idle Time (sec)"\n'
)
- for _, sD, tT, wD, wA, wB in self.filterData:
- outFile.write(f'"{sD}",{tT:.0f},{wD},{wA},{wB}\n')
+ for _, sD, tT, wD, wA, wB, tI in self.filterData:
+ outFile.write(f'"{sD}",{tT:.0f},{wD},{wA},{wB},{tI}\n')
wSuccess = True
except Exception as e:
@@ -401,6 +427,7 @@ class GuiWritingStats(QDialog):
ttNovel = 0
ttNotes = 0
ttTime = 0
+ ttIdle = 0
logFile = os.path.join(self.theProject.projMeta, nwFiles.SESS_STATS)
if not os.path.isfile(logFile):
@@ -419,7 +446,7 @@ class GuiWritingStats(QDialog):
continue
inData = inLine.split()
- if len(inData) != 6:
+ if len(inData) < 6:
continue
dStart = datetime.strptime(
@@ -429,16 +456,21 @@ class GuiWritingStats(QDialog):
"%s %s" % (inData[2], inData[3]), nwConst.FMT_TSTAMP
)
+ sIdle = 0
+ if len(inData) > 6:
+ sIdle = checkInt(inData[6], 0)
+
tDiff = dEnd - dStart
sDiff = tDiff.total_seconds()
ttTime += sDiff
+ ttIdle += sIdle
wcNovel = int(inData[4])
wcNotes = int(inData[5])
ttNovel = wcNovel
ttNotes = wcNotes
- self.logData.append((dStart, sDiff, wcNovel, wcNotes))
+ self.logData.append((dStart, sDiff, wcNovel, wcNotes, sIdle))
except Exception as e:
self.theParent.makeAlert(
@@ -448,12 +480,17 @@ class GuiWritingStats(QDialog):
ttWords = ttNovel + ttNotes
self.labelTotal.setText(formatTime(round(ttTime)))
+ self.labelIdleT.setText(formatTime(round(ttIdle)))
self.novelWords.setText(f"{ttNovel:n}")
self.notesWords.setText(f"{ttNotes:n}")
self.totalWords.setText(f"{ttWords:n}")
return True
+ ##
+ # Slots
+ ##
+
def _updateListBox(self, dummyVar=None):
"""Load/reload the content of the list box. The dummyVar
variable captures the variable sent from the widgets connecting
@@ -474,25 +511,28 @@ class GuiWritingStats(QDialog):
tempData = []
sessDate = None
sessTime = 0
+ sessIdle = 0
lstNovel = 0
lstNotes = 0
- for n, (dStart, sDiff, wcNovel, wcNotes) in enumerate(self.logData):
+ for n, (dStart, sDiff, wcNovel, wcNotes, sIdle) in enumerate(self.logData):
if n == 0:
sessDate = dStart.date()
if sessDate != dStart.date():
- tempData.append((sessDate, sessTime, lstNovel, lstNotes))
+ tempData.append((sessDate, sessTime, lstNovel, lstNotes, sessIdle))
sessDate = dStart.date()
sessTime = sDiff
+ sessIdle = sIdle
lstNovel = wcNovel
lstNotes = wcNotes
else:
sessTime += sDiff
+ sessIdle += sIdle
lstNovel = wcNovel
lstNotes = wcNotes
if sessDate is not None:
- tempData.append((sessDate, sessTime, lstNovel, lstNotes))
+ tempData.append((sessDate, sessTime, lstNovel, lstNotes, sessIdle))
else:
tempData = self.logData
@@ -502,7 +542,7 @@ class GuiWritingStats(QDialog):
pcTotal = 0
listMax = 0
isFirst = True
- for dStart, sDiff, wcNovel, wcNotes in tempData:
+ for dStart, sDiff, wcNovel, wcNotes, sIdle in tempData:
wcTotal = 0
if incNovel:
@@ -528,16 +568,24 @@ class GuiWritingStats(QDialog):
else:
sStart = dStart.strftime(nwConst.FMT_TSTAMP)
- self.filterData.append((dStart, sStart, sDiff, dwTotal, wcNovel, wcNotes))
+ self.filterData.append((dStart, sStart, sDiff, dwTotal, wcNovel, wcNotes, sIdle))
listMax = min(max(listMax, dwTotal), histMax)
pcTotal = wcTotal
# Populate the list
- for _, sStart, sDiff, nWords, _, _ in self.filterData:
+ showIdleTime = self.showIdleTime.isChecked()
+ for _, sStart, sDiff, nWords, _, _, sIdle in self.filterData:
+
+ if showIdleTime:
+ idleEntry = formatTime(sIdle)
+ else:
+ sRatio = sIdle/sDiff if sDiff > 0.0 else 0.0
+ idleEntry = "%d %%" % round(100.0 * sRatio)
newItem = QTreeWidgetItem()
newItem.setText(self.C_TIME, sStart)
newItem.setText(self.C_LENGTH, formatTime(round(sDiff)))
+ newItem.setText(self.C_IDLE, idleEntry)
newItem.setText(self.C_COUNT, f"{nWords:n}")
if nWords > 0 and listMax > 0:
@@ -550,12 +598,17 @@ class GuiWritingStats(QDialog):
newItem.setData(self.C_BAR, Qt.DecorationRole, theBar)
newItem.setTextAlignment(self.C_LENGTH, Qt.AlignRight)
+ newItem.setTextAlignment(self.C_IDLE, Qt.AlignRight)
newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight)
newItem.setTextAlignment(self.C_BAR, Qt.AlignLeft | Qt.AlignVCenter)
newItem.setFont(self.C_TIME, self.theTheme.guiFontFixed)
newItem.setFont(self.C_LENGTH, self.theTheme.guiFontFixed)
newItem.setFont(self.C_COUNT, self.theTheme.guiFontFixed)
+ if showIdleTime:
+ newItem.setFont(self.C_IDLE, self.theTheme.guiFontFixed)
+ else:
+ newItem.setFont(self.C_IDLE, self.theTheme.guiFont)
self.listBox.addTopLevelItem(newItem)
self.timeFilter += sDiff
diff --git a/nw/guimain.py b/nw/guimain.py
index 3500f87a..6cb68cb3 100644
--- a/nw/guimain.py
+++ b/nw/guimain.py
@@ -43,7 +43,7 @@ from nw.gui import (
GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiItemEditor,
GuiMainMenu, GuiMainStatus, GuiNovelTree, GuiOutline, GuiOutlineDetails,
GuiPreferences, GuiProjectDetails, GuiProjectLoad, GuiProjectSettings,
- GuiProjectTree, GuiProjectWizard, GuiTheme, GuiWritingStats
+ GuiProjectTree, GuiProjectWizard, GuiTheme, GuiWordList, GuiWritingStats
)
from nw.core import NWProject, NWDoc, NWIndex
from nw.constants import nwItemType, nwItemClass, nwAlert, nwLists
@@ -86,6 +86,8 @@ class GuiMain(QMainWindow):
self.theIndex = NWIndex(self.theProject, self)
self.hasProject = False
self.isFocusMode = False
+ self.idleRefTime = time()
+ self.idleTime = 0.0
# Prepare Main Window
self.resize(*self.mainConf.getWinSize())
@@ -241,6 +243,12 @@ class GuiMain(QMainWindow):
self.asDocTimer = QTimer()
self.asDocTimer.timeout.connect(self._autoSaveDocument)
+ # Main Clock
+ self.mainTimer = QTimer()
+ self.mainTimer.setInterval(1000)
+ self.mainTimer.timeout.connect(self._timeTick)
+ self.mainTimer.start()
+
# Shortcuts and Actions
self._connectMenuActions()
@@ -412,7 +420,11 @@ class GuiMain(QMainWindow):
self.closeDocument()
self.docViewer.clearNavHistory()
self.projView.closeOutline()
- self.theProject.closeProject()
+
+ self.theProject.closeProject(self.idleTime)
+ self.idleRefTime = time()
+ self.idleTime = 0.0
+
self.theIndex.clearIndex()
self.clearGUI()
self.hasProject = False
@@ -477,7 +489,9 @@ class GuiMain(QMainWindow):
return False
# Project is loaded
- self.hasProject = True
+ self.hasProject = True
+ self.idleRefTime = time()
+ self.idleTime = 0.0
# Load the tag index
self.theIndex.loadIndex()
@@ -786,7 +800,11 @@ class GuiMain(QMainWindow):
return False
if tHandle is None:
- tHandle = self.treeView.getSelectedHandle()
+ if self.treeView.hasFocus():
+ tHandle = self.treeView.getSelectedHandle()
+ elif self.docEditor.hasFocus():
+ tHandle = self.docEditor.theHandle
+
if tHandle is None:
logger.warning("No item selected")
return
@@ -999,6 +1017,22 @@ class GuiMain(QMainWindow):
return
+ def showProjectWordListDialog(self):
+ """Open the project word list dialog.
+ """
+ if not self.hasProject:
+ logger.error("No project open")
+ return
+
+ dlgWords = GuiWordList(self, self.theProject)
+ dlgWords.exec_()
+
+ if dlgWords.result() == QDialog.Accepted:
+ logger.debug("Reloading word list")
+ self.docEditor.setDictionaries()
+
+ return
+
def showWritingStatsDialog(self):
"""Open the session log dialog.
"""
@@ -1175,11 +1209,11 @@ class GuiMain(QMainWindow):
"""
if self.docEditor.theHandle is None:
logger.error("No document open, so not activating Focus Mode")
- self.mainMenu.aFocusMode.setChecked(self.isFocusMode)
+ self.mainMenu.setFocusMode(self.isFocusMode)
return False
self.isFocusMode = not self.isFocusMode
- self.mainMenu.aFocusMode.setChecked(self.isFocusMode)
+ self.mainMenu.setFocusMode(self.isFocusMode)
if self.isFocusMode:
logger.debug("Activating Focus Mode")
self.mainTabs.setCurrentWidget(self.splitDocs)
@@ -1414,6 +1448,28 @@ class GuiMain(QMainWindow):
# Slots
##
+ @pyqtSlot()
+ def _timeTick(self):
+ """Triggered on every tick of the timer.
+ """
+ if not self.hasProject:
+ return
+
+ currTime = time()
+ editIdle = currTime - self.docEditor.lastActive > self.mainConf.userIdleTime
+ userIdle = qApp.applicationState() != Qt.ApplicationActive
+
+ if editIdle or userIdle:
+ self.idleTime += currTime - self.idleRefTime
+ self.statusBar.setUserIdle(True)
+ else:
+ self.statusBar.setUserIdle(False)
+
+ self.idleRefTime = currTime
+ self.statusBar.updateTime(idleTime=self.idleTime)
+
+ return
+
@pyqtSlot()
def _treeSingleClick(self):
"""Single click on a project tree item just updates the details
diff --git a/sample/ToC.txt b/sample/ToC.txt
deleted file mode 100644
index b106e892..00000000
--- a/sample/ToC.txt
+++ /dev/null
@@ -1,23 +0,0 @@
-
-Table of Contents
-=================
-
-File Name Class Layout Document Label
----------------------------------------------------------------------
-content/53b69b83cdafc.nwd NOVEL TITLE Title Page
-content/974e400180a99.nwd NOVEL PAGE Page
-content/edca4be2fcaf8.nwd NOVEL PARTITION Part One
-content/6a2d6d5f4f401.nwd NOVEL CHAPTER Chapter One
-content/636b6aa9b697b.nwd NOVEL SCENE Making a Scene
-content/bc0cbd2a407f3.nwd NOVEL SCENE Another Scene
-content/ba8a28a246524.nwd NOVEL UNNUMBERED Interlude
-content/96b68994dfa3d.nwd NOVEL NOTE A Note on Structure
-content/88706ddc78b1b.nwd NOVEL CHAPTER Chapter Two
-content/ae7339df26ded.nwd NOVEL SCENE We Found John!
-content/14298de4d9524.nwd CHARACTER NOTE John Smith
-content/bb2c23b3c42cc.nwd CHARACTER NOTE Jane Smith
-content/b3e74dbc1f584.nwd WORLD NOTE Earth
-content/f1471bef9f2ae.nwd WORLD NOTE Space
-content/5eaea4e8cdee8.nwd WORLD NOTE Mars
-content/8a5deb88c0e97.nwd NOVEL SCENE Old File
-content/b8136a5a774a0.nwd NOVEL SCENE Delete Me!
diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd
index 6a1302ef..be611dce 100644
--- a/sample/content/636b6aa9b697b.nwd
+++ b/sample/content/636b6aa9b697b.nwd
@@ -9,7 +9,7 @@
A scene is defined by a level three heading, like the one at the top of this page. The scene will be assigned to the chapter preceding it in the project tree.
-Each paragraph in the scene is separated by a blank line. The text supports minimal formatting, like **bold**, _italic_ and **_bold italic_**. You can also ~~strike through~~ text. There is **some support for _nested_ emphasis**, but it isn’t fully Markdown compliant. If the syntax highlighter doesn’t show it correctly, the export tool will not either.
+Each paragraph in the scene is separated by a blank line. The text supports minimal formatting, like **bold**, _italic_ and **_bold italic_**. You can also ~~strike through~~ text. There is **some support for _nested_ emphasis**, but it isn’t fully Markdown compliant. If the syntax highlighter doesn’t show it correctly, the export tool will not either.
In addition, the editor supports automatic formatting of “quotes”, both double and ‘single’. Depending on the syntax highlighter, these can be in different colours. “You can of course use **bold** and _italic_ text inside of quotes too.”
diff --git a/sample/content/bc0cbd2a407f3.nwd b/sample/content/bc0cbd2a407f3.nwd
index 17524248..80f75eef 100644
--- a/sample/content/bc0cbd2a407f3.nwd
+++ b/sample/content/bc0cbd2a407f3.nwd
@@ -4,6 +4,7 @@
### Another Scene
@pov: John
+@focus: Jane
@location: Earth
Adding more scenes to a chapter is as easy as adding more scene files, with a level three heading, or just adding another level three heading in the same file if that works for the way you want to structure your files.
@@ -13,6 +14,7 @@ In fact, if you wish, you can add all the scenes in the chapter file too. All no
### More Scenes
@pov: Jane
+@focus: John
@location: Earth
This is a second scene in the same file as the previous scene. You can always split the files up later.
diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx
index ea219af2..e4c5ca14 100644
--- a/sample/nwProject.nwx
+++ b/sample/nwProject.nwx
@@ -1,13 +1,13 @@
-
+Sample ProjectSample ProjectJane SmithJay Doh
- 881
- 158
- 43034
+ 937
+ 161
+ 46797False
@@ -117,10 +117,10 @@
1st DraftTrueSCENE
- 1811
+ 18103188
- 1880
+ 3Another Scene
@@ -132,7 +132,7 @@
476933
- 428
+ 430Interlude
diff --git a/tests/lipsum/ToC.txt b/tests/lipsum/ToC.txt
deleted file mode 100644
index a499c6f8..00000000
--- a/tests/lipsum/ToC.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-
- Table of Contents
-===================
-
- File Name Class Document Label
---------------------------------------------------------------------------------
- content/04468803b92e1.nwd WORLD Ancient Europe
- content/2426c6f0ca922.nwd PLOT Main
- content/441420a886d82.nwd NOVEL Chapter Two
- content/47666c91c7ccf.nwd NOVEL Scene Five
- content/4c4f28287af27.nwd CHARACTER Mr. Nobody
- content/7a992350f3eb6.nwd NOVEL Lorem Ipsum
- content/846352075de7d.nwd NOVEL Interlude
- content/88243afbe5ed8.nwd NOVEL Scene One
- content/88d59a277361b.nwd NOVEL Prologue
- content/8c58a65414c23.nwd NOVEL Front Matter
- content/db7e733775d4d.nwd NOVEL Act One
- content/eb103bc70c90c.nwd NOVEL Scene Three
- content/f8c0562e50f1b.nwd NOVEL Scene Four
- content/f96ec11c6a3da.nwd NOVEL Scene Two
- content/fb609cd8319dc.nwd NOVEL Chapter One
-
diff --git a/tests/minimal/ToC.txt b/tests/minimal/ToC.txt
deleted file mode 100644
index f5b94b72..00000000
--- a/tests/minimal/ToC.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-
- Table of Contents
-===================
-
- File Name Class Document Label
---------------------------------------------------------------------------------
- content/8c659a11cd429.nwd NOVEL New Scene
- content/a35baf2e93843.nwd NOVEL Title Page
- content/f5ab3e30151e1.nwd NOVEL New Chapter
-
diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf
index 588d5263..1ab78ea7 100644
--- a/tests/reference/baseConfig_novelwriter.conf
+++ b/tests/reference/baseConfig_novelwriter.conf
@@ -1,12 +1,12 @@
[Main]
-timestamp = 2020-10-11 22:50:45
+timestamp = 2021-02-09 21:07:03
theme = default
syntax = default_light
icons = typicons_colour_light
guidark = False
guifont =
guifontsize = 11
-lastnotes = 1.0
+lastnotes = 0x0
[Sizes]
geometry = 1200, 650
@@ -56,6 +56,8 @@ highlightquotes = True
allowopensquote = False
allowopendquote = True
highlightemph = True
+stopwhenidle = True
+useridletime = 300
[Backup]
backuppath =
diff --git a/tests/reference/guiBuild_Tool_Step1G_Lorem_Ipsum.md b/tests/reference/guiBuild_Tool_Step1G_Lorem_Ipsum.md
new file mode 100644
index 00000000..8a24d29f
--- /dev/null
+++ b/tests/reference/guiBuild_Tool_Step1G_Lorem_Ipsum.md
@@ -0,0 +1,88 @@
+# Lorem Ipsum
+
+**By lipsum.com**
+
+“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…”
+
+“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…”
+
+Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32.
+
+The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.
+
+## Prologue
+
+_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
+
+# Act One
+
+“Fusce maximus felis libero”
+
+## Chapter 1: Chapter One
+
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar.
+
+* * *
+
+Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada.
+
+Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex.
+
+Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst.
+
+Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend.
+
+* * *
+
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien.
+
+Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis.
+
+Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi.
+
+Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu.
+
+Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor.
+
+Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus.
+
+## Chapter 2: Chapter Two
+
+Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at.
+
+* * *
+
+Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis.
+
+Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis.
+
+Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus.
+
+Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac.
+
+* * *
+
+Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien.
+
+Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum.
+
+Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit.
+
+Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien.
+
+Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui.
+
+Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero.
+
+* * *
+
+Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet.
+
+In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere.
+
+Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis.
+
+Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue.
+
+Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo.
+
diff --git a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.htm b/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.htm
index a3e4432f..5fa77a9b 100644
--- a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.htm
+++ b/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.htm
@@ -5,26 +5,27 @@
Lorem Ipsum
-
Lorem Ipsum
+
Lorem Ipsum
By lipsum.com
-
“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…”
-
“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…”
+
“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…”
+
“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…”
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32.
The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.
Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis.
Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue.
Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo.