python-hwpx 5.0.2__py3-none-any.whl → 5.2.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/__init__.py CHANGED
@@ -48,6 +48,10 @@ _EXPERIMENTAL_EXPORTS = {
48
48
  # 문서 프리뷰 뷰어(3.8.0 신규). 계약 유동.
49
49
  "DocumentViewer": "hwpx.tools.document_viewer",
50
50
  "render_document_viewer": "hwpx.tools.document_viewer",
51
+ # 수식 저작(5.2.0 신규, LaTeX -> EqEdit). 계약 유동.
52
+ "UnsupportedLatexError": "hwpx.equation.authoring",
53
+ "estimate_equation_size": "hwpx.equation.authoring",
54
+ "latex_to_eqedit": "hwpx.equation.authoring",
51
55
  }
52
56
 
53
57
  # Emptied in 5.0. The 4.x notice on these names said they would go in the next
hwpx/_document/fields.py CHANGED
@@ -211,10 +211,16 @@ def _form_field_payload(
211
211
  name = _field_parameter_value(parameters, "fieldName", "fieldname", "field_name", "name", "title")
212
212
  prompt = _first_attr(field_begin, _FORM_FIELD_PROMPT_ATTRS)
213
213
  if not prompt:
214
- prompt = _field_parameter_value(parameters, *_FORM_FIELD_PARAM_NAMES)
214
+ # "Direction" is the real-Hancom 안내문 parameter (P0 gold contract).
215
+ prompt = _field_parameter_value(parameters, "Direction", *_FORM_FIELD_PARAM_NAMES)
215
216
  instruction = _field_parameter_value(parameters, "instruction", "guide", "help", "description", "desc")
216
217
  if not instruction:
217
218
  instruction = prompt
219
+ memo = _field_parameter_value(parameters, "HelpState", "memo")
220
+ dirty = (field_begin.get("dirty") or "").strip()
221
+ # Contract: while dirty != "1" the content between begin/end is the prompt
222
+ # placeholder (screen-only, not printed), not a user value.
223
+ is_placeholder = dirty != "1" and bool(prompt) and current_value == prompt
218
224
  return {
219
225
  "index": index,
220
226
  "field_id": _field_identifier(field_begin),
@@ -223,9 +229,13 @@ def _form_field_payload(
223
229
  "name": name,
224
230
  "prompt": prompt,
225
231
  "instruction": instruction,
232
+ "memo": memo,
233
+ "dirty": dirty,
234
+ "is_placeholder": is_placeholder,
226
235
  "current_value": current_value,
227
236
  "field_type": field_begin.get("type", ""),
228
237
  "control_type": ctrl.get("type", ""),
238
+ "_field_begin": field_begin,
229
239
  "section_index": section_index,
230
240
  "paragraph_index": paragraph_index,
231
241
  "paragraph_index_in_section": paragraph_index_in_section,
@@ -320,6 +330,62 @@ def list_form_fields(doc: "HwpxDocument") -> list[dict[str, Any]]:
320
330
  ]
321
331
 
322
332
 
333
+ _PROMPT_TEXT_COLOR = "#FF0000"
334
+
335
+
336
+ def add_form_field(
337
+ doc: "HwpxDocument",
338
+ name: str,
339
+ *,
340
+ prompt: str = "",
341
+ memo: str = "",
342
+ editable: bool = True,
343
+ paragraph: Any | None = None,
344
+ section: Any | None = None,
345
+ section_index: int | None = None,
346
+ ) -> dict[str, Any]:
347
+ """Create a click-here (누름틀) form field and return its field payload.
348
+
349
+ The emitted XML follows the real-Hancom CLICKHERE contract
350
+ (reverse-engineered from Hancom Office 12.0.0.3288 gold documents). The
351
+ prompt (안내문) is materialized as a screen-only red-italic run, exactly as
352
+ Hancom authors it. The created field is immediately re-read through the
353
+ standard ``list_form_fields`` matcher — creation fails loudly if the
354
+ standard consumer would not recognize it (no special-casing by design).
355
+ """
356
+
357
+ if not str(name).strip():
358
+ raise ValueError("form field name must be a non-empty string")
359
+ if paragraph is None:
360
+ paragraph = doc.add_paragraph(
361
+ "", section=section, section_index=section_index, include_run=False,
362
+ )
363
+
364
+ prompt_char_pr: str | None = None
365
+ if _sanitize_field_text(prompt):
366
+ prompt_char_pr = doc.ensure_run_style(italic=True, color=_PROMPT_TEXT_COLOR)
367
+
368
+ control = paragraph.add_form_field(
369
+ name,
370
+ prompt=prompt,
371
+ memo=memo,
372
+ editable=editable,
373
+ prompt_char_pr_id_ref=prompt_char_pr,
374
+ )
375
+ _clear_form_field_layout_cache(paragraph.element)
376
+
377
+ field_begin = control.element.find(f"{_HP}fieldBegin")
378
+ created_id = field_begin.get("id", "") if field_begin is not None else ""
379
+ for match in _iter_form_field_matches(doc):
380
+ if match.get("id") == created_id:
381
+ return {
382
+ key: value for key, value in match.items() if not key.startswith("_")
383
+ }
384
+ raise RuntimeError(
385
+ "created form field was not recognized by the standard form-field matcher"
386
+ )
387
+
388
+
323
389
  def _select_form_field(
324
390
  doc: "HwpxDocument",
325
391
  matches: Sequence[dict[str, Any]],
@@ -447,9 +513,23 @@ def fill_form_field(
447
513
  node.text = ""
448
514
  for child in list(node):
449
515
  child.tail = ""
516
+ if match.get("is_placeholder"):
517
+ # Contract (P0 gold): Hancom swaps the screen-only prompt style for
518
+ # the surrounding style when a value replaces the placeholder.
519
+ begin_run = runs[int(match["_begin_run_index"])]
520
+ begin_ref = begin_run.get("charPrIDRef")
521
+ primary_run = primary.getparent()
522
+ if begin_ref is not None and primary_run is not None:
523
+ primary_run.set("charPrIDRef", begin_ref)
450
524
  else:
451
525
  _insert_form_field_text_run(doc, match, sanitized)
452
526
 
527
+ # Contract (P0 gold): a field that went through fill machinery carries
528
+ # dirty="1"; while dirty != "1" readers treat the content as the prompt.
529
+ field_begin = match.get("_field_begin")
530
+ if field_begin is not None:
531
+ field_begin.set("dirty", "1")
532
+
453
533
  if fit_result is not None:
454
534
  _apply_form_field_fit_style(doc, match, fit_result)
455
535
 
hwpx/_document/shapes.py CHANGED
@@ -227,3 +227,58 @@ def add_ellipse(
227
227
  line_color=line_color, line_width=line_width,
228
228
  fill_color=fill_color, treat_as_char=treat_as_char,
229
229
  )
230
+
231
+
232
+ def add_equation(
233
+ doc: "HwpxDocument",
234
+ script: str,
235
+ *,
236
+ paragraph: HwpxOxmlParagraph | None = None,
237
+ section: HwpxOxmlSection | None = None,
238
+ section_index: int | None = None,
239
+ base_unit: int = 1100,
240
+ size: tuple[int, int] | None = None,
241
+ char_pr_id_ref: str | int | None = None,
242
+ ) -> HwpxOxmlInlineObject:
243
+ """Insert an inline equation and prove the standard reader recognizes it.
244
+
245
+ The emitted XML follows the real-Hancom ``<hp:equation>`` contract
246
+ (specs/054-equation-authoring/evidence/p0/equation-contract.md). After
247
+ insertion the equation is re-read through the standard section scan —
248
+ creation fails loudly if the script did not land verbatim (no
249
+ special-casing by design).
250
+ """
251
+ from ..equation.authoring import estimate_equation_size
252
+ from ..equation.eqedit import MAX_SOURCE_LENGTH
253
+ from ..oxml.namespaces import HP
254
+
255
+ text = (script or "").strip()
256
+ if not text:
257
+ raise ValueError("equation script must be a non-empty string")
258
+ if len(text) > MAX_SOURCE_LENGTH:
259
+ raise ValueError("equation script exceeds size limit")
260
+ if paragraph is None:
261
+ paragraph = doc.add_paragraph(
262
+ "", section=section, section_index=section_index,
263
+ include_run=False,
264
+ )
265
+ if size is None:
266
+ size = estimate_equation_size(text, base_unit=base_unit)
267
+ inline_object = paragraph.add_equation(
268
+ text, base_unit=base_unit, size=size, char_pr_id_ref=char_pr_id_ref,
269
+ )
270
+
271
+ created_id = inline_object.element.get("id", "")
272
+ for owning_section in doc.sections:
273
+ for candidate in owning_section.element.iter(f"{HP}equation"):
274
+ if candidate.get("id") != created_id:
275
+ continue
276
+ script_element = candidate.find(f"{HP}script")
277
+ if script_element is None or (script_element.text or "") != text:
278
+ raise RuntimeError(
279
+ "created equation did not store its script verbatim"
280
+ )
281
+ return inline_object
282
+ raise RuntimeError(
283
+ "created equation was not recognized by the standard section scan"
284
+ )
hwpx/document.py CHANGED
@@ -1388,6 +1388,95 @@ class HwpxDocument:
1388
1388
  section_index=section_index,
1389
1389
  )
1390
1390
 
1391
+ def add_form_field(
1392
+ self,
1393
+ name: str,
1394
+ *,
1395
+ prompt: str = "",
1396
+ memo: str = "",
1397
+ editable: bool = True,
1398
+ paragraph: HwpxOxmlParagraph | None = None,
1399
+ section: HwpxOxmlSection | None = None,
1400
+ section_index: int | None = None,
1401
+ ) -> dict[str, Any]:
1402
+ """Create a click-here (누름틀) form field. **Experimental contract.**
1403
+
1404
+ Emits the real-Hancom CLICKHERE shape (안내문 placeholder run included)
1405
+ so the created field is indistinguishable from a Hancom-authored one:
1406
+ ``list_form_fields``/``fill_form_field`` recognize it with no
1407
+ special-casing, and real Hancom Office enumerates and fills it.
1408
+
1409
+ Args:
1410
+ name: Field name (non-empty).
1411
+ prompt: 안내문 shown while the field is empty. Screen-only —
1412
+ Hancom does not print it.
1413
+ memo: Help text (``HelpState``).
1414
+ paragraph: Target paragraph (e.g. inside a table cell). When
1415
+ omitted a new paragraph is appended to *section*.
1416
+
1417
+ Returns:
1418
+ The created field's payload, same shape as a ``list_form_fields``
1419
+ entry.
1420
+ """
1421
+
1422
+ return _fields.add_form_field(
1423
+ self,
1424
+ name,
1425
+ prompt=prompt,
1426
+ memo=memo,
1427
+ editable=editable,
1428
+ paragraph=paragraph,
1429
+ section=section,
1430
+ section_index=section_index,
1431
+ )
1432
+
1433
+
1434
+ def add_equation(
1435
+ self,
1436
+ script: str,
1437
+ *,
1438
+ paragraph: HwpxOxmlParagraph | None = None,
1439
+ section: HwpxOxmlSection | None = None,
1440
+ section_index: int | None = None,
1441
+ base_unit: int = 1100,
1442
+ size: tuple[int, int] | None = None,
1443
+ char_pr_id_ref: str | int | None = None,
1444
+ ) -> HwpxOxmlInlineObject:
1445
+ """Insert an inline equation from an EqEdit script. **Experimental contract.**
1446
+
1447
+ Emits the real-Hancom ``<hp:equation>`` shape (contract:
1448
+ ``specs/054-equation-authoring/evidence/p0/equation-contract.md``): the
1449
+ EqEdit source is stored verbatim in ``<hp:script>``, no layout cache is
1450
+ written (Hancom re-lays-out on open), and the shape is inline so it
1451
+ renders in the page flow. The created element is immediately re-read
1452
+ through the standard section scan — creation fails loudly if the
1453
+ standard consumer would not see it (no special-casing by design).
1454
+
1455
+ To author from LaTeX, convert first (typed refusal outside the
1456
+ verified token set)::
1457
+
1458
+ from hwpx.equation import latex_to_eqedit
1459
+ doc.add_equation(latex_to_eqedit(r"\\frac{a}{b}"))
1460
+
1461
+ Args:
1462
+ script: EqEdit script stored as-is (e.g. ``{a} over {b}``).
1463
+ paragraph: Target paragraph (e.g. inside a table cell). When
1464
+ omitted a new paragraph is appended to *section*.
1465
+ base_unit: Equation base font size in 1/100 pt.
1466
+ size: Optional explicit ``(width, height)`` HWPUNIT pair;
1467
+ defaults to a proportional estimate (Hancom re-measures).
1468
+ """
1469
+
1470
+ return _shapes.add_equation(
1471
+ self,
1472
+ script,
1473
+ paragraph=paragraph,
1474
+ section=section,
1475
+ section_index=section_index,
1476
+ base_unit=base_unit,
1477
+ size=size,
1478
+ char_pr_id_ref=char_pr_id_ref,
1479
+ )
1391
1480
 
1392
1481
  def set_page_size(
1393
1482
  self,
hwpx/equation/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
- """Reader-direction equation support: EqEdit → LaTeX → MathML.
2
+ """Equation support: EqEdit → LaTeX → MathML (reader) and LaTeX → EqEdit (authoring).
3
3
 
4
4
  Clean-room re-derivation of the HULK-style EqEdit vocabulary; see NOTICE for the
5
5
  referenced projects. ``latex2mathml`` is an optional dependency
@@ -9,6 +9,11 @@ block rather than dropping the equation.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from .authoring import (
13
+ UnsupportedLatexError,
14
+ estimate_equation_size,
15
+ latex_to_eqedit,
16
+ )
12
17
  from .eqedit import (
13
18
  MAX_GROUP_DEPTH,
14
19
  MAX_SOURCE_LENGTH,
@@ -39,8 +44,11 @@ __all__ = [
39
44
  "EquationConversionError",
40
45
  "EquationRender",
41
46
  "MathMLUnavailableError",
47
+ "UnsupportedLatexError",
42
48
  "eqedit_to_latex",
49
+ "estimate_equation_size",
43
50
  "latex2mathml_available",
51
+ "latex_to_eqedit",
44
52
  "latex_to_mathml",
45
53
  "render_equation",
46
54
  ]
@@ -0,0 +1,457 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Authoring-direction LaTeX → EqEdit converter (clean-room). **Experimental.**
3
+
4
+ Reverse of :func:`hwpx.equation.eqedit.eqedit_to_latex`: turns a LaTeX math
5
+ fragment into the EqEdit script Hancom's equation editor stores inside
6
+ ``<hp:script>``. Coverage is the verified token set only — any LaTeX command
7
+ or environment outside it raises :class:`UnsupportedLatexError` (typed
8
+ refusal), never a silent approximation, so callers can fail closed.
9
+
10
+ The token vocabulary is the same clean-room re-derivation used by the reader
11
+ (:mod:`hwpx.equation.tokens`); the authoring direction additionally protects
12
+ bare identifiers that collide with EqEdit reserved words by quoting them
13
+ (``T_{int}`` → ``T _{"int"}``) so Hancom does not typeset them as symbols.
14
+
15
+ Contract provenance: specs/054-equation-authoring/evidence/p0/equation-contract.md.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .eqedit import (
21
+ MAX_GROUP_DEPTH,
22
+ MAX_SOURCE_LENGTH,
23
+ EquationConversionError,
24
+ )
25
+ from .tokens import (
26
+ ACCENTS,
27
+ BIG_OPERATORS,
28
+ DELIMITERS,
29
+ FUNCTIONS,
30
+ GREEK,
31
+ MATRIX_ENVIRONMENTS,
32
+ OPERATORS,
33
+ STRUCTURAL,
34
+ )
35
+
36
+
37
+ class UnsupportedLatexError(EquationConversionError):
38
+ """Raised when a LaTeX fragment uses commands outside the verified set."""
39
+
40
+
41
+ def _invert(*maps: dict[str, str]) -> dict[str, str]:
42
+ """LaTeX command → canonical EqEdit token (first-seen wins per map order)."""
43
+
44
+ inverse: dict[str, str] = {}
45
+ for mapping in maps:
46
+ for eqedit_token, latex_command in mapping.items():
47
+ inverse.setdefault(latex_command, eqedit_token)
48
+ return inverse
49
+
50
+
51
+ # Commands whose only EqEdit spellings render as literal text (or wrong glyphs)
52
+ # on the real Hancom build — render-verified 2026-07-31, specs/054 P2 token
53
+ # battery. Emitting them would silently corrupt output, so they are excluded
54
+ # from the inverse maps and refuse with UnsupportedLatexError instead.
55
+ _RENDER_REJECTED = frozenset(
56
+ {"\\limsup", "\\liminf", "\\widehat", "\\widetilde"}
57
+ )
58
+
59
+ # Word-token maps first so lowercase word forms win (``times`` over ``TIMES``);
60
+ # explicit preferences below override where the render oracle verified a
61
+ # different spelling than the reader's first-seen entry.
62
+ _LATEX_TO_EQEDIT: dict[str, str] = {
63
+ latex: eqedit
64
+ for latex, eqedit in _invert(GREEK, OPERATORS, FUNCTIONS, BIG_OPERATORS).items()
65
+ if latex not in _RENDER_REJECTED
66
+ }
67
+ _LATEX_TO_EQEDIT.update(
68
+ {
69
+ # Gold scripts spell these symbolically (equation-contract.md §4).
70
+ "\\pm": "+-",
71
+ "\\mp": "-+",
72
+ # Render-verified spellings (P2 token battery): the word ``to`` kills
73
+ # the rest of the equation, ``leftrightarrow`` draws a plain arrow,
74
+ # lowercase ``forall`` and ``iint``/``iiint`` come out as literal text.
75
+ "\\to": "->",
76
+ "\\rightarrow": "->",
77
+ "\\leftrightarrow": "<->",
78
+ "\\forall": "FORALL",
79
+ "\\iint": "dint",
80
+ "\\iiint": "tint",
81
+ # Common LaTeX aliases sharing a verified target.
82
+ "\\le": "leq",
83
+ "\\ge": "geq",
84
+ "\\ne": "neq",
85
+ "\\dots": "cdots",
86
+ }
87
+ )
88
+ _LATEX_ACCENTS: dict[str, str] = {
89
+ latex: eqedit
90
+ for latex, eqedit in _invert(ACCENTS).items()
91
+ if latex not in _RENDER_REJECTED
92
+ }
93
+ # LaTeX delimiter commands usable after \left / \right.
94
+ _LATEX_DELIMITERS: dict[str, str] = {
95
+ latex: eqedit
96
+ for eqedit, latex in DELIMITERS.items()
97
+ if latex.startswith("\\")
98
+ # Prefer the symbol spellings (``{`` not ``LBRACE``) — both read back.
99
+ and eqedit not in {"LBRACE", "RBRACE", "LANGLE", "RANGLE"}
100
+ }
101
+ _TEXT_COMMANDS = frozenset({"\\text", "\\mathrm", "\\textrm", "\\mbox"})
102
+ # LaTeX environment name → render-verified EqEdit builder word. ``Bmatrix`` /
103
+ # ``Vmatrix`` have no verified spelling on the real build (P2 battery R10/R11)
104
+ # and refuse; the determinant matrix is EqEdit ``dmatrix`` (R12).
105
+ _ENV_TO_EQEDIT: dict[str, str] = {
106
+ "matrix": "matrix",
107
+ "pmatrix": "pmatrix",
108
+ "bmatrix": "bmatrix",
109
+ "vmatrix": "dmatrix",
110
+ "cases": "cases",
111
+ }
112
+
113
+ # Bare identifier runs that would collide with EqEdit vocabulary must be quoted
114
+ # so Hancom keeps them literal (reserved-word protection).
115
+ _RESERVED_WORDS = (
116
+ frozenset(GREEK)
117
+ | frozenset(OPERATORS)
118
+ | frozenset(FUNCTIONS)
119
+ | frozenset(BIG_OPERATORS)
120
+ | frozenset(ACCENTS)
121
+ | frozenset(MATRIX_ENVIRONMENTS)
122
+ | STRUCTURAL
123
+ )
124
+
125
+ _SINGLE_CHAR_PASSTHROUGH = frozenset("+-=<>,.;:!|/()[]'")
126
+
127
+
128
+ class _LatexLexer:
129
+ def __init__(self, source: str) -> None:
130
+ self._source = source
131
+ self._pos = 0
132
+ self.tokens: list[str] = []
133
+ self._lex()
134
+
135
+ def _lex(self) -> None:
136
+ source = self._source
137
+ i = 0
138
+ n = len(source)
139
+ while i < n:
140
+ ch = source[i]
141
+ if ch.isspace():
142
+ i += 1
143
+ elif ch == "\\":
144
+ i = self._lex_command(i)
145
+ elif ch in "{}^_&":
146
+ self.tokens.append(ch)
147
+ i += 1
148
+ elif ch.isdigit() or ch == ".":
149
+ j = i
150
+ while j < n and (source[j].isdigit() or source[j] == "."):
151
+ j += 1
152
+ self.tokens.append(source[i:j])
153
+ i = j
154
+ elif ch.isalpha():
155
+ j = i
156
+ while j < n and source[j].isalpha():
157
+ j += 1
158
+ self.tokens.append(source[i:j])
159
+ i = j
160
+ elif ch in _SINGLE_CHAR_PASSTHROUGH:
161
+ self.tokens.append(ch)
162
+ i += 1
163
+ else:
164
+ raise UnsupportedLatexError(
165
+ f"unsupported character in LaTeX input: {ch!r}"
166
+ )
167
+
168
+ def _lex_command(self, i: int) -> int:
169
+ source = self._source
170
+ n = len(source)
171
+ if i + 1 >= n:
172
+ raise UnsupportedLatexError("dangling backslash at end of input")
173
+ nxt = source[i + 1]
174
+ if nxt == "\\":
175
+ self.tokens.append("\\\\")
176
+ return i + 2
177
+ if not nxt.isalpha():
178
+ # Escaped single character: \{ \} \% \& \$ \| \, ...
179
+ self.tokens.append("\\" + nxt)
180
+ return i + 2
181
+ j = i + 1
182
+ while j < n and source[j].isalpha():
183
+ j += 1
184
+ self.tokens.append(source[i:j])
185
+ return j
186
+
187
+
188
+ class _LatexParser:
189
+ """Recursive-descent LaTeX → EqEdit token emitter (verified set only)."""
190
+
191
+ def __init__(self, tokens: list[str]) -> None:
192
+ self._tokens = tokens
193
+ self._pos = 0
194
+
195
+ def _peek(self) -> str | None:
196
+ return self._tokens[self._pos] if self._pos < len(self._tokens) else None
197
+
198
+ def _next(self) -> str | None:
199
+ token = self._peek()
200
+ if token is not None:
201
+ self._pos += 1
202
+ return token
203
+
204
+ def _expect(self, expected: str) -> None:
205
+ token = self._next()
206
+ if token != expected:
207
+ raise UnsupportedLatexError(
208
+ f"expected {expected!r}, found {token!r} — unbalanced LaTeX group"
209
+ )
210
+
211
+ # -- grammar -------------------------------------------------------------
212
+ def parse(self) -> str:
213
+ parts = self._sequence(depth=0, stop=frozenset())
214
+ if self._peek() is not None:
215
+ raise UnsupportedLatexError(
216
+ f"unbalanced LaTeX group near {self._peek()!r}"
217
+ )
218
+ return " ".join(parts)
219
+
220
+ def _sequence(self, *, depth: int, stop: frozenset[str]) -> list[str]:
221
+ if depth > MAX_GROUP_DEPTH:
222
+ raise EquationConversionError("equation nesting depth exceeded")
223
+ parts: list[str] = []
224
+ while True:
225
+ token = self._peek()
226
+ if token is None or token in stop:
227
+ break
228
+ if token in ("^", "_"):
229
+ self._next()
230
+ script = self._group_or_atom(depth)
231
+ parts.append(f"{token}{{{script}}}")
232
+ continue
233
+ parts.append(self._atom(depth))
234
+ return parts
235
+
236
+ def _group_or_atom(self, depth: int) -> str:
237
+ """A script/argument body, brace-stripped (rebraced by the caller)."""
238
+
239
+ if self._peek() == "{":
240
+ self._next()
241
+ inner = self._sequence(depth=depth + 1, stop=frozenset({"}"}))
242
+ self._expect("}")
243
+ return " ".join(inner)
244
+ return self._atom(depth)
245
+
246
+ def _atom(self, depth: int) -> str:
247
+ token = self._next()
248
+ if token is None:
249
+ return ""
250
+ if token == "{":
251
+ inner = self._sequence(depth=depth + 1, stop=frozenset({"}"}))
252
+ self._expect("}")
253
+ return "{" + " ".join(inner) + "}"
254
+ if token == "}":
255
+ raise UnsupportedLatexError("unbalanced closing brace in LaTeX input")
256
+ if token == "\\frac" or token == "\\dfrac" or token == "\\tfrac":
257
+ numerator = self._group_or_atom(depth)
258
+ denominator = self._group_or_atom(depth)
259
+ return f"{{{numerator}}} over {{{denominator}}}"
260
+ if token == "\\sqrt":
261
+ if self._peek() == "[":
262
+ self._next()
263
+ index_parts = self._sequence(depth=depth + 1, stop=frozenset({"]"}))
264
+ self._expect("]")
265
+ radicand = self._group_or_atom(depth)
266
+ return f"root {{{' '.join(index_parts)}}} of {{{radicand}}}"
267
+ return f"sqrt {{{self._group_or_atom(depth)}}}"
268
+ if token in _TEXT_COMMANDS:
269
+ return self._text_literal()
270
+ if token == "\\begin":
271
+ return self._environment(depth)
272
+ if token == "\\left":
273
+ return self._left_right(depth)
274
+ if token in ("\\right", "\\end"):
275
+ raise UnsupportedLatexError(f"{token} without a matching opener")
276
+ if token in _LATEX_ACCENTS:
277
+ return f"{_LATEX_ACCENTS[token]} {{{self._group_or_atom(depth)}}}"
278
+ if token in _LATEX_TO_EQEDIT:
279
+ return _LATEX_TO_EQEDIT[token]
280
+ if token == "\\\\":
281
+ raise UnsupportedLatexError(
282
+ "row break (\\\\) is only supported inside a matrix/cases environment"
283
+ )
284
+ if token.startswith("\\") and len(token) == 2 and not token[1].isalpha():
285
+ return self._escaped_char(token[1])
286
+ if token.startswith("\\"):
287
+ raise UnsupportedLatexError(f"unsupported LaTeX command: {token}")
288
+ if token == "&":
289
+ raise UnsupportedLatexError(
290
+ "alignment (&) is only supported inside a matrix/cases environment"
291
+ )
292
+ return self._plain_token(token)
293
+
294
+ def _escaped_char(self, char: str) -> str:
295
+ if char in "{}":
296
+ # EqEdit spells literal braces as the LBRACE/RBRACE words.
297
+ return "LBRACE" if char == "{" else "RBRACE"
298
+ if char in "%$&":
299
+ return char
300
+ raise UnsupportedLatexError(f"unsupported LaTeX escape: \\{char}")
301
+
302
+ def _plain_token(self, token: str) -> str:
303
+ if token.isalpha() and token in _RESERVED_WORDS:
304
+ # Reserved-word protection: keep the identifier literal in Hancom.
305
+ return f'"{token}"'
306
+ return token
307
+
308
+ def _text_literal(self) -> str:
309
+ self._expect("{")
310
+ parts: list[str] = []
311
+ while True:
312
+ token = self._peek()
313
+ if token is None:
314
+ raise UnsupportedLatexError("unterminated \\text{...} literal")
315
+ if token == "}":
316
+ self._next()
317
+ break
318
+ if token in ("{", "\\\\") or (
319
+ isinstance(token, str) and token.startswith("\\") and len(token) > 2
320
+ ):
321
+ raise UnsupportedLatexError(
322
+ "\\text{...} supports plain characters only"
323
+ )
324
+ self._next()
325
+ parts.append(token[1] if token.startswith("\\") else token)
326
+ literal = " ".join(parts)
327
+ if '"' in literal:
328
+ raise UnsupportedLatexError('\\text{...} may not contain a quote (")')
329
+ return f'"{literal}"'
330
+
331
+ def _environment(self, depth: int) -> str:
332
+ self._expect("{")
333
+ name = self._next()
334
+ if name == "}":
335
+ raise UnsupportedLatexError("empty \\begin{} environment name")
336
+ self._expect("}")
337
+ builder = _ENV_TO_EQEDIT.get(name or "")
338
+ if builder is None:
339
+ raise UnsupportedLatexError(f"unsupported LaTeX environment: {name}")
340
+ rows: list[list[str]] = [[]]
341
+ current: list[str] = []
342
+
343
+ def flush_cell() -> None:
344
+ rows[-1].append(" ".join(current))
345
+ current.clear()
346
+
347
+ while True:
348
+ token = self._peek()
349
+ if token is None:
350
+ raise UnsupportedLatexError(f"unterminated environment: {name}")
351
+ if token == "\\end":
352
+ self._next()
353
+ self._expect("{")
354
+ end_name = self._next()
355
+ self._expect("}")
356
+ if end_name != name:
357
+ raise UnsupportedLatexError(
358
+ f"environment mismatch: \\begin{{{name}}} closed by "
359
+ f"\\end{{{end_name}}}"
360
+ )
361
+ break
362
+ if token == "&":
363
+ self._next()
364
+ flush_cell()
365
+ continue
366
+ if token == "\\\\":
367
+ self._next()
368
+ flush_cell()
369
+ rows.append([])
370
+ continue
371
+ if token in ("^", "_"):
372
+ self._next()
373
+ current.append(f"{token}{{{self._group_or_atom(depth)}}}")
374
+ continue
375
+ current.append(self._atom(depth))
376
+ flush_cell()
377
+ body = " # ".join(
378
+ " & ".join(cell for cell in row) for row in rows if any(row)
379
+ )
380
+ return f"{builder} {{{body}}}"
381
+
382
+ def _left_right(self, depth: int) -> str:
383
+ open_token = self._next()
384
+ open_delim = self._delimiter(open_token)
385
+ body = self._sequence(depth=depth + 1, stop=frozenset({"\\right"}))
386
+ if self._peek() != "\\right":
387
+ raise UnsupportedLatexError("\\left without a matching \\right")
388
+ self._next()
389
+ close_token = self._next()
390
+ close_delim = self._delimiter(close_token)
391
+ inner = " ".join(body)
392
+ return f"LEFT {open_delim} {inner} RIGHT {close_delim}"
393
+
394
+ def _delimiter(self, token: str | None) -> str:
395
+ if token is None:
396
+ raise UnsupportedLatexError("missing \\left/\\right delimiter")
397
+ if token in _LATEX_DELIMITERS:
398
+ return _LATEX_DELIMITERS[token]
399
+ if token in ("\\{", "\\}"):
400
+ return "LBRACE" if token == "\\{" else "RBRACE"
401
+ if token in DELIMITERS and len(token) == 1:
402
+ return token
403
+ raise UnsupportedLatexError(f"unsupported \\left/\\right delimiter: {token}")
404
+
405
+
406
+ def _strip_math_delimiters(latex: str) -> str:
407
+ text = latex.strip()
408
+ for fence in ("$$", "$"):
409
+ if text.startswith(fence) and text.endswith(fence) and len(text) > 2 * len(fence):
410
+ return text[len(fence) : -len(fence)].strip()
411
+ return text
412
+
413
+
414
+ def latex_to_eqedit(latex: str) -> str:
415
+ """Convert a LaTeX math fragment to an EqEdit ``<hp:script>`` string.
416
+
417
+ Surrounding ``$...$`` / ``$$...$$`` fences are stripped for convenience.
418
+ Anything outside the verified token set raises
419
+ :class:`UnsupportedLatexError` — no silent approximation.
420
+
421
+ Raises:
422
+ UnsupportedLatexError: unsupported command/environment/character.
423
+ EquationConversionError: size or nesting-depth guard exceeded.
424
+ """
425
+
426
+ if len(latex) > MAX_SOURCE_LENGTH:
427
+ raise EquationConversionError("LaTeX input exceeds size limit")
428
+ text = _strip_math_delimiters(latex)
429
+ if "$" in text:
430
+ raise UnsupportedLatexError("interior $ math delimiters are not supported")
431
+ if not text:
432
+ raise UnsupportedLatexError("empty LaTeX input")
433
+ tokens = _LatexLexer(text).tokens
434
+ return _LatexParser(tokens).parse()
435
+
436
+
437
+ def estimate_equation_size(script: str, *, base_unit: int = 1100) -> tuple[int, int]:
438
+ """Heuristic ``(width, height)`` in HWPUNIT for ``<hp:sz>``.
439
+
440
+ Hancom re-measures the shape when the document is opened (P0 evidence:
441
+ a fixed size rendered correctly), so this only needs to be a sane
442
+ placeholder, mirroring the gold documents' proportions.
443
+ """
444
+
445
+ visible = len(script.replace("{", "").replace("}", "").replace(" ", ""))
446
+ rows = 1 + script.count("#")
447
+ tall = any(word in script for word in ("over", "sqrt", "int", "sum", "prod", "lim"))
448
+ width = int(base_unit * 0.45 * max(6, visible))
449
+ height = int(base_unit * (2.5 if tall else 1.6) * max(1, rows))
450
+ return width, height
451
+
452
+
453
+ __all__ = [
454
+ "UnsupportedLatexError",
455
+ "estimate_equation_size",
456
+ "latex_to_eqedit",
457
+ ]
hwpx/equation/tokens.py CHANGED
@@ -102,6 +102,9 @@ OPERATORS: dict[str, str] = {
102
102
  "cap": r"\cap",
103
103
  "emptyset": r"\emptyset",
104
104
  "forall": r"\forall",
105
+ # Real-Hancom EqEdit spells ∀ in ALLCAPS; lowercase renders as literal text
106
+ # (render-verified 2026-07-31, specs/054 P2 token battery R05).
107
+ "FORALL": r"\forall",
105
108
  "exists": r"\exists",
106
109
  "neg": r"\neg",
107
110
  "land": r"\land",
@@ -176,6 +179,11 @@ BIG_OPERATORS: dict[str, str] = {
176
179
  "int": r"\int",
177
180
  "iint": r"\iint",
178
181
  "iiint": r"\iiint",
182
+ # Real-Hancom EqEdit spellings for ∬/∭ (render-verified 2026-07-31,
183
+ # specs/054 P2 token battery R06/R07; ``iint``/``iiint`` render as
184
+ # literal text on the real build).
185
+ "dint": r"\iint",
186
+ "tint": r"\iiint",
179
187
  "oint": r"\oint",
180
188
  "sum": r"\sum",
181
189
  "prod": r"\prod",
@@ -213,6 +221,10 @@ MATRIX_ENVIRONMENTS: dict[str, str] = {
213
221
  "Bmatrix": "Bmatrix",
214
222
  "vmatrix": "vmatrix",
215
223
  "Vmatrix": "Vmatrix",
224
+ # Real-Hancom EqEdit determinant matrix |...| (render-verified 2026-07-31,
225
+ # specs/054 P2 token battery R12; ``vmatrix``/``Vmatrix`` render as
226
+ # literal text on the real build).
227
+ "dmatrix": "vmatrix",
216
228
  "cases": "cases",
217
229
  }
218
230
 
hwpx/experimental.py CHANGED
@@ -13,6 +13,11 @@
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ from .equation.authoring import (
17
+ UnsupportedLatexError,
18
+ estimate_equation_size,
19
+ latex_to_eqedit,
20
+ )
16
21
  from .ingest import (
17
22
  ConversionAttempt,
18
23
  DocumentConverter,
@@ -45,4 +50,7 @@ __all__ = [
45
50
  "render_layout_preview",
46
51
  "DocumentViewer",
47
52
  "render_document_viewer",
53
+ "UnsupportedLatexError",
54
+ "estimate_equation_size",
55
+ "latex_to_eqedit",
48
56
  ]
hwpx/oxml/paragraph.py CHANGED
@@ -27,7 +27,7 @@ from ._document_primitives import (
27
27
  _sanitize_text,
28
28
  )
29
29
  from .memo import HwpxOxmlNote
30
- from .namespaces import tag_local_name
30
+ from .namespaces import XML_NS, tag_local_name
31
31
  from .objects import (
32
32
  HwpxOxmlInlineObject,
33
33
  HwpxOxmlShape,
@@ -834,6 +834,191 @@ class HwpxOxmlParagraph:
834
834
  self.section.mark_dirty()
835
835
  return HwpxOxmlInlineObject(ctrl1, self)
836
836
 
837
+ def add_equation(
838
+ self,
839
+ script: str,
840
+ *,
841
+ base_unit: int = 1100,
842
+ size: tuple[int, int] | None = None,
843
+ char_pr_id_ref: str | int | None = None,
844
+ run_attributes: dict[str, str] | None = None,
845
+ ) -> HwpxOxmlInlineObject:
846
+ """Insert an inline ``<hp:equation>`` carrying an EqEdit *script*.
847
+
848
+ Emits the real-Hancom equation shape (contract reverse-engineered in
849
+ specs/054-equation-authoring/evidence/p0/equation-contract.md): the
850
+ EqEdit source lives in the ``<hp:script>`` child, no lineseg cache is
851
+ written (Hancom re-lays-out on open), and the shape is inline
852
+ (``treatAsChar="1"``) so it renders in the page flow.
853
+
854
+ Args:
855
+ script: EqEdit script stored verbatim (e.g. ``{a} over {b}``).
856
+ base_unit: Equation base font size in 1/100 pt (gold: 1100/1200).
857
+ size: Optional explicit ``(width, height)`` HWPUNIT pair for
858
+ ``<hp:sz>``; when omitted a proportional placeholder is
859
+ written — Hancom re-measures on open (P0 evidence).
860
+ """
861
+ text = script.strip()
862
+ if not text:
863
+ raise ValueError("equation script must be a non-empty string")
864
+ if base_unit <= 0:
865
+ raise ValueError("base_unit must be positive")
866
+ if size is None:
867
+ visible = len(text.replace("{", "").replace("}", "").replace(" ", ""))
868
+ width = int(base_unit * 0.45 * max(6, visible))
869
+ height = int(base_unit * 2.5)
870
+ else:
871
+ width, height = size
872
+ run = self._create_run_for_object(
873
+ run_attributes, char_pr_id_ref=char_pr_id_ref
874
+ )
875
+ equation = _append_child(run, f"{_HP}equation", {
876
+ "id": _object_id(),
877
+ "zOrder": "0",
878
+ "numberingType": "EQUATION",
879
+ "textWrap": "TOP_AND_BOTTOM",
880
+ "textFlow": "BOTH_SIDES",
881
+ "lock": "0",
882
+ "dropcapstyle": "None",
883
+ "version": "Equation Version 60",
884
+ "baseLine": str(max(1, round(base_unit * 69 / 1200))),
885
+ "textColor": "#000000",
886
+ "baseUnit": str(base_unit),
887
+ "lineMode": "CHAR",
888
+ "font": "HYhwpEQ",
889
+ })
890
+ _append_child(equation, f"{_HP}sz", {
891
+ "width": str(width),
892
+ "widthRelTo": "ABSOLUTE",
893
+ "height": str(height),
894
+ "heightRelTo": "ABSOLUTE",
895
+ "protect": "0",
896
+ })
897
+ _append_child(equation, f"{_HP}pos", {
898
+ "treatAsChar": "1",
899
+ "affectLSpacing": "0",
900
+ "flowWithText": "1",
901
+ "allowOverlap": "0",
902
+ "holdAnchorAndSO": "0",
903
+ "vertRelTo": "PARA",
904
+ "horzRelTo": "PARA",
905
+ "vertAlign": "TOP",
906
+ "horzAlign": "LEFT",
907
+ "vertOffset": "0",
908
+ "horzOffset": "0",
909
+ })
910
+ _append_child(equation, f"{_HP}outMargin", {
911
+ "left": "56",
912
+ "right": "56",
913
+ "top": "0",
914
+ "bottom": "0",
915
+ })
916
+ comment = _append_child(equation, f"{_HP}shapeComment", {})
917
+ comment.text = "수식입니다."
918
+ script_element = _append_child(equation, f"{_HP}script", {})
919
+ script_element.text = text
920
+ self.section.mark_dirty()
921
+ return HwpxOxmlInlineObject(equation, self)
922
+
923
+ def add_form_field(
924
+ self,
925
+ name: str,
926
+ *,
927
+ prompt: str = "",
928
+ memo: str = "",
929
+ editable: bool = True,
930
+ prompt_char_pr_id_ref: str | int | None = None,
931
+ char_pr_id_ref: str | int | None = None,
932
+ ) -> HwpxOxmlInlineObject:
933
+ """Insert a click-here (누름틀) form field at the end of this paragraph.
934
+
935
+ Emits the real-Hancom CLICKHERE shape (reverse-engineered from Hancom
936
+ Office 12.0.0.3288 gold documents):
937
+ a ``fieldBegin`` ctrl run carrying the ``Prop``/``Command``/``Direction``/
938
+ ``HelpState`` parameters, an optional prompt run showing *prompt* (the
939
+ 안내문, screen-only — Hancom does not print it), and a ``fieldEnd`` ctrl
940
+ run. ``Command`` lengths count UTF-16 characters, so values may contain
941
+ spaces. ``id``/``fieldid`` values are semantically free — Hancom reissues
942
+ its own on save.
943
+
944
+ Args:
945
+ name: Field name used by ``list_form_fields``/``fill_form_field``.
946
+ prompt: 안내문 text shown while the field is empty (``Direction``).
947
+ memo: Help text (``HelpState``).
948
+ prompt_char_pr_id_ref: charPr for the prompt run (callers normally
949
+ pass a red-italic style; Hancom uses one).
950
+
951
+ Returns:
952
+ The ``<hp:ctrl>`` element wrapping the ``<hp:fieldBegin>``.
953
+ """
954
+ field_id = _object_id()
955
+ field_instance_id = _object_id()
956
+ direction = _sanitize_text(prompt)
957
+ help_state = _sanitize_text(memo)
958
+
959
+ # Run 1: fieldBegin with the CLICKHERE parameter block
960
+ run1 = self._create_run_for_object(char_pr_id_ref=char_pr_id_ref)
961
+ ctrl1 = _append_child(run1, f"{_HP}ctrl", {})
962
+ field_begin = _append_child(ctrl1, f"{_HP}fieldBegin", {
963
+ "id": field_id,
964
+ "type": "CLICK_HERE",
965
+ "name": _sanitize_text(name),
966
+ "editable": "1" if editable else "0",
967
+ "dirty": "0",
968
+ "zorder": "-1",
969
+ "fieldid": field_instance_id,
970
+ "metaTag": "",
971
+ })
972
+ payload = (
973
+ f"Direction:wstring:{len(direction)}:{direction} "
974
+ f"HelpState:wstring:{len(help_state)}:{help_state} "
975
+ )
976
+ param_count = 2 + (1 if direction else 0) + (1 if help_state else 0)
977
+ parameters = _append_child(
978
+ field_begin, f"{_HP}parameters", {"cnt": str(param_count), "name": ""}
979
+ )
980
+ prop = _append_child(parameters, f"{_HP}integerParam", {"name": "Prop"})
981
+ prop.text = "9"
982
+ command = _append_child(parameters, f"{_HP}stringParam", {
983
+ "name": "Command",
984
+ f"{{{XML_NS}}}space": "preserve",
985
+ })
986
+ command.text = f"Clickhere:set:{len(payload)}:{payload} "
987
+ if direction:
988
+ direction_param = _append_child(
989
+ parameters, f"{_HP}stringParam", {"name": "Direction"}
990
+ )
991
+ direction_param.text = direction
992
+ if help_state:
993
+ help_param = _append_child(
994
+ parameters, f"{_HP}stringParam", {"name": "HelpState"}
995
+ )
996
+ help_param.text = help_state
997
+
998
+ # Run 2: the prompt placeholder (only while a prompt exists)
999
+ if direction:
1000
+ run2 = self._create_run_for_object(
1001
+ char_pr_id_ref=(
1002
+ prompt_char_pr_id_ref
1003
+ if prompt_char_pr_id_ref is not None
1004
+ else char_pr_id_ref
1005
+ ),
1006
+ )
1007
+ prompt_text = _append_child(run2, f"{_HP}t", {})
1008
+ prompt_text.text = direction
1009
+
1010
+ # Run 3: fieldEnd + trailing empty text node (gold shape)
1011
+ run3 = self._create_run_for_object(char_pr_id_ref=char_pr_id_ref)
1012
+ ctrl3 = _append_child(run3, f"{_HP}ctrl", {})
1013
+ _append_child(ctrl3, f"{_HP}fieldEnd", {
1014
+ "beginIDRef": field_id,
1015
+ "fieldid": field_instance_id,
1016
+ })
1017
+ _append_child(run3, f"{_HP}t", {})
1018
+
1019
+ self.section.mark_dirty()
1020
+ return HwpxOxmlInlineObject(ctrl1, self)
1021
+
837
1022
  @property
838
1023
  def bookmarks(self) -> list[str]:
839
1024
  """Return the names of all bookmarks in this paragraph."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 5.0.2
3
+ Version: 5.2.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -1,8 +1,8 @@
1
- hwpx/__init__.py,sha256=C63jP28R_TnVNTQifvWgOXwKg273JUpLwAa-8Z0B4dQ,15706
1
+ hwpx/__init__.py,sha256=WmlWHk6vfjr2HQa6Dzp6rmIXIUJXKWKDZJZ-LBpL3Nc,15936
2
2
  hwpx/body_patch.py,sha256=XfwrTMThS9vPLA62PDnEleCGuvYv61URxelbD_LHe8w,25730
3
- hwpx/document.py,sha256=sDbRQjIoUGw3VzKPAIuQi9oemcb4PqKHczBDrYZ9wTU,60776
3
+ hwpx/document.py,sha256=3N90Ra8krX4Skj5WUTDl4ezHf1q0yaBQCKqficnR1p8,64169
4
4
  hwpx/errors.py,sha256=l3QK2Izwkpm25ar7QnNQvyg0yosoLfQt7kW5wn5MCG0,3033
5
- hwpx/experimental.py,sha256=H8fBrmGf0BzoDS6dqwBpUChUestUgOpO7XjMEBXsssg,1499
5
+ hwpx/experimental.py,sha256=BfD3fvXDgiEEm8G66TuUe_OPETitGwFU0VurUXUuV9Q,1693
6
6
  hwpx/mutation_report.py,sha256=6hurhDdgGiONeLIeWZJT8lwLtWJZ1VHp1l6G0zy0mQo,19409
7
7
  hwpx/package.py,sha256=0rKjGCJbPQvrVBIy07Jpjsu3fI7HhbqFCGWTiTDsJpo,1141
8
8
  hwpx/patch.py,sha256=J4RrVr5s3pW9hN5EKYvUytXI_4f7NEdxV5oUsIdZkgw,25546
@@ -11,19 +11,20 @@ hwpx/table_patch.py,sha256=nl7UT-Qf5-KV25Wdxm4ve9ZxX6FfijibymPXZcJJIjQ,83729
11
11
  hwpx/templates.py,sha256=28bYqeJVeDb1Cq8G9NZG9Mhnu4K2GamAKC4QhxvUZyA,1187
12
12
  hwpx/_document/__init__.py,sha256=REiNqMbuk_TS4NVC27FIo1rUt3Z8nhJrTBGOgNtxBRg,90
13
13
  hwpx/_document/_units.py,sha256=qyC8YtnvV2VlSGltJntDaIMF_sHy_XJZCwwe-g080TQ,386
14
- hwpx/_document/fields.py,sha256=-R3tXMaTvX1DtSNouARSvcBepW1JYUk2caOYTz4sVh0,19683
14
+ hwpx/_document/fields.py,sha256=6VB7FV69cWcHLONj_Bn_DUGH5k9IMn94SRhRCia6wvI,22935
15
15
  hwpx/_document/layout.py,sha256=TicBhdxXW2lQkLyG5bg6PHW-OP-X4oWdP6XAgAXw8mg,23089
16
16
  hwpx/_document/media.py,sha256=kamsFJZ4K9aoKhjyztMHC64MVFR_ZQ50rDVgouZqxFA,11592
17
17
  hwpx/_document/memos.py,sha256=_tscEhNfPe9-iAkcEtJGjwHTN42_lx4stFoPrgJxk3o,7481
18
18
  hwpx/_document/persistence.py,sha256=NgWeFmm6jWV11194kdx32lwRQ6dyqiQYnc1ZBzJcIbE,16003
19
- hwpx/_document/shapes.py,sha256=UpPNEdfOlrEKMD5TahemQBHPOm6qgnHFxjZGEZ27xUk,6684
19
+ hwpx/_document/shapes.py,sha256=XoCp41A6AmemOv9tGSMXRyu7sE7SMVBSzXSZfkelKWA,8825
20
20
  hwpx/_document/tracked.py,sha256=1sQ9Q3-_wOAH2SQPbJQAeCG9E9HjV3y1gCORzrPUEPo,5010
21
21
  hwpx/data/Skeleton.hwpx,sha256=yR-3epkzo-VkWiV3ji0rmemBN-2ZVypdHdKe8MD9TJg,7490
22
- hwpx/equation/__init__.py,sha256=t012hR5Tc07DiC_niYtUpE4WI5-2zNVUuwRRVn2QfXw,1118
22
+ hwpx/equation/__init__.py,sha256=0wks0_pS_tBokCVMeWUJGE6tbW35Ptc0I5Z_36FY-Jo,1328
23
+ hwpx/equation/authoring.py,sha256=s8wJWNEEFJ2oS4xbs4-Hyv7Dlml_mSQrljdy0hbzpjU,16786
23
24
  hwpx/equation/eqedit.py,sha256=FjRNwc_JVC01yprqLFk5N6Nm5qCow2K2x-u3Dw7Wmio,11314
24
25
  hwpx/equation/mathml.py,sha256=mbmAojF-4e9cMEQQvgHx5wvp_dG3Mb_sPjqH7SpOaxQ,2150
25
26
  hwpx/equation/render.py,sha256=g698YmVWsfG4V-QNROXmSK0za-npJ4Z7xIe6Y5DZ1g0,2874
26
- hwpx/equation/tokens.py,sha256=9HJZrZAIc9CM-GsZv_SNrqyr7JPVKC4hvVw6jEJPESE,6474
27
+ hwpx/equation/tokens.py,sha256=RyFyqj3rMllFB5k4d2XO2L8CkiIySb573evJ-fwujhY,7097
27
28
  hwpx/form_fit/__init__.py,sha256=l1HcFge4u_wL2vogB-VxSMrb-8h7zUT_IgTWMtnH9CM,1779
28
29
  hwpx/form_fit/apply.py,sha256=X2q7K-mLku4AXxm4-xP80QGzhBlpzpqA_Ade8aPXzNY,3450
29
30
  hwpx/form_fit/engine.py,sha256=2W5ePGnpaLDoVPH8RxKdRfVx3r3usy26RBWj0jU1fpE,21553
@@ -55,7 +56,7 @@ hwpx/oxml/memo.py,sha256=QlmuELEzywnmywgjm94S3vzJB8wPpoFOmI8KVuzoNPw,9484
55
56
  hwpx/oxml/namespaces.py,sha256=c7JfdOdJbzrhyHvjbxoeeeloRE3xPuoB7v3YI8SmKDk,6524
56
57
  hwpx/oxml/numbering.py,sha256=9a0ARGW1DkKsTF7mEEXM4FA5loQmzIBuqE4APA8UFIQ,669
57
58
  hwpx/oxml/objects.py,sha256=8ErDXWvkjLm3FTsx0Z_IovUXbiv2uc-j0B5Y2-OtihA,20864
58
- hwpx/oxml/paragraph.py,sha256=0Gb8FBgzirE32_ALJQIz6TYpBeQiLX3Jh14JOKyS-SA,36779
59
+ hwpx/oxml/paragraph.py,sha256=U-bGkgMs3wiEBE1TXW6jSJj0SYwzN9xtWeOyOhLLJt0,44238
59
60
  hwpx/oxml/parser.py,sha256=pIfyNdW3WdFkCcE8JFY7hn1fobRbMngzfhVZICvWeVs,2937
60
61
  hwpx/oxml/run.py,sha256=F939J1W6zQjr-JQ1Z8-nKh2s7NQ_0Okov4QJAEyw_5M,15503
61
62
  hwpx/oxml/schema.py,sha256=ElR3_IIhhPPZEqJtNKMNCHA-VFVLdhL454W_4spuvlc,1284
@@ -103,10 +104,10 @@ hwpx/tools/toc_fidelity.py,sha256=rvoKH8QJ65WNVrxS0d-i2AtODvBy-4O8NRudfQ244v4,19
103
104
  hwpx/tools/validator.py,sha256=U856izL9NcJZOiKDYoCpwOSaFNQ2Un8Jn4pzL20A96Q,7100
104
105
  hwpx/tools/_schemas/header.xsd,sha256=mJXuFMuHGT1JnFFaluUpYUglwjMCNlfbFCRVM26eHXE,664
105
106
  hwpx/tools/_schemas/section.xsd,sha256=MgvavVHG05RDfUnVPxVU10H4FQOja5ON04_m9Uk_m7E,522
106
- python_hwpx-5.0.2.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
107
- python_hwpx-5.0.2.dist-info/licenses/NOTICE,sha256=KJgtwIIzrXoA0j3PUhwp78ZtnucocEoB7jiuwoL4i7o,3060
108
- python_hwpx-5.0.2.dist-info/METADATA,sha256=nLrp-MhviX2B4sd8FIUBc8rLKQxTX_itwHnLeyDExSg,13162
109
- python_hwpx-5.0.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
- python_hwpx-5.0.2.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
111
- python_hwpx-5.0.2.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
112
- python_hwpx-5.0.2.dist-info/RECORD,,
107
+ python_hwpx-5.2.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
108
+ python_hwpx-5.2.0.dist-info/licenses/NOTICE,sha256=auRgKYGdrOgWrj4kZfAuXQnqgDnx5myI9TI17IztIzQ,3466
109
+ python_hwpx-5.2.0.dist-info/METADATA,sha256=dTDpJNzdORyUZnVj8fANpARJ74j3QOekvJ9Mu_O5mYc,13162
110
+ python_hwpx-5.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
111
+ python_hwpx-5.2.0.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
112
+ python_hwpx-5.2.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
113
+ python_hwpx-5.2.0.dist-info/RECORD,,
@@ -30,9 +30,15 @@ reimplemented from public references, without copying source code:
30
30
  structure and roundtrip fixtures.
31
31
 
32
32
  - OpenBapul/hml-equation-parser (Apache-2.0): HULK-style EqEdit <-> LaTeX token
33
- mapping, referenced for reader-direction <hp:equation> script rendering. The
34
- token maps are a clean-room re-derivation from the public EqEdit grammar; no
35
- source code was copied, translated, or vendored.
33
+ mapping, referenced for reader-direction <hp:equation> script rendering and
34
+ for the authoring-direction LaTeX -> EqEdit conversion. The token maps are a
35
+ clean-room re-derivation from the public EqEdit grammar; no source code was
36
+ copied, translated, or vendored.
37
+ - chrisryugj/kordoc (MIT): the authoring-direction approach for native
38
+ <hp:equation> generation — the XML shell contract, reserved-word quoting,
39
+ and untrusted-input guards (source-size and recursion-depth caps) were
40
+ referenced as behavior evidence. Clean-room Python implementation; no source
41
+ code was copied, translated, or vendored.
36
42
 
37
43
  These form-fill and equation references were used as behavior evidence only. The
38
44
  implementation is a clean-room Python implementation; no source code was