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.
@@ -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(W_T):
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
- node = etree.SubElement(run, qn(W, tag_text))
357
- set_space(node, text)
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, after = text[:at], text[end:]
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
- add_text_run(paragraph, 't', before, rpr, 'plain', rev_id)
398
- add_text_run(paragraph, 'delText', original, rpr, 'del', rev_id)
399
- add_text_run(paragraph, 't', revised, rpr, 'ins', rev_id + 1)
400
- add_text_run(paragraph, 't', after, rpr, 'plain', rev_id)
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
- ordered = sorted(revisions, key=lambda revision: (
449
- revision.get('anchor', {}).get('srcStart', -1)
450
- ), reverse=True)
451
- for index, revision in enumerate(ordered):
452
- # Re-scan: a previous rewrite changes text but not paragraph count.
453
- paragraphs = all_paragraphs(root)
454
- paragraph = locate_paragraph(paragraphs, revision)
455
- rewrite_paragraph(
456
- paragraph,
457
- str(revision.get('original') or ''),
458
- str(revision.get('revised') or ''),
459
- 1000 + index * 2,
460
- revision.get('anchor') or {},
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 '未知错误'