python-hwpx 2.16.0__py3-none-any.whl → 2.17.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx/document.py +119 -2
- hwpx/oxml/__init__.py +14 -0
- hwpx/oxml/_document_impl.py +252 -6
- hwpx/oxml/body.py +154 -5
- hwpx/oxml/header.py +199 -3
- hwpx/tools/redline.py +186 -0
- {python_hwpx-2.16.0.dist-info → python_hwpx-2.17.0.dist-info}/METADATA +1 -1
- {python_hwpx-2.16.0.dist-info → python_hwpx-2.17.0.dist-info}/RECORD +13 -12
- {python_hwpx-2.16.0.dist-info → python_hwpx-2.17.0.dist-info}/WHEEL +0 -0
- {python_hwpx-2.16.0.dist-info → python_hwpx-2.17.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-2.16.0.dist-info → python_hwpx-2.17.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-2.16.0.dist-info → python_hwpx-2.17.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-2.16.0.dist-info → python_hwpx-2.17.0.dist-info}/top_level.txt +0 -0
hwpx/document.py
CHANGED
|
@@ -116,6 +116,7 @@ _FORM_FIELD_PARAM_NAMES = {
|
|
|
116
116
|
"guide",
|
|
117
117
|
}
|
|
118
118
|
_TEXT_ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]")
|
|
119
|
+
_TRACKED_TEXT_ILLEGAL = re.compile(r"[\x00-\x08\x09\x0b\x0c\x0d\x0e-\x1f\ufffe\uffff]")
|
|
119
120
|
|
|
120
121
|
|
|
121
122
|
def _local_name(node_or_tag: Any) -> str:
|
|
@@ -131,6 +132,10 @@ def _sanitize_field_text(value: str) -> str:
|
|
|
131
132
|
return _TEXT_ILLEGAL.sub("", value)
|
|
132
133
|
|
|
133
134
|
|
|
135
|
+
def _sanitize_tracked_text(value: str) -> str:
|
|
136
|
+
return _TRACKED_TEXT_ILLEGAL.sub("", value)
|
|
137
|
+
|
|
138
|
+
|
|
134
139
|
def _field_type_tokens(*values: str | None) -> set[str]:
|
|
135
140
|
tokens: set[str] = set()
|
|
136
141
|
for value in values:
|
|
@@ -558,6 +563,111 @@ class HwpxDocument:
|
|
|
558
563
|
|
|
559
564
|
return self._root.track_change_author(author_id_ref)
|
|
560
565
|
|
|
566
|
+
def add_track_change(
|
|
567
|
+
self,
|
|
568
|
+
change_type: str,
|
|
569
|
+
*,
|
|
570
|
+
author_name: str = "AI Agent",
|
|
571
|
+
date: str | None = None,
|
|
572
|
+
) -> int:
|
|
573
|
+
"""Add tracked-change header metadata and return the new change id."""
|
|
574
|
+
|
|
575
|
+
return self._root.add_track_change(
|
|
576
|
+
change_type,
|
|
577
|
+
author_name=author_name,
|
|
578
|
+
date=date,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
@staticmethod
|
|
582
|
+
def _paragraph_has_deletable_text(
|
|
583
|
+
paragraph: HwpxOxmlParagraph,
|
|
584
|
+
match: str | None,
|
|
585
|
+
) -> bool:
|
|
586
|
+
for run in paragraph.runs:
|
|
587
|
+
model = run.to_model()
|
|
588
|
+
run_text = "".join(span.text for span in model.text_spans)
|
|
589
|
+
if match is None:
|
|
590
|
+
if run_text:
|
|
591
|
+
return True
|
|
592
|
+
elif match in run_text:
|
|
593
|
+
return True
|
|
594
|
+
return False
|
|
595
|
+
|
|
596
|
+
def add_tracked_insert(
|
|
597
|
+
self,
|
|
598
|
+
paragraph: HwpxOxmlParagraph,
|
|
599
|
+
text: str,
|
|
600
|
+
*,
|
|
601
|
+
author: str = "AI Agent",
|
|
602
|
+
date: str | None = None,
|
|
603
|
+
char_pr_id_ref: str | int | None = None,
|
|
604
|
+
) -> int:
|
|
605
|
+
"""Append tracked inserted *text* to *paragraph* and return its change id."""
|
|
606
|
+
|
|
607
|
+
sanitized = _sanitize_tracked_text(text)
|
|
608
|
+
if not sanitized:
|
|
609
|
+
raise ValueError("tracked insert text must be non-empty")
|
|
610
|
+
change_id = self.add_track_change("Insert", author_name=author, date=date)
|
|
611
|
+
mark_id = self._root.next_track_change_mark_id()
|
|
612
|
+
paragraph.add_tracked_insert(
|
|
613
|
+
sanitized,
|
|
614
|
+
change_id=change_id,
|
|
615
|
+
mark_id=mark_id,
|
|
616
|
+
char_pr_id_ref=char_pr_id_ref,
|
|
617
|
+
)
|
|
618
|
+
return change_id
|
|
619
|
+
|
|
620
|
+
def add_tracked_delete(
|
|
621
|
+
self,
|
|
622
|
+
paragraph: HwpxOxmlParagraph,
|
|
623
|
+
*,
|
|
624
|
+
match: str | None = None,
|
|
625
|
+
author: str = "AI Agent",
|
|
626
|
+
date: str | None = None,
|
|
627
|
+
) -> int:
|
|
628
|
+
"""Wrap paragraph text or the first matching substring in delete marks."""
|
|
629
|
+
|
|
630
|
+
if match == "":
|
|
631
|
+
raise ValueError("match must be a non-empty string")
|
|
632
|
+
if not self._paragraph_has_deletable_text(paragraph, match):
|
|
633
|
+
if match is None:
|
|
634
|
+
raise ValueError("paragraph contains no text to delete")
|
|
635
|
+
raise ValueError("match text was not found in the paragraph")
|
|
636
|
+
|
|
637
|
+
change_id = self.add_track_change("Delete", author_name=author, date=date)
|
|
638
|
+
mark_id = self._root.next_track_change_mark_id()
|
|
639
|
+
paragraph.add_tracked_delete(
|
|
640
|
+
change_id=change_id,
|
|
641
|
+
first_mark_id=mark_id,
|
|
642
|
+
match=match,
|
|
643
|
+
)
|
|
644
|
+
return change_id
|
|
645
|
+
|
|
646
|
+
def add_tracked_replace(
|
|
647
|
+
self,
|
|
648
|
+
paragraph: HwpxOxmlParagraph,
|
|
649
|
+
old: str,
|
|
650
|
+
new: str,
|
|
651
|
+
*,
|
|
652
|
+
author: str = "AI Agent",
|
|
653
|
+
date: str | None = None,
|
|
654
|
+
) -> tuple[int, int]:
|
|
655
|
+
"""Represent a replacement as tracked delete of *old* plus tracked insert of *new*."""
|
|
656
|
+
|
|
657
|
+
delete_change_id = self.add_tracked_delete(
|
|
658
|
+
paragraph,
|
|
659
|
+
match=old,
|
|
660
|
+
author=author,
|
|
661
|
+
date=date,
|
|
662
|
+
)
|
|
663
|
+
insert_change_id = self.add_tracked_insert(
|
|
664
|
+
paragraph,
|
|
665
|
+
new,
|
|
666
|
+
author=author,
|
|
667
|
+
date=date,
|
|
668
|
+
)
|
|
669
|
+
return delete_change_id, insert_change_id
|
|
670
|
+
|
|
561
671
|
@property
|
|
562
672
|
def memos(self) -> list[HwpxOxmlMemo]:
|
|
563
673
|
"""Return all memo entries declared in every section."""
|
|
@@ -659,7 +769,11 @@ class HwpxDocument:
|
|
|
659
769
|
_append_element(parameters, f"{_HP}integerParam", {"name": "Number"}).text = str(max(1, number))
|
|
660
770
|
_append_element(parameters, f"{_HP}stringParam", {"name": "CreateDateTime"}).text = created_value
|
|
661
771
|
_append_element(parameters, f"{_HP}stringParam", {"name": "Author"}).text = author_value
|
|
662
|
-
|
|
772
|
+
# Hancom's own files use ``MemoShapeIDRef`` (65535 = the built-in default memo
|
|
773
|
+
# shape) — an empty/absent ref leaves the memo box unlinked.
|
|
774
|
+
_append_element(parameters, f"{_HP}stringParam", {"name": "MemoShapeIDRef"}).text = (
|
|
775
|
+
memo_shape_id or "65535"
|
|
776
|
+
)
|
|
663
777
|
|
|
664
778
|
sub_list = _append_element(
|
|
665
779
|
field_begin,
|
|
@@ -684,7 +798,10 @@ class HwpxDocument:
|
|
|
684
798
|
},
|
|
685
799
|
)
|
|
686
800
|
sub_run = _append_element(sub_para, f"{_HP}run", {"charPrIDRef": char_ref})
|
|
687
|
-
|
|
801
|
+
# The MEMO field's subList holds the comment TEXT — this is what Hancom shows
|
|
802
|
+
# in the margin memo box. (Previously this emitted ``memo.id``, so Hancom
|
|
803
|
+
# rendered the numeric id instead of the comment.)
|
|
804
|
+
_append_element(sub_run, f"{_HP}t").text = memo.text or ""
|
|
688
805
|
|
|
689
806
|
run_end = paragraph_element.makeelement(f"{_HP}run", {"charPrIDRef": char_ref})
|
|
690
807
|
ctrl_end = _append_element(run_end, f"{_HP}ctrl")
|
hwpx/oxml/__init__.py
CHANGED
|
@@ -10,10 +10,13 @@ from .body import (
|
|
|
10
10
|
Run,
|
|
11
11
|
Section,
|
|
12
12
|
TextSpan,
|
|
13
|
+
append_tracked_insert_to_run,
|
|
14
|
+
create_track_change_mark,
|
|
13
15
|
parse_paragraph_element,
|
|
14
16
|
parse_run_element,
|
|
15
17
|
parse_section_element,
|
|
16
18
|
parse_text_span,
|
|
19
|
+
wrap_tracked_delete_in_span,
|
|
17
20
|
)
|
|
18
21
|
from .common import GenericElement, parse_generic_element
|
|
19
22
|
|
|
@@ -74,7 +77,9 @@ from .header import (
|
|
|
74
77
|
TrackChangeAuthorList,
|
|
75
78
|
TrackChangeConfig,
|
|
76
79
|
TrackChangeList,
|
|
80
|
+
format_track_change_date,
|
|
77
81
|
memo_shape_from_attributes,
|
|
82
|
+
normalize_track_change_type,
|
|
78
83
|
parse_begin_num,
|
|
79
84
|
parse_bullet,
|
|
80
85
|
parse_bullet_para_head,
|
|
@@ -98,6 +103,8 @@ from .header import (
|
|
|
98
103
|
parse_track_change_author,
|
|
99
104
|
parse_track_change_authors,
|
|
100
105
|
parse_track_changes,
|
|
106
|
+
track_change_author_to_xml,
|
|
107
|
+
track_change_to_xml,
|
|
101
108
|
)
|
|
102
109
|
from .parser import element_to_model, parse_header_xml, parse_section_xml
|
|
103
110
|
from .schema import load_schema
|
|
@@ -173,7 +180,10 @@ __all__ = [
|
|
|
173
180
|
"TrackChangeList",
|
|
174
181
|
"TextSpan",
|
|
175
182
|
"XmlSource",
|
|
183
|
+
"append_tracked_insert_to_run",
|
|
184
|
+
"create_track_change_mark",
|
|
176
185
|
"element_to_model",
|
|
186
|
+
"format_track_change_date",
|
|
177
187
|
"load_schema",
|
|
178
188
|
"parse_begin_num",
|
|
179
189
|
"parse_bullet",
|
|
@@ -202,9 +212,13 @@ __all__ = [
|
|
|
202
212
|
"parse_track_change_author",
|
|
203
213
|
"parse_track_change_authors",
|
|
204
214
|
"parse_track_changes",
|
|
215
|
+
"normalize_track_change_type",
|
|
205
216
|
"parse_section_element",
|
|
206
217
|
"parse_section_xml",
|
|
207
218
|
"parse_text_span",
|
|
219
|
+
"track_change_author_to_xml",
|
|
220
|
+
"track_change_to_xml",
|
|
221
|
+
"wrap_tracked_delete_in_span",
|
|
208
222
|
]
|
|
209
223
|
|
|
210
224
|
logger = logging.getLogger(__name__)
|
hwpx/oxml/_document_impl.py
CHANGED
|
@@ -17,6 +17,7 @@ from . import body
|
|
|
17
17
|
from .common import GenericElement
|
|
18
18
|
from .header import (
|
|
19
19
|
Bullet,
|
|
20
|
+
Header,
|
|
20
21
|
MemoProperties,
|
|
21
22
|
MemoShape,
|
|
22
23
|
ParagraphProperty,
|
|
@@ -26,10 +27,14 @@ from .header import (
|
|
|
26
27
|
memo_shape_from_attributes,
|
|
27
28
|
parse_bullets,
|
|
28
29
|
parse_border_fills,
|
|
30
|
+
parse_header_element,
|
|
29
31
|
parse_paragraph_properties,
|
|
30
32
|
parse_styles,
|
|
33
|
+
parse_track_change_config,
|
|
31
34
|
parse_track_change_authors,
|
|
32
35
|
parse_track_changes,
|
|
36
|
+
track_change_author_to_xml,
|
|
37
|
+
track_change_to_xml,
|
|
33
38
|
)
|
|
34
39
|
from .namespaces import (
|
|
35
40
|
HWPML_COMPAT_ROOT_NAMESPACES,
|
|
@@ -1566,8 +1571,11 @@ class HwpxOxmlRun:
|
|
|
1566
1571
|
def apply_model(self, model: "body.Run") -> None:
|
|
1567
1572
|
new_node = body.serialize_run(model)
|
|
1568
1573
|
xml_bytes = LET.tostring(new_node)
|
|
1569
|
-
replacement = ET.fromstring(xml_bytes)
|
|
1570
1574
|
parent = self.paragraph.element
|
|
1575
|
+
if isinstance(parent, LET._Element):
|
|
1576
|
+
replacement = LET.fromstring(xml_bytes)
|
|
1577
|
+
else:
|
|
1578
|
+
replacement = ET.fromstring(xml_bytes)
|
|
1571
1579
|
run_children = list(parent)
|
|
1572
1580
|
index = run_children.index(self.element)
|
|
1573
1581
|
parent.remove(self.element)
|
|
@@ -3598,8 +3606,11 @@ class HwpxOxmlParagraph:
|
|
|
3598
3606
|
def apply_model(self, model: "body.Paragraph") -> None:
|
|
3599
3607
|
new_node = body.serialize_paragraph(model)
|
|
3600
3608
|
xml_bytes = LET.tostring(new_node)
|
|
3601
|
-
replacement = ET.fromstring(xml_bytes)
|
|
3602
3609
|
parent = self.section.element
|
|
3610
|
+
if isinstance(parent, LET._Element):
|
|
3611
|
+
replacement = LET.fromstring(xml_bytes)
|
|
3612
|
+
else:
|
|
3613
|
+
replacement = ET.fromstring(xml_bytes)
|
|
3603
3614
|
paragraph_children = list(parent)
|
|
3604
3615
|
index = paragraph_children.index(self.element)
|
|
3605
3616
|
parent.remove(self.element)
|
|
@@ -3628,6 +3639,99 @@ class HwpxOxmlParagraph:
|
|
|
3628
3639
|
"""Return the runs contained in this paragraph."""
|
|
3629
3640
|
return [HwpxOxmlRun(run, self) for run in self._run_elements()]
|
|
3630
3641
|
|
|
3642
|
+
def _last_text_run_for_tracked_insert(
|
|
3643
|
+
self,
|
|
3644
|
+
*,
|
|
3645
|
+
char_pr_id_ref: str | int | None = None,
|
|
3646
|
+
) -> HwpxOxmlRun:
|
|
3647
|
+
desired_char = None if char_pr_id_ref is None else str(char_pr_id_ref)
|
|
3648
|
+
runs = self.runs
|
|
3649
|
+
|
|
3650
|
+
if desired_char is not None:
|
|
3651
|
+
for run in reversed(runs):
|
|
3652
|
+
if run.char_pr_id_ref != desired_char:
|
|
3653
|
+
continue
|
|
3654
|
+
if run.to_model().text_spans:
|
|
3655
|
+
return run
|
|
3656
|
+
return self.add_run("", char_pr_id_ref=desired_char)
|
|
3657
|
+
|
|
3658
|
+
for run in reversed(runs):
|
|
3659
|
+
if run.to_model().text_spans:
|
|
3660
|
+
return run
|
|
3661
|
+
return self.add_run("", char_pr_id_ref=self.char_pr_id_ref or "0")
|
|
3662
|
+
|
|
3663
|
+
def add_tracked_insert(
|
|
3664
|
+
self,
|
|
3665
|
+
text: str,
|
|
3666
|
+
*,
|
|
3667
|
+
change_id: int,
|
|
3668
|
+
mark_id: int,
|
|
3669
|
+
char_pr_id_ref: str | int | None = None,
|
|
3670
|
+
) -> None:
|
|
3671
|
+
sanitized = _sanitize_text(text)
|
|
3672
|
+
if not sanitized:
|
|
3673
|
+
raise ValueError("tracked insert text must be non-empty")
|
|
3674
|
+
|
|
3675
|
+
run = self._last_text_run_for_tracked_insert(char_pr_id_ref=char_pr_id_ref)
|
|
3676
|
+
model = run.to_model()
|
|
3677
|
+
body.append_tracked_insert_to_run(
|
|
3678
|
+
model,
|
|
3679
|
+
sanitized,
|
|
3680
|
+
tc_id=change_id,
|
|
3681
|
+
mark_id=mark_id,
|
|
3682
|
+
)
|
|
3683
|
+
run.apply_model(model)
|
|
3684
|
+
|
|
3685
|
+
def add_tracked_delete(
|
|
3686
|
+
self,
|
|
3687
|
+
*,
|
|
3688
|
+
change_id: int,
|
|
3689
|
+
first_mark_id: int,
|
|
3690
|
+
match: str | None = None,
|
|
3691
|
+
) -> int:
|
|
3692
|
+
if match == "":
|
|
3693
|
+
raise ValueError("match must be a non-empty string")
|
|
3694
|
+
|
|
3695
|
+
next_mark_id = first_mark_id
|
|
3696
|
+
if match is not None:
|
|
3697
|
+
for run in self.runs:
|
|
3698
|
+
model = run.to_model()
|
|
3699
|
+
if match not in "".join(span.text for span in model.text_spans):
|
|
3700
|
+
continue
|
|
3701
|
+
for span in model.text_spans:
|
|
3702
|
+
if body.wrap_tracked_delete_in_span(
|
|
3703
|
+
span,
|
|
3704
|
+
tc_id=change_id,
|
|
3705
|
+
mark_id=next_mark_id,
|
|
3706
|
+
match=match,
|
|
3707
|
+
):
|
|
3708
|
+
run.apply_model(model)
|
|
3709
|
+
return next_mark_id + 1
|
|
3710
|
+
raise ValueError("match crosses inline markup and cannot be wrapped safely")
|
|
3711
|
+
raise ValueError("match text was not found in the paragraph")
|
|
3712
|
+
|
|
3713
|
+
modified = False
|
|
3714
|
+
for run in self.runs:
|
|
3715
|
+
model = run.to_model()
|
|
3716
|
+
changed = False
|
|
3717
|
+
for span in model.text_spans:
|
|
3718
|
+
if not span.text:
|
|
3719
|
+
continue
|
|
3720
|
+
body.wrap_tracked_delete_in_span(
|
|
3721
|
+
span,
|
|
3722
|
+
tc_id=change_id,
|
|
3723
|
+
mark_id=next_mark_id,
|
|
3724
|
+
)
|
|
3725
|
+
next_mark_id += 1
|
|
3726
|
+
changed = True
|
|
3727
|
+
if changed:
|
|
3728
|
+
run.apply_model(model)
|
|
3729
|
+
modified = True
|
|
3730
|
+
|
|
3731
|
+
if not modified:
|
|
3732
|
+
raise ValueError("paragraph contains no text to delete")
|
|
3733
|
+
return next_mark_id
|
|
3734
|
+
|
|
3631
3735
|
@property
|
|
3632
3736
|
def text(self) -> str:
|
|
3633
3737
|
"""Return the concatenated textual content of this paragraph."""
|
|
@@ -4710,6 +4814,15 @@ class HwpxOxmlHeader:
|
|
|
4710
4814
|
def attach_document(self, document: "HwpxOxmlDocument") -> None:
|
|
4711
4815
|
self._document = document
|
|
4712
4816
|
|
|
4817
|
+
def to_model(self) -> Header:
|
|
4818
|
+
return parse_header_element(self._convert_to_lxml(self._element))
|
|
4819
|
+
|
|
4820
|
+
@staticmethod
|
|
4821
|
+
def _coerce_serialized_child(parent: ET.Element, child: LET._Element) -> ET.Element:
|
|
4822
|
+
if isinstance(parent, LET._Element):
|
|
4823
|
+
return child
|
|
4824
|
+
return ET.fromstring(LET.tostring(child, encoding="utf-8"))
|
|
4825
|
+
|
|
4713
4826
|
def _begin_num_element(self, create: bool = False) -> ET.Element | None:
|
|
4714
4827
|
element = self._element.find(f"{_HH}beginNum")
|
|
4715
4828
|
if element is None and create:
|
|
@@ -5383,18 +5496,116 @@ class HwpxOxmlHeader:
|
|
|
5383
5496
|
return None
|
|
5384
5497
|
return ref_list.find(f"{_HH}styles")
|
|
5385
5498
|
|
|
5386
|
-
def _track_changes_element(self) -> ET.Element | None:
|
|
5387
|
-
ref_list = self._ref_list_element()
|
|
5499
|
+
def _track_changes_element(self, create: bool = False) -> ET.Element | None:
|
|
5500
|
+
ref_list = self._ref_list_element(create=create)
|
|
5388
5501
|
if ref_list is None:
|
|
5389
5502
|
return None
|
|
5390
5503
|
return ref_list.find(f"{_HH}trackChanges")
|
|
5391
5504
|
|
|
5392
|
-
def
|
|
5393
|
-
ref_list = self._ref_list_element()
|
|
5505
|
+
def _track_changes_element_or_create(self) -> ET.Element:
|
|
5506
|
+
ref_list = self._ref_list_element(create=True)
|
|
5507
|
+
if ref_list is None: # pragma: no cover - defensive branch
|
|
5508
|
+
raise RuntimeError("failed to create <refList> element")
|
|
5509
|
+
element = ref_list.find(f"{_HH}trackChanges")
|
|
5510
|
+
if element is None:
|
|
5511
|
+
element = ref_list.makeelement(f"{_HH}trackChanges", {"itemCnt": "0"})
|
|
5512
|
+
ref_list.append(element)
|
|
5513
|
+
self.mark_dirty()
|
|
5514
|
+
return element
|
|
5515
|
+
|
|
5516
|
+
def _track_change_authors_element(self, create: bool = False) -> ET.Element | None:
|
|
5517
|
+
ref_list = self._ref_list_element(create=create)
|
|
5394
5518
|
if ref_list is None:
|
|
5395
5519
|
return None
|
|
5396
5520
|
return ref_list.find(f"{_HH}trackChangeAuthors")
|
|
5397
5521
|
|
|
5522
|
+
def _track_change_authors_element_or_create(self) -> ET.Element:
|
|
5523
|
+
ref_list = self._ref_list_element(create=True)
|
|
5524
|
+
if ref_list is None: # pragma: no cover - defensive branch
|
|
5525
|
+
raise RuntimeError("failed to create <refList> element")
|
|
5526
|
+
element = ref_list.find(f"{_HH}trackChangeAuthors")
|
|
5527
|
+
if element is None:
|
|
5528
|
+
element = ref_list.makeelement(f"{_HH}trackChangeAuthors", {"itemCnt": "0"})
|
|
5529
|
+
ref_list.append(element)
|
|
5530
|
+
self.mark_dirty()
|
|
5531
|
+
return element
|
|
5532
|
+
|
|
5533
|
+
def _track_change_config_element(self, create: bool = False) -> ET.Element | None:
|
|
5534
|
+
for child in self._element:
|
|
5535
|
+
if tag_local_name(child.tag) in {"trackchageConfig", "trackchangeConfig"}:
|
|
5536
|
+
return child
|
|
5537
|
+
if not create:
|
|
5538
|
+
return None
|
|
5539
|
+
element = self._element.makeelement(f"{_HH}trackchageConfig", {"flags": "0"})
|
|
5540
|
+
self._element.append(element)
|
|
5541
|
+
self.mark_dirty()
|
|
5542
|
+
return element
|
|
5543
|
+
|
|
5544
|
+
@property
|
|
5545
|
+
def track_change_config(self):
|
|
5546
|
+
element = self._track_change_config_element()
|
|
5547
|
+
if element is None:
|
|
5548
|
+
return None
|
|
5549
|
+
return parse_track_change_config(self._convert_to_lxml(element))
|
|
5550
|
+
|
|
5551
|
+
def add_track_change(
|
|
5552
|
+
self,
|
|
5553
|
+
change_type: str,
|
|
5554
|
+
*,
|
|
5555
|
+
author_name: str = "AI Agent",
|
|
5556
|
+
date: str | None = None,
|
|
5557
|
+
) -> int:
|
|
5558
|
+
model = self.to_model()
|
|
5559
|
+
change_id = model.add_track_change(
|
|
5560
|
+
change_type,
|
|
5561
|
+
author_name=author_name,
|
|
5562
|
+
date=date,
|
|
5563
|
+
)
|
|
5564
|
+
if model.ref_list is None or model.ref_list.track_changes is None:
|
|
5565
|
+
raise RuntimeError("failed to create tracked-change metadata")
|
|
5566
|
+
|
|
5567
|
+
change = next(
|
|
5568
|
+
candidate
|
|
5569
|
+
for candidate in model.ref_list.track_changes.changes
|
|
5570
|
+
if candidate.id == change_id
|
|
5571
|
+
)
|
|
5572
|
+
changes_element = self._track_changes_element_or_create()
|
|
5573
|
+
changes_element.append(
|
|
5574
|
+
self._coerce_serialized_child(changes_element, track_change_to_xml(change))
|
|
5575
|
+
)
|
|
5576
|
+
changes_element.set("itemCnt", str(model.ref_list.track_changes.item_cnt or 0))
|
|
5577
|
+
|
|
5578
|
+
authors = model.ref_list.track_change_authors
|
|
5579
|
+
if authors is not None and change.author_id is not None:
|
|
5580
|
+
author = authors.author_by_id(change.author_id)
|
|
5581
|
+
if author is not None:
|
|
5582
|
+
authors_element = self._track_change_authors_element_or_create()
|
|
5583
|
+
existing_ids = {
|
|
5584
|
+
child.get("id")
|
|
5585
|
+
for child in authors_element.findall(f"{_HH}trackChangeAuthor")
|
|
5586
|
+
}
|
|
5587
|
+
author_ids = {str(author.id)} if author.id is not None else set()
|
|
5588
|
+
if author.raw_id is not None:
|
|
5589
|
+
author_ids.add(author.raw_id)
|
|
5590
|
+
if not existing_ids.intersection(author_ids):
|
|
5591
|
+
authors_element.append(
|
|
5592
|
+
self._coerce_serialized_child(
|
|
5593
|
+
authors_element,
|
|
5594
|
+
track_change_author_to_xml(author),
|
|
5595
|
+
)
|
|
5596
|
+
)
|
|
5597
|
+
authors_element.set("itemCnt", str(authors.item_cnt or 0))
|
|
5598
|
+
|
|
5599
|
+
config_element = self._track_change_config_element(create=True)
|
|
5600
|
+
if config_element is None: # pragma: no cover - defensive branch
|
|
5601
|
+
raise RuntimeError("failed to create <trackchageConfig> element")
|
|
5602
|
+
flags = 1
|
|
5603
|
+
if model.track_change_config is not None and model.track_change_config.flags is not None:
|
|
5604
|
+
flags = model.track_change_config.flags
|
|
5605
|
+
config_element.set("flags", str(flags | 1))
|
|
5606
|
+
self.mark_dirty()
|
|
5607
|
+
return change_id
|
|
5608
|
+
|
|
5398
5609
|
def find_basic_border_fill_id(self) -> str | None:
|
|
5399
5610
|
element = self._border_fills_element()
|
|
5400
5611
|
if element is None:
|
|
@@ -6341,6 +6552,41 @@ class HwpxOxmlDocument:
|
|
|
6341
6552
|
) -> TrackChangeAuthor | None:
|
|
6342
6553
|
return HwpxOxmlHeader._lookup_by_id(self.track_change_authors, author_id_ref)
|
|
6343
6554
|
|
|
6555
|
+
def add_track_change(
|
|
6556
|
+
self,
|
|
6557
|
+
change_type: str,
|
|
6558
|
+
*,
|
|
6559
|
+
author_name: str = "AI Agent",
|
|
6560
|
+
date: str | None = None,
|
|
6561
|
+
) -> int:
|
|
6562
|
+
if not self._headers:
|
|
6563
|
+
raise ValueError("document does not contain any headers")
|
|
6564
|
+
return self._headers[0].add_track_change(
|
|
6565
|
+
change_type,
|
|
6566
|
+
author_name=author_name,
|
|
6567
|
+
date=date,
|
|
6568
|
+
)
|
|
6569
|
+
|
|
6570
|
+
def next_track_change_mark_id(self) -> int:
|
|
6571
|
+
max_id = 0
|
|
6572
|
+
for section in self._sections:
|
|
6573
|
+
for element in section.element.iter():
|
|
6574
|
+
if tag_local_name(element.tag) not in {
|
|
6575
|
+
"insertBegin",
|
|
6576
|
+
"insertEnd",
|
|
6577
|
+
"deleteBegin",
|
|
6578
|
+
"deleteEnd",
|
|
6579
|
+
}:
|
|
6580
|
+
continue
|
|
6581
|
+
raw_id = element.get("Id")
|
|
6582
|
+
if raw_id is None:
|
|
6583
|
+
continue
|
|
6584
|
+
try:
|
|
6585
|
+
max_id = max(max_id, int(raw_id))
|
|
6586
|
+
except ValueError:
|
|
6587
|
+
continue
|
|
6588
|
+
return max_id + 1
|
|
6589
|
+
|
|
6344
6590
|
@property
|
|
6345
6591
|
def paragraphs(self) -> list[HwpxOxmlParagraph]:
|
|
6346
6592
|
paragraphs: list[HwpxOxmlParagraph] = []
|
hwpx/oxml/body.py
CHANGED
|
@@ -43,6 +43,11 @@ _TRACK_CHANGE_MARK_NAMES = {
|
|
|
43
43
|
"deleteEnd",
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
_TRACK_CHANGE_TYPES = {
|
|
47
|
+
"insert": "insert",
|
|
48
|
+
"delete": "delete",
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
PreservedElement = Union[
|
|
47
52
|
GenericElement,
|
|
48
53
|
"CommentElement",
|
|
@@ -263,10 +268,150 @@ def _qualified_tag(tag: Optional[str], name: str) -> str:
|
|
|
263
268
|
return f"{_DEFAULT_HP}{name}"
|
|
264
269
|
|
|
265
270
|
|
|
271
|
+
def _tag_namespace(tag: Optional[str]) -> Optional[str]:
|
|
272
|
+
if not tag or not tag.startswith("{") or "}" not in tag:
|
|
273
|
+
return None
|
|
274
|
+
return tag[1:].split("}", 1)[0]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _child_tag_like(parent_tag: Optional[str], name: str) -> str:
|
|
278
|
+
namespace = _tag_namespace(parent_tag)
|
|
279
|
+
if namespace:
|
|
280
|
+
return f"{{{namespace}}}{name}"
|
|
281
|
+
return _qualified_tag(None, name)
|
|
282
|
+
|
|
283
|
+
|
|
266
284
|
def _bool_to_str(value: bool) -> str:
|
|
267
285
|
return "true" if value else "false"
|
|
268
286
|
|
|
269
287
|
|
|
288
|
+
def _bool_to_flag(value: bool) -> str:
|
|
289
|
+
return "1" if value else "0"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def create_track_change_mark(
|
|
293
|
+
change_type: str,
|
|
294
|
+
*,
|
|
295
|
+
is_begin: bool,
|
|
296
|
+
tc_id: int,
|
|
297
|
+
mark_id: int,
|
|
298
|
+
para_end: bool | None = None,
|
|
299
|
+
) -> TrackChangeMark:
|
|
300
|
+
"""Create a typed inline tracked-change boundary mark."""
|
|
301
|
+
|
|
302
|
+
normalized = _TRACK_CHANGE_TYPES.get(change_type.strip().lower())
|
|
303
|
+
if normalized is None:
|
|
304
|
+
raise ValueError("change_type must be 'insert' or 'delete'")
|
|
305
|
+
name = f"{normalized}{'Begin' if is_begin else 'End'}"
|
|
306
|
+
return TrackChangeMark(
|
|
307
|
+
tag=_qualified_tag(None, name),
|
|
308
|
+
name=name,
|
|
309
|
+
change_type=normalized,
|
|
310
|
+
is_begin=is_begin,
|
|
311
|
+
para_end=para_end,
|
|
312
|
+
tc_id=int(tc_id),
|
|
313
|
+
id=int(mark_id),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def append_tracked_insert_to_run(
|
|
318
|
+
run: Run,
|
|
319
|
+
text: str,
|
|
320
|
+
*,
|
|
321
|
+
tc_id: int,
|
|
322
|
+
mark_id: int,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Append tracked inserted *text* to the last text span in *run*."""
|
|
325
|
+
|
|
326
|
+
if not text:
|
|
327
|
+
raise ValueError("tracked insert text must be non-empty")
|
|
328
|
+
|
|
329
|
+
if run.text_spans:
|
|
330
|
+
span = run.text_spans[-1]
|
|
331
|
+
else:
|
|
332
|
+
span = TextSpan(tag=_child_tag_like(run.tag, "t"), leading_text="")
|
|
333
|
+
run.text_spans.append(span)
|
|
334
|
+
run.content.append(span)
|
|
335
|
+
|
|
336
|
+
span.marks.append(
|
|
337
|
+
TextMarkup(
|
|
338
|
+
create_track_change_mark("insert", is_begin=True, tc_id=tc_id, mark_id=mark_id),
|
|
339
|
+
text,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
span.marks.append(
|
|
343
|
+
TextMarkup(
|
|
344
|
+
create_track_change_mark(
|
|
345
|
+
"insert",
|
|
346
|
+
is_begin=False,
|
|
347
|
+
tc_id=tc_id,
|
|
348
|
+
mark_id=mark_id,
|
|
349
|
+
para_end=False,
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def wrap_tracked_delete_in_span(
|
|
356
|
+
span: TextSpan,
|
|
357
|
+
*,
|
|
358
|
+
tc_id: int,
|
|
359
|
+
mark_id: int,
|
|
360
|
+
match: str | None = None,
|
|
361
|
+
) -> bool:
|
|
362
|
+
"""Wrap a whole span or a substring in tracked delete marks."""
|
|
363
|
+
|
|
364
|
+
if match == "":
|
|
365
|
+
raise ValueError("match must be a non-empty string")
|
|
366
|
+
|
|
367
|
+
begin = TextMarkup(
|
|
368
|
+
create_track_change_mark("delete", is_begin=True, tc_id=tc_id, mark_id=mark_id)
|
|
369
|
+
)
|
|
370
|
+
end = TextMarkup(
|
|
371
|
+
create_track_change_mark(
|
|
372
|
+
"delete",
|
|
373
|
+
is_begin=False,
|
|
374
|
+
tc_id=tc_id,
|
|
375
|
+
mark_id=mark_id,
|
|
376
|
+
para_end=False,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if match is None:
|
|
381
|
+
if not span.text:
|
|
382
|
+
return False
|
|
383
|
+
leading = span.leading_text
|
|
384
|
+
span.leading_text = ""
|
|
385
|
+
begin.trailing_text = leading
|
|
386
|
+
span.marks.insert(0, begin)
|
|
387
|
+
span.marks.append(end)
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
index = span.leading_text.find(match)
|
|
391
|
+
if index >= 0:
|
|
392
|
+
before = span.leading_text[:index]
|
|
393
|
+
after = span.leading_text[index + len(match) :]
|
|
394
|
+
span.leading_text = before
|
|
395
|
+
begin.trailing_text = match
|
|
396
|
+
end.trailing_text = after
|
|
397
|
+
span.marks[0:0] = [begin, end]
|
|
398
|
+
return True
|
|
399
|
+
|
|
400
|
+
for mark_index, markup in enumerate(span.marks):
|
|
401
|
+
index = markup.trailing_text.find(match)
|
|
402
|
+
if index < 0:
|
|
403
|
+
continue
|
|
404
|
+
before = markup.trailing_text[:index]
|
|
405
|
+
after = markup.trailing_text[index + len(match) :]
|
|
406
|
+
markup.trailing_text = before
|
|
407
|
+
begin.trailing_text = match
|
|
408
|
+
end.trailing_text = after
|
|
409
|
+
span.marks[mark_index + 1 : mark_index + 1] = [begin, end]
|
|
410
|
+
return True
|
|
411
|
+
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
|
|
270
415
|
def parse_track_change_mark(node: etree._Element) -> TrackChangeMark:
|
|
271
416
|
attrs = {key: value for key, value in node.attrib.items()}
|
|
272
417
|
para_end = parse_bool(attrs.pop("paraend", None))
|
|
@@ -670,13 +815,14 @@ def _preserved_element_to_xml(element: PreservedElement) -> etree._Element:
|
|
|
670
815
|
|
|
671
816
|
|
|
672
817
|
def _track_change_mark_to_xml(mark: TrackChangeMark) -> etree._Element:
|
|
673
|
-
attrs =
|
|
674
|
-
if mark.para_end is not None:
|
|
675
|
-
attrs["paraend"] = _bool_to_str(mark.para_end)
|
|
676
|
-
if mark.tc_id is not None:
|
|
677
|
-
attrs["TcId"] = str(mark.tc_id)
|
|
818
|
+
attrs: Dict[str, str] = {}
|
|
678
819
|
if mark.id is not None:
|
|
679
820
|
attrs["Id"] = str(mark.id)
|
|
821
|
+
if mark.tc_id is not None:
|
|
822
|
+
attrs["TcId"] = str(mark.tc_id)
|
|
823
|
+
if mark.para_end is not None:
|
|
824
|
+
attrs["paraend"] = _bool_to_flag(mark.para_end)
|
|
825
|
+
attrs.update(mark.attributes)
|
|
680
826
|
return etree.Element(_qualified_tag(mark.tag, mark.name), attrs)
|
|
681
827
|
|
|
682
828
|
|
|
@@ -789,6 +935,8 @@ __all__ = [
|
|
|
789
935
|
"TextSpan",
|
|
790
936
|
"TrackChangeMark",
|
|
791
937
|
"TransformMatrix",
|
|
938
|
+
"append_tracked_insert_to_run",
|
|
939
|
+
"create_track_change_mark",
|
|
792
940
|
"parse_comment_element",
|
|
793
941
|
"parse_control_element",
|
|
794
942
|
"parse_form_combo_box_element",
|
|
@@ -806,6 +954,7 @@ __all__ = [
|
|
|
806
954
|
"parse_transform_matrix_element",
|
|
807
955
|
"serialize_paragraph",
|
|
808
956
|
"serialize_run",
|
|
957
|
+
"wrap_tracked_delete_in_span",
|
|
809
958
|
]
|
|
810
959
|
|
|
811
960
|
logger = logging.getLogger(__name__)
|
hwpx/oxml/header.py
CHANGED
|
@@ -5,6 +5,7 @@ import logging
|
|
|
5
5
|
import base64
|
|
6
6
|
import binascii
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
8
9
|
from typing import Dict, List, Mapping, Optional
|
|
9
10
|
|
|
10
11
|
from lxml import etree
|
|
@@ -578,7 +579,7 @@ class TrackChangeAuthor:
|
|
|
578
579
|
id: Optional[int]
|
|
579
580
|
raw_id: Optional[str]
|
|
580
581
|
name: Optional[str]
|
|
581
|
-
mark: Optional[bool]
|
|
582
|
+
mark: Optional[bool | int]
|
|
582
583
|
color: Optional[str]
|
|
583
584
|
attributes: Dict[str, str] = field(default_factory=dict)
|
|
584
585
|
|
|
@@ -701,6 +702,128 @@ class Header:
|
|
|
701
702
|
return None
|
|
702
703
|
return self.ref_list.track_change_authors.author_by_id(author_id_ref)
|
|
703
704
|
|
|
705
|
+
def add_track_change(
|
|
706
|
+
self,
|
|
707
|
+
change_type: str,
|
|
708
|
+
*,
|
|
709
|
+
author_name: str = "AI Agent",
|
|
710
|
+
date: str | None = None,
|
|
711
|
+
) -> int:
|
|
712
|
+
"""Append tracked-change metadata and return the new change id."""
|
|
713
|
+
|
|
714
|
+
normalized_type = normalize_track_change_type(change_type)
|
|
715
|
+
resolved_author = author_name.strip() or "AI Agent"
|
|
716
|
+
|
|
717
|
+
if self.ref_list is None:
|
|
718
|
+
self.ref_list = RefList()
|
|
719
|
+
ref_list = self.ref_list
|
|
720
|
+
|
|
721
|
+
if ref_list.track_change_authors is None:
|
|
722
|
+
ref_list.track_change_authors = TrackChangeAuthorList(item_cnt=0, authors=[])
|
|
723
|
+
author_list = ref_list.track_change_authors
|
|
724
|
+
|
|
725
|
+
author = next(
|
|
726
|
+
(candidate for candidate in author_list.authors if candidate.name == resolved_author),
|
|
727
|
+
None,
|
|
728
|
+
)
|
|
729
|
+
if author is None:
|
|
730
|
+
author_id = _next_track_change_author_id(author_list.authors)
|
|
731
|
+
author = TrackChangeAuthor(
|
|
732
|
+
id=author_id,
|
|
733
|
+
raw_id=None,
|
|
734
|
+
name=resolved_author,
|
|
735
|
+
mark=author_id,
|
|
736
|
+
color=None,
|
|
737
|
+
)
|
|
738
|
+
author_list.authors.append(author)
|
|
739
|
+
else:
|
|
740
|
+
author_id = _resolved_id(author.id, author.raw_id)
|
|
741
|
+
author_list.item_cnt = len(author_list.authors)
|
|
742
|
+
|
|
743
|
+
if ref_list.track_changes is None:
|
|
744
|
+
ref_list.track_changes = TrackChangeList(item_cnt=0, changes=[])
|
|
745
|
+
change_list = ref_list.track_changes
|
|
746
|
+
|
|
747
|
+
change_id = _next_track_change_id(change_list.changes)
|
|
748
|
+
change_list.changes.append(
|
|
749
|
+
TrackChange(
|
|
750
|
+
id=change_id,
|
|
751
|
+
raw_id=None,
|
|
752
|
+
change_type=normalized_type,
|
|
753
|
+
date=format_track_change_date(date),
|
|
754
|
+
author_id=author_id,
|
|
755
|
+
char_shape_id=None,
|
|
756
|
+
para_shape_id=None,
|
|
757
|
+
hide=False,
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
change_list.item_cnt = len(change_list.changes)
|
|
761
|
+
|
|
762
|
+
if self.track_change_config is None:
|
|
763
|
+
self.track_change_config = TrackChangeConfig(flags=1)
|
|
764
|
+
else:
|
|
765
|
+
self.track_change_config.flags = (self.track_change_config.flags or 0) | 1
|
|
766
|
+
return change_id
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def format_track_change_date(value: str | None = None) -> str:
|
|
770
|
+
if value is not None:
|
|
771
|
+
return value
|
|
772
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def normalize_track_change_type(change_type: str) -> str:
|
|
776
|
+
normalized = change_type.strip().lower()
|
|
777
|
+
aliases = {
|
|
778
|
+
"insert": "Insert",
|
|
779
|
+
"delete": "Delete",
|
|
780
|
+
"charshape": "CharShape",
|
|
781
|
+
"char_shape": "CharShape",
|
|
782
|
+
"char-shape": "CharShape",
|
|
783
|
+
}
|
|
784
|
+
resolved = aliases.get(normalized)
|
|
785
|
+
if resolved is None:
|
|
786
|
+
raise ValueError("change_type must be 'Insert', 'Delete', or 'CharShape'")
|
|
787
|
+
return resolved
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _resolved_id(identifier: int | None, raw_id: str | None) -> int:
|
|
791
|
+
if identifier is not None:
|
|
792
|
+
return identifier
|
|
793
|
+
if raw_id is not None:
|
|
794
|
+
return int(raw_id)
|
|
795
|
+
raise ValueError("tracked-change reference is missing an id")
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _next_numeric_id(values: list[int]) -> int:
|
|
799
|
+
return 1 if not values else max(values) + 1
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _next_track_change_id(changes: List[TrackChange]) -> int:
|
|
803
|
+
ids: list[int] = []
|
|
804
|
+
for change in changes:
|
|
805
|
+
if change.id is not None:
|
|
806
|
+
ids.append(change.id)
|
|
807
|
+
elif change.raw_id is not None:
|
|
808
|
+
try:
|
|
809
|
+
ids.append(int(change.raw_id))
|
|
810
|
+
except ValueError:
|
|
811
|
+
continue
|
|
812
|
+
return _next_numeric_id(ids)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _next_track_change_author_id(authors: List[TrackChangeAuthor]) -> int:
|
|
816
|
+
ids: list[int] = []
|
|
817
|
+
for author in authors:
|
|
818
|
+
if author.id is not None:
|
|
819
|
+
ids.append(author.id)
|
|
820
|
+
elif author.raw_id is not None:
|
|
821
|
+
try:
|
|
822
|
+
ids.append(int(author.raw_id))
|
|
823
|
+
except ValueError:
|
|
824
|
+
continue
|
|
825
|
+
return _next_numeric_id(ids)
|
|
826
|
+
|
|
704
827
|
|
|
705
828
|
def parse_begin_num(node: etree._Element) -> BeginNum:
|
|
706
829
|
return BeginNum(
|
|
@@ -1226,11 +1349,12 @@ def parse_track_change_author(node: etree._Element) -> TrackChangeAuthor:
|
|
|
1226
1349
|
attributes = {
|
|
1227
1350
|
key: value for key, value in node.attrib.items() if key not in known_attrs
|
|
1228
1351
|
}
|
|
1352
|
+
mark = _parse_track_change_author_mark(node.get("mark"))
|
|
1229
1353
|
return TrackChangeAuthor(
|
|
1230
1354
|
id=parse_int(node.get("id")),
|
|
1231
1355
|
raw_id=node.get("id"),
|
|
1232
1356
|
name=node.get("name"),
|
|
1233
|
-
mark=
|
|
1357
|
+
mark=mark,
|
|
1234
1358
|
color=node.get("color"),
|
|
1235
1359
|
attributes=attributes,
|
|
1236
1360
|
)
|
|
@@ -1301,7 +1425,7 @@ def parse_header_element(node: etree._Element) -> Header:
|
|
|
1301
1425
|
header.doc_option = parse_doc_option(child)
|
|
1302
1426
|
elif name == "metaTag":
|
|
1303
1427
|
header.meta_tag = text_or_none(child)
|
|
1304
|
-
elif name
|
|
1428
|
+
elif name in {"trackchageConfig", "trackchangeConfig"}:
|
|
1305
1429
|
header.track_change_config = parse_track_change_config(child)
|
|
1306
1430
|
else:
|
|
1307
1431
|
header.other_elements.setdefault(name, []).append(parse_generic_element(child))
|
|
@@ -1309,6 +1433,74 @@ def parse_header_element(node: etree._Element) -> Header:
|
|
|
1309
1433
|
return header
|
|
1310
1434
|
|
|
1311
1435
|
|
|
1436
|
+
def _parse_track_change_author_mark(value: str | None) -> bool | int | None:
|
|
1437
|
+
if value is None:
|
|
1438
|
+
return None
|
|
1439
|
+
try:
|
|
1440
|
+
return parse_bool(value)
|
|
1441
|
+
except ValueError:
|
|
1442
|
+
return parse_int(value, allow_none=False)
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def _set_optional_int_attr(
|
|
1446
|
+
attributes: Dict[str, str],
|
|
1447
|
+
name: str,
|
|
1448
|
+
value: int | None,
|
|
1449
|
+
) -> None:
|
|
1450
|
+
if value is not None:
|
|
1451
|
+
attributes[name] = str(value)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _set_optional_str_attr(
|
|
1455
|
+
attributes: Dict[str, str],
|
|
1456
|
+
name: str,
|
|
1457
|
+
value: str | None,
|
|
1458
|
+
) -> None:
|
|
1459
|
+
if value is not None:
|
|
1460
|
+
attributes[name] = value
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _set_optional_flag_attr(
|
|
1464
|
+
attributes: Dict[str, str],
|
|
1465
|
+
name: str,
|
|
1466
|
+
value: bool | None,
|
|
1467
|
+
) -> None:
|
|
1468
|
+
if value is not None:
|
|
1469
|
+
attributes[name] = "1" if value else "0"
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def track_change_to_xml(change: TrackChange) -> etree._Element:
|
|
1473
|
+
attributes = dict(change.attributes)
|
|
1474
|
+
_set_optional_str_attr(attributes, "type", change.change_type)
|
|
1475
|
+
_set_optional_str_attr(attributes, "date", change.date)
|
|
1476
|
+
_set_optional_int_attr(attributes, "authorID", change.author_id)
|
|
1477
|
+
_set_optional_int_attr(attributes, "charShapeID", change.char_shape_id)
|
|
1478
|
+
_set_optional_int_attr(attributes, "paraShapeID", change.para_shape_id)
|
|
1479
|
+
_set_optional_flag_attr(attributes, "hide", change.hide)
|
|
1480
|
+
_set_optional_int_attr(attributes, "id", change.id)
|
|
1481
|
+
if change.raw_id is not None and change.id is None:
|
|
1482
|
+
attributes["id"] = change.raw_id
|
|
1483
|
+
return etree.Element(f"{{http://www.hancom.co.kr/hwpml/2011/head}}trackChange", attributes)
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def track_change_author_to_xml(author: TrackChangeAuthor) -> etree._Element:
|
|
1487
|
+
attributes = dict(author.attributes)
|
|
1488
|
+
_set_optional_str_attr(attributes, "name", author.name)
|
|
1489
|
+
if author.mark is not None:
|
|
1490
|
+
if isinstance(author.mark, bool):
|
|
1491
|
+
attributes["mark"] = "1" if author.mark else "0"
|
|
1492
|
+
else:
|
|
1493
|
+
attributes["mark"] = str(author.mark)
|
|
1494
|
+
_set_optional_int_attr(attributes, "id", author.id)
|
|
1495
|
+
if author.raw_id is not None and author.id is None:
|
|
1496
|
+
attributes["id"] = author.raw_id
|
|
1497
|
+
_set_optional_str_attr(attributes, "color", author.color)
|
|
1498
|
+
return etree.Element(
|
|
1499
|
+
f"{{http://www.hancom.co.kr/hwpml/2011/head}}trackChangeAuthor",
|
|
1500
|
+
attributes,
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
|
|
1312
1504
|
__all__ = [
|
|
1313
1505
|
"BeginNum",
|
|
1314
1506
|
"BorderFillList",
|
|
@@ -1350,7 +1542,9 @@ __all__ = [
|
|
|
1350
1542
|
"TrackChangeAuthorList",
|
|
1351
1543
|
"TrackChangeConfig",
|
|
1352
1544
|
"TrackChangeList",
|
|
1545
|
+
"format_track_change_date",
|
|
1353
1546
|
"memo_shape_from_attributes",
|
|
1547
|
+
"normalize_track_change_type",
|
|
1354
1548
|
"parse_begin_num",
|
|
1355
1549
|
"parse_bullet",
|
|
1356
1550
|
"parse_bullet_para_head",
|
|
@@ -1379,6 +1573,8 @@ __all__ = [
|
|
|
1379
1573
|
"parse_track_change_author",
|
|
1380
1574
|
"parse_track_change_authors",
|
|
1381
1575
|
"parse_track_changes",
|
|
1576
|
+
"track_change_author_to_xml",
|
|
1577
|
+
"track_change_to_xml",
|
|
1382
1578
|
]
|
|
1383
1579
|
|
|
1384
1580
|
logger = logging.getLogger(__name__)
|
hwpx/tools/redline.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Redline authoring verification helpers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from os import PathLike, fspath
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from hwpx.document import HwpxDocument
|
|
11
|
+
from hwpx.oxml.body import TrackChangeMark
|
|
12
|
+
from hwpx.visual.oracle import RenderBackend, resolve_oracle, visual_check
|
|
13
|
+
|
|
14
|
+
REDLINE_VERIFY_REPORT_VERSION = "redline-verify-v1"
|
|
15
|
+
_DEMO_DATE = "2026-06-30T00:00:00Z"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def verify_redline(
|
|
19
|
+
before_hwpx: str | PathLike[str],
|
|
20
|
+
after_hwpx: str | PathLike[str],
|
|
21
|
+
*,
|
|
22
|
+
oracle: RenderBackend | None = None,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""Verify authored redline structure and fold in the VisualComplete report."""
|
|
25
|
+
|
|
26
|
+
warnings: list[str] = []
|
|
27
|
+
document: HwpxDocument | None = None
|
|
28
|
+
structural_opens_clean = False
|
|
29
|
+
try:
|
|
30
|
+
document = HwpxDocument.open(after_hwpx)
|
|
31
|
+
structural_opens_clean = True
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
warnings.append(f"after document did not reopen structurally: {exc}")
|
|
34
|
+
|
|
35
|
+
change_count = 0
|
|
36
|
+
changes_by_type: dict[str, int] = {}
|
|
37
|
+
marks_linked = False
|
|
38
|
+
display_enabled = False
|
|
39
|
+
|
|
40
|
+
if document is not None:
|
|
41
|
+
changes = document.track_changes
|
|
42
|
+
changes_by_id = {
|
|
43
|
+
int(change.id): change
|
|
44
|
+
for change in changes.values()
|
|
45
|
+
if change.id is not None
|
|
46
|
+
}
|
|
47
|
+
change_count = len(changes_by_id)
|
|
48
|
+
type_counter = Counter(_normalise_change_type(change.change_type) for change in changes_by_id.values())
|
|
49
|
+
changes_by_type = dict(sorted(type_counter.items()))
|
|
50
|
+
|
|
51
|
+
if not changes_by_id:
|
|
52
|
+
warnings.append("after document has no header trackChanges entries")
|
|
53
|
+
|
|
54
|
+
body_marks = _collect_track_change_marks(document)
|
|
55
|
+
marks_linked = _marks_are_linked(body_marks, changes_by_id)
|
|
56
|
+
if not body_marks:
|
|
57
|
+
warnings.append("after document has no body insert/delete track-change marks")
|
|
58
|
+
elif not marks_linked:
|
|
59
|
+
warnings.append("body track-change marks are not fully linked to header trackChanges by TcId")
|
|
60
|
+
|
|
61
|
+
display_enabled = _track_change_display_enabled(document)
|
|
62
|
+
if not display_enabled:
|
|
63
|
+
warnings.append("trackChangeConfig display flag is not enabled")
|
|
64
|
+
|
|
65
|
+
backend = oracle if oracle is not None else resolve_oracle()
|
|
66
|
+
visual_report = visual_check(
|
|
67
|
+
fspath(before_hwpx),
|
|
68
|
+
fspath(after_hwpx),
|
|
69
|
+
oracle=backend,
|
|
70
|
+
)
|
|
71
|
+
warnings.extend(visual_report.warnings)
|
|
72
|
+
warnings.extend(f"visual error: {error}" for error in visual_report.errors)
|
|
73
|
+
warnings.extend(_visual_signal_warnings(visual_report))
|
|
74
|
+
|
|
75
|
+
render_checked = bool(visual_report.render_checked)
|
|
76
|
+
opens_clean: bool | None
|
|
77
|
+
if render_checked:
|
|
78
|
+
opens_clean = True
|
|
79
|
+
elif visual_report.errors:
|
|
80
|
+
opens_clean = False
|
|
81
|
+
else:
|
|
82
|
+
opens_clean = None
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"report_version": REDLINE_VERIFY_REPORT_VERSION,
|
|
86
|
+
"changeCount": change_count,
|
|
87
|
+
"changesByType": changes_by_type,
|
|
88
|
+
"marksLinked": marks_linked,
|
|
89
|
+
"displayEnabled": display_enabled,
|
|
90
|
+
"opensClean": opens_clean if structural_opens_clean else False,
|
|
91
|
+
"render_checked": render_checked,
|
|
92
|
+
"visual_ok": visual_report.ok if render_checked else None,
|
|
93
|
+
"warnings": warnings,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def author_demo_redline(doc: HwpxDocument) -> HwpxDocument:
|
|
98
|
+
"""Apply one tracked insert and one tracked delete to *doc*."""
|
|
99
|
+
|
|
100
|
+
paragraph = doc.add_paragraph("redline delete target", char_pr_id_ref="0")
|
|
101
|
+
doc.add_tracked_insert(paragraph, " inserted", date=_DEMO_DATE)
|
|
102
|
+
doc.add_tracked_delete(paragraph, match="delete", date=_DEMO_DATE)
|
|
103
|
+
return doc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _normalise_change_type(value: str | None) -> str:
|
|
107
|
+
text = str(value or "").strip()
|
|
108
|
+
if not text:
|
|
109
|
+
return "Unknown"
|
|
110
|
+
lowered = text.lower()
|
|
111
|
+
aliases = {
|
|
112
|
+
"insert": "Insert",
|
|
113
|
+
"delete": "Delete",
|
|
114
|
+
"charshape": "CharShape",
|
|
115
|
+
"parashape": "ParaShape",
|
|
116
|
+
}
|
|
117
|
+
return aliases.get(lowered, text[:1].upper() + text[1:].lower())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _collect_track_change_marks(document: HwpxDocument) -> list[TrackChangeMark]:
|
|
121
|
+
marks: list[TrackChangeMark] = []
|
|
122
|
+
for paragraph in document.paragraphs:
|
|
123
|
+
for run in paragraph.runs:
|
|
124
|
+
model = run.to_model()
|
|
125
|
+
for span in model.text_spans:
|
|
126
|
+
for markup in span.marks:
|
|
127
|
+
if isinstance(markup.element, TrackChangeMark):
|
|
128
|
+
marks.append(markup.element)
|
|
129
|
+
return marks
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _marks_are_linked(
|
|
133
|
+
marks: list[TrackChangeMark],
|
|
134
|
+
changes_by_id: dict[int, Any],
|
|
135
|
+
) -> bool:
|
|
136
|
+
if not marks or not changes_by_id:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
mark_names_by_change: dict[int, set[str]] = defaultdict(set)
|
|
140
|
+
for mark in marks:
|
|
141
|
+
if mark.tc_id is None or mark.tc_id not in changes_by_id:
|
|
142
|
+
return False
|
|
143
|
+
mark_type = _normalise_change_type(mark.change_type)
|
|
144
|
+
change_type = _normalise_change_type(changes_by_id[mark.tc_id].change_type)
|
|
145
|
+
if mark_type != change_type:
|
|
146
|
+
return False
|
|
147
|
+
mark_names_by_change[mark.tc_id].add(mark.name)
|
|
148
|
+
|
|
149
|
+
for change_id, change in changes_by_id.items():
|
|
150
|
+
change_type = _normalise_change_type(change.change_type)
|
|
151
|
+
if change_type not in {"Insert", "Delete"}:
|
|
152
|
+
continue
|
|
153
|
+
names = mark_names_by_change.get(change_id, set())
|
|
154
|
+
if f"{change_type.lower()}Begin" not in names or f"{change_type.lower()}End" not in names:
|
|
155
|
+
return False
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _track_change_display_enabled(document: HwpxDocument) -> bool:
|
|
160
|
+
for header in document.headers:
|
|
161
|
+
config = header.to_model().track_change_config
|
|
162
|
+
if config is not None and config.flags is not None and config.flags & 1:
|
|
163
|
+
return True
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _visual_signal_warnings(report: Any) -> list[str]:
|
|
168
|
+
warnings: list[str] = []
|
|
169
|
+
if report.unexpected_diff_outside_mask:
|
|
170
|
+
warnings.append("visual signal: unexpected_diff_outside_mask")
|
|
171
|
+
if report.overlap_detected:
|
|
172
|
+
warnings.append("visual signal: overlap_detected")
|
|
173
|
+
if report.overflow_detected:
|
|
174
|
+
warnings.append("visual signal: overflow_detected")
|
|
175
|
+
if report.table_break_detected:
|
|
176
|
+
warnings.append("visual signal: table_break_detected")
|
|
177
|
+
if report.page_count_changed:
|
|
178
|
+
warnings.append("visual signal: page_count_changed")
|
|
179
|
+
return warnings
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = [
|
|
183
|
+
"REDLINE_VERIFY_REPORT_VERSION",
|
|
184
|
+
"author_demo_redline",
|
|
185
|
+
"verify_redline",
|
|
186
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
hwpx/__init__.py,sha256=ikzGacbkMN7PhNFVb5h3UhtpfSZ1Smr_cKu0OdSaOes,5052
|
|
2
2
|
hwpx/authoring.py,sha256=_AUGOzwmiHHuO08HZhGCEd6eCHCZV0yXeBaYKMsJJ34,125020
|
|
3
|
-
hwpx/document.py,sha256=
|
|
3
|
+
hwpx/document.py,sha256=M_f1KDkyQNuK8LN-jyLTe4mnQQaMLcPh7vQx0Eg7wYk,107048
|
|
4
4
|
hwpx/form_fill.py,sha256=VUIU53Qa9Ho2aP72biDvJwnDW7ngdAzu3PSd5A7d1JM,9908
|
|
5
5
|
hwpx/package.py,sha256=0rKjGCJbPQvrVBIy07Jpjsu3fI7HhbqFCGWTiTDsJpo,1141
|
|
6
6
|
hwpx/patch.py,sha256=8G1wgGVgCp0KyhQb-y0ZI2FJEatCwLt_JpepHJmYhYQ,23799
|
|
@@ -72,13 +72,13 @@ hwpx/opc/package.py,sha256=4Ydaw-1uyTZ7DwNZ89ICJ7CHYwUGzeGR-FbZiyJ5XWk,35484
|
|
|
72
72
|
hwpx/opc/relationships.py,sha256=tPWLHRMlw0Spvtwou2jCDRfHdcm9FEKKLd95YVHLwYI,6971
|
|
73
73
|
hwpx/opc/security.py,sha256=hsA73sZxUoGIgy2zue9EnV7ChHMfasnplmqzxFs3Mp4,4419
|
|
74
74
|
hwpx/opc/xml_utils.py,sha256=L_fHY1-D5I_TfdRkDQV-bn55EnXc6AqEDWItfMpawVs,3840
|
|
75
|
-
hwpx/oxml/__init__.py,sha256=
|
|
76
|
-
hwpx/oxml/_document_impl.py,sha256=
|
|
77
|
-
hwpx/oxml/body.py,sha256=
|
|
75
|
+
hwpx/oxml/__init__.py,sha256=7koxG5Xc6ia1dWmAqUJJETkAL5wzamSrNVcKuiUH58I,5466
|
|
76
|
+
hwpx/oxml/_document_impl.py,sha256=teZ-5dIaeu2DwIARiBop5u61bSuUkE3ND-lkRkZbBGk,249701
|
|
77
|
+
hwpx/oxml/body.py,sha256=3PzGiYmm03si5OVjGtS-R_tT1rWmsYwE8Ds8ZLhIIio,30609
|
|
78
78
|
hwpx/oxml/canonical_defaults.py,sha256=WHAK7u_W-PDv3P3N1-1-aWAoiUgBh4x-UybFL4NqW-w,3791
|
|
79
79
|
hwpx/oxml/common.py,sha256=TJkafzg7x4T3J29tZchRZk57ZTsrM9PEiqGT3rX3w5o,1044
|
|
80
80
|
hwpx/oxml/document.py,sha256=9_gJgk-Zyoeqft3OB-sce4MQLo-UejR79e1fDEsC7b0,1154
|
|
81
|
-
hwpx/oxml/header.py,sha256=
|
|
81
|
+
hwpx/oxml/header.py,sha256=m878yWuMv9dRA4enaHHBjPEsL6QECBJLhfsQetJ7l2I,50017
|
|
82
82
|
hwpx/oxml/header_part.py,sha256=nIMsH3gHdKP_BB5g4LwFwCqmMzjnyY5y40vitQMSPwc,237
|
|
83
83
|
hwpx/oxml/memo.py,sha256=BxfNt8k7_A0eUcZ3uu97t5rM5OA315pR7lCEHVkS8b8,304
|
|
84
84
|
hwpx/oxml/namespaces.py,sha256=c7JfdOdJbzrhyHvjbxoeeeloRE3xPuoB7v3YI8SmKDk,6524
|
|
@@ -117,6 +117,7 @@ hwpx/tools/package_reconcile.py,sha256=y1Hl7hbPh4YaV59LTdDLzQwgn4g1qEnFmSjmajnrE
|
|
|
117
117
|
hwpx/tools/package_validator.py,sha256=AA5wy6YgwlU6BTq1p2qCbCVCM8lmIBLhPANKCfaPb-s,29369
|
|
118
118
|
hwpx/tools/page_guard.py,sha256=nDAVPcvrnuyDxVTA_j22wiYD7CXAD6XlzsMzaz3h_q8,9701
|
|
119
119
|
hwpx/tools/recover.py,sha256=EOVAzMFAqR9YAT3sinZKCdjSkKygo4dKrs6T6SbGA7o,4963
|
|
120
|
+
hwpx/tools/redline.py,sha256=p6aMVDBOrkqywlrlxqhB-5ZjCEtgrCNa8cYeRMJlprk,6478
|
|
120
121
|
hwpx/tools/repair.py,sha256=wYO4Zd8ZMwkbYy2_EPz0eyqvTCrzcAyvbEsVE1P87Zk,11197
|
|
121
122
|
hwpx/tools/report_parser.py,sha256=3Daqn2hqIcj5pG1qUxeYbvWr7CvdhwzatWvxCCcnSZg,4307
|
|
122
123
|
hwpx/tools/report_utils.py,sha256=6HYEeQc3ZxTpxbwF11s47uZ-KmV4tsHPE1MV4491KDE,4434
|
|
@@ -145,10 +146,10 @@ hwpx/visual/diff.py,sha256=0X5T9IgwRZU3td-7vnPrlowovtGud7P_ymq0KVehlKk,5677
|
|
|
145
146
|
hwpx/visual/masks.py,sha256=oXhgynAb4uKjJtZ2BGHHdAjyvWGqSFlZFQ-iJxzHiuo,1832
|
|
146
147
|
hwpx/visual/oracle.py,sha256=QXAyc0xVIjLPWUHM2rBjsustQTmwfoW2Xkb9iX9ai2E,21786
|
|
147
148
|
hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
|
|
148
|
-
python_hwpx-2.
|
|
149
|
-
python_hwpx-2.
|
|
150
|
-
python_hwpx-2.
|
|
151
|
-
python_hwpx-2.
|
|
152
|
-
python_hwpx-2.
|
|
153
|
-
python_hwpx-2.
|
|
154
|
-
python_hwpx-2.
|
|
149
|
+
python_hwpx-2.17.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
150
|
+
python_hwpx-2.17.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
|
|
151
|
+
python_hwpx-2.17.0.dist-info/METADATA,sha256=PnLwDDOUcJkC-b4pK-qryUc1p5ru2zbssEpBfhmjaV0,19982
|
|
152
|
+
python_hwpx-2.17.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
153
|
+
python_hwpx-2.17.0.dist-info/entry_points.txt,sha256=4U6WXYWHxEiWp2VRHo97fvOYNh7ebu6roonk7chxKcY,453
|
|
154
|
+
python_hwpx-2.17.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
155
|
+
python_hwpx-2.17.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|