python-hwpx 6.7.0__py3-none-any.whl → 6.8.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
@@ -14,7 +14,7 @@
14
14
  import importlib
15
15
  import warnings
16
16
  from dataclasses import dataclass
17
- from typing import Literal
17
+ from typing import TYPE_CHECKING, Literal
18
18
 
19
19
 
20
20
  def _resolve_version() -> str:
@@ -28,9 +28,9 @@ def _resolve_version() -> str:
28
28
 
29
29
  # --- experimental / deprecated 최상위 표면 (지연 접근, 접근 시 경고) ---------------
30
30
  #
31
- # stable 이름은 아래에서 eager import 되어 모듈 전역에 존재하므로 ``__getattr__``이
32
- # 호출되지 않습니다(=경고 없음). 여기 등록된 이름만 지연 해석되어 경고를 냅니다.
33
- # 4.0.0에서 제거되는 이름은 0개 — 모두 계속 import 가능합니다.
31
+ # stable 이름은 경고 없이 해석됩니다. 대부분 아래에서 eager import 되고, ``hwpx.tools``의
32
+ # 이름은 ``_TOOLS_STABLE_EXPORTS``가 지연 해석합니다. 여기 등록된 이름만 지연 해석되어
33
+ # 경고를 냅니다. 4.0.0에서 제거되는 이름은 0개 — 모두 계속 import 가능합니다.
34
34
 
