Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
1196da7
perf(table): 표 격자를 표 객체에 한 번 만들어 두고 다시 쓴다 (EFF-01)
airmang Nov 10, 2026
14e5c13
perf(table-patch): fill_cells가 표마다 격자를 한 번만 만든다 (EFF-31)
airmang Nov 10, 2026
4ccadb9
perf(header): 스타일 조회의 직렬화 왕복을 없애고 결과를 보관한다 (EFF-02)
airmang Nov 10, 2026
844b409
perf(paragraph): 문단 추가·제목·문단 서식이 본문 문단 전체를 다시 감싸지 않는다 (EFF-18)
airmang Nov 10, 2026
05dfb1e
perf(text-extractor): 문단 경로의 형제 순번을 부모마다 한 번만 계산한다 (EFF-09)
airmang Nov 10, 2026
9fb794a
perf(save): save_report가 직렬화 때의 open-safety 검증 결과를 넘겨받는다 (EFF-08 일부)
airmang Nov 10, 2026
7feacdc
fix(paragraph): _body_indexes의 lxml 전용 호출을 pyright가 통과하게 한다 (EFF-18 후속)
airmang Nov 10, 2026
26ad5a7
ci(tests): push는 main에서만 돌고 PR의 앞선 실행은 취소한다 (EFF-04)
airmang Nov 10, 2026
6d65c37
test(coverage-ledger): 세 테스트가 원장을 한 번만 만들어 같이 쓴다 (EFF-05)
airmang Nov 10, 2026
eedc8f0
perf(mail-merge): 저장 때 검증한 바이트를 행마다 다시 검증하지 않는다 (EFF-19 일부)
airmang Nov 10, 2026
4651e14
refactor(table): 격자 보관 판정을 oxml/table_grid.py로 옮긴다 (EFF-01 후속)
airmang Nov 10, 2026
25fc358
refactor(table): 격자 보관 판정을 새 모듈 대신 oxml/table_merge.py에 둔다 (EFF-01 후속)
airmang Nov 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ name: Tests

on:
push:
branches: [main]
pull_request:

