dsh-doc-assistant 0.1.14 → 0.1.18
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/README.md +27 -2
- package/lib/client.js +590 -174
- package/package.json +3 -3
- package/src/client/20-api.js +2 -0
- package/src/client/40-revisions.js +67 -24
- package/src/client/50-library.js +1 -1
- package/src/client/60-reader.js +505 -146
- package/src/client/70-shelf.js +14 -2
- package/src/host/chat.js +43 -13
- package/src/host/covers.js +56 -15
- package/src/host/docir.js +26 -3
- package/src/host/legacyWord.js +91 -0
- package/src/host/modelVision.js +44 -0
- package/src/host/ocr.js +120 -210
- package/src/host/ocr_vision.js +39 -0
- package/src/host/ocr_windows.ps1 +89 -0
- package/src/host/office.js +3 -1
- package/src/host/officeImages.js +269 -0
- package/src/host/office_tool.py +451 -29
- package/src/host/preview.js +132 -28
- package/src/host/revise.js +146 -21
- package/src/host/revisionBoundary.js +45 -0
- package/src/host/revisionRebase.js +123 -0
- package/src/host/revisions.js +20 -6
- package/src/host/routes.js +18 -5
- package/src/host/tree.js +1 -0
- package/src/host/upload.js +113 -11
- package/src/host/visionOcr.js +101 -0
- package/src/host/visualContext.js +248 -0
- package/src/host/wordLayout.js +131 -0
- package/src/host/wordSelection.js +114 -0
- package/src/host/writeback.js +108 -19
- package/src/host/writers/index.js +1 -0
package/src/host/office_tool.py
CHANGED
|
@@ -41,6 +41,8 @@ W_BODY = f'{{{W}}}body'
|
|
|
41
41
|
W_PPR = f'{{{W}}}pPr'
|
|
42
42
|
W_RPR = f'{{{W}}}rPr'
|
|
43
43
|
W_SECT = f'{{{W}}}sectPr'
|
|
44
|
+
DSH_BREAK_RSID = 'D5A00001'
|
|
45
|
+
DSH_MERGED_RSID = 'D5A00002'
|
|
44
46
|
MC_FALLBACK = f'{{{MC}}}Fallback'
|
|
45
47
|
A_T = f'{{{A}}}t'
|
|
46
48
|
|
|
@@ -53,6 +55,9 @@ AUTHOR = '文档助手'
|
|
|
53
55
|
|
|
54
56
|
class ToolError(Exception):
|
|
55
57
|
"""Chinese, user-facing, no traceback."""
|
|
58
|
+
def __init__(self, message, code=None):
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.code = code
|
|
56
61
|
|
|
57
62
|
|
|
58
63
|
def local(tag):
|
|
@@ -154,11 +159,17 @@ def repack(src_path, dest_path, updates):
|
|
|
154
159
|
|
|
155
160
|
def visible_text(paragraph):
|
|
156
161
|
parts = []
|
|
157
|
-
for el in paragraph.iter(
|
|
162
|
+
for el in paragraph.iter():
|
|
158
163
|
if in_fallback(el) or ancestor_is(el, W_DEL) or ancestor_is(el, qn(W, 'moveFrom')):
|
|
159
164
|
continue
|
|
160
|
-
if el.text:
|
|
165
|
+
if el.tag == W_T and el.text:
|
|
161
166
|
parts.append(el.text)
|
|
167
|
+
elif el.tag == qn(W, 'br'):
|
|
168
|
+
run = el.getparent()
|
|
169
|
+
# Existing soft breaks retain the legacy offset space. Only new
|
|
170
|
+
# plugin insertion runs opt into preserving real line breaks.
|
|
171
|
+
if run is not None and run.tag == qn(W, 'r') and run.get(qn(W, 'rsidR')) == DSH_BREAK_RSID:
|
|
172
|
+
parts.append('\n')
|
|
162
173
|
return ''.join(parts)
|
|
163
174
|
|
|
164
175
|
|
|
@@ -222,7 +233,7 @@ def core_title(zf):
|
|
|
222
233
|
return (el.text or '').strip() if el is not None else ''
|
|
223
234
|
|
|
224
235
|
|
|
225
|
-
def table_rows(tbl, para_of):
|
|
236
|
+
def table_rows(tbl, para_of, layout_styles=None):
|
|
226
237
|
rows = []
|
|
227
238
|
truncated = False
|
|
228
239
|
reason = ''
|
|
@@ -245,7 +256,7 @@ def table_rows(tbl, para_of):
|
|
|
245
256
|
text = visible_text(paragraph)
|
|
246
257
|
if text:
|
|
247
258
|
texts.append(text)
|
|
248
|
-
paragraphs.append({'text': text, 'para': para_of[id(paragraph)]})
|
|
259
|
+
paragraphs.append({'text': text, 'para': para_of[id(paragraph)], 'wordLayout': word_paragraph_layout(paragraph, layout_styles)})
|
|
249
260
|
cells.append({'text': '\n'.join(texts), 'paragraphs': paragraphs})
|
|
250
261
|
rows.append({'cells': cells})
|
|
251
262
|
return rows, truncated, reason
|
|
@@ -259,6 +270,7 @@ def extract_docx(path):
|
|
|
259
270
|
raise ToolError('这个文件不是有效的 Word 文档(缺少正文)') from error
|
|
260
271
|
root = etree.fromstring(xml)
|
|
261
272
|
styles = heading_styles(zf)
|
|
273
|
+
layout_styles = word_layout_styles(zf)
|
|
262
274
|
title = core_title(zf)
|
|
263
275
|
paragraphs = all_paragraphs(root)
|
|
264
276
|
para_of = {id(p): index for index, p in enumerate(paragraphs)}
|
|
@@ -294,13 +306,14 @@ def extract_docx(path):
|
|
|
294
306
|
'type': 'heading' if level else 'para',
|
|
295
307
|
'text': text,
|
|
296
308
|
'para': para_of.get(id(child), 0),
|
|
309
|
+
'wordLayout': word_paragraph_layout(child, layout_styles),
|
|
297
310
|
}
|
|
298
311
|
if level:
|
|
299
312
|
block['level'] = level
|
|
300
313
|
if not emit(block):
|
|
301
314
|
return
|
|
302
315
|
elif child.tag == W_TBL:
|
|
303
|
-
rows, row_cut, row_reason = table_rows(child, para_of)
|
|
316
|
+
rows, row_cut, row_reason = table_rows(child, para_of, layout_styles)
|
|
304
317
|
if row_cut and row_reason:
|
|
305
318
|
truncated = True
|
|
306
319
|
reasons.append(row_reason)
|
|
@@ -334,6 +347,157 @@ def first_rpr(paragraph):
|
|
|
334
347
|
return None
|
|
335
348
|
|
|
336
349
|
|
|
350
|
+
def word_layout_styles(zf):
|
|
351
|
+
try:
|
|
352
|
+
root = etree.fromstring(zf.read('word/styles.xml'))
|
|
353
|
+
except KeyError:
|
|
354
|
+
return {}
|
|
355
|
+
return {style.get(qn(W, 'styleId')): style for style in root.findall(qn(W, 'style'))}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def effective_paragraph_property(paragraph, name, styles=None):
|
|
359
|
+
ppr = paragraph.find(W_PPR)
|
|
360
|
+
node = ppr.find(qn(W, name)) if ppr is not None else None
|
|
361
|
+
if node is not None:
|
|
362
|
+
return node
|
|
363
|
+
sid = ppr.find(qn(W, 'pStyle')) if ppr is not None else None
|
|
364
|
+
style_id = sid.get(qn(W, 'val')) if sid is not None else None
|
|
365
|
+
if style_id is None:
|
|
366
|
+
style_id = next((sid for sid, style in (styles or {}).items()
|
|
367
|
+
if style.get(qn(W, 'type')) == 'paragraph' and style.get(qn(W, 'default')) in ('1', 'true', 'on')), None)
|
|
368
|
+
seen = set()
|
|
369
|
+
while style_id and style_id not in seen:
|
|
370
|
+
seen.add(style_id)
|
|
371
|
+
style = (styles or {}).get(style_id)
|
|
372
|
+
if style is None:
|
|
373
|
+
break
|
|
374
|
+
properties = style.find(W_PPR)
|
|
375
|
+
node = properties.find(qn(W, name)) if properties is not None else None
|
|
376
|
+
if node is not None:
|
|
377
|
+
return node
|
|
378
|
+
base = style.find(qn(W, 'basedOn'))
|
|
379
|
+
style_id = base.get(qn(W, 'val')) if base is not None else None
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def effective_run_property(paragraph, run, name, styles=None):
|
|
384
|
+
rpr = run.find(W_RPR)
|
|
385
|
+
direct = rpr.find(qn(W, name)) if rpr is not None else None
|
|
386
|
+
if direct is not None:
|
|
387
|
+
return direct
|
|
388
|
+
run_style = rpr.find(qn(W, 'rStyle')) if rpr is not None else None
|
|
389
|
+
ppr = paragraph.find(W_PPR)
|
|
390
|
+
pstyle = ppr.find(qn(W, 'pStyle')) if ppr is not None else None
|
|
391
|
+
default = next((sid for sid, style in (styles or {}).items()
|
|
392
|
+
if style.get(qn(W, 'type')) == 'paragraph' and style.get(qn(W, 'default')) in ('1', 'true', 'on')), None)
|
|
393
|
+
for initial in (run_style.get(qn(W, 'val')) if run_style is not None else None,
|
|
394
|
+
pstyle.get(qn(W, 'val')) if pstyle is not None else default):
|
|
395
|
+
sid, seen = initial, set()
|
|
396
|
+
while sid and sid not in seen:
|
|
397
|
+
seen.add(sid)
|
|
398
|
+
style = (styles or {}).get(sid)
|
|
399
|
+
if style is None:
|
|
400
|
+
break
|
|
401
|
+
properties = style.find(W_RPR)
|
|
402
|
+
node = properties.find(qn(W, name)) if properties is not None else None
|
|
403
|
+
if node is not None:
|
|
404
|
+
return node
|
|
405
|
+
base = style.find(qn(W, 'basedOn'))
|
|
406
|
+
sid = base.get(qn(W, 'val')) if base is not None else None
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def word_paragraph_layout(paragraph, styles=None):
|
|
411
|
+
"""Describe layout constraints; the writer independently rechecks XML."""
|
|
412
|
+
ppr = paragraph.find(W_PPR)
|
|
413
|
+
frame = effective_paragraph_property(paragraph, 'framePr', styles)
|
|
414
|
+
spacing = effective_paragraph_property(paragraph, 'spacing', styles)
|
|
415
|
+
names = {local(node.tag) for node in paragraph.iter() if isinstance(node.tag, str)}
|
|
416
|
+
text_box = 'txbxContent' in names or ancestor_is(paragraph, qn(W, 'txbxContent'))
|
|
417
|
+
objects = sorted(names.intersection({'drawing', 'pict', 'object', 'fldChar', 'fldSimple', 'hyperlink', 'footnoteReference', 'endnoteReference'}))
|
|
418
|
+
floating = []
|
|
419
|
+
wp = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
|
|
420
|
+
for anchor in paragraph.iter(qn(wp, 'anchor')):
|
|
421
|
+
floating.append({'kind': 'drawing', 'foreground': anchor.get('behindDoc') not in ('1', 'true', 'on')})
|
|
422
|
+
for shape in paragraph.iter('{urn:schemas-microsoft-com:vml}shape'):
|
|
423
|
+
props = dict((item.split(':', 1)[0].strip().lower(), item.split(':', 1)[1].strip())
|
|
424
|
+
for item in (shape.get('style') or '').split(';') if ':' in item)
|
|
425
|
+
if props.get('position') == 'absolute':
|
|
426
|
+
floating.append({'kind': 'vml', 'foreground': not props.get('z-index', '').startswith('-')})
|
|
427
|
+
row = paragraph.getparent()
|
|
428
|
+
while row is not None and row.tag != qn(W, 'tr'):
|
|
429
|
+
row = row.getparent()
|
|
430
|
+
row_pr = row.find(qn(W, 'trPr')) if row is not None else None
|
|
431
|
+
height = row_pr.find(qn(W, 'trHeight')) if row_pr is not None else None
|
|
432
|
+
table = paragraph.getparent()
|
|
433
|
+
while table is not None and table.tag != W_TBL:
|
|
434
|
+
table = table.getparent()
|
|
435
|
+
table_pr = table.find(qn(W, 'tblPr')) if table is not None else None
|
|
436
|
+
floating_table = table_pr.find(qn(W, 'tblpPr')) if table_pr is not None else None
|
|
437
|
+
page_width, margin_left = 12240, 1440
|
|
438
|
+
owner = paragraph
|
|
439
|
+
while owner.getparent() is not None and owner.getparent().tag != W_BODY:
|
|
440
|
+
owner = owner.getparent()
|
|
441
|
+
body = owner.getparent()
|
|
442
|
+
if body is not None and body.tag == W_BODY:
|
|
443
|
+
following = list(body)[list(body).index(owner):]
|
|
444
|
+
section = next((child if child.tag == W_SECT else child.find('./' + W_PPR + '/' + W_SECT)
|
|
445
|
+
for child in following if child.tag == W_SECT or child.find('./' + W_PPR + '/' + W_SECT) is not None), None)
|
|
446
|
+
if section is not None:
|
|
447
|
+
size, margins = section.find(qn(W, 'pgSz')), section.find(qn(W, 'pgMar'))
|
|
448
|
+
if size is not None:
|
|
449
|
+
page_width = int(size.get(qn(W, 'w')) or page_width)
|
|
450
|
+
if margins is not None:
|
|
451
|
+
margin_left = int(margins.get(qn(W, 'left')) or 0)
|
|
452
|
+
return {'version': 1, 'mode': 'fixed' if frame is not None or text_box or floating_table is not None else 'flow',
|
|
453
|
+
**({'frame': {local(k): v for k, v in frame.attrib.items()}} if frame is not None else {}),
|
|
454
|
+
**({'pageWidthTwips': page_width, 'marginLeftTwips': margin_left} if frame is not None else {}),
|
|
455
|
+
**({'lineRule': spacing.get(qn(W, 'lineRule')), 'lineTwips': spacing.get(qn(W, 'line'))} if spacing is not None else {}),
|
|
456
|
+
**({'rowHeight': {local(k): v for k, v in height.attrib.items()}} if height is not None else {}),
|
|
457
|
+
**({'textBox': True} if text_box else {}), **({'objects': objects} if objects else {}),
|
|
458
|
+
**({'floatingObjects': floating} if floating else {}),
|
|
459
|
+
**({'floatingTable': {local(k): value for k, value in floating_table.attrib.items()}} if floating_table is not None else {}),
|
|
460
|
+
**({'sectionBreak': True} if ppr is not None and ppr.find(W_SECT) is not None else {})}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def protect_word_layout(paragraphs, locations, styles=None):
|
|
464
|
+
"""Permit flow to grow, preserving widths; never flatten opaque objects."""
|
|
465
|
+
for location in locations:
|
|
466
|
+
for para, *_ in location['segments']:
|
|
467
|
+
paragraph = paragraphs[para]
|
|
468
|
+
layout = word_paragraph_layout(paragraph, styles)
|
|
469
|
+
if layout.get('textBox') or layout.get('objects'):
|
|
470
|
+
raise ToolError('这段文字与图片、文本框、链接或复杂域位于同一段,无法安全写回。修订尚未写入。请点击文档标题右侧的 Office 图标,手动打开文档调整排版。',
|
|
471
|
+
'WORD_LAYOUT_UNSUPPORTED')
|
|
472
|
+
ppr = paragraph.find(W_PPR)
|
|
473
|
+
if ppr is not None and ppr.find(W_SECT) is not None and location['compound']:
|
|
474
|
+
raise ToolError('选区跨越分节或分栏边界,无法合并写回;请在同一正文区域内选择')
|
|
475
|
+
# Fixed frames are left exactly as they are. The host renders both
|
|
476
|
+
# temporary versions and checks them before publication.
|
|
477
|
+
if layout['mode'] != 'flow':
|
|
478
|
+
continue
|
|
479
|
+
inherited = effective_paragraph_property(paragraph, 'spacing', styles)
|
|
480
|
+
if inherited is not None and inherited.get(qn(W, 'lineRule')) == 'exact':
|
|
481
|
+
if ppr is None:
|
|
482
|
+
ppr = etree.Element(W_PPR)
|
|
483
|
+
paragraph.insert(0, ppr)
|
|
484
|
+
spacing = ppr.find(qn(W, 'spacing'))
|
|
485
|
+
if spacing is None:
|
|
486
|
+
spacing = deepcopy(inherited)
|
|
487
|
+
ppr.append(spacing)
|
|
488
|
+
spacing.set(qn(W, 'lineRule'), 'atLeast')
|
|
489
|
+
row = paragraph.getparent()
|
|
490
|
+
while row is not None and row.tag != qn(W, 'tr'):
|
|
491
|
+
row = row.getparent()
|
|
492
|
+
row_pr = row.find(qn(W, 'trPr')) if row is not None else None
|
|
493
|
+
if row_pr is not None:
|
|
494
|
+
for height in row_pr.findall(qn(W, 'trHeight')):
|
|
495
|
+
if height.get(qn(W, 'hRule')) == 'exact':
|
|
496
|
+
height.set(qn(W, 'hRule'), 'atLeast')
|
|
497
|
+
for lock in row_pr.findall(qn(W, 'cantSplit')):
|
|
498
|
+
row_pr.remove(lock)
|
|
499
|
+
|
|
500
|
+
|
|
337
501
|
def add_text_run(parent, tag_text, text, rpr, kind, rev_id):
|
|
338
502
|
"""Append plain / ins / del content. Empty text appends nothing."""
|
|
339
503
|
if text == '':
|
|
@@ -353,8 +517,14 @@ def add_text_run(parent, tag_text, text, rpr, kind, rev_id):
|
|
|
353
517
|
run = etree.SubElement(host, qn(W, 'r'))
|
|
354
518
|
if rpr is not None:
|
|
355
519
|
run.append(deepcopy(rpr))
|
|
356
|
-
|
|
357
|
-
|
|
520
|
+
if '\n' in text:
|
|
521
|
+
run.set(qn(W, 'rsidR'), DSH_BREAK_RSID)
|
|
522
|
+
for index, piece in enumerate(text.split('\n')):
|
|
523
|
+
if index:
|
|
524
|
+
etree.SubElement(run, qn(W, 'br'))
|
|
525
|
+
if piece:
|
|
526
|
+
node = etree.SubElement(run, qn(W, tag_text))
|
|
527
|
+
set_space(node, piece)
|
|
358
528
|
|
|
359
529
|
|
|
360
530
|
def utf16_position(text, offset):
|
|
@@ -388,16 +558,22 @@ def rewrite_paragraph(paragraph, original, revised, rev_id, anchor):
|
|
|
388
558
|
if text.find(original, at + 1) >= 0:
|
|
389
559
|
raise ToolError('原文在同一段里出现了多次,无法确定要改哪一处')
|
|
390
560
|
end = at + len(original)
|
|
391
|
-
before
|
|
561
|
+
before = styled_text_slice(paragraph, 0, at)
|
|
562
|
+
after = styled_text_slice(paragraph, end, len(text))
|
|
563
|
+
selected = styled_text_slice(paragraph, at, end)
|
|
564
|
+
inserted = revised_body_runs(selected, revised)
|
|
392
565
|
ppr = paragraph.find(W_PPR)
|
|
393
|
-
rpr = first_rpr(paragraph)
|
|
394
566
|
for child in list(paragraph):
|
|
395
567
|
if child is not ppr:
|
|
396
568
|
paragraph.remove(child)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
569
|
+
for content, style in before:
|
|
570
|
+
add_text_run(paragraph, 't', content, style, 'plain', rev_id)
|
|
571
|
+
for content, style in selected:
|
|
572
|
+
add_text_run(paragraph, 'delText', content, style, 'del', rev_id)
|
|
573
|
+
for content, style in inserted:
|
|
574
|
+
add_text_run(paragraph, 't', content, style, 'ins', rev_id + 1)
|
|
575
|
+
for content, style in after:
|
|
576
|
+
add_text_run(paragraph, 't', content, style, 'plain', rev_id)
|
|
401
577
|
|
|
402
578
|
|
|
403
579
|
def locate_paragraph(paragraphs, revision):
|
|
@@ -435,6 +611,240 @@ def enable_track_revisions(zf):
|
|
|
435
611
|
return None
|
|
436
612
|
|
|
437
613
|
|
|
614
|
+
def word_source_positions(root):
|
|
615
|
+
"""The same block/cell joins as DocIR, with actual physical paragraph ids."""
|
|
616
|
+
paragraphs = all_paragraphs(root)
|
|
617
|
+
para_of = {id(p): index for index, p in enumerate(paragraphs)}
|
|
618
|
+
blocks = []
|
|
619
|
+
|
|
620
|
+
def walk(parent):
|
|
621
|
+
for child in parent:
|
|
622
|
+
if child.tag == MC_FALLBACK or in_fallback(child):
|
|
623
|
+
continue
|
|
624
|
+
if child.tag == W_P:
|
|
625
|
+
text = visible_text(child)
|
|
626
|
+
if text:
|
|
627
|
+
blocks.append((text, [(para_of[id(child)], 0, len(text))]))
|
|
628
|
+
elif child.tag == W_TBL:
|
|
629
|
+
rows, positions, cursor = [], [], 0
|
|
630
|
+
for row_index, tr in enumerate(child.findall(qn(W, 'tr'))):
|
|
631
|
+
if row_index:
|
|
632
|
+
cursor += 1
|
|
633
|
+
cells = []
|
|
634
|
+
for cell_index, tc in enumerate(tr.findall(qn(W, 'tc'))):
|
|
635
|
+
if cell_index:
|
|
636
|
+
cursor += 3
|
|
637
|
+
texts = []
|
|
638
|
+
for p in tc.iter(W_P):
|
|
639
|
+
if in_fallback(p):
|
|
640
|
+
continue
|
|
641
|
+
text = visible_text(p)
|
|
642
|
+
if not text:
|
|
643
|
+
continue
|
|
644
|
+
if texts:
|
|
645
|
+
cursor += 1
|
|
646
|
+
positions.append((para_of[id(p)], cursor, cursor + len(text)))
|
|
647
|
+
texts.append(text)
|
|
648
|
+
cursor += len(text)
|
|
649
|
+
cells.append('\n'.join(texts))
|
|
650
|
+
rows.append(cells)
|
|
651
|
+
text = '\n'.join(' | '.join(cells) for cells in rows) if any(any(cells) for cells in rows) else ''
|
|
652
|
+
blocks.append((text, positions))
|
|
653
|
+
elif child.tag != W_SECT:
|
|
654
|
+
walk(child)
|
|
655
|
+
|
|
656
|
+
body = root.find(W_BODY)
|
|
657
|
+
if body is None:
|
|
658
|
+
raise ToolError('Word 文档缺少正文')
|
|
659
|
+
walk(body)
|
|
660
|
+
source, positions, cursor = [], {}, 0
|
|
661
|
+
for text, spans in blocks:
|
|
662
|
+
for para, start, end in spans:
|
|
663
|
+
positions[para] = (cursor + start, cursor + end)
|
|
664
|
+
source.append(text)
|
|
665
|
+
cursor += len(text) + 1
|
|
666
|
+
return '\n'.join(source), positions
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def word_revision_location(paragraphs, revision, source, positions):
|
|
670
|
+
"""Preflight all source slices before any paragraph is rewritten."""
|
|
671
|
+
anchor = revision.get('anchor') or {}
|
|
672
|
+
segments = anchor.get('segments')
|
|
673
|
+
if not isinstance(segments, list):
|
|
674
|
+
paragraph = locate_paragraph(paragraphs, revision)
|
|
675
|
+
para = paragraphs.index(paragraph)
|
|
676
|
+
text = visible_text(paragraph)
|
|
677
|
+
original = str(revision.get('original') or '')
|
|
678
|
+
start, end = paragraph_span(text, anchor, original) if 'paraStart' in anchor else (text.index(original), text.index(original) + len(original))
|
|
679
|
+
if para not in positions:
|
|
680
|
+
raise ToolError('选区无法定位到 Word 正文')
|
|
681
|
+
return {'revision': revision, 'segments': [(para, start, end, original)],
|
|
682
|
+
'start': positions[para][0] + start, 'end': positions[para][0] + end, 'compound': False}
|
|
683
|
+
if not segments or len(segments) > 256:
|
|
684
|
+
raise ToolError('Word 复合选区段落数量不正确')
|
|
685
|
+
spans, originals, previous = [], [], -1
|
|
686
|
+
for segment in segments:
|
|
687
|
+
para = segment.get('para')
|
|
688
|
+
if type(para) is not int or not 0 <= para < len(paragraphs) or para not in positions:
|
|
689
|
+
raise ToolError('复合选区的段落位置已经变化')
|
|
690
|
+
text = visible_text(paragraphs[para])
|
|
691
|
+
start = utf16_position(text, segment.get('paraStart'))
|
|
692
|
+
end = utf16_position(text, segment.get('paraEnd'))
|
|
693
|
+
if start is None or end is None or end <= start:
|
|
694
|
+
raise ToolError('复合选区的字符位置不正确')
|
|
695
|
+
original = text[start:end]
|
|
696
|
+
if 'original' in segment and segment['original'] != original:
|
|
697
|
+
raise ToolError('复合选区原文已经变化,请重新选择')
|
|
698
|
+
first, last = positions[para][0] + start, positions[para][0] + end
|
|
699
|
+
if first < previous or (previous >= 0 and source[previous:first].strip()):
|
|
700
|
+
raise ToolError('复合选区重叠或包含未选中的正文')
|
|
701
|
+
if utf16_position(source, segment.get('srcStart')) != first or utf16_position(source, segment.get('srcEnd')) != last:
|
|
702
|
+
raise ToolError('复合选区与文档原文位置不一致')
|
|
703
|
+
spans.append((para, start, end, original))
|
|
704
|
+
originals.append(original)
|
|
705
|
+
previous = last
|
|
706
|
+
if '\n'.join(originals) != str(revision.get('original') or ''):
|
|
707
|
+
raise ToolError('复合选区没有包含全部原文,请重新选择')
|
|
708
|
+
parent = paragraphs[spans[0][0]].getparent()
|
|
709
|
+
if any(paragraphs[para].getparent() != parent for para, *_ in spans):
|
|
710
|
+
raise ToolError('选区跨越表格单元格或不同正文区域')
|
|
711
|
+
children = list(parent)
|
|
712
|
+
first_child = children.index(paragraphs[spans[0][0]])
|
|
713
|
+
last_child = children.index(paragraphs[spans[-1][0]])
|
|
714
|
+
selected = {para for para, *_ in spans}
|
|
715
|
+
for child in children[first_child:last_child + 1]:
|
|
716
|
+
if child.tag != W_P:
|
|
717
|
+
raise ToolError('选区跨越表格或非正文结构,无法合并写回')
|
|
718
|
+
if paragraphs.index(child) not in selected and visible_text(child).strip():
|
|
719
|
+
raise ToolError('选区中间有未选中的段落,无法合并写回')
|
|
720
|
+
if any(node.tag in {qn(W, name) for name in ('drawing', 'pict', 'object', 'fldChar', 'fldSimple', 'hyperlink')}
|
|
721
|
+
for node in child.iter()):
|
|
722
|
+
raise ToolError('选区所在段落包含图片、链接或复杂域,暂不支持跨段合并写回')
|
|
723
|
+
first, last = positions[spans[0][0]][0] + spans[0][1], positions[spans[-1][0]][0] + spans[-1][2]
|
|
724
|
+
if ('srcStart' in anchor and utf16_position(source, anchor['srcStart']) != first) or ('srcEnd' in anchor and utf16_position(source, anchor['srcEnd']) != last):
|
|
725
|
+
raise ToolError('复合选区完整边界不一致')
|
|
726
|
+
return {'revision': revision, 'segments': spans, 'start': first, 'end': last, 'compound': True}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def styled_text_slice(paragraph, start, end):
|
|
730
|
+
"""Retain each untouched prefix/suffix run's formatting during a merge."""
|
|
731
|
+
chunks, at = [], 0
|
|
732
|
+
for node in paragraph.iter():
|
|
733
|
+
if in_fallback(node) or ancestor_is(node, W_DEL) or ancestor_is(node, qn(W, 'moveFrom')):
|
|
734
|
+
continue
|
|
735
|
+
run = node.getparent()
|
|
736
|
+
text = node.text or '' if node.tag == W_T else ''
|
|
737
|
+
if node.tag == qn(W, 'br') and run is not None and run.get(qn(W, 'rsidR')) == DSH_BREAK_RSID:
|
|
738
|
+
text = '\n'
|
|
739
|
+
if not text:
|
|
740
|
+
continue
|
|
741
|
+
lo, hi = max(start, at), min(end, at + len(text))
|
|
742
|
+
if hi > lo:
|
|
743
|
+
while run is not None and run.tag != qn(W, 'r'):
|
|
744
|
+
run = run.getparent()
|
|
745
|
+
rpr = run.find(W_RPR) if run is not None else None
|
|
746
|
+
chunks.append((text[lo - at:hi - at], deepcopy(rpr) if rpr is not None else None))
|
|
747
|
+
at += len(text)
|
|
748
|
+
return chunks
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def body_run_style(chunks):
|
|
752
|
+
"""Choose the predominant selected text style, excluding list markers."""
|
|
753
|
+
def key(node):
|
|
754
|
+
if node is None:
|
|
755
|
+
return None
|
|
756
|
+
return (node.tag, tuple(sorted(node.attrib.items())), (node.text or '').strip(),
|
|
757
|
+
tuple(sorted((key(child) for child in node), key=repr)))
|
|
758
|
+
|
|
759
|
+
weights, styles = {}, {}
|
|
760
|
+
for text, style in chunks:
|
|
761
|
+
weight = sum(not char.isspace() for char in text)
|
|
762
|
+
if not weight:
|
|
763
|
+
continue
|
|
764
|
+
identity = key(style)
|
|
765
|
+
weights[identity] = weights.get(identity, 0) + weight
|
|
766
|
+
styles.setdefault(identity, style)
|
|
767
|
+
if not weights:
|
|
768
|
+
return chunks[0][1] if chunks else None
|
|
769
|
+
return styles[max(weights, key=weights.get)]
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def revised_body_runs(chunks, revised):
|
|
773
|
+
"""Preserve a common prefix's runs, then style the whole new body once."""
|
|
774
|
+
original = ''.join(text for text, _ in chunks)
|
|
775
|
+
common = 0
|
|
776
|
+
while common < min(len(original), len(revised)) and original[common] == revised[common]:
|
|
777
|
+
common += 1
|
|
778
|
+
style = body_run_style(chunks)
|
|
779
|
+
result, remaining = [], common
|
|
780
|
+
for text, old_style in chunks:
|
|
781
|
+
if not remaining:
|
|
782
|
+
break
|
|
783
|
+
piece = text[:remaining]
|
|
784
|
+
result.append((piece, old_style))
|
|
785
|
+
remaining -= len(piece)
|
|
786
|
+
if common < len(revised):
|
|
787
|
+
result.append((revised[common:], style))
|
|
788
|
+
return result
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def rewrite_word_compound(paragraphs, location, rev_id):
|
|
792
|
+
revision, spans = location['revision'], location['segments']
|
|
793
|
+
revised = str(revision.get('revised') or '')
|
|
794
|
+
marker_pattern = r'^[\s⚫•●▪◦‣⁃∙○◉◎■□◆◇◼◾⬤*\-]+$'
|
|
795
|
+
protected, count = [], 0
|
|
796
|
+
while count < len(spans) - 1:
|
|
797
|
+
para, start, end, original = spans[count]
|
|
798
|
+
text = visible_text(paragraphs[para])
|
|
799
|
+
if start != 0 or end != len(text) or not re.fullmatch(marker_pattern, text) or not text.strip():
|
|
800
|
+
break
|
|
801
|
+
protected.append(text)
|
|
802
|
+
count += 1
|
|
803
|
+
if protected:
|
|
804
|
+
prefix = '\n'.join(protected) + '\n'
|
|
805
|
+
if not revised.startswith(prefix):
|
|
806
|
+
raise ToolError('复合修订不能改动选区开头的列表标记')
|
|
807
|
+
revised = revised[len(prefix):]
|
|
808
|
+
spans = spans[count:]
|
|
809
|
+
first_para, start, _, _ = spans[0]
|
|
810
|
+
last_para, _, end, _ = spans[-1]
|
|
811
|
+
first, last = paragraphs[first_para], paragraphs[last_para]
|
|
812
|
+
parent, children = first.getparent(), list(first.getparent())
|
|
813
|
+
selected_paragraphs = children[children.index(first):children.index(last) + 1]
|
|
814
|
+
before = styled_text_slice(first, 0, start)
|
|
815
|
+
last_text = visible_text(last)
|
|
816
|
+
after = styled_text_slice(last, end, len(last_text))
|
|
817
|
+
rpr = first_rpr(first)
|
|
818
|
+
selected_styles = []
|
|
819
|
+
for index, (para, lo, hi, _) in enumerate(spans):
|
|
820
|
+
if index:
|
|
821
|
+
selected_styles.append(('\n', selected_styles[-1][1] if selected_styles else None))
|
|
822
|
+
selected_styles.extend(styled_text_slice(paragraphs[para], lo, hi))
|
|
823
|
+
inserted = revised_body_runs(selected_styles, revised)
|
|
824
|
+
old_texts = [visible_text(p) for p in selected_paragraphs]
|
|
825
|
+
old_styles = [first_rpr(p) for p in selected_paragraphs]
|
|
826
|
+
for index, paragraph in enumerate(selected_paragraphs):
|
|
827
|
+
ppr = paragraph.find(W_PPR)
|
|
828
|
+
for child in list(paragraph):
|
|
829
|
+
if child is not ppr:
|
|
830
|
+
paragraph.remove(child)
|
|
831
|
+
old = old_texts[index]
|
|
832
|
+
if index == 0:
|
|
833
|
+
for text, style in before:
|
|
834
|
+
add_text_run(paragraph, 't', text, style, 'plain', rev_id)
|
|
835
|
+
deleted = old[start:end] if first is last else old[start:]
|
|
836
|
+
add_text_run(paragraph, 'delText', deleted, rpr, 'del', rev_id)
|
|
837
|
+
for text, style in inserted:
|
|
838
|
+
add_text_run(paragraph, 't', text, style, 'ins', rev_id + 1)
|
|
839
|
+
for text, style in after:
|
|
840
|
+
# Moving the original suffix is tracked, so rejecting changes
|
|
841
|
+
# in Office restores it to its original paragraph as well.
|
|
842
|
+
add_text_run(paragraph, 't', text, style, 'plain' if first is last else 'ins', rev_id + 1)
|
|
843
|
+
else:
|
|
844
|
+
add_text_run(paragraph, 'delText', old, old_styles[index], 'del', rev_id)
|
|
845
|
+
paragraph.set(qn(W, 'rsidDel'), DSH_MERGED_RSID)
|
|
846
|
+
|
|
847
|
+
|
|
438
848
|
def write_docx(path, output, revisions):
|
|
439
849
|
with read_zip(path) as zf:
|
|
440
850
|
try:
|
|
@@ -444,21 +854,25 @@ def write_docx(path, output, revisions):
|
|
|
444
854
|
root = etree.fromstring(document)
|
|
445
855
|
settings = enable_track_revisions(zf)
|
|
446
856
|
paragraphs = all_paragraphs(root)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
1000 + index * 2
|
|
460
|
-
|
|
461
|
-
|
|
857
|
+
source, positions = word_source_positions(root)
|
|
858
|
+
locations = [word_revision_location(paragraphs, revision, source, positions) for revision in revisions]
|
|
859
|
+
ascending = sorted(locations, key=lambda item: item['start'])
|
|
860
|
+
if any(b['start'] < a['end'] for a, b in zip(ascending, ascending[1:])):
|
|
861
|
+
raise ToolError('修订选区重叠,只能接受同一选区的一份结果')
|
|
862
|
+
protect_word_layout(paragraphs, locations, word_layout_styles(zf))
|
|
863
|
+
expected = source
|
|
864
|
+
# From back to front: offsets of every earlier revision remain valid.
|
|
865
|
+
for index, location in enumerate(reversed(ascending)):
|
|
866
|
+
revision = location['revision']
|
|
867
|
+
expected = expected[:location['start']] + str(revision.get('revised') or '') + expected[location['end']:]
|
|
868
|
+
if location['compound']:
|
|
869
|
+
rewrite_word_compound(paragraphs, location, 1000 + index * 2)
|
|
870
|
+
else:
|
|
871
|
+
paragraph = locate_paragraph(paragraphs, revision)
|
|
872
|
+
rewrite_paragraph(paragraph, str(revision.get('original') or ''), str(revision.get('revised') or ''),
|
|
873
|
+
1000 + index * 2, revision.get('anchor') or {})
|
|
874
|
+
if any(location['compound'] for location in locations) and word_source_positions(root)[0] != expected:
|
|
875
|
+
raise ToolError('写回后的完整正文与修订不一致,文档未被修改')
|
|
462
876
|
updates = {
|
|
463
877
|
'word/document.xml': etree.tostring(
|
|
464
878
|
root, xml_declaration=True, encoding='UTF-8', standalone=True,
|
|
@@ -1117,6 +1531,14 @@ def preview_docx(req):
|
|
|
1117
1531
|
at += 1
|
|
1118
1532
|
parent.remove(node)
|
|
1119
1533
|
changed = True
|
|
1534
|
+
# Only these plugin-merged empty paragraphs are omitted from the
|
|
1535
|
+
# disposable preview. Original blank paragraphs remain untouched,
|
|
1536
|
+
# and the source keeps every physical paragraph id for undo/bulk.
|
|
1537
|
+
for paragraph in list(root.iter(W_P)):
|
|
1538
|
+
parent = paragraph.getparent()
|
|
1539
|
+
if parent is not None and paragraph.get(qn(W, 'rsidDel')) == DSH_MERGED_RSID and not visible_text(paragraph):
|
|
1540
|
+
parent.remove(paragraph)
|
|
1541
|
+
changed = True
|
|
1120
1542
|
if changed:
|
|
1121
1543
|
updates[name] = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
|
1122
1544
|
repack(path, output, updates)
|
|
@@ -1166,7 +1588,7 @@ def main():
|
|
|
1166
1588
|
else:
|
|
1167
1589
|
result = {'ok': False, 'error': '未知的文档操作'}
|
|
1168
1590
|
except ToolError as error:
|
|
1169
|
-
result = {'ok': False, 'error': str(error)}
|
|
1591
|
+
result = {'ok': False, 'error': str(error), **({'code': error.code} if error.code else {})}
|
|
1170
1592
|
except Exception as error:
|
|
1171
1593
|
line = str(error).strip().splitlines()
|
|
1172
1594
|
detail = line[0][:160] if line else '未知错误'
|