Skip to content

perf: 표 격자·스타일 조회·문단 추가·추출의 반복 비용 제거 - #679

Open
airmang wants to merge 12 commits into
mainfrom
fix/audit-efficiency
Open

airmang wants to merge 12 commits into
mainfrom
fix/audit-efficiency

Conversation

@airmang

@airmang airmang commented Nov 10, 2026

Copy link
Copy Markdown
Owner

요약

2026-11-10 점검에서 나온 효율 항목을 고칩니다. 출력은 바뀌지 않습니다. 커밋은 항목별로 나눴습니다.

항목 전 후
표 격자를 호출마다 다시 만들던 것: 40×40 표의 tables.map() 81.3초 0.14초
같은 문제: form_002.hwpx(131 KB 양식)의 tables.map() 16.0초 0.19초
fill_cells로 800칸을 한 번에 채우기 13.7초 0.33초
스타일 조회 paragraph_property 1회 0.47 ms 0.075 ms
add_paragraph 4,000번 8.3초 0.35초
TextExtractor.extract_text 4,000문단 3,788 ms 59 ms
save_report(131 KB 양식, 문단 1개 추가 뒤) 847 ms 575 ms
tests/test_coverage_ledger.py 592초 128초
  • 표 격자: 표 객체에 한 번 만들어 두고 표 XML과 대조해 달라졌으면 버립니다. table.cell()은 같은 래퍼 객체를 돌려줍니다.
  • 스타일 조회: 이미 lxml인 요소를 문자열로 썼다 다시 파싱하던 왕복을 없애고 조회 결과를 보관합니다. 헤더가 바뀌면 버립니다.
  • 저장: save_report가 직렬화 때 통과한 검증 결과를 넘겨받습니다(바이트의 SHA-256이 같을 때만). 검증 항목은 줄이지 않았습니다. to_bytes 안의 검증기들이 같은 바이트를 각자 푸는 것은 이번에 건드리지 않았습니다.
  • CI: Tests 워크플로의 push를 main으로 좁히고 PR 실행에 concurrency를 걸었습니다. 전에는 PR 가지의 커밋마다 두 번 돌았습니다.
  • 메일 머지: 행마다의 재검증을 없앴습니다.

측정은 Windows 11에서 다른 작업이 함께 도는 중에 여러 번 재서 낸 중앙값입니다.

출력이 달라지지 않았다는 확인

  • tests의 .hwpx 1,210개를 열어 그대로 저장한 바이트의 SHA-256이 수정 전후로 모두 같습니다.
  • 44개 문서 × 17개 시나리오(표 격자·탐색·칸 채움·구조 편집·fill_cells·문단 추가·추출·스타일 조회·save_report·lint·쪽 추정·검증·마크다운)의 결과 해시가 전후로 같습니다.
  • 1,210개 자료 전부의 스타일 조회 결과가 전후로 같습니다.

병합 전에 볼 것

  • PR이 없는 가지와 태그의 push에서는 Tests가 돌지 않게 됩니다.
  • 스타일 조회가 돌려주는 모델 객체는 보관본입니다(char_property와 같은 방식). 받은 객체를 고쳐 쓰는 코드는 src 안에서 찾지 못했습니다.
  • 표 격자의 좌표 조회(cell(r, c))는 행·칸 수와 돌려줄 칸만 대조합니다. 다른 칸을 XML로 직접 고쳐 조회 대상과 겹치게 만든 깨진 표는 전처럼 ValueError가 나지 않을 수 있습니다.
  • 헤더 목록 안에 XML 주석이 있으면 전에는 조회가 TypeError로 실패했고 이제는 조회됩니다.
  • 워크플로 변경은 실행해 보지 않았습니다.

관련 이슈

Refs #628

변경 종류

  • 버그 수정
  • 기능
  • 문서
  • 리팩터링

체크리스트

  • pytest 테스트가 통과합니다. — Windows 11에서 7,690 통과, 14 실패. 실패 14건은 이 변경 전 main에서도 같은 이름으로 실패하던 것입니다(cp949, 심볼릭 링크 권한, CRLF). Linux CI 결과는 이 PR의 체크로 확인해 주세요.
  • CHANGELOG.md를 갱신했습니다(해당하는 경우).
  • 문서 갱신이 필요한지 확인했습니다.

🤖 Generated with Claude Code

airmang and others added 12 commits November 10, 2026 15:46
table.cell(r, c)·set_cell_text(r, c)가 호출마다 모든 칸 래퍼와 격자를 새로 만들어
칸 수의 제곱으로 느려지던 것을 고친다.

