@allansantos-dev/smart-tool 0.9.43 → 0.9.46
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.
- package/CHANGELOG.md +53 -0
- package/README.md +25 -5
- package/autostart.py +5 -3
- package/browser_control.py +71 -15
- package/client_hooks.py +2 -2
- package/config.py +12 -0
- package/document_read.py +272 -0
- package/document_text.py +212 -54
- package/folder_picker.py +7 -5
- package/hook_decision.py +9 -1
- package/i18n.py +213 -0
- package/lazy_mcp_proxy.py +185 -0
- package/locales/pt-BR.json +923 -0
- package/package.json +1 -1
- package/projects_ui.py +7 -2
- package/requester.py +76 -0
- package/research_metrics.py +5 -3
- package/setup_audit.py +5 -3
- package/setup_ui.py +15 -2
- package/smart_tool_daemon.py +69 -10
- package/toast_notice.py +3 -2
- package/tool_redirect.py +2 -1
- package/tray.py +106 -49
- package/update_check.py +28 -10
- package/update_splash.py +197 -0
- package/version.py +1 -1
- package/web_search_health.py +5 -2
package/document_read.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""read_document: PDF, Word, PowerPoint and Excel files read as compact text, one page, slide or sheet range at a time.
|
|
2
|
+
|
|
3
|
+
The agent gets the file facts first (type, size, pages, slides or sheets, estimated tokens) and then only the range it
|
|
4
|
+
asks for, so a 200-page PDF is never loaded whole. Extraction is the one of document_text (pypdf in an isolated
|
|
5
|
+
subprocess for PDF; zipfile and ElementTree with the shared guards for docx, pptx and xlsx); this module adds the unit
|
|
6
|
+
split, the markdown shape, the compaction of PDF line wrapping and the range arithmetic."""
|
|
7
|
+
import collections
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
import unicodedata
|
|
13
|
+
|
|
14
|
+
import document_text
|
|
15
|
+
|
|
16
|
+
KINDS = {'.pdf': 'pdf', '.docx': 'docx', '.pptx': 'pptx', '.xlsx': 'xlsx'}
|
|
17
|
+
UNIT_NAMES = {'pdf': 'page', 'docx': 'part', 'pptx': 'slide', 'xlsx': 'sheet'}
|
|
18
|
+
DEFAULT_MAX_CHARS = 20_000
|
|
19
|
+
MAX_CHARS_LIMIT = 100_000
|
|
20
|
+
DOCX_PART_CHARS = 6_000
|
|
21
|
+
CHARS_PER_TOKEN = 4
|
|
22
|
+
CACHE_DOCUMENTS = 4
|
|
23
|
+
LONG_LINE = 60
|
|
24
|
+
|
|
25
|
+
_cache = collections.OrderedDict()
|
|
26
|
+
_cache_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Unit:
|
|
30
|
+
"""One readable unit of a document: a PDF page, a docx part, a slide or a sheet."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, number, text, name=''):
|
|
33
|
+
self.number = number
|
|
34
|
+
self.text = text
|
|
35
|
+
self.name = name
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Document:
|
|
39
|
+
"""A document split into units, with the facts the metadata block reports."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, path, kind, size, units, facts):
|
|
42
|
+
self.path = path
|
|
43
|
+
self.kind = kind
|
|
44
|
+
self.size = size
|
|
45
|
+
self.units = units
|
|
46
|
+
self.facts = facts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def estimate_tokens(text):
|
|
50
|
+
"""Token estimate of a text: characters divided by four, rounded up (a rule of thumb, not a tokenizer count).
|
|
51
|
+
|
|
52
|
+
Parameters: text. Returns: the estimate as an int."""
|
|
53
|
+
return math.ceil(len(text) / CHARS_PER_TOKEN)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compact_pdf_text(text):
|
|
57
|
+
"""PDF page text with the layout noise taken out: ligatures and compatibility forms normalised, a hyphen at a line
|
|
58
|
+
end joined to the word, a wrapped line (long, no closing punctuation, next one starting in lower case) joined to the
|
|
59
|
+
next, spaces collapsed and blank-line runs reduced to one.
|
|
60
|
+
|
|
61
|
+
Parameters: text (one page as pypdf returns it). Returns: the compact text."""
|
|
62
|
+
text = unicodedata.normalize('NFKC', text).replace('\r\n', '\n').replace('\r', '\n')
|
|
63
|
+
text = re.sub(r'(\w)-\n(?=[a-zà-ÿ])', r'\1', text)
|
|
64
|
+
lines = [re.sub(r'[ \t]+', ' ', line).strip() for line in text.split('\n')]
|
|
65
|
+
joined = []
|
|
66
|
+
for line in lines:
|
|
67
|
+
previous = joined[-1] if joined else ''
|
|
68
|
+
if (previous and len(previous) >= LONG_LINE and line[:1].islower()
|
|
69
|
+
and not previous.endswith(('.', ':', ';', '!', '?', ')', '|'))):
|
|
70
|
+
joined[-1] = previous + ' ' + line
|
|
71
|
+
else:
|
|
72
|
+
joined.append(line)
|
|
73
|
+
return re.sub(r'\n{3,}', '\n\n', '\n'.join(joined)).strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _pdf_units(data):
|
|
77
|
+
"""Units and facts of a PDF, one unit per page; raises DocumentError for a password, a corrupt file or a file with no
|
|
78
|
+
text layer at all (scanned pages are not read, there is no OCR)."""
|
|
79
|
+
pages, count = document_text.pdf_pages(data, document_text.PDF_TIMEOUT_S, document_text.PDF_MAX_PAGES,
|
|
80
|
+
document_text.PDF_MAX_CHARS)
|
|
81
|
+
if not any(pages):
|
|
82
|
+
raise document_text.DocumentError(
|
|
83
|
+
f'PDF of {count} pages has no text layer (scanned pages are not read; there is no OCR).')
|
|
84
|
+
units = [Unit(number, compact_pdf_text(page) or '(no text on this page)') for number, page in enumerate(pages, 1)]
|
|
85
|
+
facts = {'pages': count, 'pages without text': sum(1 for page in pages if not page)}
|
|
86
|
+
if count > len(pages):
|
|
87
|
+
facts['not read'] = f'pages {len(pages) + 1}-{count} (over the {document_text.PDF_MAX_PAGES} page or ' \
|
|
88
|
+
f'{document_text.PDF_MAX_CHARS:,} character cap)'
|
|
89
|
+
return units, facts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _docx_units(data):
|
|
93
|
+
"""Units and facts of a docx: the markdown blocks grouped in parts of about DOCX_PART_CHARS characters, a block never
|
|
94
|
+
split (a docx has no fixed pages, they depend on the renderer)."""
|
|
95
|
+
parts, current, size = [], [], 0
|
|
96
|
+
for block in document_text.docx_blocks(data):
|
|
97
|
+
if current and size + len(block) > DOCX_PART_CHARS:
|
|
98
|
+
parts.append('\n'.join(current))
|
|
99
|
+
current, size = [], 0
|
|
100
|
+
current.append(block)
|
|
101
|
+
size += len(block) + 1
|
|
102
|
+
parts.append('\n'.join(current))
|
|
103
|
+
return [Unit(number, text) for number, text in enumerate(parts, 1)], {
|
|
104
|
+
'parts': len(parts), 'note': f'a docx has no fixed pages; a part is about {DOCX_PART_CHARS:,} characters'}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _pptx_units(data):
|
|
108
|
+
"""Units and facts of a pptx: one unit per slide under `[slide N]`, the speaker notes under `[notes N]`."""
|
|
109
|
+
units = []
|
|
110
|
+
for number, body, notes in document_text.pptx_slides(data):
|
|
111
|
+
lines = [f'[slide {number}]'] + (body or ['(no text)']) + ([f'[notes {number}]'] + notes if notes else [])
|
|
112
|
+
units.append(Unit(number, '\n'.join(lines)))
|
|
113
|
+
return units, {'slides': len(units), 'slides without text': sum(1 for u in units if '\n(no text)' in u.text)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _xlsx_units(data):
|
|
117
|
+
"""Units and facts of an xlsx: one unit per sheet under `[sheet NAME]`, then one tab-separated line per row."""
|
|
118
|
+
units = []
|
|
119
|
+
for number, (name, rows) in enumerate(document_text.xlsx_sheets(data), 1):
|
|
120
|
+
units.append(Unit(number, '\n'.join([f'[sheet {name}]'] + (rows or ['(no text)'])), name))
|
|
121
|
+
return units, {'sheet names': ', '.join(f'{u.number}={u.name}' for u in units)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_BUILDERS = {'pdf': _pdf_units, 'docx': _docx_units, 'pptx': _pptx_units, 'xlsx': _xlsx_units}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load(path):
|
|
128
|
+
"""The document at `path`, split into units. Parsed documents are kept for a few calls (keyed by path, size and
|
|
129
|
+
modification time), so reading page after page parses the file once.
|
|
130
|
+
|
|
131
|
+
Parameters: path (absolute path of a .pdf, .docx, .pptx or .xlsx file). Returns: a Document. Raises: ValueError for a
|
|
132
|
+
relative path, a missing file, an unsupported type or a file over the size limit of its type; DocumentError with a
|
|
133
|
+
readable reason for a file the extractor refuses (corrupt, encrypted, hostile, scanned, without text)."""
|
|
134
|
+
if not isinstance(path, str) or not path.strip():
|
|
135
|
+
raise ValueError("read_document requires 'path'")
|
|
136
|
+
if not os.path.isabs(path):
|
|
137
|
+
raise ValueError(f'read_document needs an absolute path, got {path!r}')
|
|
138
|
+
extension = os.path.splitext(path)[1].lower()
|
|
139
|
+
if extension not in KINDS:
|
|
140
|
+
raise ValueError(f"read_document reads {', '.join(KINDS)} files; {extension or 'this file'} is not supported "
|
|
141
|
+
'(use the normal Read for plain text; legacy .doc, .xls and .ppt and macro files are not read)')
|
|
142
|
+
try:
|
|
143
|
+
info = os.stat(path)
|
|
144
|
+
except OSError as exc:
|
|
145
|
+
raise ValueError(f'cannot read {path}: {exc.strerror or type(exc).__name__}') from None
|
|
146
|
+
if not os.path.isfile(path):
|
|
147
|
+
raise ValueError(f'{path} is not a file')
|
|
148
|
+
if info.st_size > document_text.size_limit(path):
|
|
149
|
+
raise ValueError(f'{path} is {info.st_size / 1048576:.1f} MiB, over the '
|
|
150
|
+
f'{document_text.size_limit(path) // 1048576} MiB limit of a {extension} file')
|
|
151
|
+
key = (os.path.normcase(path), info.st_size, info.st_mtime_ns)
|
|
152
|
+
with _cache_lock:
|
|
153
|
+
if key in _cache:
|
|
154
|
+
_cache.move_to_end(key)
|
|
155
|
+
return _cache[key]
|
|
156
|
+
with open(path, 'rb') as stream:
|
|
157
|
+
data = stream.read()
|
|
158
|
+
kind = KINDS[extension]
|
|
159
|
+
units, facts = _BUILDERS[kind](data)
|
|
160
|
+
document = Document(path, kind, info.st_size, units, facts)
|
|
161
|
+
with _cache_lock:
|
|
162
|
+
_cache[key] = document
|
|
163
|
+
while len(_cache) > CACHE_DOCUMENTS:
|
|
164
|
+
_cache.popitem(last=False)
|
|
165
|
+
return document
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_range(spec, document):
|
|
169
|
+
"""Unit numbers selected by a range text: `3`, `3-5`, `7-` (to the end), `-4` (the first four), a comma list of
|
|
170
|
+
those (`1,4-6`) and, for a workbook, a sheet name (case-insensitive).
|
|
171
|
+
|
|
172
|
+
Parameters: spec (the text), document (a Document). Returns: the sorted list of unit numbers. Raises: ValueError
|
|
173
|
+
naming the valid range for a malformed or out-of-range spec or an unknown sheet name."""
|
|
174
|
+
count = len(document.units)
|
|
175
|
+
unit = UNIT_NAMES[document.kind]
|
|
176
|
+
chosen = set()
|
|
177
|
+
for part in (piece.strip() for piece in spec.split(',')):
|
|
178
|
+
if document.kind == 'xlsx':
|
|
179
|
+
named = [u.number for u in document.units if u.name.lower() == part.lower()]
|
|
180
|
+
if named:
|
|
181
|
+
chosen.update(named)
|
|
182
|
+
continue
|
|
183
|
+
found = re.fullmatch(r'(\d*)\s*(-?)\s*(\d*)', part)
|
|
184
|
+
if not part or not found or not (found.group(1) or found.group(3)):
|
|
185
|
+
raise ValueError(f'range {spec!r} is not valid: use a {unit} number, a span like 3-5, 7- or -4, or a comma list'
|
|
186
|
+
+ ('; a sheet name also works' if document.kind == 'xlsx' else ''))
|
|
187
|
+
first, dash, last = found.groups()
|
|
188
|
+
if not dash and last:
|
|
189
|
+
raise ValueError(f'range {spec!r} is not valid: use a {unit} number, a span like 3-5, 7- or -4, or a comma list')
|
|
190
|
+
low = int(first) if first else 1
|
|
191
|
+
high = int(last) if last else (count if dash else low)
|
|
192
|
+
if low < 1 or high > count or low > high:
|
|
193
|
+
raise ValueError(f'range {spec!r} is outside the document: it has {count} {unit}s (1-{count})')
|
|
194
|
+
chosen.update(range(low, high + 1))
|
|
195
|
+
return sorted(chosen)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _spans(numbers):
|
|
199
|
+
"""Compact text of unit numbers: `1-3, 7`."""
|
|
200
|
+
spans, start, previous = [], None, None
|
|
201
|
+
for number in numbers + [None]:
|
|
202
|
+
if start is None:
|
|
203
|
+
start = previous = number
|
|
204
|
+
elif number is not None and number == previous + 1:
|
|
205
|
+
previous = number
|
|
206
|
+
else:
|
|
207
|
+
spans.append(str(start) if start == previous else f'{start}-{previous}')
|
|
208
|
+
start = previous = number
|
|
209
|
+
return ', '.join(spans)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def header(document):
|
|
213
|
+
"""The metadata block that opens every answer: path, type, size, units and the estimated tokens of the whole text.
|
|
214
|
+
|
|
215
|
+
Parameters: document. Returns: the block as text."""
|
|
216
|
+
total = sum(estimate_tokens(unit.text) for unit in document.units)
|
|
217
|
+
lines = [f'document: {document.path}',
|
|
218
|
+
f'type: {document.kind} | size: {document.size / 1048576:.2f} MiB | '
|
|
219
|
+
f'{UNIT_NAMES[document.kind]}s: {len(document.units)} | est. tokens (all text): {total:,}']
|
|
220
|
+
lines += [f'{key}: {value}' for key, value in document.facts.items()
|
|
221
|
+
if value and key != UNIT_NAMES[document.kind] + 's']
|
|
222
|
+
return '\n'.join(lines)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def read(path, range_spec=None, max_chars=DEFAULT_MAX_CHARS, info_only=False):
|
|
226
|
+
"""The answer of read_document: the metadata block, then the chosen units as markdown, a note naming the next range
|
|
227
|
+
when the text was cut.
|
|
228
|
+
|
|
229
|
+
Parameters: path (see load), range_spec (see parse_range; None reads from the first unit up to max_chars),
|
|
230
|
+
max_chars (cap of the text returned, 1000-MAX_CHARS_LIMIT; a single unit over the cap is cut at a line with a
|
|
231
|
+
notice), info_only (only the metadata block). Returns: the text. Raises: as load and parse_range, ValueError for a
|
|
232
|
+
max_chars out of bounds."""
|
|
233
|
+
if isinstance(max_chars, bool) or not isinstance(max_chars, int) or not 1000 <= max_chars <= MAX_CHARS_LIMIT:
|
|
234
|
+
raise ValueError(f'max_chars must be an integer from 1000 to {MAX_CHARS_LIMIT}')
|
|
235
|
+
document = load(path)
|
|
236
|
+
head = header(document)
|
|
237
|
+
if info_only:
|
|
238
|
+
return head
|
|
239
|
+
numbers = parse_range(range_spec, document) if range_spec else list(range(1, len(document.units) + 1))
|
|
240
|
+
unit = UNIT_NAMES[document.kind]
|
|
241
|
+
body, shown, size, cut = [], [], 0, None
|
|
242
|
+
for number in numbers:
|
|
243
|
+
text = document.units[number - 1].text
|
|
244
|
+
label = f'[{unit} {number}]\n' if document.kind in ('pdf', 'docx') else ''
|
|
245
|
+
piece = label + text
|
|
246
|
+
if shown and size + len(piece) > max_chars:
|
|
247
|
+
break
|
|
248
|
+
if len(piece) > max_chars:
|
|
249
|
+
lines = piece.split('\n')
|
|
250
|
+
kept, used = [], 0
|
|
251
|
+
for line in lines:
|
|
252
|
+
if used + len(line) + 1 > max_chars and kept:
|
|
253
|
+
break
|
|
254
|
+
kept.append(line)
|
|
255
|
+
used += len(line) + 1
|
|
256
|
+
piece = '\n'.join(kept)
|
|
257
|
+
cut = (f'{unit} {number} cut after {len(kept)} of {len(lines)} lines; raise max_chars '
|
|
258
|
+
f'(up to {MAX_CHARS_LIMIT:,}) to read the rest')
|
|
259
|
+
body.append(piece)
|
|
260
|
+
shown.append(number)
|
|
261
|
+
size += len(piece) + 1
|
|
262
|
+
if cut:
|
|
263
|
+
break
|
|
264
|
+
returned = '\n\n'.join(body)
|
|
265
|
+
notes = [f'returned: {unit}s {_spans(shown)} (est. {estimate_tokens(returned):,} tokens)']
|
|
266
|
+
remaining = [number for number in numbers if number > shown[-1]]
|
|
267
|
+
if cut:
|
|
268
|
+
notes.append(cut)
|
|
269
|
+
if remaining:
|
|
270
|
+
notes.append(f'next: range="{remaining[0]}-{remaining[-1]}"' if not range_spec
|
|
271
|
+
else f'not returned: {unit}s {_spans(remaining)} (max_chars); ask for them with range')
|
|
272
|
+
return '\n'.join([head] + notes + ['---', returned])
|
package/document_text.py
CHANGED
|
@@ -115,39 +115,157 @@ def _local(tag):
|
|
|
115
115
|
return tag.rsplit('}', 1)[-1]
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
def _docx_namespace(root):
|
|
119
|
+
"""The WordprocessingML namespace prefix (transitional or strict) of a parsed part."""
|
|
120
|
+
return W if root.tag.startswith(W) else '{http://purl.oclc.org/ooxml/wordprocessingml/main}'
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _docx_text(paragraph, namespace):
|
|
124
|
+
"""Text of one `w:p`: runs, tabs and line breaks; a nested paragraph (text box) is not part of it."""
|
|
125
|
+
pending = list(reversed(paragraph))
|
|
126
|
+
pieces = []
|
|
127
|
+
while pending:
|
|
128
|
+
child = pending.pop()
|
|
129
|
+
if child.tag == namespace + 'p':
|
|
130
|
+
continue
|
|
131
|
+
if child.tag == namespace + 't':
|
|
132
|
+
pieces.append(child.text or '')
|
|
133
|
+
elif child.tag == namespace + 'tab':
|
|
134
|
+
pieces.append('\t')
|
|
135
|
+
elif child.tag in (namespace + 'br', namespace + 'cr'):
|
|
136
|
+
pieces.append('\n')
|
|
137
|
+
else:
|
|
138
|
+
pending.extend(reversed(child))
|
|
139
|
+
return ''.join(pieces)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _docx_extra_parts(package):
|
|
143
|
+
"""Names of the header, footer, footnote and endnote parts of a .docx, in a stable order."""
|
|
144
|
+
return sorted(name for name in package.entries if re.fullmatch(
|
|
145
|
+
r'word/(?:header\d+|footer\d+|footnotes|endnotes)\.xml', name))
|
|
146
|
+
|
|
147
|
+
|
|
118
148
|
def extract_docx(data):
|
|
119
149
|
"""Text of a .docx: body, headers, footers, footnotes and endnotes, one line per non-empty paragraph.
|
|
120
150
|
|
|
121
151
|
Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
|
|
122
152
|
corrupt, encrypted or oversized package, or one with a DOCTYPE or entity."""
|
|
123
153
|
with _open_package(data, 'DOCX', 'word/document.xml') as package:
|
|
124
|
-
names = ['word/document.xml'] + sorted(name for name in package.entries if re.fullmatch(
|
|
125
|
-
r'word/(?:header\d+|footer\d+|footnotes|endnotes)\.xml', name))
|
|
126
154
|
paragraphs = _Lines('DOCX')
|
|
127
|
-
for name in
|
|
155
|
+
for name in ['word/document.xml'] + _docx_extra_parts(package):
|
|
128
156
|
root = package.parse(name)
|
|
129
|
-
namespace =
|
|
157
|
+
namespace = _docx_namespace(root)
|
|
130
158
|
for paragraph in root.iter(namespace + 'p'):
|
|
131
|
-
|
|
132
|
-
pending = list(reversed(node))
|
|
133
|
-
while pending:
|
|
134
|
-
child = pending.pop()
|
|
135
|
-
if child.tag == namespace + 'p':
|
|
136
|
-
continue
|
|
137
|
-
if child.tag == namespace + 't':
|
|
138
|
-
yield child.text or ''
|
|
139
|
-
elif child.tag == namespace + 'tab':
|
|
140
|
-
yield '\t'
|
|
141
|
-
elif child.tag in (namespace + 'br', namespace + 'cr'):
|
|
142
|
-
yield '\n'
|
|
143
|
-
else:
|
|
144
|
-
pending.extend(reversed(child))
|
|
145
|
-
text = ''.join(parts(paragraph))
|
|
159
|
+
text = _docx_text(paragraph, namespace)
|
|
146
160
|
if text.strip():
|
|
147
161
|
paragraphs.add(text)
|
|
148
162
|
return ('\n'.join(paragraphs) + '\n' if paragraphs else '').encode('utf-8')
|
|
149
163
|
|
|
150
164
|
|
|
165
|
+
def _docx_heading_levels(package, namespace):
|
|
166
|
+
"""`{styleId: level}` of the heading styles of word/styles.xml (the style name is `heading N` in every language,
|
|
167
|
+
`Title` is level 1); empty when the part is missing."""
|
|
168
|
+
if 'word/styles.xml' not in package.entries:
|
|
169
|
+
return {}
|
|
170
|
+
levels = {}
|
|
171
|
+
for style in package.parse('word/styles.xml').iter(namespace + 'style'):
|
|
172
|
+
name = next((node.get(namespace + 'val', '') for node in style if node.tag == namespace + 'name'), '').lower()
|
|
173
|
+
found = re.fullmatch(r'heading (\d)', name)
|
|
174
|
+
if found or name == 'title':
|
|
175
|
+
levels[style.get(namespace + 'styleId')] = int(found.group(1)) if found else 1
|
|
176
|
+
return levels
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _docx_paragraph_line(paragraph, namespace, levels):
|
|
180
|
+
"""Markdown line of a paragraph: `#` heading by style or outline level, `- ` item (indented by level) for a list
|
|
181
|
+
paragraph, else the plain text; an empty string for a paragraph without text."""
|
|
182
|
+
text = ' '.join(_docx_text(paragraph, namespace).split())
|
|
183
|
+
if not text:
|
|
184
|
+
return ''
|
|
185
|
+
properties = next((node for node in paragraph if node.tag == namespace + 'pPr'), None)
|
|
186
|
+
if properties is None:
|
|
187
|
+
return text
|
|
188
|
+
style = next((node.get(namespace + 'val') for node in properties if node.tag == namespace + 'pStyle'), None)
|
|
189
|
+
outline = next((node.get(namespace + 'val') for node in properties if node.tag == namespace + 'outlineLvl'), None)
|
|
190
|
+
level = levels.get(style) or (int(outline) + 1 if outline and outline.isdigit() and int(outline) < 6 else 0)
|
|
191
|
+
if level:
|
|
192
|
+
return '#' * min(level, 6) + ' ' + text
|
|
193
|
+
numbering = next((node for node in properties if node.tag == namespace + 'numPr'), None)
|
|
194
|
+
if numbering is not None:
|
|
195
|
+
depth = next((node.get(namespace + 'val') for node in numbering if node.tag == namespace + 'ilvl'), '0')
|
|
196
|
+
return ' ' * (int(depth) if depth.isdigit() else 0) + '- ' + text
|
|
197
|
+
return text
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _docx_table_lines(table, namespace):
|
|
201
|
+
"""GFM table lines of a `w:tbl`: one row per `w:tr`, the first row as the header, cell text on one line."""
|
|
202
|
+
rows = []
|
|
203
|
+
for row in table:
|
|
204
|
+
if row.tag != namespace + 'tr':
|
|
205
|
+
continue
|
|
206
|
+
cells = []
|
|
207
|
+
for cell in row:
|
|
208
|
+
if cell.tag == namespace + 'tc':
|
|
209
|
+
text = ' '.join(' '.join(_docx_text(p, namespace).split()) for p in cell.iter(namespace + 'p'))
|
|
210
|
+
cells.append(' '.join(text.split()).replace('|', '\\|'))
|
|
211
|
+
if any(cells):
|
|
212
|
+
rows.append('| ' + ' | '.join(cells) + ' |')
|
|
213
|
+
if len(rows) > 1:
|
|
214
|
+
rows.insert(1, '|' + ' --- |' * (rows[0].count(' | ') + 1))
|
|
215
|
+
return rows
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _docx_body_items(node, namespace):
|
|
219
|
+
"""The paragraphs and tables of a body (or of a content control inside it) in document order."""
|
|
220
|
+
for child in node:
|
|
221
|
+
if child.tag in (namespace + 'p', namespace + 'tbl'):
|
|
222
|
+
yield child
|
|
223
|
+
elif child.tag == namespace + 'sdt':
|
|
224
|
+
for content in child:
|
|
225
|
+
if content.tag == namespace + 'sdtContent':
|
|
226
|
+
yield from _docx_body_items(content, namespace)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def docx_blocks(data):
|
|
230
|
+
"""Markdown blocks of a .docx in document order, for reading: headings as `#`, list items as `- `, tables as GFM
|
|
231
|
+
tables, other paragraphs as text. Footnotes, endnotes and the distinct header and footer lines follow under
|
|
232
|
+
`## Notes` and `## Headers and footers`.
|
|
233
|
+
|
|
234
|
+
Parameters: data (the file bytes). Returns: a list of blocks (a block is a paragraph line or a whole table, lines
|
|
235
|
+
joined by newline). Raises: DocumentError with a readable reason for a corrupt, encrypted, oversized or hostile
|
|
236
|
+
package, or one without text."""
|
|
237
|
+
with _open_package(data, 'DOCX', 'word/document.xml') as package:
|
|
238
|
+
root = package.parse('word/document.xml')
|
|
239
|
+
namespace = _docx_namespace(root)
|
|
240
|
+
levels = _docx_heading_levels(package, namespace)
|
|
241
|
+
blocks = _Lines('DOCX')
|
|
242
|
+
body = next((node for node in root if node.tag == namespace + 'body'), root)
|
|
243
|
+
for item in _docx_body_items(body, namespace):
|
|
244
|
+
if item.tag == namespace + 'tbl':
|
|
245
|
+
table = _docx_table_lines(item, namespace)
|
|
246
|
+
if table:
|
|
247
|
+
blocks.add('\n'.join(table))
|
|
248
|
+
else:
|
|
249
|
+
line = _docx_paragraph_line(item, namespace, levels)
|
|
250
|
+
if line:
|
|
251
|
+
blocks.add(line)
|
|
252
|
+
notes, frame = [], []
|
|
253
|
+
for name in _docx_extra_parts(package):
|
|
254
|
+
part = package.parse(name)
|
|
255
|
+
target = frame if re.search(r'header|footer', name) else notes
|
|
256
|
+
for paragraph in part.iter(_docx_namespace(part) + 'p'):
|
|
257
|
+
text = ' '.join(_docx_text(paragraph, _docx_namespace(part)).split())
|
|
258
|
+
if text and text not in target:
|
|
259
|
+
target.append(text)
|
|
260
|
+
if notes:
|
|
261
|
+
blocks.add('## Notes', *notes)
|
|
262
|
+
if frame:
|
|
263
|
+
blocks.add('## Headers and footers', *frame)
|
|
264
|
+
if not blocks:
|
|
265
|
+
raise DocumentError('DOCX has no text.')
|
|
266
|
+
return list(blocks)
|
|
267
|
+
|
|
268
|
+
|
|
151
269
|
def _drawing_paragraphs(root):
|
|
152
270
|
"""Non-empty paragraphs (`a:p`) of a slide or notes part, from the `a:t` runs; the slide-number field is not text."""
|
|
153
271
|
lines = []
|
|
@@ -173,26 +291,40 @@ def _drawing_paragraphs(root):
|
|
|
173
291
|
return lines
|
|
174
292
|
|
|
175
293
|
|
|
176
|
-
def
|
|
177
|
-
"""
|
|
294
|
+
def pptx_slides(data):
|
|
295
|
+
"""The slides of a .pptx in slide order, for extraction and for reading.
|
|
178
296
|
|
|
179
|
-
Parameters: data (the file bytes). Returns:
|
|
180
|
-
|
|
297
|
+
Parameters: data (the file bytes). Returns: a list of `(number, body lines, notes lines)`, a slide without text
|
|
298
|
+
included with empty lists. Raises: DocumentError with a readable reason for a corrupt, encrypted, oversized or hostile
|
|
299
|
+
package or one over MAX_TEXT_BYTES of text."""
|
|
181
300
|
def has_slides(names):
|
|
182
301
|
return any(re.fullmatch(r'ppt/slides/slide\d+\.xml', name) for name in names)
|
|
183
302
|
|
|
184
303
|
with _open_package(data, 'PPTX', has_slides) as package:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
304
|
+
numbered = sorted((int(m.group(1)), name) for name in package.entries
|
|
305
|
+
if (m := re.fullmatch(r'ppt/slides/slide(\d+)\.xml', name)))
|
|
306
|
+
budget = _Lines('PPTX')
|
|
307
|
+
slides = []
|
|
308
|
+
for number, name in numbered:
|
|
189
309
|
body = _drawing_paragraphs(package.parse(name))
|
|
190
|
-
if body:
|
|
191
|
-
lines.add(f'[slide {number}]', *body)
|
|
192
310
|
notes_name = f'ppt/notesSlides/notesSlide{number}.xml'
|
|
193
311
|
notes = _drawing_paragraphs(package.parse(notes_name)) if notes_name in package.entries else []
|
|
194
|
-
|
|
195
|
-
|
|
312
|
+
budget.add(*body, *notes)
|
|
313
|
+
slides.append((number, body, notes))
|
|
314
|
+
return slides
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def extract_pptx(data):
|
|
318
|
+
"""Text of a .pptx: each slide under `[slide N]`, then its speaker notes under `[notes N]`, in slide order.
|
|
319
|
+
|
|
320
|
+
Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
|
|
321
|
+
corrupt, encrypted, oversized or hostile package, or a deck without any text."""
|
|
322
|
+
lines = _Lines('PPTX')
|
|
323
|
+
for number, body, notes in pptx_slides(data):
|
|
324
|
+
if body:
|
|
325
|
+
lines.add(f'[slide {number}]', *body)
|
|
326
|
+
if notes:
|
|
327
|
+
lines.add(f'[notes {number}]', *notes)
|
|
196
328
|
if not lines:
|
|
197
329
|
raise DocumentError('PPTX has no text.')
|
|
198
330
|
return ('\n'.join(lines) + '\n').encode('utf-8')
|
|
@@ -264,14 +396,13 @@ def _sheet_rows(root, strings):
|
|
|
264
396
|
yield '\t'.join(values)
|
|
265
397
|
|
|
266
398
|
|
|
267
|
-
def
|
|
268
|
-
"""
|
|
269
|
-
|
|
270
|
-
value, nothing is calculated.
|
|
399
|
+
def xlsx_sheets(data):
|
|
400
|
+
"""The sheets of an .xlsx in workbook order, for extraction and for reading. Shared strings are resolved; numbers
|
|
401
|
+
and inline strings are kept; a formula contributes its cached value, nothing is calculated.
|
|
271
402
|
|
|
272
|
-
Parameters: data (the file bytes). Returns:
|
|
273
|
-
|
|
274
|
-
|
|
403
|
+
Parameters: data (the file bytes). Returns: a list of `(name, rows)`, `rows` being the tab-joined text of each
|
|
404
|
+
non-empty row (empty for a sheet without text). Raises: DocumentError with a readable reason for a corrupt,
|
|
405
|
+
encrypted, oversized or hostile package, or a workbook over XLSX_MAX_ROWS rows or MAX_TEXT_BYTES of text."""
|
|
275
406
|
with _open_package(data, 'XLSX', 'xl/workbook.xml') as package:
|
|
276
407
|
workbook = package.parse('xl/workbook.xml')
|
|
277
408
|
targets = {}
|
|
@@ -281,22 +412,40 @@ def extract_xlsx(data):
|
|
|
281
412
|
targets[rel.get('Id')] = posixpath.normpath(target.lstrip('/') if target.startswith('/')
|
|
282
413
|
else posixpath.join('xl', target))
|
|
283
414
|
strings = _shared_strings(package)
|
|
284
|
-
|
|
285
|
-
|
|
415
|
+
budget = _Lines('XLSX')
|
|
416
|
+
sheets = []
|
|
417
|
+
total = 0
|
|
286
418
|
for sheet in (node for node in workbook.iter() if _local(node.tag) == 'sheet'):
|
|
287
419
|
rid = next((value for key, value in sheet.attrib.items() if _local(key) == 'id'), None)
|
|
288
420
|
part = targets.get(rid)
|
|
289
421
|
if part not in package.entries:
|
|
290
422
|
raise DocumentError('Invalid XLSX or excessive structure.')
|
|
291
|
-
|
|
423
|
+
name = ' '.join(sheet.get('name', '').split())
|
|
424
|
+
rows = []
|
|
292
425
|
for line in _sheet_rows(package.parse(part), strings):
|
|
293
|
-
|
|
294
|
-
if
|
|
426
|
+
total += 1
|
|
427
|
+
if total > XLSX_MAX_ROWS:
|
|
295
428
|
raise DocumentError(f'XLSX over {XLSX_MAX_ROWS} rows.')
|
|
296
|
-
if
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
429
|
+
if not rows:
|
|
430
|
+
budget.add(f'[sheet {name}]')
|
|
431
|
+
budget.add(line)
|
|
432
|
+
rows.append(line)
|
|
433
|
+
sheets.append((name, rows))
|
|
434
|
+
return sheets
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def extract_xlsx(data):
|
|
438
|
+
"""Text of an .xlsx: each sheet, in workbook order, under `[sheet NAME]`, then one line per row with the cells
|
|
439
|
+
separated by tabs. Shared strings are resolved; numbers and inline strings are kept; a formula contributes its cached
|
|
440
|
+
value, nothing is calculated.
|
|
441
|
+
|
|
442
|
+
Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
|
|
443
|
+
corrupt, encrypted, oversized or hostile package, a workbook over XLSX_MAX_ROWS rows or MAX_TEXT_BYTES of text, or
|
|
444
|
+
one without any text."""
|
|
445
|
+
lines = _Lines('XLSX')
|
|
446
|
+
for name, rows in xlsx_sheets(data):
|
|
447
|
+
if rows:
|
|
448
|
+
lines.add(f'[sheet {name}]', *rows)
|
|
300
449
|
if not lines:
|
|
301
450
|
raise DocumentError('XLSX has no text.')
|
|
302
451
|
return ('\n'.join(lines) + '\n').encode('utf-8')
|
|
@@ -320,14 +469,15 @@ print(json.dumps({"pages": pages, "count": len(reader.pages)}))
|
|
|
320
469
|
"""
|
|
321
470
|
|
|
322
471
|
|
|
323
|
-
def
|
|
324
|
-
"""Text of a PDF (pypdf, pure Python)
|
|
325
|
-
|
|
472
|
+
def pdf_pages(data, timeout, max_pages, max_chars):
|
|
473
|
+
"""Text of each page of a PDF (pypdf, pure Python). A hostile PDF can make pypdf parse for minutes, so it runs in an
|
|
474
|
+
isolated subprocess (`-I`) killed at the deadline.
|
|
326
475
|
|
|
327
476
|
Parameters: data (PDF bytes), timeout (seconds before the subprocess is killed), max_pages, max_chars (cap on the
|
|
328
|
-
text read, over all pages). Returns: the
|
|
329
|
-
|
|
330
|
-
the last line pypdf reported), all
|
|
477
|
+
text read, over all pages). Returns: `(pages, count)`: the text of the pages read (an empty string for a page
|
|
478
|
+
without text) and the page count of the file, which is larger when a cap cut the reading. Raises: PdfTimeout,
|
|
479
|
+
PdfEncrypted (a password is needed), PdfUnreadable (corrupt; `detail` has the last line pypdf reported), all
|
|
480
|
+
DocumentError."""
|
|
331
481
|
try:
|
|
332
482
|
done = subprocess.run([sys.executable, "-I", "-c", PDF_SCRIPT, str(max_pages), str(max_chars)],
|
|
333
483
|
input=data, capture_output=True, timeout=timeout,
|
|
@@ -341,11 +491,19 @@ def run_pdf(data, timeout, max_pages, max_chars):
|
|
|
341
491
|
result = json.loads(done.stdout)
|
|
342
492
|
if result.get("encrypted"):
|
|
343
493
|
raise PdfEncrypted("PDF protected by a password.")
|
|
344
|
-
|
|
494
|
+
return result["pages"], result["count"]
|
|
345
495
|
except (ValueError, KeyError, AttributeError) as exc:
|
|
346
496
|
if isinstance(exc, PdfEncrypted):
|
|
347
497
|
raise
|
|
348
498
|
raise PdfUnreadable("PDF could not be read.", f"unexpected extractor output ({type(exc).__name__})") from None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def run_pdf(data, timeout, max_pages, max_chars):
|
|
502
|
+
"""Text of a PDF through pdf_pages, with `[page N]` markers.
|
|
503
|
+
|
|
504
|
+
Parameters: as pdf_pages. Returns: the marked text, with a `[pages A-B not read]` line when a cap cut it; empty
|
|
505
|
+
pages are left out. Raises: as pdf_pages."""
|
|
506
|
+
pages, count = pdf_pages(data, timeout, max_pages, max_chars)
|
|
349
507
|
text = "\n\n".join(f"[page {number}]\n{page}" for number, page in enumerate(pages, 1) if page)
|
|
350
508
|
if count > len(pages):
|
|
351
509
|
text += f"\n\n[pages {len(pages) + 1}-{count} not read]"
|
package/folder_picker.py
CHANGED
|
@@ -6,14 +6,16 @@ import threading
|
|
|
6
6
|
import urllib.request
|
|
7
7
|
import webbrowser
|
|
8
8
|
|
|
9
|
+
import i18n
|
|
10
|
+
|
|
9
11
|
_PICKER_LOCK = threading.Lock()
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
def choose_folder():
|
|
13
15
|
if os.name != "nt":
|
|
14
|
-
raise RuntimeError("Native folder selection is only available on Windows; enter the folder path.")
|
|
16
|
+
raise RuntimeError(i18n.t("Native folder selection is only available on Windows; enter the folder path."))
|
|
15
17
|
if not _PICKER_LOCK.acquire(blocking=False):
|
|
16
|
-
raise ValueError("A folder picker is already open.")
|
|
18
|
+
raise ValueError(i18n.t("A folder picker is already open."))
|
|
17
19
|
import ctypes
|
|
18
20
|
from ctypes import wintypes
|
|
19
21
|
|
|
@@ -37,14 +39,14 @@ def choose_folder():
|
|
|
37
39
|
label = ctypes.create_unicode_buffer(32768)
|
|
38
40
|
info = BrowseInfo()
|
|
39
41
|
info.pszDisplayName = ctypes.cast(label, wintypes.LPWSTR)
|
|
40
|
-
info.lpszTitle = "Smart Tool — select the folder to index"
|
|
42
|
+
info.lpszTitle = i18n.t("Smart Tool — select the folder to index")
|
|
41
43
|
info.ulFlags = 0x0001 | 0x0040 # Diretórios reais; diálogo redimensionável.
|
|
42
44
|
selected = shell.SHBrowseForFolderW(ctypes.byref(info))
|
|
43
45
|
if not selected:
|
|
44
46
|
return None
|
|
45
47
|
path = ctypes.create_unicode_buffer(32768)
|
|
46
48
|
if not shell.SHGetPathFromIDListEx(selected, path, len(path), 0):
|
|
47
|
-
raise ValueError("The selection is not an accessible folder.")
|
|
49
|
+
raise ValueError(i18n.t("The selection is not an accessible folder."))
|
|
48
50
|
return path.value
|
|
49
51
|
finally:
|
|
50
52
|
if selected:
|
|
@@ -64,7 +66,7 @@ def register_from_tray():
|
|
|
64
66
|
page = response.read().decode("utf-8")
|
|
65
67
|
match = re.search(r'<meta name="setup-token" content="([a-zA-Z0-9_-]+)">', page)
|
|
66
68
|
if not match:
|
|
67
|
-
raise RuntimeError("Reopen Smart Tool to load the updated projects page.")
|
|
69
|
+
raise RuntimeError(i18n.t("Reopen Smart Tool to load the updated projects page."))
|
|
68
70
|
request = urllib.request.Request(url + "/setup/projects", method="POST",
|
|
69
71
|
headers={"Content-Type": "application/json", "X-Setup-Token": match.group(1)},
|
|
70
72
|
data=json.dumps({"action": "register", "project_root": root}).encode("utf-8"))
|