Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add footnotes #1832

Merged
merged 40 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
08b17da
Add parsing for terms in comments
vkbo Apr 7, 2024
dff2d59
Add new index classes to store special text comments
vkbo Apr 7, 2024
ba42285
Remove summaries from text index
vkbo Apr 7, 2024
5568b43
Add key generator to text index
vkbo Apr 14, 2024
da102d3
Merge branch 'main' into features/footnotes
vkbo Apr 14, 2024
70033cb
Add insert footnote feature in editor
vkbo Apr 14, 2024
ec6a571
Update tests
vkbo Apr 14, 2024
9823677
Add text marker processing in tokenizer class
vkbo Apr 14, 2024
7c4d4de
Use format list also for marker keys
vkbo Apr 14, 2024
8d24243
Complete footnotes processing in tokenizer
vkbo Apr 15, 2024
44192c8
Add footnote handling in html and markdown output
vkbo Apr 15, 2024
9288833
Add footnotes to output translation file
vkbo Apr 15, 2024
c65dee1
Skip footnote section if there are none
vkbo Apr 15, 2024
0729dd7
Add formatted comments for ODT
vkbo Apr 15, 2024
26ac68e
Block footnotes in footnotes, and only output referenced footnotes
vkbo Apr 15, 2024
17960d2
Add a working ODT footnotes implementation
vkbo Apr 15, 2024
988d969
Drop the non-compliant print-orientation attribute from the ODT class
vkbo Apr 16, 2024
57f9a0a
Refactor and improve highlighter class
vkbo Apr 16, 2024
bdce0e1
Simplify footnote handling in writer classes
vkbo Apr 16, 2024
ae893be
Merge branch 'main' into features/footnotes
vkbo Apr 17, 2024
a2d8aef
Reduce footnote index storage to keys only
vkbo Apr 18, 2024
48e653a
Clean up footnote key generation
vkbo Apr 18, 2024
0d294f4
Merge branch 'main' into features/footnotes
vkbo Apr 21, 2024
b5e3284
Merge branch 'main' into features/footnotes
vkbo Apr 24, 2024
b278d64
Fix uninitialised shared class in tests
vkbo Apr 24, 2024
dc41f49
Fix ODT tests
vkbo Apr 24, 2024
96741fc
Fix remaining broken tests
vkbo Apr 25, 2024
e5ef27d
Revert change in test
vkbo Apr 25, 2024
52e8ae9
Merge branch 'main' into features/footnotes
vkbo Apr 25, 2024
b585b8e
Make the footnote registry per-file
vkbo Apr 26, 2024
b722224
Improve comment processing
vkbo Apr 26, 2024
4150691
Fix regex flag in highlighter
vkbo Apr 26, 2024
4572d10
Change to run MacOS test on 13
vkbo Apr 26, 2024
db9305c
Add a footnote to the Lorem Ipsum test project
vkbo Apr 27, 2024
e9596e1
Fix footnote formatting for ODT and update document builder tests
vkbo Apr 27, 2024
e01e140
Update editor and highlight, and update tests
vkbo Apr 28, 2024
864172e
Add an elide function for shortening text
vkbo Apr 28, 2024
205b17a
Fix deprecation warning in xml module
vkbo Apr 28, 2024
8a08fe2
Fix more deprecation warnings in xml module
vkbo Apr 28, 2024
6be7da0
Clean up a few minor bits in the index tests
vkbo Apr 28, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test_mac.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ on:

jobs:
testMac:
runs-on: macos-latest
runs-on: macos-13
steps:
- name: Python Setup
uses: actions/setup-python@v5
Expand Down
1 change: 1 addition & 0 deletions novelwriter/assets/i18n/project_en_GB.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"Synopsis": "Synopsis",
"Short Description": "Short Description",
"Footnotes": "Footnotes",
"Comment": "Comment",
"Notes": "Notes",
"Tag": "Tag",
Expand Down
28 changes: 21 additions & 7 deletions novelwriter/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,32 @@
from __future__ import annotations

import json
import uuid
import logging
import unicodedata
import uuid
import xml.etree.ElementTree as ET

from typing import TYPE_CHECKING, Any, Literal
from pathlib import Path
from datetime import datetime
from configparser import ConfigParser
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, TypeVar
from urllib.parse import urljoin
from urllib.request import pathname2url