- HwpxOxmlTable이 만든 격자를 보관한다. 다시 쓰기 전에 표 XML과 대조한다.
  좌표 조회는 행·칸 수와 돌려줄 칸의 주소·병합·크기 속성을 보고, iter_grid·
  get_cell_map·병합은 모든 칸의 주소·병합·크기(크기 0인 1x1 칸은 글 유무까지)를
  본다. 다르면 새로 만든다. 그래서 XML을 직접 고쳤거나 같은 표의 다른 래퍼가
  고친 경우에도 낡은 격자를 쓰지 않는다.
- 병합·분할·행/열 추가·삭제·칸 크기/병합 설정은 격자를 바로 버린다.
- tools/table_navigation의 표 지도는 iter_grid() 한 번으로 돈다.

측정(40x40 표, 3회 중앙값): cell 전 칸 42.5초 -> 0.11초, tables.map 118초 -> 0.14초,
set_cell_text 전 칸 43초 -> 0.29초. m2_corpus/form_002.hwpx tables.map 16.0초 -> 0.19초.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
칸 지정마다 표 바이트 전체를 다시 훑어 격자를 만들던 것을 표(table_index)마다
한 번으로 줄이고, 이미 채운 칸 구간(occupied)을 집합으로 둔다. 구역 바이트는
편집을 모두 모은 뒤에야 바뀌므로 같은 표의 지정은 같은 격자를 본다. 결과
바이트와 applied/skipped는 그대로다.

측정(전 칸을 한 번에, 5회 중앙값): 200칸 1.04초 -> 0.09초, 400칸 3.56초 -> 0.15초,
800칸 13.7초 -> 0.33초.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
- _convert_to_lxml: 이미 lxml 요소면 그대로 넘긴다. 표준 라이브러리 직렬기로
  쓰고 다시 파싱하던 왕복이 조회마다 있었다.
- paragraph_property·paragraph_properties·styles·tab_properties·border_fills·
  bullets·track_changes·track_change_authors의 파싱 결과를 헤더에 보관한다.
  mark_dirty()에서 버리고, 쓸 때마다 요소의 직렬화 바이트를 견주므로 dirty 표시
  없이 XML을 직접 고친 경우에도 낡은 결과를 주지 않는다. 사전은 호출마다 새로
  만들고, 그 안의 모델 객체는 char_property처럼 보관본을 그대로 준다.
- paragraph_property의 id -> 요소 색인도 목록의 자식과 id가 같을 동안 보관한다.

달라지는 점: 헤더 목록 안에 XML 주석·처리 명령이 있으면 예전에는 조회가
TypeError(cannot serialize Comment)로 실패했고 이제는 조회된다.

측정(m2_corpus/form_002.hwpx, 조회 1회, 5회 중앙값): paragraph_property 0.47 -> 0.075 ms,
styles.get 1.01 -> 0.10 ms, border_fill 2.5 -> 0.17 ms, paragraph_properties 전체
21.7 -> 1.36 ms. lint_layout 963 -> 578 ms.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
- section.add_paragraph: 서식을 물려받을 때 마지막 hp:p 요소 하나만 본다.
- set_paragraph_format(paragraphs=[...]): 대상이 8개 이하이면 본문 순번을 그 문단
  앞의 형제 문단을 세어 구한다(_body_indexes). 결과의 순번은 그대로다.
- bind_outline_level(add_heading): 문단 목록에서 순번을 찾았다가 순번으로 다시
  찾던 것을 문단 객체로 바로 넘긴다.

doc.paragraphs 자체는 여전히 호출마다 새 목록을 만든다(공개 동작 유지).

측정(새 문서, 중앙값): add_paragraph 2,000번 2.10초 -> 0.16초, 4,000번 8.3초 -> 0.35초.
append_markdown 2,000블록 0.45초 -> 0.26초.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
iter_paragraphs가 문단마다 describe_element_path를 불러 조상 단계마다 같은 이름
형제 목록을 새로 만들던 것을, 부모별 순번과 부모 경로를 보관하는 _ElementPaths로
바꾼다. 경로 문자열은 같다. ObjectFinder.iter도 같은 것을 쓴다.
describe_element_path(공개 함수)는 그대로 둔다.

측정(extract_text, 5회 중앙값): 합성 2,000문단 948 -> 24 ms, 4,000문단 3,788 -> 59 ms,
m2_corpus/gov_donation_report_form.hwpx 278 -> 84 ms.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
save_report는 직렬화에서 검증한 것과 같은 바이트로 validate_editor_open_safety를
한 번 더 돌렸다. 패키지가 통과한 보고서를 그 바이트의 SHA-256과 함께 보관하고,
save_report는 다이제스트가 같을 때 그 보고서를 SavePipeline에 넘긴다(파이프라인이
스스로 만들던 것과 같은 ok/summary/detail). 다이제스트가 다르거나 보관본이 없으면
지금처럼 파이프라인이 직접 검증한다. 검증 항목은 그대로다.