35
35
  _EXPERIMENTAL_EXPORTS = {
36
36
  # 문서 ingestion 프레임워크(임의 포맷 -> HWPX). 계약 유동.
@@ -293,8 +293,8 @@ class RetiredSurface(ImportError):
293
293
  def __getattr__(name: str) -> object:
294
294
  """Resolve dynamic module attributes.
295
295
 
296
- ``__version__``은 경고 없이 지연 해석하고, experimental/deprecated 이름은
297
- 해석 시 ``DeprecationWarning``을 냅니다.
296
+ ``__version__``과 ``hwpx.tools``의 stable 이름은 경고 없이 지연 해석하고,
297
+ experimental/deprecated 이름은 해석 시 ``DeprecationWarning``을 냅니다.
298
298
  """
299
299
 
300
300
  if name == "__version__":
@@ -318,6 +318,17 @@ def __getattr__(name: str) -> object:
318
318
  warnings.warn(_deprecated_message(name), DeprecationWarning, stacklevel=2)
319
319
  return getattr(importlib.import_module(module_name), name)
320
320
 
321
+ if name in _TOOLS_STABLE_EXPORTS or name == "tools":
322
+ # Not ``from . import tools``: that asks this module for ``tools`` first and
323
+ # re-enters this function.
324
+ import hwpx.tools as tools
325
+
326
+ if name == "tools":
327
+ return tools
328
+ value = getattr(tools, name)
329
+ globals()[name] = value
330
+ return value
331
+
321
332
  moved = _MOVED_TO_COMPANION.get(name)
322
333
  if moved is not None:
323
334
  statement = moved.import_statement(name)
@@ -346,11 +357,76 @@ def __dir__() -> list[str]:
346
357
  return sorted(
347
358
  set(globals())
348
359
  | set(__all__)
360
+ | {"tools"}
349
361
  | set(_EXPERIMENTAL_EXPORTS)
350
362
  | set(_DEPRECATED_EXPORTS)
351
363
  )
352
364
 
353
365
 
366
+ # --- 지연 stable 최상위 표면 (첫 접근 때 해석, 경고 없음) ---------------------------
367
+ #
368
+ # ``hwpx.tools``에 사는 stable 이름은 첫 접근 때 ``__getattr__``이 그 모듈을 읽어
369
+ # 같은 객체를 돌려주고 모듈 전역에 둡니다(경고 없음). ``import hwpx``가 도구 모듈
370
+ # 전부(내보내기·비교·검증 등)를 읽지 않게 하려는 것입니다. ``__all__``·``dir()``·
371
+ # ``from hwpx import *``에는 그대로 있습니다.
372
+
373
+ #: ``hwpx.tools``의 하위 모듈이 정의하고 ``hwpx.tools``가 다시 내보내는 stable 이름(텍스트 추출·개체
374
+ #: 찾기·문서 비교·메일 머지·패키지 검증). 정적 import로 해석하므로 동적 import 지점은
375
+ #: 늘지 않는다.
376
+ _TOOLS_STABLE_EXPORTS = frozenset(
377
+ {
378
+ "DEFAULT_NAMESPACES",
379
+ "ParagraphInfo",
380
+ "SectionInfo",
381
+ "TextExtractor",
382
+ "FoundElement",
383
+ "ObjectFinder",
384
+ "DOC_DIFF_REPORT_VERSION",
385
+ "REFERENCE_CONSISTENCY_REPORT_VERSION",
386
+ "diff_paragraphs",
387
+ "doc_diff",
388
+ "inspect_reference_consistency",
389
+ "MAIL_MERGE_REPORT_VERSION",
390
+ "inspect_mail_merge_placeholders",
391
+ "load_mail_merge_rows",
392
+ "merge_template_rows",
393
+ "EditorOpenSafetyReport",
394
+ "PackageValidationReport",
395
+ "validate_editor_open_safety",
396
+ "validate_package",
397
+ }
398
+ )
399
+
400
+
401
+ if TYPE_CHECKING: # type checkers see the real objects; at runtime they load lazily
402
+ from .tools.doc_diff import (
403
+ DOC_DIFF_REPORT_VERSION,
404
+ REFERENCE_CONSISTENCY_REPORT_VERSION,
405
+ diff_paragraphs,
406
+ doc_diff,
407
+ inspect_reference_consistency,
408
+ )
409
+ from .tools.mail_merge import (
410
+ MAIL_MERGE_REPORT_VERSION,
411
+ inspect_mail_merge_placeholders,
412
+ load_mail_merge_rows,
413
+ merge_template_rows,
414
+ )
415
+ from .tools.object_finder import FoundElement, ObjectFinder
416
+ from .tools.package_validator import (
417
+ EditorOpenSafetyReport,
418
+ PackageValidationReport,
419
+ validate_editor_open_safety,
420
+ validate_package,
421
+ )
422
+ from .tools.text_extractor import (
423
+ DEFAULT_NAMESPACES,
424
+ ParagraphInfo,
425
+ SectionInfo,
426
+ TextExtractor,
427
+ )
428
+
429
+
354
430
  # ``import hwpx.builder`` goes through the import system, not ``__getattr__``,
355
431
  # so the moved module names get their destination from a finder instead.
356
432
  from . import _moved_modules
@@ -359,35 +435,9 @@ _moved_modules.install(_MOVED_TO_COMPANION)
359
435
 
360
436
 
361
437
  # --- stable 최상위 표면 (eager import) ------------------------------------------
362
- from .tools.text_extractor import (
363
- DEFAULT_NAMESPACES,
364
- ParagraphInfo,
365
- SectionInfo,
366
- TextExtractor,
367
- )
368
- from .tools.object_finder import FoundElement, ObjectFinder
369
- from .tools.doc_diff import (
370
- DOC_DIFF_REPORT_VERSION,
371
- REFERENCE_CONSISTENCY_REPORT_VERSION,
372
- diff_paragraphs,
373
- doc_diff,
374
- inspect_reference_consistency,
375
- )
376
- from .tools.mail_merge import (
377
- MAIL_MERGE_REPORT_VERSION,
378
- inspect_mail_merge_placeholders,
379
- load_mail_merge_rows,
380
- merge_template_rows,
381
- )
382
- from .tools.package_validator import (
383
- EditorOpenSafetyReport,
384
- PackageValidationReport,
385
- validate_editor_open_safety,
386
- validate_package,
387
- )
438
+ # ``hwpx.tools``의 stable 이름은 위 ``_TOOLS_STABLE_EXPORTS``가 지연 해석한다.
388
439
  from .ingest import HwpxMarkdownConverter
389
- from .errors import HwpxError
390
- from .hwp5.errors import Hwp5ConversionWarning, Hwp5Error
440
+ from .errors import Hwp5ConversionWarning, Hwp5Error, HwpxError
391
441
  from .mutation_report import (
392
442
  MutationReport,
393
443
  PreservationDowngradeError,
@@ -408,7 +408,8 @@ def save_report(
408
408
  oracle-verified ``visual_complete`` tier (plan §0.0).
409
409
 
410
410
  Like the legacy savers it raises only if the serialize step's open-safety
411
- check fails; all other gate outcomes are returned in the report.
411
+ check fails; all other gate outcomes are returned in the report. An ``.hwp``
412
+ path is written as HWP 5.0, as :func:`save_to_path` writes it.
412
413
  """
413
414
 
414
415
  _run_pre_save_validation(doc)
@@ -420,10 +421,15 @@ def save_report(
420
421
  output_path = path_or_stream
421
422
  elif path_or_stream is not None:
422
423
  output_stream = path_or_stream
424
+ # An .hwp target gets HWP 5.0 made from the HWPX parts the gate checks; it raises before anything is
425
+ # written when the HWP 5.0 writer cannot express the document.
426
+ hwp5 = None
427
+ if output_path is not None and str(output_path).lower().endswith(".hwp"):
428
+ hwp5 = _hwp5_bytes(archive_bytes)
423
429
 
424
430
  report = doc._save_pipeline.run(
425
431
  archive_bytes,
426
- output_path=output_path,
432
+ output_path=None if hwp5 is not None else output_path,
427
433
  output_stream=output_stream,
428
434
  quality=quality or QualityPolicy.transparent(),
429
435
  before=before,
@@ -432,6 +438,12 @@ def save_report(
432
438
  reference_document=doc,
433
439
  source_label="document.save_report",
434
440
  )
441
+ if hwp5 is not None and report.ok: # the gate passed: publish the HWP 5.0 file in its place
442
+ from ..quality.save_pipeline import write_bytes_atomically
443
+
444
+ assert output_path is not None
445
+ write_bytes_atomically(output_path, hwp5)
446
+ report.output_path = str(output_path)
435
447
  if report.ok:
436
448
  _mark_save_clean(doc)
437
449
  return report
hwpx/document.py CHANGED
@@ -37,7 +37,6 @@ from ._document import fields as _fields
37
37
  from ._document import media as _media
38
38
  from ._document import persistence as _persistence
39
39
  from ._document.persistence import SaveFormat
40
- from .hwp5.errors import Hwp5ConversionReport
41
40
  from ._document import _resolve
42
41
  from ._document import headings as _headings
43
42
  from ._document import layout as _layout
@@ -64,6 +63,7 @@ register_owpml_namespaces(ET.register_namespace)
64
63
  logger = logging.getLogger(__name__)
65
64
 
66
65
  if TYPE_CHECKING:
66
+ from .hwp5.errors import Hwp5ConversionReport
67
67
  from .tools.validator import ValidationReport
68
68
 
69
69
 
@@ -836,7 +836,9 @@ class HwpxDocument(_LegacyFacade):
836
836
  oracle-verified ``visual_complete`` tier (plan §0.0).
837
837
 
838
838
  Like the legacy savers it raises only if the serialize step's open-safety
839
- check fails; all other gate outcomes are returned in the report.
839
+ check fails; all other gate outcomes are returned in the report. An ``.hwp``
840
+ path is written as HWP 5.0, as :meth:`save_to_path` writes it, and raises
841
+ :class:`~hwpx.Hwp5Error` before writing when the document cannot be.
840
842
  """
841
843
 
842
844
  return _persistence.save_report(
@@ -270,6 +270,8 @@ class _LatexParser:
270
270
  radicand = self._group_or_atom(depth)
271
271
  return f"root {{{' '.join(index_parts)}}} of {{{radicand}}}"
272
272
  return f"sqrt {{{self._group_or_atom(depth)}}}"
273
+ if token in ("\\lbrace", "\\rbrace"): # the command spellings of \{ and \}
274
+ return "LBRACE" if token == "\\lbrace" else "RBRACE"
273
275
  if token in _TEXT_COMMANDS:
274
276
  return self._text_literal()
275
277
  if token == "\\mathbf":
@@ -407,8 +409,8 @@ class _LatexParser:
407
409
  raise UnsupportedLatexError("missing \\left/\\right delimiter")
408
410
  if token in _LATEX_DELIMITERS:
409
411
  return _LATEX_DELIMITERS[token]
410
- if token in ("\\{", "\\}"):
411
- return "LBRACE" if token == "\\{" else "RBRACE"
412
+ if token in ("\\{", "\\}", "\\lbrace", "\\rbrace"):
413
+ return "LBRACE" if token in ("\\{", "\\lbrace") else "RBRACE"
412
414
  if token in DELIMITERS and len(token) == 1:
413
415
  return token
414
416
  raise UnsupportedLatexError(f"unsupported \\left/\\right delimiter: {token}")
@@ -450,7 +452,8 @@ def estimate_equation_size(script: str, *, base_unit: int = 1100) -> tuple[int,
450
452
 
451
453
  A reader that lays the page out from the file uses the stored box, so the
452
454
  box has to fit the script; Hancom itself was observed (on macOS) to lay the
453
- equation out again and rewrite the box when it saves the document (see
455
+ equation out again and rewrite the box when it saves the document, while on
456
+ Windows it keeps the stored box and lays the page out with it (see
454
457
  :func:`hwpx.equation.measure.measure_equation`).
455
458
  """
456
459
 
hwpx/equation/eqedit.py CHANGED
@@ -42,8 +42,10 @@ from .tokens import (
42
42
  MAX_SOURCE_LENGTH = 10_000
43
43
  MAX_GROUP_DEPTH = 64
44
44
 
45
- # Words EqEdit reads as symbols or structure. The writer quotes a bare identifier that collides with one
46
- # (``T_{int}`` → ``T _{"int"}``); the reader turns such a quoted word back into plain letters.
45
+ # Words EqEdit reads as symbols, structure or font switches. The writer quotes a bare identifier that collides
46
+ # with one (``T_{int}`` → ``T _{"int"}``, ``e^{it}`` → ``e ^{"it"}``: a lone ``it`` in a superscript switches to
47
+ # italic and draws nothing); the reader turns such a quoted word back into plain letters.
48
+ _FONT_SWITCHES = frozenset({"rm", "it", "bold", "RM", "IT", "BOLD"})
47
49
  _RESERVED_WORDS = (
48
50
  frozenset(GREEK)
49
51
  | frozenset(OPERATORS)
@@ -52,6 +54,7 @@ _RESERVED_WORDS = (
52
54
  | frozenset(ACCENTS)
53
55
  | frozenset(MATRIX_ENVIRONMENTS)
54
56
  | STRUCTURAL
57
+ | _FONT_SWITCHES
55
58
  )
56
59
 
57
60
  # Characters that always terminate a token even when not whitespace separated.
@@ -338,6 +341,8 @@ class _Parser:
338
341
  return OPERATORS[token]
339
342
  if token in FUNCTIONS:
340
343
  return FUNCTIONS[token]
344
+ if token in ("LBRACE", "RBRACE"): # a literal brace outside LEFT/RIGHT
345
+ return r"\{" if token == "LBRACE" else r"\}"
341
346
  if token.startswith('"') and token.endswith('"') and len(token) >= 2:
342
347
  literal = token[1:-1]
343
348
  if literal.isalpha() and literal in _RESERVED_WORDS: