python-hwpx 6.4.0__py3-none-any.whl → 6.5.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.
@@ -78,6 +78,11 @@ _LATEX_TO_EQEDIT.update(
78
78
  "\\forall": "FORALL",
79
79
  "\\iint": "dint",
80
80
  "\\iiint": "tint",
81
+ # ``triangle`` is accepted when reading (observed in real documents) but
82
+ # only the documented uppercase spelling is emitted when authoring: the
83
+ # lowercase form has not been render-verified, and lowercase ``forall``
84
+ # already turned out to render as literal text.
85
+ "\\triangle": "TRIANGLE",
81
86
  # Common LaTeX aliases sharing a verified target.
82
87
  "\\le": "leq",
83
88
  "\\ge": "geq",
hwpx/equation/mathml.py CHANGED
@@ -9,11 +9,16 @@ code block rather than dropping the equation.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import re
12
13
  from typing import Callable
13
14
 
15
+ from .tokens import EQEDIT_MATHML_OPERATOR_COMMANDS
16
+
14
17
  # ``False`` marks a resolved-but-unavailable converter; ``None`` means "not yet
15
18
  # probed" so the import is attempted lazily on first use.
16
19
  _converter: Callable[[str], str] | bool | None = None
20
+ _LATEX_WORD_COMMAND_RE = re.compile(r"\\[A-Za-z]+")
21
+ _TEXT_COMMAND = "\\text{"
17
22
 
18
23
 
19
24
  class MathMLUnavailableError(RuntimeError):
@@ -62,8 +67,69 @@ def latex_to_mathml(latex: str) -> str:
62
67
  raise ValueError(f"latex2mathml could not render fragment: {exc}") from exc
63
68
 
64
69
 
70
+ def eqedit_latex_to_mathml(latex: str) -> str:
71
+ """Convert EqEdit-derived LaTeX while preserving known token roles.
72
+
73
+ ``latex2mathml`` classifies plain ``\\triangle`` as an identifier. EqEdit's
74
+ token map identifies it as a mathematical symbol, so the MathML-only input
75
+ receives an operator wrapper before conversion. The returned/public LaTeX
76
+ is not rewritten, and serialized MathML is never patched afterward.
77
+ """
78
+
79
+ def annotate_role(match: re.Match[str]) -> str:
80
+ command = match.group(0)
81
+ if command in EQEDIT_MATHML_OPERATOR_COMMANDS:
82
+ return rf"\mathop{{{command}}}"
83
+ return command
84
+
85
+ return latex_to_mathml(
86
+ "".join(
87
+ segment if literal else _LATEX_WORD_COMMAND_RE.sub(annotate_role, segment)
88
+ for segment, literal in _split_text_literals(latex)
89
+ )
90
+ )
91
+
92
+
93
+ def _split_text_literals(latex: str) -> list[tuple[str, bool]]:
94
+ """Split ``latex`` into ``(segment, is_text_literal)`` pieces.
95
+
96
+ Quoted EqEdit literals become ``\\text{...}``; their content is prose, so
97
+ the role annotation must not rewrite a ``\\triangle`` typed inside quotes.
98
+ Braces inside the literal are balanced; an unterminated group is passed
99
+ through untouched so ``latex2mathml`` reports it instead of this splitter.
100
+ """
101
+
102
+ pieces: list[tuple[str, bool]] = []
103
+ cursor = 0
104
+ while True:
105
+ start = latex.find(_TEXT_COMMAND, cursor)
106
+ if start < 0:
107
+ break
108
+ depth = 0
109
+ end = -1
110
+ for index in range(start + len(_TEXT_COMMAND) - 1, len(latex)):
111
+ char = latex[index]
112
+ if char == "{":
113
+ depth += 1
114
+ elif char == "}":
115
+ depth -= 1
116
+ if depth == 0:
117
+ end = index + 1
118
+ break
119
+ if end < 0:
120
+ break
121
+ if start > cursor:
122
+ pieces.append((latex[cursor:start], False))
123
+ pieces.append((latex[start:end], True))
124
+ cursor = end
125
+ if cursor < len(latex):
126
+ pieces.append((latex[cursor:], False))
127
+ return pieces
128
+
129
+
65
130
  __all__ = [
66
131
  "MathMLUnavailableError",
132
+ "eqedit_latex_to_mathml",
67
133
  "latex2mathml_available",
68
134
  "latex_to_mathml",
69
135
  ]
hwpx/equation/render.py CHANGED
@@ -18,7 +18,7 @@ from dataclasses import dataclass
18
18
  import html
19
19
 
20
20
  from .eqedit import EquationConversionError, eqedit_to_latex
21
- from .mathml import MathMLUnavailableError, latex_to_mathml
21
+ from .mathml import MathMLUnavailableError, eqedit_latex_to_mathml
22
22
 
23
23
  # Fidelity labels surfaced in the preview (Constitution IX, honest reporting).
24
24
  LABEL_MATHML = "수식 MathML 렌더"
@@ -63,7 +63,7 @@ def render_equation(script: str) -> EquationRender:
63
63
  return _fallback_block("script", LABEL_SCRIPT, script, latex=None)
64
64
 
65
65
  try:
66
- mathml = latex_to_mathml(latex)
66
+ mathml = eqedit_latex_to_mathml(latex)
67
67
  except MathMLUnavailableError:
68
68
  return _fallback_block("latex", LABEL_LATEX_NO_LIB, latex, latex=latex)
69
69
  except ValueError:
hwpx/equation/tokens.py CHANGED
@@ -116,6 +116,11 @@ OPERATORS: dict[str, str] = {
116
116
  "partial": r"\partial",
117
117
  "nabla": r"\nabla",
118
118
  "angle": r"\angle",
119
+ # Hancom's symbol guide spells this token in uppercase, while legacy
120
+ # documents also contain the exact lowercase form. Mixed-case variants are
121
+ # intentionally not accepted because the reader uses exact token lookup.
122
+ "triangle": r"\triangle",
123
+ "TRIANGLE": r"\triangle",
119
124
  "cdots": r"\cdots",
120
125
  "ldots": r"\ldots",
121
126
  "vdots": r"\vdots",
@@ -254,11 +259,17 @@ DELIMITERS: dict[str, str] = {
254
259
  # Structural keywords handled directly by the parser (not simple substitution).
255
260
  STRUCTURAL = frozenset({"over", "atop", "sqrt", "root", "of", "LEFT", "RIGHT", "left", "right"})
256
261
 
262
+ # LaTeX commands emitted from EqEdit operator tokens that latex2mathml otherwise
263
+ # classifies as identifiers. The render boundary uses this metadata only while
264
+ # producing MathML; the public LaTeX remains the canonical command above.
265
+ EQEDIT_MATHML_OPERATOR_COMMANDS = frozenset({OPERATORS["triangle"]})
266
+
257
267
 
258
268
  __all__ = [
259
269
  "ACCENTS",
260
270
  "BIG_OPERATORS",
261
271
  "DELIMITERS",
272
+ "EQEDIT_MATHML_OPERATOR_COMMANDS",
262
273
  "FUNCTIONS",
263
274
  "GREEK",
264
275
  "MATRIX_ENVIRONMENTS",
@@ -0,0 +1,42 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Small helpers for a style-preserving paragraph text edit."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import xml.etree.ElementTree as ET
7
+
8
+ from ..errors import HwpxValueError
9
+ from ._document_primitives import _is_tab_control_element
10
+ from .namespaces import tag_local_name
11
+
12
+
13
+ def plain_text_nodes_for_edit(runs: list[ET.Element]) -> list[ET.Element]:
14
+ nodes: list[ET.Element] = []
15
+ for run in runs:
16
+ for child in run:
17
+ if tag_local_name(child.tag) == "t":
18
+ if len(child):
19
+ raise HwpxValueError(
20
+ "mixed text markup cannot be edited safely",
21
+ code="paragraph-mixed-text-unsupported",
22
+ )
23
+ nodes.append(child)
24
+ elif tag_local_name(child.tag) == "tab" or _is_tab_control_element(child):
25
+ raise HwpxValueError(
26
+ "tab controls require an explicit run target",
27
+ code="paragraph-tab-target-required",
28
+ )
29
+ return nodes
30
+
31
+
32
+ def edit_node_candidates(
33
+ nodes: list[ET.Element], prefix: int, end: int
34
+ ) -> list[tuple[ET.Element, int]]:
35
+ offset = 0
36
+ candidates: list[tuple[ET.Element, int]] = []
37
+ for node in nodes:
38
+ length = len(node.text or "")
39
+ if offset <= prefix and end <= offset + length:
40
+ candidates.append((node, offset))
41
+ offset += length
42
+ return candidates
hwpx/oxml/paragraph.py CHANGED
@@ -40,6 +40,7 @@ from .note_authoring import (
40
40
  _paragraph_endnotes,
41
41
  _paragraph_footnotes,
42
42
  )
43
+ from ._paragraph_text_edit import edit_node_candidates, plain_text_nodes_for_edit
43
44
  from .objects import (
44
45
  HwpxOxmlInlineObject,
45
46
  _create_picture_element,
@@ -336,6 +337,42 @@ class HwpxOxmlParagraph:
336
337
  _clear_paragraph_layout_cache(self.element)
337
338
  self.section.mark_dirty()
338
339
 
340
+ def set_text_preserving_runs(self, value: str) -> None:
341
+ """Replace one unambiguous text span without changing run boundaries.
342
+
343
+ The edit must fit inside one plain ``hp:t`` node. This covers a
344
+ correction within a styled run while retaining every other run and
345
+ control; cross-style replacements require an explicit run target.
346
+ """
347
+ from ..errors import HwpxValueError
348
+
349
+ nodes = plain_text_nodes_for_edit(self._run_elements())
350
+ old = "".join(node.text or "" for node in nodes)
351
+ if old == value:
352
+ return
353
+ if not nodes:
354
+ raise HwpxValueError("paragraph has no plain text node", code="paragraph-text-missing")
355
+ prefix = 0
356
+ while prefix < min(len(old), len(value)) and old[prefix] == value[prefix]:
357
+ prefix += 1
358
+ suffix = 0
359
+ while (suffix < len(old) - prefix and suffix < len(value) - prefix
360
+ and old[len(old) - suffix - 1] == value[len(value) - suffix - 1]):
361
+ suffix += 1
362
+ end = len(old) - suffix
363
+ replacement = value[prefix:len(value) - suffix if suffix else len(value)]
364
+ candidates = edit_node_candidates(nodes, prefix, end)
365
+ if len(candidates) != 1:
366
+ raise HwpxValueError(
367
+ "text edit crosses run boundaries or has ambiguous insertion style",
368
+ code="paragraph-text-style-ambiguous",
369
+ )
370
+ node, offset = candidates[0]
371
+ current = node.text or ""
372
+ node.text = _sanitize_text(current[:prefix - offset] + replacement + current[end - offset:])
373
+ _clear_paragraph_layout_cache(self.element)
374
+ self.section.mark_dirty()
375
+
339
376
  def clear_text(self) -> None:
340
377
  """Remove all text content while preserving styles and non-text elements.
341
378
 
hwpx/oxml/table.py CHANGED
@@ -464,6 +464,30 @@ class HwpxOxmlTable:
464
464
  ")"
465
465
  )
466
466
 
467
+ @property
468
+ def height(self) -> int:
469
+ """Declared table height in HWP units."""
470
+ size = self.element.find(f"{_HP}sz")
471
+ return int(size.get("height", "0")) if size is not None else 0
472
+
473
+ @property
474
+ def treat_as_char(self) -> bool:
475
+ """Whether Hancom lays out this table as one inline character."""
476
+ position = self.element.find(f"{_HP}pos")
477
+ return position is not None and position.get("treatAsChar", "1") == "1"
478
+
479
+ def set_treat_as_char(self, value: bool) -> None:
480
+ """Choose inline layout or flowing table layout across pages."""
481
+ position = self.element.find(f"{_HP}pos")
482
+ if position is None:
483
+ from ..errors import HwpxValueError
484
+
485
+ raise HwpxValueError("table has no hp:pos", code="table-position-missing")
486
+ desired = "1" if value else "0"
487
+ if position.get("treatAsChar") != desired:
488
+ position.set("treatAsChar", desired)
489
+ self.mark_dirty()
490
+
467
491
  # --- caption (hp:caption) -----------------------------------------
468
492
 
469
493
  @property
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 6.4.0
3
+ Version: 6.5.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -43,11 +43,11 @@ hwpx/data/contract_docs/mutation-semantics.md,sha256=3GjuRgPd7W58y9N1aj7xK10U4DZ
43
43
  hwpx/data/contract_docs/recipes-traversal.md,sha256=tj9CsabtI4EmiFmUdNz4MwBQm4apS12s6uZ5v9vek0M,4419
44
44
  hwpx/data/contract_docs/support-matrix.md,sha256=vODB2JsgEGFRFHkeEQovwy6McxBSEimjlkhIG4N-93Y,65733
45
45
  hwpx/equation/__init__.py,sha256=0wks0_pS_tBokCVMeWUJGE6tbW35Ptc0I5Z_36FY-Jo,1328
46
- hwpx/equation/authoring.py,sha256=s8wJWNEEFJ2oS4xbs4-Hyv7Dlml_mSQrljdy0hbzpjU,16786
46
+ hwpx/equation/authoring.py,sha256=xuLRxkIPPJmiDfSVX-ngcQYYKZRqSuHV1PBVvBvo6vY,17117
47
47
  hwpx/equation/eqedit.py,sha256=FjRNwc_JVC01yprqLFk5N6Nm5qCow2K2x-u3Dw7Wmio,11314
48
- hwpx/equation/mathml.py,sha256=mbmAojF-4e9cMEQQvgHx5wvp_dG3Mb_sPjqH7SpOaxQ,2150
49
- hwpx/equation/render.py,sha256=g698YmVWsfG4V-QNROXmSK0za-npJ4Z7xIe6Y5DZ1g0,2874
50
- hwpx/equation/tokens.py,sha256=RyFyqj3rMllFB5k4d2XO2L8CkiIySb573evJ-fwujhY,7097
48
+ hwpx/equation/mathml.py,sha256=Wbuw9hikBJoxzUdKjMDasvBe8suGGjNUXBTeNrp-GSM,4424
49
+ hwpx/equation/render.py,sha256=P475CF6xhEYveDgw2aiEHhg9TW-3ZTs3MlpyzU5PPvY,2888
50
+ hwpx/equation/tokens.py,sha256=76T57UgeE-08NVPHjN0FD3wvxvIdPksbxgjegvUgf5w,7729
51
51
  hwpx/form_fit/__init__.py,sha256=l1HcFge4u_wL2vogB-VxSMrb-8h7zUT_IgTWMtnH9CM,1779
52
52
  hwpx/form_fit/apply.py,sha256=X2q7K-mLku4AXxm4-xP80QGzhBlpzpqA_Ade8aPXzNY,3450
53
53
  hwpx/form_fit/engine.py,sha256=2W5ePGnpaLDoVPH8RxKdRfVx3r3usy26RBWj0jU1fpE,21553
@@ -74,6 +74,7 @@ hwpx/opc/xml_utils.py,sha256=r4wc0irlf4tOXlOAwXoqReJOY3-EIqd7Uan56XR6dBs,4474
74
74
  hwpx/oxml/__init__.py,sha256=Ogl__NFyPORPxboCHqrsn2IrRdcjaQBzmnYm4pPWXpA,6731
75
75
  hwpx/oxml/_document_impl.py,sha256=41cxktJqXHER37fjA5HIFR8kw7dHMoISTxEv53PRBYo,5367
76
76
  hwpx/oxml/_document_primitives.py,sha256=IcGRT7B4gblNgJ5XDivV_DME-5cU5p88hRSAM1L8_Hk,56887
77
+ hwpx/oxml/_paragraph_text_edit.py,sha256=8IHjDPRh1yXtCYSbL0LER9Z4_wVnXybpCh3qQ8jwmPo,1453
77
78
  hwpx/oxml/body.py,sha256=RRopsZ7rvHDMIYNoEwHPty7DXyaZaFRDqJQSI6gVIls,52196
78
79
  hwpx/oxml/canonical_defaults.py,sha256=WHAK7u_W-PDv3P3N1-1-aWAoiUgBh4x-UybFL4NqW-w,3791
79
80
  hwpx/oxml/color.py,sha256=K2lbU_JdqRxFkQelGD6BbBkH1IspJSQyGlKHNz1Nmf4,2393
@@ -96,7 +97,7 @@ hwpx/oxml/note_authoring.py,sha256=xOoYeX640XkwdftUQeHsdojGHHbiLbobZPi3aahh5ek,7
96
97
  hwpx/oxml/numbering.py,sha256=9a0ARGW1DkKsTF7mEEXM4FA5loQmzIBuqE4APA8UFIQ,669
97
98
  hwpx/oxml/numbering_kinds.py,sha256=dikaExXr8GZ0gNL4KM5xxvGQOZDCkCdcA5qKDLsstsg,4034
98
99
  hwpx/oxml/objects.py,sha256=kL4xy0tBmq8E598sJ67bY2xk5Zwg09apelPqQGnD44w,55570
99
- hwpx/oxml/paragraph.py,sha256=sdoUFlQ7tsZe0mdyTlpzcj4Peh2mkjtrfL1GuqYFFDw,60979
100
+ hwpx/oxml/paragraph.py,sha256=2TN9XDwBUNA4qFBh7yTPM9M43zpnA-x_grsevh_B7fE,62747
100
101
  hwpx/oxml/parser.py,sha256=pIfyNdW3WdFkCcE8JFY7hn1fobRbMngzfhVZICvWeVs,2937
101
102
  hwpx/oxml/run.py,sha256=khz9hf2_YpQjYSNR5Qq8XKJ4EmGfiJu9LtfCfLgblnU,16375
102
103
  hwpx/oxml/schema.py,sha256=ElR3_IIhhPPZEqJtNKMNCHA-VFVLdhL454W_4spuvlc,1284
@@ -106,7 +107,7 @@ hwpx/oxml/section_layout.py,sha256=TyzddL31pdKSFWYfxVKdIjs3vo8UiG0lERa5-X6bKHI,5
106
107
  hwpx/oxml/section_story.py,sha256=OqCXk78gPL0ILQJqDBnhmjtq_tUru0j23IeXXpoWApA,25896
107
108
  hwpx/oxml/settings.py,sha256=0cI0zODpxwUgcleVR0J6I4nGT3FGSW8CI0qxwZHXaAk,5523
108
109
  hwpx/oxml/simple_parts.py,sha256=XpeVmBWOjv1UVN6sGbskYqwyCIDsNSgzsnnG4QG80Us,3810
109
- hwpx/oxml/table.py,sha256=DD9NdBZJ2OAJU64D7uXsF4VGizao3ZCOEMLq8ZSaBdI,56582
110
+ hwpx/oxml/table.py,sha256=Pgy3vW2sS2SqLEcnrQVaV8uHmmWivE5C86yX-fNrSlA,57561
110
111
  hwpx/oxml/utils.py,sha256=2L9au3SD_MFkFt2DXx3udXBT5caYuYO623lhGhgldVo,2972
111
112
  hwpx/oxml/version_part.py,sha256=kFXKQUKgClrM-LuVvBEaSD4RePV1kk1Kt8ltgttefS8,2627
112
113
  hwpx/plan/__init__.py,sha256=WvlBce8ic63hf8MsA6x-K_1BVrkNu6gaxKVIM7Axxks,1471
@@ -153,10 +154,10 @@ hwpx/tools/toc_fidelity.py,sha256=rvoKH8QJ65WNVrxS0d-i2AtODvBy-4O8NRudfQ244v4,19
153
154
  hwpx/tools/validator.py,sha256=U856izL9NcJZOiKDYoCpwOSaFNQ2Un8Jn4pzL20A96Q,7100
154
155
  hwpx/tools/_schemas/header.xsd,sha256=mJXuFMuHGT1JnFFaluUpYUglwjMCNlfbFCRVM26eHXE,664
155
156
  hwpx/tools/_schemas/section.xsd,sha256=MgvavVHG05RDfUnVPxVU10H4FQOja5ON04_m9Uk_m7E,522
156
- python_hwpx-6.4.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
157
- python_hwpx-6.4.0.dist-info/licenses/NOTICE,sha256=he3PBLXnIZK0sNsozc-SAQ2jvWjV61goylWUZ8HPi3s,3837
158
- python_hwpx-6.4.0.dist-info/METADATA,sha256=i_FNveRXdfDhRbj7G_l5pvAJ5KmRVDR6qKVMzhHwMnI,14995
159
- python_hwpx-6.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
160
- python_hwpx-6.4.0.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
161
- python_hwpx-6.4.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
162
- python_hwpx-6.4.0.dist-info/RECORD,,
157
+ python_hwpx-6.5.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
158
+ python_hwpx-6.5.0.dist-info/licenses/NOTICE,sha256=he3PBLXnIZK0sNsozc-SAQ2jvWjV61goylWUZ8HPi3s,3837
159
+ python_hwpx-6.5.0.dist-info/METADATA,sha256=MLawxpU4hei8fmJR8XZXiU2Mc-RBjnI3dHxuywHr7tE,14995
160
+ python_hwpx-6.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
161
+ python_hwpx-6.5.0.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
162
+ python_hwpx-6.5.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
163
+ python_hwpx-6.5.0.dist-info/RECORD,,