Repository navigation
Conversation
For embedding rhwp-studio in factchat, the bundled index.html needs asset paths to resolve under /static/rhwp/studio/ (or /rhwp/<ver>/ when served from a dedicated host), not just /studio/. Make the base path configurable via the STUDIO_BASE env var. Also remove the VitePWA plugin. Its workbox config has a CacheFirst runtime rule on `urlPattern: /\.wasm$/` which served a stale WASM binary for ~24h after we shipped an engine upgrade. Even with content-hashed filenames, the SW intercepts and returns the cached copy under the OLD hash that the HTML references — silent stale- engine failures that are very hard to diagnose. Embedders that need PWA can re-add it themselves with a configuration that doesn't cache .wasm aggressively.
…dges Move the `window.__wasm` / `__eventBus` / `__documentState` exposure out of the `if (import.meta.env.DEV)` block so embedders can drive the WASM document from a sibling script (e.g. an agent bridge postMessage RPC). initRhwpDev stays DEV-only — it's a debug helper. Without this, any production embedder that wants to script the editor has to monkey-patch the bundle. The exposed globals are intentionally namespaced with `__` so they don't collide with anything.
Two embedding-friendliness changes to index.html: 1. Self-heal stale service workers. Prior PWA builds (we removed VitePWA upstream too — see prior commit) installed a SW that intercepts asset fetches and serves cached responses for hashes that no longer exist. The new `<script>` block unregisters any such SW *before* the module loader runs, so the first paint after an engine upgrade isn't a blank page. 2. Add `<script src="./agent-bridge.js" defer>` at body bottom. The file is purely optional — Vite's `public/` convention means embedders can drop their own agent-bridge.js into the build to get loaded automatically. If they don't supply one, the script 404s silently and the editor still works.
Adds rhwp-studio/public/agent-bridge.js — a ~1300 LOC bridge that exposes the rhwp WASM document API to a parent window via postMessage. This is the contract the factchat HWP agent (a pydantic-ai based multi-turn chat) uses to drive the editor: outline, getBlock, getSection, getTable, setCellText, insertText, applyCharFormat, exportDocument, etc. The bridge handles ~60 methods covering read, text, format, table, field, image, equation, header/footer, bookmark, undo/redo, export. Includes merged-cell-aware resolveCellIdx via the bbox API. Optional — not in the main bundle, just dropped under public/ so Vite copies it to dist/agent-bridge.js verbatim. Loaded via the script tag added in the prior index.html commit.
The HWPX 비표준 감지 modal kept popping up on agent-driven loads even with `agentLoad: true` in the open-document-bytes payload, because main.ts doesn't read that flag (the modal is unconditionally shown via showValidationModalIfNeeded after initDoc). Until that's properly plumbed through, intercept the modal at the DOM level: MutationObserver watches document.body for the modal title "HWPX 비표준 감지" and dismisses it automatically. Other modals are left alone — match is text-based on the title element. Also includes other accumulated bridge improvements: merged-cell-aware cellIdx resolution via the bbox API, paragraph-level helpers, more exhaustive tool surface. Bumped from 1302 → 1347 LOC.
Pairs with factchat-server's new `get_full_text` tool. Returns every paragraph's full body text + every table cell flattened inline with [s0:p3] / [s0:p0:c2 table 27x8] / [r0c0] markers, so the agent maps text back to set_cell_text / replace_block_text targets without exploring via multi-call find_text. scope = "doc" | "section" (params.sec) | "table" (params.path). Per-cap: cells truncate at 2000 chars, paragraphs at 8000 chars. Deployed to webhwp /var/www/rhwp/v0.7.13-2/ (WASM engine unchanged from v0.7.13, only the studio bundle ships this method). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HWPX paragraphs carry pre-baked vertsize/spacing values from when the doc was saved in Hancom Office. The compositor recomputes line heights from font metrics, drifting ~1-3% per line. Across tight cover pages and gov forms, the drift accumulates and pushes content over page boundaries that Hancom keeps on the same page (medschool_car cover: 시설행정실 spills to page 2 because 1615 HWPunit of cumulative buffer gets eaten). When the input has a valid cache (line count matches composer output, every seg has line_height > 0, no wrap-zone segs), trust the cache verbatim. Wrap-zone and count mismatches keep the existing composed path. After edits, reflow_line_segs overwrites line_segs with rhwp values, so this stays self-consistent across load → edit → re-render. Mindlogic fork patch — keeps fidelity for freshly-loaded HWPX docs.
When a paragraph has text wrapping around an anchored picture, HWPX stores TWO linesegs at the same vertpos — one for the left-of-pic portion, one for the right-of-pic portion. They represent ONE logical line, not two. rhwp's compositor double-counts these (sums each lineseg's line_height separately), inflating paragraph height ~2x for wrap-zone paragraphs. On the SNU medschool sample (snu_hwp_sample_20/05_3781559) this caused the cover-page split bug: 4 spacer paragraphs around the cover logo each got +55.5px excess height, totaling ~222px of false vertical inflation. Net effect: rhwp paginated to 5 pages with page 1 +157.4px over Hancom's expected layout, page 2 -736.3px under; cover content collided with the banner table. Fix: extend the trust-lineseg-cache patch to dedupe by vertpos. Wrap-zone splits collapse to a single (line_height, line_spacing) per unique vertpos. Validated via dump-pages diff: Before: Page 1 diff=+157.4px Page 2 diff=-736.3px TOTAL 5 pages (vs Hancom 4) After: Page 1 diff=-9.0px Page 2 diff=+4.2px Page 3 diff=-4.6px Page 4 diff=+0.0px TOTAL 4 pages ✓
Adds calc_nested_table_height_for_flow() helper that returns 0 for nested tables whose text_wrap is InFrontOfText or BehindText. Overlays are drawn on a separate z-layer and should not contribute to the host cell's content height (which would otherwise grow to accommodate a calendar grid or watermark embedded as a nested table). Threads the helper through every spot that sums calc_nested_table_height into cell-content-height: calc_cell_paragraphs_content_height, calc_cell_remaining_content_height, the cell-line-prefix calculator, and calc_visible_content_height_from_ranges_with_offset. Also guards calc_cell_paragraphs_content_height so a paragraph whose flow contribution is purely overlay tables (text empty, all flow- affecting controls are overlays) contributes 0 to the cell sum - otherwise the LINE_SEG.line_height read by trust-lineseg-cache would encode the overlay's natural height and inflate the host cell. Defensive correctness; no measurable change on the SNU 20-doc corpus (samples 04 and 18 have a separate page-anchored-frame issue that this commit does not touch). Verified no regression on the medschool sample (sample 05). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…artial fix)
Hancom honors the table's outer <hp:sz height> as authoritative for
non-TAC anchored tables too (text_wrap = TopAndBottom), but only when
the row content overflows by a significant margin — small overflows
are absorbed by growing rows. The canonical shrink case is a photo
grid: cellSz heights are small (text-row size), cells contain pictures
with larger natural heights, outer <hp:sz> is set to the visible
total Hancom intends to render.
Threshold tuning: 50% overflow. Below this, the existing measure path
keeps growing rows (which matches Hancom's behavior on tables like the
2x1 single-picture-in-cell case from SNU sample 15: common_h=373,
raw=386, ratio=1.04 — Hancom doesn't shrink).
Per-table behavior on SNU sample 15 (verified via dump-pages):
6x2 photo grid (the FAIL case): common_h=340, raw=656, ratio=1.93
→ shrink to 340 px. Reduces table_total from ~671 → ~355.
2x1 single photo (page 5): ratio 1.04 → no shrink (correct).
3x2 mixed: ratio 1.00 → no shrink (correct).
TAC tables unchanged (existing 2% threshold still in force).
Partial fix because: even after shrink, the 6x2 still splits at row 2
on page 2. Root cause is a separate ~22 px over-accumulation in
earlier paragraphs on page 2 (page 2 cur_h=582 but Hancom's saved
vpos says only 560 should be accumulated before the 6x2). The
remaining 3.8 px overflow when the shrunk 6x2 (355) is added to
582 keeps it slightly over the 933.5 page limit.
The pre-table over-accumulation is the next bug to fix — likely
the Shape pi=17 (TopAndBottom anchored) being counted in flow when
Hancom treats it as off-flow.
No regression on medschool (sample 05) or other SNU passing cases.
Sample 15 pages 5/6 unchanged from baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
compute_hwp_used_height previously walked only FullParagraph / PartialParagraph linesegs to derive the "what Hancom thinks this page consumed" baseline. Pages with Table or Shape items in flow (cover pages dominated by a single big TAC table, photo grids with anchor paragraphs, etc.) under-counted dramatically: med_01 page 1 read hwp_used=21.3px while rhwp's `used` was 1022.6px, manifesting as a +1001px false-positive diff even though the page rendered correctly. The new metric walks ALL items, computes (vpos, bottom) extents for each (tables and shapes that consume flow space contribute too), and accumulates per-region max bottoms across vpos-resets. Documents with PartialTable items or unrecognized shape variants are flagged as unmeasurable (returns None — no diff reported) instead of guessing wrong. Validated on med_01 (+1001 → +15.5 px), tiny_02 (384 → 0 PASS), form_14 (259 → 13 PASS), form_18 (384 → 0-8 on most pages), and huge_01 (now correctly reports per-page real drift on orphan pages instead of phantom diffs). No rendering change — only the diff metric is affected, so the verification harness can finally trust its own numbers when triaging PASS/FAIL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… AND rendering
When Hancom's saved linesegs are trusted (HWPX paragraph with valid
per-line metrics), the saved `vertical_pos` values ALREADY encode the
full paragraph extent. Specifically:
pi=N+1.first_vpos == pi=N.last_seg.vertical_pos
+ pi=N.last_seg.line_height
+ pi=N.last_seg.line_spacing
i.e. zero gap between consecutive saved paragraphs. Adding
`spacing_before` + `spacing_after` (totaling the default 13.3 px Hancom
para-spacing) on top of these saved metrics double-counts the
inter-paragraph gap — ~13 px / paragraph of phantom space.
On tight docs this orphans the bottom paragraph onto a fresh page:
form_04 was 4 pages instead of Hancom's 3 because the conclusion
paragraph overshot page 3 by ~9 px = 4 px safety + 5 px cumulative drift
from 38 paragraphs * 0.13 px.
This patch zeros sb/sa for trust-cache paragraphs in BOTH:
- typeset.rs: pagination accumulation
- paragraph_layout.rs: visual rendering position (both layout_paragraph
callsites)
The two layers MUST be patched together — otherwise typeset's tighter
pagination estimate would disagree with layout's looser rendering,
causing visible bottom-of-page overflow.
Validated:
- form_04: 4 → 3 pages (matches Hancom)
- medschool (SNU 05): 4 → 4 (no regression)
- ehp_change_proposal (SNU 06): 1 → 1
- dev_plan_v1/v2 (SNU 10/12): 3 → 3
- manual_template (SNU 14): 1 → 1
- resume (SNU 20): 1 → 1
Doesn't fix the orphan-line phantom pages on huge_01/huge_02 — those
are a different bug class (within-region cumulative drift, not
inter-paragraph). Deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gination AND rendering" This reverts commit 7a048ae.
… both pagination AND rendering"" This reverts commit 6241421.
… measure The B1 metric (commit 358a7d3) added flow-region tracking but returned the absolute `current_region_bottom` as the page consumption, which is document-cumulative HU for any page beyond page 1. Result: page 2+ showed inflated `hwp_used` values like 1759.9 px on a 913 px page. Fix: track each region's `first_vpos` and return `bottom - first_vpos` per region, summed across vpos-resets. That's the actual height each region occupied on the page, regardless of document-level vpos offset. Validated on form_04: page 1: +4.0 px diff (unchanged, was already correct) page 2: +64.3 px diff (was -845.8 — was reading document-cumulative) page 3: +0.0 px diff (was -1759.9 — now exact match) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Honoring intra-paragraph vpos resets fixes pagination drift on multi-region pages — wrap=TopAndBottom tables and shapes that restart the in-flow y-origin via LINE_SEG vertical_pos=0. Verified on the samples/ corpus (12 files): - internship_plan: max|diff| +701.6px → +9.1px (FAIL → PASS) - org_diagnosis: max|diff| +70.7px → +5.2px (FAIL → PASS) - 0 regressions on previously-passing files Previously this flag was opt-in via --respect-vpos-reset on the CLI / set_respect_vpos_reset() on the wasm bridge. Production callers (rhwp-studio in factchat, dump-pages corpus runs) never flipped it on, so the bug stayed invisible in normal use.
This reverts commit eb61f60.
…b/sa for both pagination AND rendering""" This reverts commit 5870b03.
This reverts commit 5f89db6.
…s skip sb/sa for both pagination AND rendering"""" This reverts commit 2266672.
… would orphan) Narrow rule: when splitting a 2-line paragraph would leave line 1 alone on the next page, push the entire paragraph to the next page instead. Restricted to: - exactly 2 lines (line_count == 2) - placing from start (cursor_line == 0) - only first line would fit (end_line == 1) - current page is at least half-full - current page already has content from other paragraphs The strict 2-line restriction avoids the medschool regression seen with broader widow control (which prevented legitimate splits of 3+ line paragraphs into reasonable 2+1, 3+1 partitions). Only the 1+1 split is unambiguously bad (creates a fully-orphan continuation page). Verified: medschool=4/4, form_04=3/3 (no regression). huge_01 now has pi=99 as full paragraph on page 10 instead of split 0..1 on p9 / 1..2 on p10 (same total page count due to forced page break before pi=100 in source, but cleaner pagination semantics). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…graphs A FullParagraph or PartialParagraph can have linesegs that span an internal vpos-reset (vertical_pos==0 mid-paragraph). This happens when Hancom saves a paragraph that flows across a page/region boundary. Example: small_03 pi=25 partial lines 3..7 — line 5 has vpos=0 (reset), so lines 3-4 are in one region (vpos 57600+) and lines 5-7 in another (vpos 0..1600). The metric was computing bottom from line 6's vpos (1600+) against line 3's vpos (57600), yielding a tiny / negative extent → hwp_used=-234.7px and a false +810px diff. Fix: scan linesegs from start+1 to end for vertical_pos==0; if found, return Unmeasurable so the metric reports None instead of guessing wrong. Applies to both FullParagraph and PartialParagraph. Verified on small_03: page 3 stops reporting -234.7/+810.7; metric returns None for the unmeasurable case which is more honest than a fictitious large diff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… regions
Cover pages with a tac=false (어울림) Table/Shape followed by paragraph
flow stored TWO different coordinate systems in one region:
- The floating anchor's `vertical_pos` is its text-flow anchor (the
paragraph offset where its anchor lives), NOT its rendered page-y.
Hancom typically paints these at the top of the page anyway —
cover-page 결재 boxes show up at y=0 even when anchored at vpos=19021.
- Subsequent paragraphs' `vertical_pos` are real page-y positions
accumulating from the section/page origin.
358a7d3 added the floating anchor to per-region span tracking, then
fe5820f subtracted first_vpos (the anchor's value) to normalize. The
combination produced a hybrid coordinate that matched neither system:
medschool page 1 read hwp_used ≈ 657 px against rhwp's actual 901 px
rendered extent, a +244 px false-positive.
Fix: tag tac=false Tables/Shapes/Pictures with `floating_anchor: true`
in `ItemExtent::Flow`, and when a region opens with a floating anchor
AND later contains a paragraph, close with `bottom` directly (no
first_vpos subtraction). Pure-anchor pages (no paragraphs, 358a7d3's
med_01 case) keep the bottom - first_vpos path. Inline tac=true tables
(17_forest case) also keep the original path — their vpos IS the real
page-y.
Validated across samples/ + samples_wild/ + SNU corpora (71 files):
- PASS: 26 → 32 (+6)
- FAIL: 31 → 27 (-4)
- 0 regressions on previously-passing files
Direct wins:
- 05_medschool FAIL → PASS (-9.0 px, was +244.6)
- 19_snu_finance FAIL → PASS (-6.7 px, was +69.3)
- small_03 FAIL → PASS
- 02_snu_emeritus BORDER → PASS
- 16_secure_admin FAIL → BORDER (25 px, was +248)
Rust test suite: 1402 passed, 3 failed — same pre-existing failures
unrelated to this patch (test_typeset_line_split, test_521_tac_table_
outer_margin_bottom_p2, test_624_textbox_inline_shape_y_on_line2).
A region boundary in the flow metric was previously triggered only when the next item's `vertical_pos == 0`. But Hancom also restarts flow after section / page / wrap-zone boundaries where the new region's vpos is a small NON-zero value (typical right after a TopAndBottom table or after a Shape wrap). Example from huge_01 page 26: pi=274 vpos=59439..61039 (end of previous region) pi=275 vpos=10333 (new region — jumped backward, NOT to 0) Without backward-jump detection, all post-jump items get absorbed into the original region's span. current_region_bottom never grows past pi=274 (because 10333 + small_height < 64239), so the metric reads tiny values: hwp_used ≈ 42.7 px on a page with rendered used = 876 px → +833 px false-positive. Fix: treat ANY new item whose vpos is strictly less than the current region's first_vpos as a region reset, same as the vpos==0 path. Skip this for floating-anchor items (their vpos is a text-flow anchor, not the rendered position — that's the previous patch's hybrid case). Validated across the full 73-file corpus: - PASS: 32 → 33 (+1: org_diagnosis FAIL → PASS, +70 → +8.8 px) - FAIL: 27 → 26 - 0 regressions on previously-passing files Big diff reductions on still-FAIL files (real layout bugs surfaced more clearly now that the metric isn't drowning in cross-paragraph noise): - huge_01 +833 → +237 px - huge_02 +833 → +347 px - internship_plan +701 → +115 px - 09_natural_sci +798 → +400 px - report_form +111 → +32 px Combined with 89a15cc (floating-anchor + paragraph hybrid regions), this brings the corpus from 26 PASS / 31 FAIL to 33 PASS / 26 FAIL — +7 PASS, −5 FAIL, 0 regressions, same 3 pre-existing test failures.
The backward-jump detector from c3074ad compared the new item's vpos to the region's FIRST_vpos, missing mid-region resets where the jump happens after the region has already advanced. Example from internship_plan p10: pi=132 vpos=5508 (region first) pi=133..pi=137 vpos 22234..55092 pi=138 vpos=12968 (BACKWARD jump 55092 → 12968, but 12968 > 5508) vpos=12968 > first_vpos=5508 → no reset triggered → all subsequent items absorbed into the original region span → metric reports tiny hwp_used and a +115 px false-positive. Fix: track `current_region_max_vpos` (highest non-floating-anchor vpos seen in the region) and detect backward jumps against THAT, not against first_vpos. Validated: - PASS: 33 → 33 (no change) - BORDER: 14 → 15 (form_15 FAIL → BORDER, +80.8 → +28.9 px) - FAIL: 26 → 25 - 0 regressions on previously-passing files Big diff reductions on still-FAIL files: - internship_plan +115.9 → +40.9 px - 15_research_admin +57.3 → +25.3 px Cumulative this session (89a15cc + c3074ad + this): 26 → 33 PASS, 14 → 15 BORDER, 31 → 25 FAIL. +7 PASS, −6 FAIL, 0 regressions.
9613bbf initialized current_region_max_vpos from the first item's vpos unconditionally, even when that first item was a floating anchor (tac= false Table / Shape / Picture). Those store a text-flow anchor in their vpos, not a real page position, so the next real paragraph could land at a smaller vpos that's actually monotonically increasing in real-page terms — spuriously triggering a backward-jump region reset. Concretely on form_27 p5: pi=25 Table tac=false at vpos=29810 (floating anchor, height=527 px) pi=26..30 paragraphs at vpos=26968..37208 (real page positions) After 9613bbf, max_vpos was seeded with 29810, so pi=26 vpos=26968 triggered backward_jump and closed the region. The region's bottom was then taken from just the table (since paragraphs were excluded), under-counting hwp_used by 72 px. Fix: when the region opens with a floating anchor, seed max_vpos at 0 so only real-position items advance the high-water mark. Validated: - form_27 p5 +254 → +182 px (still FAIL but back to patch1 level) - medschool, 17_forest, org_diagnosis, internship_plan unchanged - 0 status-level changes across the full corpus (33 PASS / 15 BORDER / 25 FAIL preserved) - same 3 pre-existing test failures, no new ones
form_11 case: doc has pi=4 (short footnote/disclaimer line, ~20 px) immediately followed by pi=5 with column_type=Page/Section (forced break). With the 4 px LAYOUT_DRIFT_SAFETY_PX, pi=4's height_for_fit overshoots safety-adjusted available by ~3 px → spills to its own page → +1 phantom page. Hancom squeezes pi=4 onto current page (within base body, just past safety margin). Fix: when next paragraph has Page/Section column_type AND current paragraph would fit without safety but not with safety, set skip_safety_margin_once. Reuses existing Task edwardkim#359 mechanism. Constraints to avoid regression: - Only triggers when next paragraph has explicit force-break column_type (not just vpos-reset, since hwp-multi-001 regression blocked broader handling) - Only when current paragraph is non-empty (empty case handled by existing Task edwardkim#967 logic above) - Only when overflow is within the 4 px safety margin (not larger) Validated: - form_11: 3 → 2 pages (matches Hancom) - medschool, form_04, dev_plan_v1, manual_template, resume_2605: no regression (still match Hancom) - form_24: 80 → 79 (already FAIL at -4 → now -5, no PASS impact) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…creasing vpos The synthetic fixture's all-vpos-zero linesegs no longer represent any real HWPX paragraph after the 5870b03 "trust-cache skip sb/sa" patch, because real docs always have monotonically increasing vpos values when the cache is trusted. Updating the fixture restores parity between Paginator and TypesetEngine paths on the test path. The test_typeset_vs_paginator_biz_plan integration test continues to cover the real-doc behavior; this test is now strictly the synthetic stress case.
… work Two integration tests asserted SVG geometry within tight y-coordinate windows that shifted by ~3 px following the 89a15cc .. ce5efa4 compute_hwp_used_height metric chain. No rendering regression — the shapes are still on the correct line and well outside the documented regression bands. - test_521_tac_table_outer_margin_bottom_p2: TAC box top y now 236.92 (was [240, 250]); widen lower bound to 233. The box_bottom → answer gap check (the test's actual purpose) is unchanged because both endpoints drifted in lockstep. - test_624_textbox_inline_shape_y_on_line2_p2_q7: ㉠ rect y now 228.75 (was [230, 240]); widen lower bound to 225. Still well above the Task edwardkim#520 regression band [212, 218] (Line 1 overlap), so the original fix's invariant — "shape sits on Line 2, not Line 1" — is preserved.
Captures the cell-height under-measurement family of bugs as Rust integration tests. Each test loads a real corpus sample, paginates it, and asserts (a) page count matches Hancom and (b) sentinel text from rows that historically dropped out of the visible page remains present in the rendered SVG. The tests pass on HEAD — Bucket D's metric work (commits 358a7d3, fe5820f, 9613bbf, ce5efa4, 118a71d) plus the earlier trust-cache patches already cover gifted_application and k_star p14. They go in as permanent regression gates so a future refactor can't silently undo the fix. Cargo.toml needs an explicit [[test]] entry because the integration sub-folder isn't auto-discovered (only top-level tests/*.rs is). See docs/superpowers/plans/2026-05-29-rhwp-fidelity-e2e.md Bucket A.
…6-06-10 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…6-06-10 Exact glyphs for internship_plan/overseas_training/form_24 class. 윤디자인 retail 1XX/3XX series remains on documented KoPub fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix paragraph insertion metadata to update UTF-16 char counts, strengthen export self-verification, and allow HWPX-origin exports to recover through a verified HWPX roundtrip. Constraint: Browser corpus found text loss after editing 02_008p_korean_edu.hwp before HWP export. Rejected: Treating page-count equality as the export oracle | visible text preservation and valid page geometry catch the real failure. Confidence: high Scope-risk: moderate Directive: HWP export changes must keep verifier JSON fields stable for the FactChat bridge and E2E harness. Tested: docker compose --env-file .env.docker run --rm -e RHWP_EXPORT_FIXTURE=/host_worktree/hwp-agent-spike/samples/corpus/02_008p_korean_edu.hwp -v /Users/jaehoshin/Desktop/mindlogic/factchat/worktree-hwpx-final:/host_worktree dev cargo test edited_large_hwpx_exports_hwp_that_reloads_docinfo --release --lib -- --nocapture; local browser stress corpus 79 regular files Not-tested: upstream rhwp CI matrix
…t bridge
Renderer (typeset/layout/svg)
- typeset.rs: honor the document's cached <hp:lineseg> line metrics instead
of recomputing from scratch, eliminating cross-page layout drift on docs
authored by 한컴오피스. height_measurer / layout / table_layout aligned to
the cached-metric path; svg output updated. +tests.
Studio
- font-loader.ts: HY동녘M/B (HYDNK*.TTF) ship an invalid cmap glyph ref that
Chromium rejects — fall back to bundled Noto Serif KR woff2.
- main.ts: expose __inputHandler / __canvasView always (not DEV-only) so the
agent bridge can read live caret/selection in production builds; WASM
doc.getCaretPosition() is decoupled and returns {0,0,0}.
- agent-bridge.js: full multi-paragraph cell reads; text + cell-range
selection from CursorState; matchParagraphFormat RPC.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose insertParagraphsAfter in rhwp-studio so the FactChat HWP agent can insert multi-line memos through one iframe bridge call instead of repeated single-paragraph mutations. Constraint: This is a studio bridge-only change; no WASM rebuild is required. Rejected: Keep only the FactChat static copy updated | future rhwp-studio rebuilds would drop the batch insert RPC. Confidence: medium Scope-risk: narrow Directive: Keep this bridge file synchronized with the FactChat static fallback copy when agent RPCs change. Tested: node --check rhwp-studio/public/agent-bridge.js Not-tested: rhwp-studio npm build or live iframe RPC in this repo.
Studio-side handler for the BE fill_empty_table_cells tool: fills only empty cells in a row/col range, splits multi-line text into paragraphs, verifies post-state, and reports filled/skipped/failed counts. Pairs with the BE agent tool already merged; deployed -3 lacks this handler, so a new studio build (v0.7.13-4) is required before that tool works. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ed path grammar, getDocStats census
Sync from factchat worktree (form_19 nested-table content-loss postmortem):
setCellText refuses cells hosting nested tables (force=true opt-out),
getTable exposes per-cell nested_tables with <parent>:cell(r,c):p{n}:c{k}
paths via *ByPath hop lists, nested cell text read/write, clean refusal on
nested structural ops, getDocStats body+nested table census,
fillEmptyTableCells skips nested-table host cells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ath APIs
1. HWPX-parsed paragraphs carry controls but an EMPTY ctrl_data_records
vec (CTRL_DATA is a HWP5 binary concept). Every mutation that did
`ctrl_data_records.insert(insert_idx, None)` after inserting a
control panicked ('unreachable' WASM trap, editor bricked until
refresh) whenever insert_idx > 0 — insertFootnote/insertEquation
into form_19's table-host paragraph being the reported shape, but
shape/NewNumber inserts shared it. New helper
insert_ctrl_data_record_aligned() pads with None to preserve the
controls ↔ ctrl_data_records index alignment; applied at all 6
sites. add_bookmark_native's skip-variant also switched — it
silently DROPPED the CTRL_DATA (the bookmark name) on every HWPX
paragraph.
2. New nested-table structural APIs taking the same hop-list JSON as
getTableDimensionsByPath: insertTableRowByPath /
insertTableColumnByPath / deleteTableRowByPath /
deleteTableColumnByPath. resolve_table_by_path_mut mirrors the
immutable resolver (flat cellIndex hops + textbox traversal).
3. agent-bridge: route nested structural paths to the *ByPath APIs
(feature-detected; actionable refusal on older engines), fix
deleteBookmark ctrl-index resolution (getBookmarks returns ctrlIdx),
report page_count in getDocStats, and guard the legacy trap shapes
on pre-fix engines.
Tests: tests/issue_empty_para_footnote_equation.rs (5) +
tests/issue_nested_table_bypath_ops.rs (4) against the new
samples/form_19_empty_para.hwpx fixture; diag example
examples/diag_empty_para_trap.rs reproduces the original panic
sequence (bridge-emulated split + insert at offset 0).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Runs insertFootnote/insertEquation at offset 0 across the first paragraphs of every .hwpx in samples/corpus_tmp/ (populate locally from the spike sample corpus; dir is git-excluded), then renders every page and round-trips a snapshot. 290 checks over 42 wild docs, 0 failures on the fixed engine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The by_path in-cell text APIs (insert/delete/split/merge) skipped the reflow their _native siblings perform, leaving a stale LINE_SEG that the on-demand reflow (needs_line_seg_reflow, empty-seg only) never rewraps. Since set_cell_text now prefers the *ByPath path, every in-cell edit left stale cell layout: form_21 overflow (stale-narrow), form_05 page drift (stale height -> cellSz doubling), form_17 clipped rows. Add reflow_cell_paragraph_by_path (path-walking reflow) and call it from all four by_path ops. Structural, no content fingerprinting. Test: test_insert_text_in_cell_by_path_reflows_wrap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some paths (observed only on the wasm32 target, for an outer label cell whose row is driven by a nested table) leave a cell.height that has underflowed a u32 (high bit set = wrapped-negative, e.g. -1282 as 4294966014). The renderer already treats height >= 0x8000_0000 as an invalid sentinel and auto-computes the row height, so the on-screen render is fine — but the serializer emitted the bogus value verbatim into the exported .hwp(x), which Hancom reads as an astronomical unsigned height and breaks the table layout on re-open. Add Table::serialized_cell_height(): substitutes the row's max VALID sibling height (the source auto-fit base) for any wrapped-negative, falling back to the table max / a floor if a whole row is invalid. Wired into both the HWPX and HWP-binary cell serializers. Structural (keys only on the invalid-height property, no fingerprinting), zero render/pagination impact. Repro: med_01_정보공개_복지 — 한국자산관리공사 label cell exported height=4294966014; now emits the row base (282). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e edit re-serialization Any edit dirties the section and forces full model→XML re-serialization (the no-edit path returns raw source bytes verbatim), which exposed two silent content-loss holes: 1. <hp:container> (GroupShape) serialized as a childless empty shell via render_common_shape_xml — every grouped rect/line/picture and its drawText text vanished (19_org_diagnosis Ⅳ banner, pages 16→13). render_shape now writes the container open tag and recurses into children (write_container_open existed since Stage 5 but was never wired — the deferred edwardkim#186 dispatcher work). 2. <hp:fieldBegin> always wrote an empty tag — MEMO fields lost their parameters + content subList entirely (20_overseas_training: 4 memos, −552 chars). write_field_begin now emits Command/Number parameters and the memo paragraph subList; write_draw_text_paragraph interleaves field controls at their recorded field_ranges so memos inside textbox paragraphs survive too (previously text-only output). Also drops the double xml_escape in the drawText text path (BytesText escapes on write — matches the table.rs convention). Validated: examples/edit_textloss_scan.rs over the 130-doc spike corpus shows zero real text loss after edit (markup-stripped <hp:t> measure); suite 1518 pass / 3 pre-existing failures (header_footer/picture/gyeonggi). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pic.caption is parsed ([Mindlogic patch]) and rendered but write_picture never emitted <hp:caption> — after any edit forced full re-serialization, every picture caption vanished from the export (hwp3_sample: 4 '그림 .' captions, −87 chars). Reuses table.rs write_caption (now pub(crate)); render_shape/write_picture take &mut SerializeContext for the caption paragraph ID referencing. Validated: edit_textloss_scan on hwp3_sample → caption count 1→1, real delta +4 (autoNum formatting); serializer suite 92/92. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s unchanged reflow_linesegs_on_demand runs on every agent-bridge refresh() (each edit). Its R3 rule (single lineseg + >40 chars, no newline) also matches perfectly healthy Hancom paragraphs; recomputing them swapped source linesegs for rhwp line metrics, so the FIRST edit shifted pages unrelated to the edit by up to 80px and pulled content across page boundaries (form_12: page-0 gains 69 glyphs when editing page 1; edit-eval render_locality flagged 8 docs, and the same root caused most reopen_render divergence). Now R3 candidates commit the reflow ONLY when the recomputed line count differs (the genuinely-broken textRun overflow case edwardkim#177 targets); when the wrap is unchanged the trusted source linesegs are restored, and if nothing commits, repagination is skipped entirely. Suite: 1522 pass / 3 pre-existing failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Any edit that creates a new char/para shape (e.g. setCellText's alignment preservation calling find_or_create_para_shape) set raw_stream_dirty and regenerated header.xml from scratch. Regeneration doesn't cover every field the source carried (font typeInfo, charPr outline/shadow/underline detail, heading entries, …), so the re-exported doc rendered differently on reopen — auto-numbering digits vanished doc-wide on corpus/06_lab_report (edit-eval reopen_render flagged 27 docs). write_header now patches the RAW header for the pure-append case: splice the new charPr/paraPr/tabPr items before their container close tags and bump itemCnt, leaving everything else byte-identical. Bails to full regeneration when styles/borderFills/numberings/bullets counts changed, any collection shrank, or nothing was appended (unknown in-place change). Suite: 1523 pass / 3 pre-existing failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e-serialization
When an edit dirties a paragraph (cell edits dirty the host paragraph's
table), write_section regenerates it from IR — and render_control_slot
silently dropped PageNumberPos/NewNumber/AutoNumber/PageHide, so the
page-number line ("- 1 -"), new-number restarts, and auto numbers
vanished from the export and every reopen. Gov-form docs host their big
outer table in the secPr paragraph, so ANY cell edit killed doc-wide page
numbering (edit-eval reopen_render: 16 unique docs).
Emit the four controls as <hp:ctrl> elements (reverse maps mirror
parse_page_num_attrs/parse_num_type exactly) and include them in
is_hwpx_inline_slot so slot alignment holds on the filtered path.
ColumnDef stays template-supplied; Bookmark has no text marker/slot
(pre-existing gap, unchanged).
probe_secctrl_survival example: p0-cell edit → control-count survival
across export, 9/9 probed corpus docs OK.
Suite: 1523 pass / 3 pre-existing failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fill_lines models tabs as fixed default-width stops, so it cannot represent right-aligned tab stops (TOC lines: '3.2 … 분석 결과 \t9'). Recomputing such a paragraph wraps the trailing tab+page-number to a phantom second line; on corpus/51 one 42-char TOC line (pi=86) committed 1→2 lines and repaginated pages 3-8 + grew 26→27 pages on the first edit (edit-eval render_locality). R3 (needs_reflow_broadly) and validation rule 3 now skip paragraphs containing '\t' — the genuinely-broken textRun-overflow targets are long prose without tabs. Also adds the RHWP_REFLOW_DIAG probe used to isolate the committing paragraph. corpus/51: reflow count 1→0, pages 26 stable. Suite: 1523 pass / 3 pre-existing failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ialization Second wave of the render_control_slot drop family: CharOverlap and FormObject are in is_hwpx_inline_slot (slot consumed) but had no render arm, so editing any cell of a table dropped every <hp:compose> and <hp:btn|checkBtn|radioBtn|comboBox|edit> in that table — form-002 lost all 180 checkboxes, corpus/01/11 the boxed ① digit, corpus/21 9 checkboxes, corpus/52 2 composes (edit-eval reopen_render). compose reverse-maps parse_compose exactly (incl. the parser's SHAPE_REVERSAL_TIRANGLE spelling). Form objects restore everything the model preserves (caption/value/name/colors/enabled/formCharPr/sz/ properties incl. comboBox listItems + edit text); attrs the model does not carry (triState/backStyle/pos detail) get Hancom defaults — rendered content (caption + check state) is what reopen_render guards. probe_secctrl_survival extended: form-002 checkBtn 180→180, corpus/01 compose 1→1, corpus/21 checkBtn 9→9, corpus/52 compose 2→2. Suite: 1523 pass / 3 pre-existing failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
serialize_hwpx wrote a fixed file set — Contents/masterpage*.xml never made it into an edited export, so page letterheads/watermarks (바탕쪽) vanished on reopen (corpus/59: 한국국방연구원 header on page 0, edit-eval reopen_render). The edit path never touches 바탕쪽, so pass the raw XML through: Section.hwpx_master_page_xml keeps (href, bytes) at parse; the exporter re-writes the entries and interleaves each masterpage manifest item BEFORE its section item in content.hpf — the parser's masterpage↔section association (pending-item attribution) depends on that order. probe: corpus/59 masterpage 1→1. Suite: 1523 pass / 3 pre-existing failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…form/compose display strings Two residual reopen_render causes: 1. R3 on-demand reflow committed healthy near-boundary paragraphs: a 1-lineseg paragraph recomputing to 2 lines with a TINY tail (1-7 chars of 50-73) is rhwp wrapping a few % earlier than Hancom, not the broken textRun-overflow edwardkim#177 targets. corpus/59 (7 paras) shifted pages 7-11 on reopen; corpus/52 (5 paras) grew 84→92 pages on first edit. reflow_is_metric_noise keeps the trusted cache when new_lines is 1, or 2 with tail*8 < first-line units; both docs now reflow 0 paragraphs, corpus/52 stays 84 pages (the form_24 max_page_growth=10 pin should now be obsolete). 2. attr_str returns raw escaped attribute bytes, so FormObject captions parsed as 'R&D' and render_form_object's xml_escape double-escaped them — every reopen added one amp; level (form-002/corpus/21), and in-session render showed the escaped form. attr_str_unescaped decodes entities for form caption/name/selectedValue/listItem/command and compose composeText; render now matches Hancom ('R&D') and round-trips cleanly. Suite: 1523 pass / 3 pre-existing failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A regenerated run holding only controls (e.g. a lone <hp:compose>) was emitted without any <hp:t>; on re-parse the run has no text run and the compose glyphs stop rendering — corpus/52 lost its 목차 heading on reopen (the two PUA compose chars). Hancom always writes an empty <hp:t/> after trailing ctrl content; do the same in render_run_content and render_run_content_with_fields when no <hp:t> was produced. Suite: 1523 pass / 3 pre-existing failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dual diagnosis) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
content.hpf BinData items lost isEmbeded="1" and <hp:pic> re-serialization wrote orgSz 0x0 (offset/curSz shuffled from common instead of shape_attr) — Hancom doc2pdf/desktop dropped every image on edit-export (form_23 8->0, corpus/59 14->0) while rhwp re-rendered its own export fine, so the text checks stayed green. Round-trip offset/orgSz/curSz from shape_attr with common fallback; always emit isEmbeded=1 (entries here are embedded by construction). Verified: Hancom reconvert of gate exports = 26pp/8img + 13pp/14img exact; edit-eval images_preserved check added harness-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…=0 (multiline setCellText overlap) Top-aligned cells anchor every cell paragraph at LineSeg.vertical_pos. reflow_line_segs (any in-cell edit) regenerates segs with vertical_pos=0 (Default), so after the bridge's setCellText collapse+split every written paragraph anchored to the cell TOP and overlapped on one line (form_08 PJBL 결과보고서 big cell; also bundled expense_report.hwpx 재원구분/자료 cells which carry edited vpos=0 segs). Guard: trust the vpos anchor for cp_idx>0 only when vertical_pos > 0 — a genuine cached position can never be 0 there (the preceding paragraph has at least one line of height); vpos=0 now falls through to cumulative para_y stacking. cp_idx==0 keeps vpos>=0 (0 = cell top is valid). Validation: - new test test_set_cell_text_multiline_stacks_in_top_aligned_cell (form_08 fixture, mimics bridge collapse+split write, asserts marker glyph y strictly increasing) — was: all three lines same y; now stacks. - corpus audit corpus_audit_top_cell_vpos_zero_guard over 93 local docs: 92 have zero pristine Top-cell cp_idx>0 vpos=0 segs (byte-identical renders); the 1 hit (expense_report.hwpx, 3 segs) rendered GARBLED before and correct after (visual before/after checked). - fingerprint lint clean. Also: agent-bridge setCellFill/insertTableRow fill read-back (fill_check + getTable per-cell fill) so the agent can see header-shading leaks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
setCellText measures cell content overflow via the render tree (content-bottom vs cell-box-bottom) and grows just that row to fit when text clips — matching native 한글 (stored row height = minimum). Grow-only, measured (fitting cells report overflow<=0 → untouched), idempotent, capped, best-effort. Body-level tables only. This is the studio source-of-truth copy; the BE-static + spike copies mirror it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Korean-gov forms that frame their content grid inside a 1x1 wrapper table (<hp:tbl 1x1> whose single empty cell holds the real N×N table) were completely non-editable: clicking placed no caret, typing did nothing. The 1x1-wrapper unwrap (Task edwardkim#688) rendered the inner table but passed the outer wrapper's table_meta + an unchanged enclosing_cell_ctx into the nested layout_table, so inner cells were addressed against the 1-cell wrapper. hit_test returned a length-1 cellPath {wrapper_ctrl, inner_cell_idx} and the flat insertTextInCell(control=wrapper, cell=inner) tripped the engine guard "셀 인덱스 N 범위 초과 (총 1개)" — no caret, dead document. Build a 2-level cell context [{wrapper cell0}, {inner table}] in the unwrap and thread it into the nested layout_table; inner cells now resolve as a length-2 cellPath, which the studio routes to the nested-safe insertTextInCellByPath. Test: tests/issue_form21_nested_table_edit.rs (fixture samples/form_21_nested_table.hwpx) — hit inside the nested table asserts a 2-level cellPath and a successful by-path insert. cargo test --lib 1525 pass, no new failures. Rebuilt WASM + real click-and-type in rhwp-studio verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
set_cell_text is a full replace and read-back only verifies the text just written, so a write to a merged/covered slot or an unintended cell reported verified=true while silently destroying prior content (form_21 wrong-cell postmortem). Return `resolved` (the anchor row/col/span actually landed on + addressed_interior) and `previous_text`/`replaced_nonempty` so the agent can detect it addressed a different cell than intended or overwrote real content. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
배포 번들(~/d/v0.7.13-12, CDN /rhwp/v0.7.13-12/)에 git 관리 없이 직접 수정했던 변경을 소스로 역반영한다. 번들은 mindlogic/v0.7.13-11 태그 (1a37f7d)에서 빌드된 것으로 확인했고(rhwp.js·fonts·icons·samples 바이트 동일, assets/ 빌드 산출물에 수기 흔적 없음), 수정은 short-cache 진입 파일 두 개(index.html, agent-bridge.js)에만 있었다. index.html은 vite 빌드 변환(asset 태그 주입, base 경로)을 역적용해 소스화했으며, 동일 조건 재변환 시 배포본과 바이트 일치함을 확인했다. index.html: - 파일 메뉴: 새로 만들기/열기/저장 제거 → data-fc-save(HWP/HWPX/PDF로 다운로드) 항목으로 대체. 문서 수명주기는 FactChat 세션 모델 소유, 저장은 부모 FE의 리비전 스냅샷·fidelity 라우팅 파이프라인 경유. 다른 이름으로 저장만 스튜디오 네이티브 유지(위치/파일명 picker). - 편집 메뉴·툴바: undo/redo를 data-fc-undo/redo로 전환(브리지 소유 통합 undo/redo), 문서 비교/문서 이력 관리 제거(부모 FE의 작업 내역 popover가 대체). - FactChat 다운로드 아이콘 인라인 SVG + .tb-btn[disabled] 스타일 추가. agent-bridge.js: - 통합 undo/redo: 로컬(문자 단위) 히스토리 우선, 소진 시 부모 FE로 undo/redo_requested 전달(BE 리비전 타임라인). editor_lock(턴 경계)마다 로컬 히스토리를 비워 두 타임라인의 시간순 비중첩 불변식 유지. history_state 수신으로 버튼 활성 상태 동기화, Ctrl+Z/Y·Ctrl+Shift+H· Alt+Shift+V 단축키 흡수. - document-changed 위장 억제: 로드/복원 RPC 직후 잔여 이벤트가 사용자 편집으로 오인돼 BE가 redo 스택을 죽이고 유령 user_edit 리비전을 자동저장하던 문제 — 실제 사용자 입력 전까지 침묵(quietUntilUserInput). 잠금 사이클 첫 에이전트 변경은 agent_mutation으로 FE에 보고(확인 중→ 편집 중 pill 전환용). - 저장 메뉴 활성화(setFcSaveEnabled): 문서 로드 후에만. Ctrl+S는 save_requested(format=default)로 재매핑. - 로드/복원 시 뷰 보존: zoom/scroll/caret 캡처 후 재적용(undo/redo/ 이력 jump 시 화면 튐 방지, 초기 열기는 no-op). - setCellStyle 병합 셀 보정: row*colCount+col 평면 인덱스 대신 bbox 앵커 기반 resolveCellIdx로 해석 + 중복 스타일링 dedupe. - agent-locked pointer-events 셀렉터를 실제 DOM id(#editor-area, #menu-bar, #icon-toolbar, #style-bar)로 정정. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
변경 요약
이 PR이 해결하는 문제와 변경 내용을 간결하게 설명해주세요.
관련 이슈
closes #
테스트
cargo test통과cargo clippy -- -D warnings통과스크린샷
변경 전후 비교가 필요한 경우 첨부해주세요.