from PyQt5.QtGui import QColor, QDesktopServices
from PyQt5.QtCore import QCoreApplication, QUrl
from PyQt5.QtGui import QColor, QDesktopServices

from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout
from novelwriter.error import logException
from novelwriter.constants import nwConst, nwLabels, nwUnicode, trConst
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException

if TYPE_CHECKING: # pragma: no cover
from typing import TypeGuard # Requires Python 3.10

logger = logging.getLogger(__name__)

_Type = TypeVar("_Type")


##
# Checker Functions
Expand Down Expand Up @@ -172,6 +174,11 @@ def isItemLayout(value: Any) -> TypeGuard[str]:
return isinstance(value, str) and value in nwItemLayout.__members__


def isListInstance(data: Any, check: type[_Type]) -> TypeGuard[list[_Type]]:
"""Check that all items of a list is of a given type."""
return isinstance(data, list) and all(isinstance(item, check) for item in data)


def hexToInt(value: Any, default: int = 0) -> int:
"""Convert a hex string to an integer."""
if isinstance(value, str):
Expand Down Expand Up @@ -272,6 +279,13 @@ def simplified(text: str) -> str:
return " ".join(str(text).strip().split())


def elide(text: str, length: int) -> str:
"""Elide a piece of text to a maximum length."""
if len(text) > (cut := max(4, length)):
return f"{text[:cut-4].rstrip()} ..."
return text