범위를 좁힌 부분: to_bytes 안에서 validate_package·validate_document·재열기가
같은 바이트를 각자 풀어 파싱하는 것은 건드리지 않았다(서로 다른 파서와 트리를
쓴다).

측정(문단 1개 추가 뒤 save_report, 5회 중앙값): 작은 72 -> 43 ms, 중간 847 -> 575 ms,
큰 1,886 -> 1,318 ms. to_bytes·save_to_path는 변화 없음.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
가지 제한 없는 push와 pull_request가 함께 걸려 PR 커밋 하나가 두 번 검사됐다
(최근 8.7일에 SHA 55개). push를 main으로 좁히고 concurrency를 건다: 같은 PR의
새 커밋이 앞선 실행을 취소하고, main의 실행은 커밋마다 따로 묶여 취소되지 않는다.

달라지는 점: PR이 없는 가지와 태그의 push에서는 Tests가 돌지 않는다
(릴리스는 release.yml이 따로 검사한다).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
--check 그린, 드리프트 감지, 경로 구분자 테스트가 각자 build_ledger를 돌려
(둘째는 main()에서 한 번 더) 네 번 만들던 것을 모듈 범위 fixture 하나로 줄인다.
main()은 그 원장의 사본을 받아 같은 비교 코드를 돈다. 그린 검사는 프로세스
안에서 돌고, 스크립트가 프로세스로 뜨는지는 --help로 따로 본다.

측정(이 PC, 파일 전체): 592초 -> 128초.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
행마다 save_to_path가 검증한 것과 같은 바이트를 validate_editor_open_safety로 한 번
더 검증했다. 쓴 파일의 SHA-256이 저장이 검증한 다이제스트와 같으면 그 보고서를
쓰고, 다르면 지금처럼 파일을 검증한다. 보고서 내용은 같다.

남긴 부분: 행마다 템플릿을 다시 여는 것과, 남은 자리표시자를 보려고 결과물을
다시 여는 것은 그대로다.

측정(10행, 5회 중앙값): 본문 200문단 1,277 -> 765 ms, 800문단 2,410 -> 1,624 ms.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
EFF-01이 table.py를 모듈 크기 검토선(코드 1,300줄) 위로 올리고 격자 생성 함수의
이름을 바꿔 test_oxml_modularization의 두 게이트가 깨졌다.
- 보관한 격자가 표 XML과 같은지 보는 TableGridCache를 table_grid.py로 옮긴다.
- 격자를 만드는 함수는 _build_cell_grid 이름을 그대로 두고(복잡도 래칫),
  보관본을 주는 쪽을 _cell_grid로 둔다. 동작은 같다.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
새 모듈 table_grid.py는 소유 등록·크기 래칫 게이트(test_product_boundary,
test_size_ratchet)에 걸렸다. table.py의 줄 예산 때문에 격자 관련 코드를 두는
기존 모듈 table_merge.py로 옮기고 table_grid.py를 지운다. 동작은 같다.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

@airmang airmang left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the table/style/save/extraction optimizations at 25fc358. All eight exact-head checks pass; the Python 3.12 log records 7,704 passed, 18 skipped, 1 xfailed. Local focused checks pass (27 tests), and the integration with #676 passed 44 tests. An independent baseline/head comparison reproduces the loss of the overlapping-grid rejection described inline, so merge is held. The signed-in account owns this PR, so this COMMENT records the required correction rather than an approval.

Comment thread src/hwpx/oxml/table.py
entry = self._build_cell_grid().get((row_index, col_index))
entry = None
cache = getattr(self, "_grid_cache", None)
if cache is not None and cache.fast and cache.same_shape(self.element):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate changes to other cells before returning a cached grid entry

The fast path checks only the returned cell. With a warmed 1×2 table, change the second cell's raw hp:cellAddr/@colAddr from 1 to 0, then call table.cell(0, 0). Main 168fe98 raises ValueError: table grid contains overlapping cell spans; this head returns the first cell successfully, while this head's own _build_cell_grid() still raises the same ValueError. same_shape() sees unchanged row/child counts and cell_unchanged() sees an unchanged first cell, so the cache bypasses the existing invalid-grid guard. Raw XML changes are explicitly supported by the new invalidation contract and tests. Please invalidate/revalidate for structural changes to any contributing cell, and add the regression where the changed cell is different from the queried cell, preserving the previous fail-closed lookup behavior.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant