extract-msg 0.48.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- extract_msg/__init__.py +71 -0
- extract_msg/__main__.py +115 -0
- extract_msg/_rtf/__init__.py +21 -0
- extract_msg/_rtf/create_doc.py +29 -0
- extract_msg/_rtf/inject_rtf.py +174 -0
- extract_msg/_rtf/token.py +35 -0
- extract_msg/_rtf/tokenize_rtf.py +253 -0
- extract_msg/attachments/__init__.py +144 -0
- extract_msg/attachments/attachment.py +188 -0
- extract_msg/attachments/attachment_base.py +730 -0
- extract_msg/attachments/broken_att.py +42 -0
- extract_msg/attachments/custom_att.py +173 -0
- extract_msg/attachments/custom_att_handler/__init__.py +80 -0
- extract_msg/attachments/custom_att_handler/custom_handler.py +134 -0
- extract_msg/attachments/custom_att_handler/lnk_obj_att.py +109 -0
- extract_msg/attachments/custom_att_handler/outlook_image_dib.py +141 -0
- extract_msg/attachments/emb_msg_att.py +134 -0
- extract_msg/attachments/signed_att.py +234 -0
- extract_msg/attachments/unsupported_att.py +40 -0
- extract_msg/attachments/web_att.py +74 -0
- extract_msg/constants/__init__.py +294 -0
- extract_msg/constants/ps.py +46 -0
- extract_msg/constants/re.py +42 -0
- extract_msg/constants/st.py +110 -0
- extract_msg/data/logging-config/logging-nt.json +64 -0
- extract_msg/data/logging-config/logging-posix.json +64 -0
- extract_msg/encoding/__init__.py +277 -0
- extract_msg/encoding/_dt/__init__.py +4 -0
- extract_msg/encoding/_dt/_mac_ce.py +8 -0
- extract_msg/encoding/_dt/_mac_cyrillic.py +8 -0
- extract_msg/encoding/_dt/_mac_greek.py +8 -0
- extract_msg/encoding/_dt/_mac_iceland.py +8 -0
- extract_msg/encoding/_dt/_mac_turkish.py +8 -0
- extract_msg/encoding/_dt/_win874_dec.py +8 -0
- extract_msg/encoding/_dt/_win950_dec.py +6 -0
- extract_msg/encoding/utils.py +329 -0
- extract_msg/enums.py +2067 -0
- extract_msg/exceptions.py +170 -0
- extract_msg/msg_classes/__init__.py +49 -0
- extract_msg/msg_classes/appointment.py +195 -0
- extract_msg/msg_classes/calendar.py +92 -0
- extract_msg/msg_classes/calendar_base.py +496 -0
- extract_msg/msg_classes/contact.py +1335 -0
- extract_msg/msg_classes/journal.py +131 -0
- extract_msg/msg_classes/meeting_cancellation.py +108 -0
- extract_msg/msg_classes/meeting_exception.py +55 -0
- extract_msg/msg_classes/meeting_forward.py +115 -0
- extract_msg/msg_classes/meeting_related.py +65 -0
- extract_msg/msg_classes/meeting_request.py +189 -0
- extract_msg/msg_classes/meeting_response.py +74 -0
- extract_msg/msg_classes/message.py +12 -0
- extract_msg/msg_classes/message_base.py +1382 -0
- extract_msg/msg_classes/message_signed.py +17 -0
- extract_msg/msg_classes/message_signed_base.py +168 -0
- extract_msg/msg_classes/msg.py +1172 -0
- extract_msg/msg_classes/post.py +53 -0
- extract_msg/msg_classes/sticky_note.py +67 -0
- extract_msg/msg_classes/task.py +363 -0
- extract_msg/msg_classes/task_request.py +86 -0
- extract_msg/null_date.py +34 -0
- extract_msg/ole_writer.py +1086 -0
- extract_msg/open_msg.py +204 -0
- extract_msg/properties/__init__.py +23 -0
- extract_msg/properties/named.py +481 -0
- extract_msg/properties/prop.py +506 -0
- extract_msg/properties/properties_store.py +476 -0
- extract_msg/py.typed +0 -0
- extract_msg/recipient.py +362 -0
- extract_msg/structures/__init__.py +34 -0
- extract_msg/structures/_helpers.py +345 -0
- extract_msg/structures/business_card.py +422 -0
- extract_msg/structures/cfoas.py +108 -0
- extract_msg/structures/contact_link_entry.py +51 -0
- extract_msg/structures/dev_mode_a.py +621 -0
- extract_msg/structures/dv_target_device.py +168 -0
- extract_msg/structures/entry_id.py +697 -0
- extract_msg/structures/misc_id.py +222 -0
- extract_msg/structures/mon_stream.py +55 -0
- extract_msg/structures/odt.py +74 -0
- extract_msg/structures/ole_pres.py +302 -0
- extract_msg/structures/ole_stream_struct.py +117 -0
- extract_msg/structures/recurrence_pattern.py +199 -0
- extract_msg/structures/report_tag.py +127 -0
- extract_msg/structures/system_time.py +158 -0
- extract_msg/structures/time_zone_definition.py +111 -0
- extract_msg/structures/time_zone_struct.py +102 -0
- extract_msg/structures/toc_entry.py +137 -0
- extract_msg/structures/tz_rule.py +148 -0
- extract_msg/utils.py +1270 -0
- extract_msg-0.48.2.dist-info/LICENSE.txt +674 -0
- extract_msg-0.48.2.dist-info/METADATA +321 -0
- extract_msg-0.48.2.dist-info/RECORD +95 -0
- extract_msg-0.48.2.dist-info/WHEEL +5 -0
- extract_msg-0.48.2.dist-info/entry_points.txt +2 -0
- extract_msg-0.48.2.dist-info/top_level.txt +1 -0
extract_msg/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: latin-1 -*-
|
|
3
|
+
# Date Format: YYYY-MM-DD
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
extract_msg:
|
|
7
|
+
Extracts emails and attachments saved in Microsoft Outlook's .msg files.
|
|
8
|
+
|
|
9
|
+
https://github.com/TeamMsgExtractor/msg-extractor
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# --- LICENSE.txt --------------------------------------------------------------
|
|
13
|
+
#
|
|
14
|
+
# Copyright 2013-2023 Matthew Walker and Destiny Peterson
|
|
15
|
+
#
|
|
16
|
+
# This program is free software: you can redistribute it and/or modify
|
|
17
|
+
# it under the terms of the GNU General Public License as published by
|
|
18
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
19
|
+
# (at your option) any later version.
|
|
20
|
+
#
|
|
21
|
+
# This program is distributed in the hope that it will be useful,
|
|
22
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
23
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
24
|
+
# GNU General Public License for more details.
|
|
25
|
+
#
|
|
26
|
+
# You should have received a copy of the GNU General Public License
|
|
27
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
28
|
+
|
|
29
|
+
__author__ = 'Destiny Peterson & Matthew Walker'
|
|
30
|
+
__date__ = '2024-03-09'
|
|
31
|
+
__version__ = '0.48.2'
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
# Modules:
|
|
35
|
+
'attachments',
|
|
36
|
+
'constants',
|
|
37
|
+
'enums',
|
|
38
|
+
'exceptions',
|
|
39
|
+
'msg_classes',
|
|
40
|
+
'null_date',
|
|
41
|
+
'properties',
|
|
42
|
+
'structures',
|
|
43
|
+
|
|
44
|
+
# Classes:
|
|
45
|
+
'Attachment',
|
|
46
|
+
'AttachmentBase',
|
|
47
|
+
'Message',
|
|
48
|
+
'MSGFile',
|
|
49
|
+
'Named',
|
|
50
|
+
'NamedProperties',
|
|
51
|
+
'OleWriter',
|
|
52
|
+
'PropertiesStore',
|
|
53
|
+
'Recipient',
|
|
54
|
+
'SignedAttachment',
|
|
55
|
+
|
|
56
|
+
# Functions:
|
|
57
|
+
'openMsg',
|
|
58
|
+
'openMsgBulk',
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Ensure these are imported before anything else.
|
|
63
|
+
from . import constants, enums, exceptions
|
|
64
|
+
|
|
65
|
+
from . import attachments, msg_classes, null_date, properties, structures
|
|
66
|
+
from .attachments import Attachment, AttachmentBase, SignedAttachment
|
|
67
|
+
from .msg_classes import Message, MSGFile
|
|
68
|
+
from .ole_writer import OleWriter
|
|
69
|
+
from .open_msg import openMsg, openMsgBulk
|
|
70
|
+
from .properties import Named, NamedProperties, PropertiesStore
|
|
71
|
+
from .recipient import Recipient
|
extract_msg/__main__.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'main',
|
|
3
|
+
]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import traceback
|
|
9
|
+
import zipfile
|
|
10
|
+
|
|
11
|
+
from extract_msg import __doc__, openMsg, utils
|
|
12
|
+
from extract_msg.enums import ErrorBehavior
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv : List[str]) -> None:
|
|
17
|
+
# Setup logging to stdout, indicate running from cli
|
|
18
|
+
CLI_LOGGING = 'extract_msg_cli'
|
|
19
|
+
args = utils.getCommandArgs(argv[1:])
|
|
20
|
+
|
|
21
|
+
# Determine where to save the files to.
|
|
22
|
+
currentDir = os.getcwd() # Store this in case the path changes.
|
|
23
|
+
if not args.zip:
|
|
24
|
+
if args.outPath:
|
|
25
|
+
if not os.path.exists(args.outPath):
|
|
26
|
+
os.makedirs(args.outPath)
|
|
27
|
+
out = args.outPath
|
|
28
|
+
else:
|
|
29
|
+
out = currentDir
|
|
30
|
+
else:
|
|
31
|
+
out = args.outPath if args.outPath else ''
|
|
32
|
+
|
|
33
|
+
if not args.dumpStdout:
|
|
34
|
+
utils.setupLogging(args.configPath, args.logLevel, args.log, args.fileLogging)
|
|
35
|
+
|
|
36
|
+
if args.zip:
|
|
37
|
+
createdZip = True
|
|
38
|
+
_zip = zipfile.ZipFile(args.zip, 'a', zipfile.ZIP_DEFLATED)
|
|
39
|
+
else:
|
|
40
|
+
createdZip = False
|
|
41
|
+
_zip = None
|
|
42
|
+
|
|
43
|
+
# Quickly make a dictionary for the keyword arguments.
|
|
44
|
+
kwargs = {
|
|
45
|
+
'allowFallback': args.allowFallback,
|
|
46
|
+
'attachmentsOnly': args.attachmentsOnly,
|
|
47
|
+
'charset': args.charset,
|
|
48
|
+
'contentId': args.cid,
|
|
49
|
+
'customFilename': args.outName,
|
|
50
|
+
'customPath': out,
|
|
51
|
+
'extractEmbedded': args.extractEmbedded,
|
|
52
|
+
'html': args.html,
|
|
53
|
+
'json': args.json,
|
|
54
|
+
'overwriteExisting': args.overwriteExisting,
|
|
55
|
+
'pdf': args.pdf,
|
|
56
|
+
'preparedHtml': args.preparedHtml,
|
|
57
|
+
'rtf': args.rtf,
|
|
58
|
+
'saveHeader': args.saveHeader,
|
|
59
|
+
'skipBodyNotFound': args.skipBodyNotFound,
|
|
60
|
+
'skipEmbedded': args.skipEmbedded,
|
|
61
|
+
'skipHidden': args.skipHidden,
|
|
62
|
+
'skipNotImplemented': args.skipNotImplemented,
|
|
63
|
+
'useMsgFilename': args.useFilename,
|
|
64
|
+
'wkOptions': args.wkOptions,
|
|
65
|
+
'wkPath': args.wkPath,
|
|
66
|
+
'zip': _zip,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
openKwargs = {
|
|
70
|
+
'errorBehavior': ErrorBehavior.RTFDE if args.ignoreRtfDeErrors else ErrorBehavior.THROW,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# If we are skipping the NotImplementedError attachments, we need to
|
|
74
|
+
# suppress the error.
|
|
75
|
+
if args.skipNotImplemented:
|
|
76
|
+
openKwargs['errorBehavior'] |= ErrorBehavior.ATTACH_NOT_IMPLEMENTED
|
|
77
|
+
|
|
78
|
+
def strSanitize(inp):
|
|
79
|
+
"""
|
|
80
|
+
Small function to santize parts of a string when failing to print
|
|
81
|
+
them.
|
|
82
|
+
"""
|
|
83
|
+
return ''.join((x if x.isascii() else
|
|
84
|
+
f'\\x{ord(x):02X}' if ord(x) <= 0xFF else
|
|
85
|
+
f'\\u{ord(x):04X}' if ord(x) <= 0xFFFF else
|
|
86
|
+
f'\\U{ord(x):08X}') for x in repr(inp))
|
|
87
|
+
|
|
88
|
+
for x in args.msgs:
|
|
89
|
+
if args.progress:
|
|
90
|
+
# This may throw an error sometimes and not othertimes.
|
|
91
|
+
# Unclear why, so let's just silence it.
|
|
92
|
+
try:
|
|
93
|
+
print(f'Saving file "{x}"...')
|
|
94
|
+
except UnicodeEncodeError:
|
|
95
|
+
print(f'Saving file "{strSanitize(x)}" (failed to print without repr)...')
|
|
96
|
+
try:
|
|
97
|
+
with openMsg(x, **openKwargs) as msg:
|
|
98
|
+
if args.dumpStdout:
|
|
99
|
+
print(msg.body)
|
|
100
|
+
elif args.noFolders:
|
|
101
|
+
msg.saveAttachments(**kwargs)
|
|
102
|
+
else:
|
|
103
|
+
msg.save(**kwargs)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
try:
|
|
106
|
+
print(f'Error with file "{x}": {traceback.format_exc()}')
|
|
107
|
+
except UnicodeEncodeError:
|
|
108
|
+
print(f'Error with file "{strSanitize(x)}": {traceback.format_exc()}')
|
|
109
|
+
|
|
110
|
+
# Close the zip file if we opened it.
|
|
111
|
+
if createdZip:
|
|
112
|
+
_zip.close()
|
|
113
|
+
|
|
114
|
+
if __name__ == '__main__':
|
|
115
|
+
main(sys.argv)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module that provides access to functions to help manage RTF data.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
# Classes:
|
|
7
|
+
'Token',
|
|
8
|
+
'TokenType',
|
|
9
|
+
|
|
10
|
+
# Functions:
|
|
11
|
+
'createDocument',
|
|
12
|
+
'injectStartRTF',
|
|
13
|
+
'injectStartRTFTokenized',
|
|
14
|
+
'tokenizeRTF',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from .create_doc import createDocument
|
|
19
|
+
from .inject_rtf import injectStartRTF, injectStartRTFTokenized
|
|
20
|
+
from .token import Token, TokenType
|
|
21
|
+
from .tokenize_rtf import tokenizeRTF
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'createDocument',
|
|
3
|
+
]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from .token import Token, TokenType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def createDocument(tokens: Iterable[Token]) -> bytes:
|
|
13
|
+
"""
|
|
14
|
+
Combines the tokenized data into bytes and returns the document.
|
|
15
|
+
"""
|
|
16
|
+
document = b''
|
|
17
|
+
|
|
18
|
+
# Recombining follows a few very basic rules that are based solely on the
|
|
19
|
+
# token type. Since every token has the raw bytes, this is pretty easy. In
|
|
20
|
+
# fact, control words are the only place where we put a space, as a space
|
|
21
|
+
# anywhere else would be literal, and omitting a space could cause issues on
|
|
22
|
+
# some control words.
|
|
23
|
+
for token in tokens:
|
|
24
|
+
if token.type in (TokenType.CONTROL, TokenType.DESTINATION, TokenType.IGNORABLE_DESTINATION):
|
|
25
|
+
document += token.raw + b' '
|
|
26
|
+
else:
|
|
27
|
+
document += token.raw
|
|
28
|
+
|
|
29
|
+
return document
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'injectStartRTF',
|
|
3
|
+
'injectStartRTFTokenized',
|
|
4
|
+
]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from .token import Token, TokenType
|
|
8
|
+
from .tokenize_rtf import tokenizeRTF
|
|
9
|
+
|
|
10
|
+
from typing import List, Iterable, TypeVar, Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_T = TypeVar('_T')
|
|
14
|
+
|
|
15
|
+
# A tuple of destinations used in the header. All ignorable ones are skipped
|
|
16
|
+
# anyways, so we don't need to list those here.
|
|
17
|
+
_HEADER_DESTINATIONS = (
|
|
18
|
+
b'fonttbl',
|
|
19
|
+
b'colortbl',
|
|
20
|
+
b'stylesheet',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# A tuple of control words that are part of the header and that we simply skip.
|
|
24
|
+
_HEADER_SKIPPABLE = (
|
|
25
|
+
# Tag used to specify something.
|
|
26
|
+
b'fbidis',
|
|
27
|
+
# Character set tags.
|
|
28
|
+
b'ansi',
|
|
29
|
+
b'mac',
|
|
30
|
+
b'pc',
|
|
31
|
+
b'pca',
|
|
32
|
+
b'ansicpg',
|
|
33
|
+
# From.
|
|
34
|
+
b'fromtext',
|
|
35
|
+
b'fromhtml',
|
|
36
|
+
# Def font.
|
|
37
|
+
b'deff',
|
|
38
|
+
b'adeff',
|
|
39
|
+
b'stshfdbch',
|
|
40
|
+
b'stshfloch',
|
|
41
|
+
b'stshfhich',
|
|
42
|
+
b'stshfbi',
|
|
43
|
+
# Def lang.
|
|
44
|
+
b'deflang',
|
|
45
|
+
b'deflangfe',
|
|
46
|
+
b'adeflang',
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _listInsertMult(dest: List[_T], source: Iterable[_T], index: int = -1):
|
|
51
|
+
"""
|
|
52
|
+
Inserts into :param dest: all the items in :param source: at the index
|
|
53
|
+
specified.
|
|
54
|
+
|
|
55
|
+
:param dest: Any mutable sequence with methods ``insert``, ``__len__``, and
|
|
56
|
+
``extend``.
|
|
57
|
+
|
|
58
|
+
If :param index: is not specified, the default position is the end of the
|
|
59
|
+
list. This is also where things will be inserted if index is greater than or
|
|
60
|
+
equal to the size of the list.
|
|
61
|
+
"""
|
|
62
|
+
if index == -1 or index >= len(dest):
|
|
63
|
+
dest.extend(source)
|
|
64
|
+
else:
|
|
65
|
+
for offset, item in enumerate(source):
|
|
66
|
+
dest.insert(index + offset, item)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def injectStartRTF(document: bytes, injectTokens: Union[bytes, List[Token]]) -> List[Token]:
|
|
70
|
+
"""
|
|
71
|
+
Injects the specified tokens into the document, returning a new copy of the
|
|
72
|
+
document as a list of Tokens.
|
|
73
|
+
|
|
74
|
+
Injects the data just before the first rendered character.
|
|
75
|
+
|
|
76
|
+
:param document: The bytes representing the RTF document.
|
|
77
|
+
:param injectTokens: The tokens to inject into the document. Can either be
|
|
78
|
+
a list of ``Token``\\s or ``bytes`` to be tokenized.
|
|
79
|
+
|
|
80
|
+
:raises TypeError: The data is not recognized as RTF.
|
|
81
|
+
:raises ValueError: An issue with basic parsing occured.
|
|
82
|
+
"""
|
|
83
|
+
return injectStartRTFTokenized(tokenizeRTF(document), injectTokens)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def injectStartRTFTokenized(document: List[Token], injectTokens: Union[bytes, Iterable[Token]]) -> List[Token]:
|
|
87
|
+
"""
|
|
88
|
+
Like :function injectStartRTF:, injects the specified tokens into the
|
|
89
|
+
document, returning a reference to the document, except that it accepts a
|
|
90
|
+
document in the form of a list of tokens.
|
|
91
|
+
|
|
92
|
+
Injects the data just before the first rendered character.
|
|
93
|
+
|
|
94
|
+
:param document: The list of tokens representing the RTF document. Will only
|
|
95
|
+
be modified if the function is successful.
|
|
96
|
+
:param injectTokens: The tokens to inject into the document. Can either be
|
|
97
|
+
a list of ``Token``\\s or ``bytes`` to be tokenized.
|
|
98
|
+
|
|
99
|
+
:raises TypeError: The data is not recognized as RTF.
|
|
100
|
+
:raises ValueError: An issue with basic parsing occured.
|
|
101
|
+
"""
|
|
102
|
+
# Get to a list of tokens to inject instead of
|
|
103
|
+
if isinstance(injectTokens, bytes):
|
|
104
|
+
injectTokens = tokenizeRTF(injectTokens, False)
|
|
105
|
+
|
|
106
|
+
# Find the location to insert into. This is annoyingly complicated, and we
|
|
107
|
+
# do this by looking for the parts of the header (if they exist) as we go
|
|
108
|
+
# token by token. The moment we confirm we are no longer in the header (and
|
|
109
|
+
# we are not in a custom destination that we can simply ignore), we use the
|
|
110
|
+
# last recorded spot as the insert point. We don't move that recorded spot
|
|
111
|
+
# until we know that what we checked was part of the header.
|
|
112
|
+
|
|
113
|
+
# First confirm the first two tokens are what we expect.
|
|
114
|
+
if len(document) < 3:
|
|
115
|
+
raise ValueError('RTF documents cannot be less than 3 tokens.')
|
|
116
|
+
if document[0].type is not TokenType.GROUP_START or document[1].raw != b'\\rtf1':
|
|
117
|
+
raise TypeError('RTF document *must* start with "{\\rtf1".')
|
|
118
|
+
|
|
119
|
+
# Confirm that all start groups have an end group somewhere.
|
|
120
|
+
if sum(x.type == TokenType.GROUP_START for x in document) != sum(x.type == TokenType.GROUP_END for x in document):
|
|
121
|
+
raise ValueError('Number of group opens did not match number of group closes.')
|
|
122
|
+
|
|
123
|
+
# If the length is exactly 3, insert right before the end and return.
|
|
124
|
+
if len(document) == 3:
|
|
125
|
+
_listInsertMult(document, injectTokens, 2)
|
|
126
|
+
return document
|
|
127
|
+
|
|
128
|
+
# We have verified the minimal amount. Now, iterate through the rest to find
|
|
129
|
+
# the injection point.
|
|
130
|
+
currentInsertPos = 2
|
|
131
|
+
# Current number of open groups.
|
|
132
|
+
groupCount = 1
|
|
133
|
+
# Set to True when looking for if the group is a destination.
|
|
134
|
+
checkingDest = False
|
|
135
|
+
|
|
136
|
+
for item in document[2:]:
|
|
137
|
+
if groupCount == 1:
|
|
138
|
+
if item.type is TokenType.GROUP_END:
|
|
139
|
+
break
|
|
140
|
+
elif item.type is TokenType.GROUP_START:
|
|
141
|
+
groupCount += 1
|
|
142
|
+
checkingDest = True
|
|
143
|
+
elif item.type is TokenType.CONTROL and item.name in _HEADER_SKIPPABLE:
|
|
144
|
+
# If the control is one we know about in the header, skip it.
|
|
145
|
+
currentInsertPos += 1
|
|
146
|
+
else:
|
|
147
|
+
# Anything else means we are out of the header.
|
|
148
|
+
break
|
|
149
|
+
elif checkingDest:
|
|
150
|
+
if item.type is TokenType.DESTINATION:
|
|
151
|
+
# If it is *not* a header destination, just break, otherwise add
|
|
152
|
+
# 2 to the insert location and skip the destination.
|
|
153
|
+
if item.name in _HEADER_DESTINATIONS:
|
|
154
|
+
currentInsertPos += 2
|
|
155
|
+
else:
|
|
156
|
+
break
|
|
157
|
+
elif item.type is TokenType.IGNORABLE_DESTINATION:
|
|
158
|
+
# Add 2 to insert location and skip.
|
|
159
|
+
currentInsertPos += 2
|
|
160
|
+
else:
|
|
161
|
+
# If it is not an ignorible destination, we are now out of the
|
|
162
|
+
# header, so break.
|
|
163
|
+
break
|
|
164
|
+
checkingDest = False
|
|
165
|
+
else:
|
|
166
|
+
# Skip the current token, keeping track of groups.
|
|
167
|
+
if item.type is TokenType.GROUP_START:
|
|
168
|
+
groupCount += 1
|
|
169
|
+
if item.type is TokenType.GROUP_END:
|
|
170
|
+
groupCount -= 1
|
|
171
|
+
currentInsertPos += 1
|
|
172
|
+
|
|
173
|
+
_listInsertMult(document, injectTokens, currentInsertPos)
|
|
174
|
+
return document
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'Token',
|
|
3
|
+
'TokenType',
|
|
4
|
+
]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import enum
|
|
8
|
+
|
|
9
|
+
from typing import NamedTuple, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TokenType(enum.Enum):
|
|
13
|
+
GROUP_START = 0
|
|
14
|
+
GROUP_END = 1
|
|
15
|
+
CONTROL = 2
|
|
16
|
+
SYMBOL = 3
|
|
17
|
+
TEXT = 4
|
|
18
|
+
DESTINATION = 5
|
|
19
|
+
IGNORABLE_DESTINATION = 6
|
|
20
|
+
# This one is special, used for handling the binary data.
|
|
21
|
+
BINARY = 7
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Token(NamedTuple):
|
|
26
|
+
# The raw bytes for the token, used to recreate the document.
|
|
27
|
+
raw: bytes
|
|
28
|
+
# The type of the token.
|
|
29
|
+
type: TokenType
|
|
30
|
+
## The following are optional as they only apply for certain types of tokens.
|
|
31
|
+
# The name of the token, if it is a control or destination.
|
|
32
|
+
name: Optional[bytes] = None
|
|
33
|
+
# The parameter of the token, if it has one. If the token is a `\'hh` token,
|
|
34
|
+
# this will be the decimal equivelent of the hex value.
|
|
35
|
+
parameter: Optional[int] = None
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'tokenizeRTF',
|
|
3
|
+
]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
|
|
8
|
+
from typing import List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from .token import Token, TokenType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_KNOWN_DESTINATIONS = (
|
|
14
|
+
b'aftncn',
|
|
15
|
+
b'aftnsep',
|
|
16
|
+
b'aftnsepc',
|
|
17
|
+
b'annotation',
|
|
18
|
+
b'author',
|
|
19
|
+
b'buptim',
|
|
20
|
+
b'category',
|
|
21
|
+
b'colortbl',
|
|
22
|
+
b'comment',
|
|
23
|
+
b'company',
|
|
24
|
+
b'creatim',
|
|
25
|
+
b'doccomm',
|
|
26
|
+
b'dptxbxtext',
|
|
27
|
+
b'factoidname',
|
|
28
|
+
b'fonttbl',
|
|
29
|
+
b'footer',
|
|
30
|
+
b'footerf',
|
|
31
|
+
b'footerl',
|
|
32
|
+
b'footerr',
|
|
33
|
+
b'ftncn',
|
|
34
|
+
b'ftnsep',
|
|
35
|
+
b'ftnsepc',
|
|
36
|
+
b'header',
|
|
37
|
+
b'headerf',
|
|
38
|
+
b'headerl',
|
|
39
|
+
b'headerr',
|
|
40
|
+
b'hlinkbase',
|
|
41
|
+
b'keywords',
|
|
42
|
+
b'manager',
|
|
43
|
+
b'operator',
|
|
44
|
+
b'pict',
|
|
45
|
+
b'printim',
|
|
46
|
+
b'private',
|
|
47
|
+
b'revtim',
|
|
48
|
+
b'stylesheet',
|
|
49
|
+
b'subject',
|
|
50
|
+
b'title',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _finishTag(startText: bytes, reader: io.BytesIO) -> Tuple[bytes, Optional[bytes], Optional[int], bytes]:
|
|
55
|
+
"""
|
|
56
|
+
Finishes reading a tag, returning the needed parameters to make it a
|
|
57
|
+
token.
|
|
58
|
+
|
|
59
|
+
The return is a 4 tuple of the raw token bytes, the name field, the
|
|
60
|
+
parameter field (as an int), and the next character after the tag.
|
|
61
|
+
"""
|
|
62
|
+
# Very simple rules here. Anything other than a letter and we change
|
|
63
|
+
# state. If the next character is a hypen, check if the character after
|
|
64
|
+
# is a digit, otherwise return. If it is a digit or that previously
|
|
65
|
+
# mentioned next character was a digit, read digits until anything else
|
|
66
|
+
# is detected, then return.
|
|
67
|
+
name = startText[-1:]
|
|
68
|
+
param = b''
|
|
69
|
+
|
|
70
|
+
while (nextChar := reader.read(1)) != b'' and nextChar.isalpha():
|
|
71
|
+
# Read until not alpha.
|
|
72
|
+
startText += nextChar
|
|
73
|
+
name += nextChar
|
|
74
|
+
|
|
75
|
+
# Check what the next character is to decide what to do with it.
|
|
76
|
+
if nextChar == b'-':
|
|
77
|
+
# We do this as a separate check.
|
|
78
|
+
nextNext = reader.read(1)
|
|
79
|
+
if nextNext == b'':
|
|
80
|
+
raise ValueError('Unexpected end of data.')
|
|
81
|
+
elif nextNext.isdigit():
|
|
82
|
+
startText += nextChar
|
|
83
|
+
nextChar = nextNext
|
|
84
|
+
|
|
85
|
+
if nextChar.isdigit():
|
|
86
|
+
startText += nextChar
|
|
87
|
+
param += nextChar
|
|
88
|
+
while (nextChar := reader.read(1)) != b'' and nextChar.isdigit():
|
|
89
|
+
startText += nextChar
|
|
90
|
+
param += nextChar
|
|
91
|
+
|
|
92
|
+
param = int(param)
|
|
93
|
+
else:
|
|
94
|
+
param = None
|
|
95
|
+
|
|
96
|
+
# Finally, check if the next char is a space, and if it is, read one
|
|
97
|
+
# more char to replace it.
|
|
98
|
+
if nextChar == b' ':
|
|
99
|
+
nextChar = reader.read(1)
|
|
100
|
+
|
|
101
|
+
return startText, name, param, nextChar
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _readControl(startChar: bytes, reader: io.BytesIO) -> Tuple[Tuple[Token, ...], bytes]:
|
|
105
|
+
"""
|
|
106
|
+
Attempts to read the next data as a control, returning as many tokens
|
|
107
|
+
as necessary.
|
|
108
|
+
"""
|
|
109
|
+
# First, read the next character, as it decides how to handle
|
|
110
|
+
# everything.
|
|
111
|
+
nextChar = reader.read(1)
|
|
112
|
+
if nextChar == b'':
|
|
113
|
+
raise ValueError('Unexpected end of data.')
|
|
114
|
+
elif nextChar.isalpha():
|
|
115
|
+
# If is an alphabetical character, so start the handling of a tag.
|
|
116
|
+
text, name, param, nextChar = _finishTag(startChar + nextChar, reader)
|
|
117
|
+
# Important, check if the name is "bin". If it is, handle that
|
|
118
|
+
# specially before returning.
|
|
119
|
+
if name == b'bin':
|
|
120
|
+
if nextChar == b'':
|
|
121
|
+
raise ValueError('Unexpected end of data.')
|
|
122
|
+
binText = nextChar + reader.read(param - 1)
|
|
123
|
+
if len(binText) != param:
|
|
124
|
+
raise ValueError('Unexpected end of data.')
|
|
125
|
+
return (Token(text, TokenType.CONTROL, name, param), Token(binText, TokenType.BINARY)), nextChar
|
|
126
|
+
elif name in _KNOWN_DESTINATIONS:
|
|
127
|
+
return (Token(text, TokenType.DESTINATION, name, param),), nextChar
|
|
128
|
+
|
|
129
|
+
return (Token(text, TokenType.CONTROL, name, param),), nextChar
|
|
130
|
+
else:
|
|
131
|
+
# Most control symbols would return immediately, but there are two
|
|
132
|
+
# exceptions.
|
|
133
|
+
startChar += nextChar
|
|
134
|
+
if nextChar == b'*':
|
|
135
|
+
# This is going to be a custom destination. First, validation.
|
|
136
|
+
if len(nextChar := reader.read(1)) != 1:
|
|
137
|
+
raise ValueError('Unexpected end of data.')
|
|
138
|
+
elif nextChar != b'\\':
|
|
139
|
+
raise ValueError(f'Bad custom destination (expected a backslash, got {nextChar}).')
|
|
140
|
+
|
|
141
|
+
startChar += nextChar
|
|
142
|
+
|
|
143
|
+
# Check the the next char is alpha.
|
|
144
|
+
if not (nextChar := reader.read(1)).isalpha():
|
|
145
|
+
raise ValueError(f'Expected alpha character for destination, got {nextChar}.')
|
|
146
|
+
|
|
147
|
+
startChar += nextChar
|
|
148
|
+
|
|
149
|
+
# Call the function to read until a clear end of tag.
|
|
150
|
+
text, name, param, nextChar = _finishTag(startChar, reader)
|
|
151
|
+
return (Token(text, TokenType.IGNORABLE_DESTINATION, name, param),), nextChar
|
|
152
|
+
elif nextChar == b'\'':
|
|
153
|
+
# This is a hex character, so immediately read 2 more bytes.
|
|
154
|
+
hexChars = reader.read(2)
|
|
155
|
+
if len(hexChars) != 2:
|
|
156
|
+
raise ValueError('Unexpected end of data.')
|
|
157
|
+
try:
|
|
158
|
+
param = int(hexChars, 16)
|
|
159
|
+
except ValueError as e:
|
|
160
|
+
context = e.__cause__ or e.__context__
|
|
161
|
+
raise ValueError(f'Hex data was not hexidecimal (got {hexChars}).') from context
|
|
162
|
+
return (Token(startChar + hexChars, TokenType.SYMBOL, None, param),), reader.read(1)
|
|
163
|
+
else:
|
|
164
|
+
# If it is a control symbol, immediately return.
|
|
165
|
+
return (Token(startChar, TokenType.SYMBOL),), reader.read(1)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _readText(startChar: bytes, reader: io.BytesIO) -> Tuple[Tuple[Token, ...], bytes]:
|
|
169
|
+
"""
|
|
170
|
+
Attempts to read the next data as text.
|
|
171
|
+
"""
|
|
172
|
+
chars = [startChar]
|
|
173
|
+
# Text is actually the easiest to read, as we just read until end of
|
|
174
|
+
# stream or until a special character. However, a few characters are
|
|
175
|
+
# simply dropped during reading.
|
|
176
|
+
while (nextChar := reader.read(1)) != b'' and nextChar not in (b'{', b'}', b'\\'):
|
|
177
|
+
# Certain characters are simply dropped.
|
|
178
|
+
if nextChar not in (b'\r', b'\n'):
|
|
179
|
+
chars.append(nextChar)
|
|
180
|
+
|
|
181
|
+
# Now, we actually are reading the text as *individual tokens*, so we
|
|
182
|
+
# need to
|
|
183
|
+
|
|
184
|
+
return tuple(Token(x, TokenType.TEXT) for x in chars), nextChar
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def tokenizeRTF(data: bytes, validateStart: bool = True) -> List[Token]:
|
|
188
|
+
"""
|
|
189
|
+
Reads in the bytes and sets the tokens list to the contents after
|
|
190
|
+
tokenizing.
|
|
191
|
+
|
|
192
|
+
If tokenizing fails, the current tokens list will not be changed.
|
|
193
|
+
|
|
194
|
+
:param validateStart: If ``False``, does not check the first few tags.
|
|
195
|
+
Useful when tokenizing a snippet rather than a document.
|
|
196
|
+
|
|
197
|
+
:raises TypeError: The data is not recognized as RTF.
|
|
198
|
+
:raises ValueError: An issue with basic parsing occured.
|
|
199
|
+
"""
|
|
200
|
+
reader = io.BytesIO(data)
|
|
201
|
+
if validateStart:
|
|
202
|
+
# This tokenizer *only* breaks things up. It does *not* care about
|
|
203
|
+
# groups and stuff, as that is for a parser to deal with. All we do is
|
|
204
|
+
# track the current backslash state and token state. We also simply
|
|
205
|
+
# check that the first token is "\rtf1" preceeded by a group start, and
|
|
206
|
+
# that is it.
|
|
207
|
+
start = reader.read(6)
|
|
208
|
+
if start != b'{\\rtf1':
|
|
209
|
+
raise TypeError('Data does not start with "{\\rtf1".')
|
|
210
|
+
|
|
211
|
+
tokens = [
|
|
212
|
+
Token(b'{', TokenType.GROUP_START),
|
|
213
|
+
Token(b'\\rtf1', TokenType.CONTROL, b'rtf', 1),
|
|
214
|
+
]
|
|
215
|
+
nextChar = reader.read(1)
|
|
216
|
+
|
|
217
|
+
# If the next character is a space, ignore it.
|
|
218
|
+
if nextChar == b' ':
|
|
219
|
+
nextChar = reader.read(1)
|
|
220
|
+
else:
|
|
221
|
+
tokens = []
|
|
222
|
+
nextChar = reader.read(1)
|
|
223
|
+
|
|
224
|
+
# At every iteration, so long as there is more data, nextChar should be
|
|
225
|
+
# set. As such, use it to determine what kind of data to try to read,
|
|
226
|
+
# using the delimeter of that type of data to know what to do next.
|
|
227
|
+
while nextChar != b'':
|
|
228
|
+
# We should have exactly one character, the start of the next
|
|
229
|
+
# section. Use it to determine what to do.
|
|
230
|
+
if nextChar in (b'\r', b'\n'):
|
|
231
|
+
# Just read the next character and start the loop over.
|
|
232
|
+
nextChar = reader.read(1)
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
if nextChar == b'\\':
|
|
236
|
+
newTokens, nextChar = _readControl(nextChar, reader)
|
|
237
|
+
elif nextChar == b'{':
|
|
238
|
+
# This will always be a group start, which has nothing left to
|
|
239
|
+
# read.
|
|
240
|
+
nextChar = reader.read(1)
|
|
241
|
+
newTokens = (Token(b'{', TokenType.GROUP_START),)
|
|
242
|
+
elif nextChar == b'}':
|
|
243
|
+
# This will always be a group end, which has nothing left to
|
|
244
|
+
# read.
|
|
245
|
+
nextChar = reader.read(1)
|
|
246
|
+
newTokens = (Token(b'}', TokenType.GROUP_END),)
|
|
247
|
+
else:
|
|
248
|
+
# Otherwise, it's just text.
|
|
249
|
+
newTokens, nextChar = _readText(nextChar, reader)
|
|
250
|
+
|
|
251
|
+
tokens.extend(newTokens)
|
|
252
|
+
|
|
253
|
+
return tokens
|