def yesNo(value: int | bool | None) -> Literal["yes", "no"]:
"""Convert a boolean evaluated variable to a yes or no."""
return "yes" if value else "no"
Expand Down
15 changes: 12 additions & 3 deletions novelwriter/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
"""
from __future__ import annotations

from PyQt5.QtCore import QCoreApplication, QT_TRANSLATE_NOOP
from PyQt5.QtCore import QT_TRANSLATE_NOOP, QCoreApplication

from novelwriter.enum import nwBuildFmt, nwItemClass, nwItemLayout, nwOutline, nwStatusShape
from novelwriter.enum import (
nwBuildFmt, nwComment, nwItemClass, nwItemLayout, nwOutline, nwStatusShape
)


def trConst(text: str) -> str:
Expand Down Expand Up @@ -67,7 +69,7 @@ class nwRegEx:
FMT_EB = r"(?<![\w\\])([\*]{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_ST = r"(?<![\w\\])([~]{2})(?![\s~])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:i|b|s|u|m|sup|sub)\])"
FMT_SV = r"(?<!\\)(\[(?i)(?:fn|footnote):)(.+?)(?<!\\)(\])"
FMT_SV = r"(?<!\\)(\[(?i)(?:footnote):)(.+?)(?<!\\)(\])"

# END Class nwRegEx

Expand All @@ -89,6 +91,13 @@ class nwShortcode:
SUB_O = "[sub]"
SUB_C = "[/sub]"

FOOTNOTE_B = "[footnote:"

COMMENT_STYLES = {
nwComment.FOOTNOTE: "[footnote:{0}]",
nwComment.COMMENT: "[comment:{0}]",
}

# END Class nwShortcode


Expand Down
4 changes: 4 additions & 0 deletions novelwriter/core/docbuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ def iterBuildHTML(self, path: Path | None, asJson: bool = False) -> Iterable[tup
else:
yield i, False

makeObj.appendFootnotes()

if not (self._build.getBool("html.preserveTabs") or self._preview):
makeObj.replaceTabs()

Expand Down Expand Up @@ -231,6 +233,8 @@ def iterBuildMarkdown(self, path: Path, extendedMd: bool) -> Iterable[tuple[int,
else:
yield i, False

makeObj.appendFootnotes()

self._error = None
self._cache = makeObj

Expand Down
135 changes: 109 additions & 26 deletions novelwriter/core/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,20 @@

import json
import logging
import random

from time import time
from typing import TYPE_CHECKING
from pathlib import Path
from collections.abc import ItemsView, Iterable
from pathlib import Path
from time import time
from typing import TYPE_CHECKING, Literal

from novelwriter import SHARED
from novelwriter.enum import nwComment, nwItemClass, nwItemType, nwItemLayout
from novelwriter.common import (
checkInt, isHandle, isItemClass, isListInstance, isTitleTag, jsonEncode
)
from novelwriter.constants import nwFiles, nwHeaders, nwKeyWords
from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
from novelwriter.common import checkInt, isHandle, isItemClass, isTitleTag, jsonEncode
from novelwriter.constants import nwFiles, nwKeyWords, nwHeaders
from novelwriter.text.counting import standardCounter

if TYPE_CHECKING: # pragma: no cover
Expand All @@ -48,7 +51,12 @@

logger = logging.getLogger(__name__)

TT_NONE = "T0000"
T_NoteTypes = Literal["footnotes", "comments"]

TT_NONE = "T0000" # Default title key
MAX_RETRY = 1000 # Key generator recursion limit
KEY_SOURCE = "0123456789bcdfghjklmnpqrstvwxz"
NOTE_TYPES: list[T_NoteTypes] = ["footnotes", "comments"]


class NWIndex:
Expand Down Expand Up @@ -301,9 +309,9 @@ def scanText(self, tHandle: str, text: str, blockSignal: bool = False) -> bool:

def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict[str, bool]) -> None:
"""Scan an active document for meta data."""
nTitle = 0 # Line Number of the previous title
cTitle = TT_NONE # Tag of the current title
pTitle = TT_NONE # Tag of the previous title
nTitle = 0 # Line Number of the previous title
cTitle = TT_NONE # Tag of the current title
pTitle = TT_NONE # Tag of the previous title
canSetHead = True # First heading has not yet been set

lines = text.splitlines()
Expand Down Expand Up @@ -335,10 +343,11 @@ def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict[str, b
self._indexKeyword(tHandle, line, cTitle, nwItem.itemClass, tags)

elif line.startswith("%"):
if cTitle != TT_NONE:
cStyle, cText, _ = processComment(line)
if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
cStyle, cKey, cText, _, _ = processComment(line)
if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
elif cStyle == nwComment.FOOTNOTE:
self._itemIndex.addNoteKey(tHandle, "footnotes", cKey)

# Count words for remaining text after last heading
if pTitle != TT_NONE:
Expand Down Expand Up @@ -506,6 +515,14 @@ def parseValue(self, text: str) -> tuple[str, str]:
name, _, display = text.partition("|")
return name.rstrip(), display.lstrip()

def newCommentKey(self, tHandle: str, style: nwComment) -> str:
"""Generate a new key for a comment style."""
if style == nwComment.FOOTNOTE:
return self._itemIndex.genNewNoteKey(tHandle, "footnotes")
elif style == nwComment.COMMENT:
return self._itemIndex.genNewNoteKey(tHandle, "comments")
return "err"

##
# Extract Data
##
Expand Down Expand Up @@ -790,7 +807,7 @@ def unpackData(self, data: dict) -> None:

for key, entry in data.items():
if not isinstance(key, str):
raise ValueError("tagsIndex keys must be a string")
raise ValueError("tagsIndex key must be a string")
if not isinstance(entry, dict):
raise ValueError("tagsIndex entry is not a dict")

Expand Down Expand Up @@ -950,6 +967,25 @@ def addHeadingRef(self, tHandle: str, sTitle: str, tagKeys: list[str], refType:
self._items[tHandle].addHeadingRef(sTitle, tagKeys, refType)
return

def addNoteKey(self, tHandle: str, style: T_NoteTypes, key: str) -> None:
"""Set notes key for a given item."""
if tHandle in self._items:
self._items[tHandle].addNoteKey(style, key)
return

def genNewNoteKey(self, tHandle: str, style: T_NoteTypes) -> str:
"""Set notes key for a given item."""
if style in NOTE_TYPES and (item := self._items.get(tHandle)):
keys = set()
for entry in self._items.values():
keys.update(entry.noteKeys(style))
for _ in range(MAX_RETRY):
key = style[:1] + "".join(random.choices(KEY_SOURCE, k=4))
if key not in keys:
item.addNoteKey(style, key)
return key
return "err"

##
# Pack/Unpack
##
Expand Down Expand Up @@ -991,12 +1027,13 @@ class IndexItem:
must be reset each time the item is re-indexed.
"""

__slots__ = ("_handle", "_item", "_headings", "_count")
__slots__ = ("_handle", "_item", "_headings", "_count", "_notes")

def __init__(self, tHandle: str, nwItem: NWItem) -> None:
self._handle = tHandle
self._item = nwItem
self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(TT_NONE)}
self._notes: dict[str, set[str]] = {}
self._count = 0
return

Expand Down Expand Up @@ -1064,6 +1101,13 @@ def addHeadingRef(self, sTitle: str, tagKeys: list[str], refType: str) -> None:
self._headings[sTitle].addReference(tagKey, refType)
return

def addNoteKey(self, style: T_NoteTypes, key: str) -> None:
"""Add a note key to the index."""
if style not in self._notes:
self._notes[style] = set()
self._notes[style].add(key)
return

##
# Data Methods
##
Expand All @@ -1085,6 +1129,10 @@ def nextHeading(self) -> str:
self._count += 1
return f"T{self._count:04d}"

def noteKeys(self, style: T_NoteTypes) -> set[str]:
"""Return a set of all note keys."""
return self._notes.get(style, set())

##
# Pack/Unpack
##
Expand All @@ -1103,6 +1151,8 @@ def packData(self) -> dict:
data["headings"] = heads
if refs:
data["references"] = refs
if self._notes:
data["notes"] = {style: list(keys) for style, keys in self._notes.items()}

return data

Expand All @@ -1116,6 +1166,14 @@ def unpackData(self, data: dict) -> None:
tHeading.unpackData(hData)
tHeading.unpackReferences(references.get(sTitle, {}))
self.addHeading(tHeading)

for style, keys in data.get("notes", {}).items():
if style not in NOTE_TYPES:
raise ValueError("The notes style is invalid")
if not isListInstance(keys, str):
raise ValueError("The notes keys must be a list of strings")
self._notes[style] = set(keys)

return

# END Class IndexItem
Expand Down Expand Up @@ -1302,18 +1360,43 @@ def unpackReferences(self, data: dict) -> None:
# Text Processing Functions
# =============================================================================================== #

CLASSIFIERS = {
"short": nwComment.SHORT,
MODIFIERS = {
"synopsis": nwComment.SYNOPSIS,
"short": nwComment.SHORT,
"note": nwComment.NOTE,
"footnote": nwComment.FOOTNOTE,
}
KEY_REQ = {
"synopsis": 0, # Key not allowed
"short": 0, # Key not allowed
"note": 1, # Key optional
"footnote": 2, # Key required
}


def processComment(text: str) -> tuple[nwComment, str, int]:
"""Extract comment style and text. Should only be called on text
starting with a %.
def _checkModKey(modifier: str, key: str) -> bool:
"""Check if a modifier and key set are ok."""
if modifier in MODIFIERS:
if key == "":
return KEY_REQ[modifier] < 2
elif key.replace("_", "").isalnum():
return KEY_REQ[modifier] > 0
return False


def processComment(text: str) -> tuple[nwComment, str, str, int, int]:
"""Extract comment style, key and text. Should only be called on
text starting with a %.
"""
check = text[1:].lstrip()
classifier, _, content = check.partition(":")
if content and (clean := classifier.strip().lower()) in CLASSIFIERS:
return CLASSIFIERS[clean], content.strip(), text.find(":") + 1
return nwComment.PLAIN, check, 0
if text[:2] == "%~":
return nwComment.IGNORE, "", text[2:].lstrip(), 0, 0

check = text[1:].strip()
start, _, content = check.partition(":")
modifier, _, key = start.rstrip().partition(".")
if content and (clean := modifier.lower()) and _checkModKey(clean, key):
col = text.find(":") + 1
dot = text.find(".", 0, col) + 1
return MODIFIERS[clean], key, content.lstrip(), dot, col

return nwComment.PLAIN, "", check, 0, 0
4 changes: 2 additions & 2 deletions novelwriter/core/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import random

from collections.abc import Iterable
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal

from PyQt5.QtCore import QPointF, Qt
from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor, QPolygonF
Expand Down Expand Up @@ -75,7 +75,7 @@ class NWStatus:

__slots__ = ("_store", "_default", "_prefix", "_height")

def __init__(self, prefix: str) -> None:
def __init__(self, prefix: Literal["s", "i"]) -> None:
self._store: dict[str, StatusEntry] = {}
self._default = None
self._prefix = prefix[:1]
Expand Down
Loading