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.
Files changed (95) hide show
  1. extract_msg/__init__.py +71 -0
  2. extract_msg/__main__.py +115 -0
  3. extract_msg/_rtf/__init__.py +21 -0
  4. extract_msg/_rtf/create_doc.py +29 -0
  5. extract_msg/_rtf/inject_rtf.py +174 -0
  6. extract_msg/_rtf/token.py +35 -0
  7. extract_msg/_rtf/tokenize_rtf.py +253 -0
  8. extract_msg/attachments/__init__.py +144 -0
  9. extract_msg/attachments/attachment.py +188 -0
  10. extract_msg/attachments/attachment_base.py +730 -0
  11. extract_msg/attachments/broken_att.py +42 -0
  12. extract_msg/attachments/custom_att.py +173 -0
  13. extract_msg/attachments/custom_att_handler/__init__.py +80 -0
  14. extract_msg/attachments/custom_att_handler/custom_handler.py +134 -0
  15. extract_msg/attachments/custom_att_handler/lnk_obj_att.py +109 -0
  16. extract_msg/attachments/custom_att_handler/outlook_image_dib.py +141 -0
  17. extract_msg/attachments/emb_msg_att.py +134 -0
  18. extract_msg/attachments/signed_att.py +234 -0
  19. extract_msg/attachments/unsupported_att.py +40 -0
  20. extract_msg/attachments/web_att.py +74 -0
  21. extract_msg/constants/__init__.py +294 -0
  22. extract_msg/constants/ps.py +46 -0
  23. extract_msg/constants/re.py +42 -0
  24. extract_msg/constants/st.py +110 -0
  25. extract_msg/data/logging-config/logging-nt.json +64 -0
  26. extract_msg/data/logging-config/logging-posix.json +64 -0
  27. extract_msg/encoding/__init__.py +277 -0
  28. extract_msg/encoding/_dt/__init__.py +4 -0
  29. extract_msg/encoding/_dt/_mac_ce.py +8 -0
  30. extract_msg/encoding/_dt/_mac_cyrillic.py +8 -0
  31. extract_msg/encoding/_dt/_mac_greek.py +8 -0
  32. extract_msg/encoding/_dt/_mac_iceland.py +8 -0
  33. extract_msg/encoding/_dt/_mac_turkish.py +8 -0
  34. extract_msg/encoding/_dt/_win874_dec.py +8 -0
  35. extract_msg/encoding/_dt/_win950_dec.py +6 -0
  36. extract_msg/encoding/utils.py +329 -0
  37. extract_msg/enums.py +2067 -0
  38. extract_msg/exceptions.py +170 -0
  39. extract_msg/msg_classes/__init__.py +49 -0
  40. extract_msg/msg_classes/appointment.py +195 -0
  41. extract_msg/msg_classes/calendar.py +92 -0
  42. extract_msg/msg_classes/calendar_base.py +496 -0
  43. extract_msg/msg_classes/contact.py +1335 -0
  44. extract_msg/msg_classes/journal.py +131 -0
  45. extract_msg/msg_classes/meeting_cancellation.py +108 -0
  46. extract_msg/msg_classes/meeting_exception.py +55 -0
  47. extract_msg/msg_classes/meeting_forward.py +115 -0
  48. extract_msg/msg_classes/meeting_related.py +65 -0
  49. extract_msg/msg_classes/meeting_request.py +189 -0
  50. extract_msg/msg_classes/meeting_response.py +74 -0
  51. extract_msg/msg_classes/message.py +12 -0
  52. extract_msg/msg_classes/message_base.py +1382 -0
  53. extract_msg/msg_classes/message_signed.py +17 -0
  54. extract_msg/msg_classes/message_signed_base.py +168 -0
  55. extract_msg/msg_classes/msg.py +1172 -0
  56. extract_msg/msg_classes/post.py +53 -0
  57. extract_msg/msg_classes/sticky_note.py +67 -0
  58. extract_msg/msg_classes/task.py +363 -0
  59. extract_msg/msg_classes/task_request.py +86 -0
  60. extract_msg/null_date.py +34 -0
  61. extract_msg/ole_writer.py +1086 -0
  62. extract_msg/open_msg.py +204 -0
  63. extract_msg/properties/__init__.py +23 -0
  64. extract_msg/properties/named.py +481 -0
  65. extract_msg/properties/prop.py +506 -0
  66. extract_msg/properties/properties_store.py +476 -0
  67. extract_msg/py.typed +0 -0
  68. extract_msg/recipient.py +362 -0
  69. extract_msg/structures/__init__.py +34 -0
  70. extract_msg/structures/_helpers.py +345 -0
  71. extract_msg/structures/business_card.py +422 -0
  72. extract_msg/structures/cfoas.py +108 -0
  73. extract_msg/structures/contact_link_entry.py +51 -0
  74. extract_msg/structures/dev_mode_a.py +621 -0
  75. extract_msg/structures/dv_target_device.py +168 -0
  76. extract_msg/structures/entry_id.py +697 -0
  77. extract_msg/structures/misc_id.py +222 -0
  78. extract_msg/structures/mon_stream.py +55 -0
  79. extract_msg/structures/odt.py +74 -0
  80. extract_msg/structures/ole_pres.py +302 -0
  81. extract_msg/structures/ole_stream_struct.py +117 -0
  82. extract_msg/structures/recurrence_pattern.py +199 -0
  83. extract_msg/structures/report_tag.py +127 -0
  84. extract_msg/structures/system_time.py +158 -0
  85. extract_msg/structures/time_zone_definition.py +111 -0
  86. extract_msg/structures/time_zone_struct.py +102 -0
  87. extract_msg/structures/toc_entry.py +137 -0
  88. extract_msg/structures/tz_rule.py +148 -0
  89. extract_msg/utils.py +1270 -0
  90. extract_msg-0.48.2.dist-info/LICENSE.txt +674 -0
  91. extract_msg-0.48.2.dist-info/METADATA +321 -0
  92. extract_msg-0.48.2.dist-info/RECORD +95 -0
  93. extract_msg-0.48.2.dist-info/WHEEL +5 -0
  94. extract_msg-0.48.2.dist-info/entry_points.txt +2 -0
  95. extract_msg-0.48.2.dist-info/top_level.txt +1 -0
@@ -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
@@ -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