# A new commit on a pull request cancels the run of the one before it. Runs on main are
# never cancelled: each has its own group.
concurrency:
group: tests-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
runs-on: ubuntu-latest
Expand Down
12 changes: 5 additions & 7 deletions src/hwpx/_document/headings.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,11 @@ def bind_outline_level(
from . import layout as _layout

_check_level(level)
target = paragraph.element
for index, candidate in enumerate(document.paragraphs):
if candidate.element is target:
_layout.set_paragraph_format(
document, paragraph_index=index, outline_level=level
)
return
# The paragraph itself is the target: looking its index up in the list of every body paragraph,
# and resolving that index back, made a run of headings quadratic.
if _layout._body_indexes(document, [paragraph])[0] is not None:
_layout.set_paragraph_format(document, paragraphs=[paragraph], outline_level=level)
return
# 방금 만든 문단이라 항상 찾힌다. 못 찾으면 조용히 넘어가는 대신 말한다 —
# 개요 수준이 빠진 문단은 "번호가 안 붙는 제목"이라 무음 실패가 가장 나쁘다.
raise HwpxLookupError( # pragma: no cover - 방어
Expand Down
46 changes: 43 additions & 3 deletions src/hwpx/_document/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,48 @@ def _resolve_paragraph_objects(
return resolved


def _body_indexes(
doc: "HwpxDocument", objects: Sequence[HwpxOxmlParagraph]
) -> list[int | None]:
"""Where each paragraph object sits in ``doc.paragraphs``; ``None`` for one outside the body.

A few objects are placed by counting the paragraphs before them in their section, which reads
no other paragraph; wrapping every body paragraph to place one made a run of single-paragraph
calls (a heading per call) quadratic. Many objects, or plain ElementTree elements, go through
the list of body paragraphs once.
"""

elements: list[Any] = [paragraph.element for paragraph in objects]
if len(elements) > 8 or not all(hasattr(element, "itersiblings") for element in elements):
body_index = {paragraph.element: index for index, paragraph in enumerate(doc.paragraphs)}
return [body_index.get(element) for element in elements]

section_roots: list[Any] = [section.element for section in doc.sections]
starts: list[int] = [] # body index of each section's first paragraph, filled as far as needed

def start_of(section_position: int) -> int:
while len(starts) <= section_position:
previous = len(starts) - 1
starts.append(
0 if previous < 0
else starts[previous] + sum(1 for _ in section_roots[previous].iterchildren("{*}p"))
)
return starts[section_position]

indexes: list[int | None] = []
for element in elements:
parent = element.getparent()
position = next(
(index for index, root in enumerate(section_roots) if root is parent), None
)
if position is None or not isinstance(element.tag, str) or element.tag.rsplit("}", 1)[-1] != "p":
indexes.append(None)
continue
before = sum(1 for _ in element.itersiblings("{*}p", preceding=True))
indexes.append(start_of(position) + before)
return indexes


def set_paragraph_format(
doc: "HwpxDocument",
*,
Expand Down Expand Up @@ -376,9 +418,7 @@ def set_paragraph_format(
suggestion="Pass only one.",
)
objects = _resolve_paragraph_objects(doc, paragraphs)
body = doc.paragraphs
body_index = {paragraph.element: index for index, paragraph in enumerate(body)}
targets = [(body_index.get(paragraph.element), paragraph) for paragraph in objects]
targets = list(zip(_body_indexes(doc, objects), objects))
else:
targets = list(_resolve_paragraph_targets(doc,
paragraph_index=paragraph_index,
Expand Down
20 changes: 20 additions & 0 deletions src/hwpx/_document/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ def save_report(
before=before,
edit_mask=edit_mask,
ledger=ledger,
open_safety=_serialize_open_safety(doc, archive_bytes),
reference_document=doc,
source_label="document.save_report",
)
Expand Down Expand Up @@ -475,6 +476,25 @@ def to_bytes(
return data


def _serialize_open_safety(doc: "HwpxDocument", archive_bytes: bytes) -> OpenSafetyReport | None:
"""The open-safety verdict the package's save reached for exactly *archive_bytes*, in the form
the SavePipeline composes from its own run; ``None`` when the package did not validate these
bytes (the pipeline then validates them itself).

The same validation of the same bytes gives the same report, so ``save_report`` hands the
pipeline this one instead of running it a second time.
"""

package = doc._package
report = getattr(package, "_verified_archive_report", None)
digest = getattr(package, "_verified_archive_digest", None)
if report is None or digest is None or not report.ok:
return None
if digest != hashlib.sha256(archive_bytes).digest():
return None
return OpenSafetyReport(ok=report.ok, summary=report.summary, detail=report.to_dict())


def _to_bytes_raw(
doc: "HwpxDocument",
*,
Expand Down
10 changes: 5 additions & 5 deletions src/hwpx/opc/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,8 @@ def __init__(
# SHA-256 of the last archive bytes this package's save verified as
# editor-open safe (see _save_to_zip); None until such a save.
self._verified_archive_digest: bytes | None = None
# The report of that validation, for a caller that needs its evidence for the same bytes.
self._verified_archive_report: "EditorOpenSafetyReport | None" = None
self._validate_structure()

@staticmethod
Expand Down Expand Up @@ -1230,11 +1232,9 @@ def _save_to_zip(
report = self._verify_editor_open_safe_archive(payload)
# Remember which exact bytes passed, so the document-level
# check of the same save need not run the validation again.
self._verified_archive_digest = (
hashlib.sha256(payload).digest()
if report is not None and report.ok
else None
)
verified = report is not None and report.ok
self._verified_archive_digest = hashlib.sha256(payload).digest() if verified else None
self._verified_archive_report = report if verified else None
_write_stream_or_rollback(pkg_file, payload)
if mark_clean and version_was_dirty:
self._version.mark_clean()
Expand Down
69 changes: 51 additions & 18 deletions src/hwpx/oxml/header_part.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ def __init__(self, part_name: str, element: ET.Element, document: "HwpxOxmlDocum
self._element = element
self._dirty = False
self._document = document
# parsed lookups (paragraph shapes, styles, tab definitions, border fills ...), see _parsed()
self._lookup_cache: dict[tuple[object, ET.Element], tuple[bytes, object]] = {}
self._para_pr_index: tuple[object, dict[str, ET.Element]] | None = None

@property
def element(self) -> ET.Element:
Expand Down Expand Up @@ -1225,7 +1228,7 @@ def border_fills(self) -> dict[str, GenericElement]:
if element is None:
return {}

fill_list = parse_border_fills(self._convert_to_lxml(element))
fill_list = self._parsed(element, parse_border_fills)
mapping: dict[str, GenericElement] = {}
for border_fill in fill_list.fills:
raw_id = border_fill.attributes.get("id")
Expand All @@ -1248,8 +1251,32 @@ def border_fill(self, border_fill_id_ref: int | str | None) -> GenericElement |

@staticmethod
def _convert_to_lxml(element: ET.Element) -> LET._Element:
# The header tree is lxml already; only a plain ElementTree element needs the round trip.
if isinstance(element, LET._Element):
return element
return LET.fromstring(ET.tostring(element, encoding="utf-8"))

def _parsed(self, element: ET.Element, parse: Callable[[LET._Element], T]) -> T:
"""``parse(element)``, kept until the header is marked dirty or the element's XML differs.

The kept result is compared by the element's serialized bytes, so an edit made on the tree
without ``mark_dirty()`` is seen as well. Callers get the kept objects themselves, as
``char_property`` gives its cached styles: treat them as read-only.
"""

if not isinstance(element, LET._Element):
return parse(self._convert_to_lxml(element))
stamp = LET.tostring(element)
key = (parse, element)
kept = self._lookup_cache.get(key)
if kept is not None and kept[0] == stamp:
return kept[1] # type: ignore[return-value]
value = parse(element)
if len(self._lookup_cache) >= 4096:
self._lookup_cache.clear()
self._lookup_cache[key] = (stamp, value)
return value

@staticmethod
def _lookup_by_id(mapping: dict[str, T], identifier: int | str | None) -> T | None:
if identifier is None:
Expand Down Expand Up @@ -1397,7 +1424,7 @@ def bullets(self) -> dict[str, Bullet]:
if bullets_element is None:
return {}

bullet_list = parse_bullets(self._convert_to_lxml(bullets_element))
bullet_list = self._parsed(bullets_element, parse_bullets)
return bullet_list.as_dict()

def bullet(self, bullet_id_ref: int | str | None) -> Bullet | None:
Expand All @@ -1409,32 +1436,38 @@ def paragraph_properties(self) -> dict[str, ParagraphProperty]:
if para_props_element is None:
return {}

para_properties = parse_paragraph_properties(
self._convert_to_lxml(para_props_element)
)
para_properties = self._parsed(para_props_element, parse_paragraph_properties)
return para_properties.as_dict()

def paragraph_property(
self, para_pr_id_ref: int | str | None
) -> ParagraphProperty | None:
# Parse only the hh:paraPr asked for, under the keys paragraph_properties gives it; parsing
# them all on each lookup cost most of a second in a header of many paragraph shapes.
elements: dict[str, ET.Element] = {}
parent = self._para_properties_element()
for child in [] if parent is None else parent:
raw = child.get("id") if _element_local_name(child) == "paraPr" else None
for key in (raw, str(int(raw)) if raw and raw.strip().lstrip("+-").isdigit() else None):
if key and key not in elements:
elements[key] = child
children = [] if parent is None else list(parent)
# The id -> element index is kept while the list has the same children with the same ids.
stamp = (children, [(child.tag, child.get("id")) for child in children])
kept = self._para_pr_index
if kept is not None and kept[0] == stamp:
elements = kept[1]
else:
elements = {}
for child in children:
raw = child.get("id") if _element_local_name(child) == "paraPr" else None
for key in (raw, str(int(raw)) if raw and raw.strip().lstrip("+-").isdigit() else None):
if key and key not in elements:
elements[key] = child
self._para_pr_index = (stamp, elements)
found = self._lookup_by_id(elements, para_pr_id_ref)
return None if found is None else parse_paragraph_property(self._convert_to_lxml(found))
return None if found is None else self._parsed(found, parse_paragraph_property)

@property
def tab_properties(self) -> dict[str, TabDefinition]:
element = self._tab_properties_element()
if element is None:
return {}
return parse_tab_definitions(self._convert_to_lxml(element)).as_dict()
return self._parsed(element, parse_tab_definitions).as_dict()

def tab_property(self, tab_pr_id_ref: int | str | None) -> TabDefinition | None:
return self._lookup_by_id(self.tab_properties, tab_pr_id_ref)
Expand All @@ -1445,7 +1478,7 @@ def styles(self) -> dict[str, Style]:
if styles_element is None:
return {}

style_list = parse_styles(self._convert_to_lxml(styles_element))
style_list = self._parsed(styles_element, parse_styles)
return style_list.as_dict()

def style(self, style_id_ref: int | str | None) -> Style | None:
Expand All @@ -1457,7 +1490,7 @@ def track_changes(self) -> dict[str, TrackChange]:
if changes_element is None:
return {}

change_list = parse_track_changes(self._convert_to_lxml(changes_element))
change_list = self._parsed(changes_element, parse_track_changes)
return change_list.as_dict()

def track_change(self, change_id_ref: int | str | None) -> TrackChange | None:
Expand All @@ -1469,9 +1502,7 @@ def track_change_authors(self) -> dict[str, TrackChangeAuthor]:
if authors_element is None:
return {}

author_list = parse_track_change_authors(
self._convert_to_lxml(authors_element)
)
author_list = self._parsed(authors_element, parse_track_change_authors)
return author_list.as_dict()

def track_change_author(
Expand All @@ -1485,6 +1516,8 @@ def dirty(self) -> bool:

def mark_dirty(self) -> None:
self._dirty = True
self._lookup_cache.clear()
self._para_pr_index = None

def reset_dirty(self) -> None:
self._dirty = False
Expand Down
10 changes: 7 additions & 3 deletions src/hwpx/oxml/section.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,10 +357,14 @@ def _inherited_paragraph_refs(
every following paragraph turning into a list item without this guard.
"""

existing = self.paragraphs
if not existing:
# Only the last paragraph is read: wrapping every paragraph of the section for each append
# made a long run of appends quadratic.
last_element = next(
(child for child in reversed(self._element) if tag_local_name(child.tag) == "p"), None
)
if last_element is None:
return None, None, None
last = existing[-1]
last = HwpxOxmlParagraph(last_element, self)
prev_para_ref = last.para_pr_id_ref
prev_style_ref = last.style_id_ref
prev_char_ref = last.char_pr_id_ref
Expand Down
Loading
Loading