diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87758d1c5d..92495a2227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,9 @@ jobs: - name: Format check run: cargo fmt --all -- --check + - name: Renderer overfit guard + run: python3 scripts/check_renderer_overfit.py + - name: Build run: cargo build --verbose diff --git a/.github/workflows/render-diff.yml b/.github/workflows/render-diff.yml index 18ae7eb14d..036bc9782c 100644 --- a/.github/workflows/render-diff.yml +++ b/.github/workflows/render-diff.yml @@ -10,6 +10,8 @@ on: - 'src/renderer/**' - 'src/document_core/queries/rendering.rs' - 'src/wasm_api.rs' + - 'scripts/check_renderer_overfit.py' + - 'scripts/renderer_overfit_baseline.txt' - 'rhwp-studio/**' - '.github/workflows/render-diff.yml' workflow_dispatch: @@ -93,6 +95,9 @@ jobs: node --check e2e/canvas-render-diff.test.mjs node --check e2e/run-render-diff.mjs + - name: Renderer overfit guard + run: python3 scripts/check_renderer_overfit.py + - name: Run canvas visual diff working-directory: rhwp-studio env: diff --git a/Cargo.toml b/Cargo.toml index 52d46f210d..ee424dfce9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,13 @@ path = "src/main.rs" name = "font-metric-gen" path = "src/tools/font_metric_gen.rs" +# === [Mindlogic — fidelity corpus integration tests] === +# Lives under tests/integration/ (Cargo's default discovery picks up +# only top-level tests/*.rs; nested files need an explicit entry). +[[test]] +name = "test_fidelity_corpus" +path = "tests/integration/test_fidelity_corpus.rs" + [dependencies] wasm-bindgen = "0.2" cfb = "0.14" diff --git a/examples/diag_empty_para_trap.rs b/examples/diag_empty_para_trap.rs new file mode 100644 index 0000000000..2acb08af88 --- /dev/null +++ b/examples/diag_empty_para_trap.rs @@ -0,0 +1,168 @@ +// Diagnose the insertFootnote/insertEquation 'unreachable' trap on form_19's +// empty body paragraph by emulating the agent-bridge call sequence natively. +use rhwp::model::control::Control; +use rhwp::wasm_api::HwpDocument; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +fn load() -> HwpDocument { + let bytes = std::fs::read("samples/form_19_empty_para.hwpx").expect("read fixture"); + HwpDocument::from_bytes(&bytes).expect("parse") +} + +fn step(name: &str, f: impl FnOnce()) { + match catch_unwind(AssertUnwindSafe(f)) { + Ok(()) => println!(" ok {name}"), + Err(e) => { + let msg = e + .downcast_ref::() + .cloned() + .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "".into()); + println!(" PANIC {name}: {msg}"); + } + } +} + +fn main() { + let doc = load(); + { + let document = doc.document(); + let section = &document.sections[0]; + println!("paragraphs: {}", section.paragraphs.len()); + for (pi, para) in section.paragraphs.iter().enumerate().take(12) { + let has_table = para.controls.iter().any(|c| matches!(c, Control::Table(_))); + let kinds: Vec<&str> = para + .controls + .iter() + .map(|c| match c { + Control::Table(_) => "Table", + Control::Shape(_) => "Shape", + Control::Footnote(_) => "Footnote", + Control::Bookmark(_) => "Bookmark", + Control::AutoNumber(_) => "AutoNumber", + _ => "Other", + }) + .collect(); + println!( + " p{pi}: table={has_table} text={:?} chars(text)={} char_count={} offsets={} controls={:?} linesegs={}", + para.text.chars().take(30).collect::(), + para.text.chars().count(), + para.char_count, + para.char_offsets.len(), + kinds, + para.line_segs.len(), + ); + } + } + + // The probe's P_TEXT actually resolves to an empty-text TABLE-HOSTING + // paragraph: both form_19 paragraphs host tables, the probe splits p0 to + // manufacture a text para at p1, and P_TEXT = s0:p2 = the shifted + // original p1 (table host, text="", char_offsets=[]). + let p_text = 1usize; // pristine-doc equivalent: the second table para + println!("P_TEXT equivalent = s0:p{p_text} (table host, empty text)"); + + println!("--- footnote sequence (bridge emulation, offset 0, with inner text) ---"); + let mut d = load(); + step("beginBatch", || { + d.begin_batch().unwrap(); + }); + let mut ctrl_idx = 0usize; + step("insertFootnote(sec=0, para=P_TEXT, offset=0)", || { + let r = d.insert_footnote_native(0, p_text, 0).expect("insert"); + println!(" -> {r}"); + ctrl_idx = r + .split("\"controlIdx\":") + .nth(1) + .and_then(|s| s.split([',', '}']).next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + }); + step("insertTextInFootnote(..., inner=0, off=0)", || { + let r = d + .insert_text_in_footnote_native(0, p_text, ctrl_idx, 0, 0, "프로브 각주") + .expect("insert text in footnote"); + println!(" -> {r}"); + }); + step("endBatch", || { + d.end_batch().unwrap(); + }); + step("render all pages", || { + for pg in 0..d.page_count() { + d.render_page_svg(pg).unwrap(); + } + }); + step("snapshot save+restore", || { + let id = d.save_snapshot(); + d.restore_snapshot(id).unwrap(); + }); + + println!("--- equation sequence (offset 0) ---"); + let mut d2 = load(); + step("beginBatch", || { + d2.begin_batch().unwrap(); + }); + step("insertEquation(sec=0, para=P_TEXT, offset=0)", || { + let r = d2 + .insert_equation_native(0, p_text, 0, "a^2+b^2=c^2", 1200, 0) + .expect("insert equation"); + println!(" -> {r}"); + }); + step("endBatch", || { + d2.end_batch().unwrap(); + }); + step("render all pages", || { + for pg in 0..d2.page_count() { + d2.render_page_svg(pg).unwrap(); + } + }); + step("snapshot save+restore", || { + let id = d2.save_snapshot(); + d2.restore_snapshot(id).unwrap(); + }); + + println!("--- exact probe emulation: split p0, text at p1, footnote at p2 off 0 ---"); + let mut d3 = load(); + step("splitParagraph(p0 at end) + insertText(p1)", || { + let len = { + let para = &d3.document().sections[0].paragraphs[0]; + para.text.chars().count() as u32 + }; + d3.split_paragraph(0, 0, len).unwrap(); + d3.insert_text(0, 1, 0, "프로브 텍스트 문단").unwrap(); + }); + step("beginBatch", || { + d3.begin_batch().unwrap(); + }); + let mut ctrl_idx3 = 0usize; + step("insertFootnote(sec=0, para=2, offset=0)", || { + let r = d3.insert_footnote_native(0, 2, 0).expect("insert"); + println!(" -> {r}"); + ctrl_idx3 = r + .split("\"controlIdx\":") + .nth(1) + .and_then(|s| s.split([',', '}']).next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + }); + step("insertTextInFootnote(..., inner=0, off=0)", || { + let r = d3 + .insert_text_in_footnote_native(0, 2, ctrl_idx3, 0, 0, "프로브 각주") + .expect("insert text in footnote"); + println!(" -> {r}"); + }); + step("endBatch", || { + d3.end_batch().unwrap(); + }); + step("render all pages", || { + for pg in 0..d3.page_count() { + d3.render_page_svg(pg).unwrap(); + } + }); + step("snapshot save+restore", || { + let id = d3.save_snapshot(); + d3.restore_snapshot(id).unwrap(); + }); + + println!("done"); +} diff --git a/examples/edit_textloss_scan.rs b/examples/edit_textloss_scan.rs new file mode 100644 index 0000000000..cf4a133a62 --- /dev/null +++ b/examples/edit_textloss_scan.rs @@ -0,0 +1,76 @@ +//! Scan: for each input doc, compare char counts between the no-edit +//! export (raw pass-through) and an edited export (forced re-serialize). +//! Any deficit beyond the inserted text = content destroyed by edit-path +//! re-serialization. +//! +//! cargo run --release --example edit_textloss_scan -- [...] + +use std::env; +use std::fs; + +use rhwp::document_core::DocumentCore; + +fn t_chars(bytes: &[u8]) -> usize { + let cursor = std::io::Cursor::new(bytes); + let mut zip = zip::ZipArchive::new(cursor).expect("zip"); + let mut total = 0usize; + let names: Vec = (0..zip.len()) + .map(|i| zip.by_index(i).unwrap().name().to_string()) + .filter(|n| n.starts_with("Contents/section") && n.ends_with(".xml")) + .collect(); + for n in names { + use std::io::Read; + let mut xml = String::new(); + zip.by_name(&n).unwrap().read_to_string(&mut xml).unwrap(); + let mut rest = xml.as_str(); + while let Some(i) = rest.find("") { + rest = &rest[i + 6..]; + if let Some(j) = rest.find("") { + total += rest[..j].chars().count(); + rest = &rest[j..]; + } else { + break; + } + } + } + total +} + +fn main() { + const INS: &str = "probe "; + for path in env::args().skip(1) { + let data = match fs::read(&path) { + Ok(d) => d, + Err(e) => { println!("{path}: READ ERR {e}"); continue; } + }; + let core = match DocumentCore::from_bytes(&data) { + Ok(c) => c, + Err(e) => { println!("{path}: PARSE ERR {e:?}"); continue; } + }; + let dump = env::var("DUMP_DIR").ok(); + let stem = std::path::Path::new(&path) + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "doc".into()); + let base_bytes = match core.export_hwpx_native() { Ok(b) => b, Err(e) => { println!("{path}: EXPORT ERR {e:?}"); continue; } }; + if let Some(ref d) = dump { let _ = fs::write(format!("{d}/{stem}.base.hwpx"), &base_bytes); } + let base = t_chars(&base_bytes); + + let mut core = DocumentCore::from_bytes(&data).expect("parse2"); + if core.insert_text_native(0, 0, 0, INS).is_err() { + println!("{path}: EDIT ERR"); + continue; + } + match core.export_hwpx_native() { + Ok(b) => { + if let Some(ref d) = dump { let _ = fs::write(format!("{d}/{stem}.edited.hwpx"), &b); } + let after = t_chars(&b); + let expected = base + INS.chars().count(); + let delta = after as i64 - expected as i64; + let flag = if delta < 0 { "LOSS" } else { "ok" }; + println!("{path}: base={base} after={after} delta_vs_expected={delta} {flag}"); + } + Err(e) => println!("{path}: EXPORT2 ERR {e:?}"), + } + } +} diff --git a/examples/fuzz_empty_para_inserts.rs b/examples/fuzz_empty_para_inserts.rs new file mode 100644 index 0000000000..8b0f3b936d --- /dev/null +++ b/examples/fuzz_empty_para_inserts.rs @@ -0,0 +1,79 @@ +// Corpus-wide regression fuzz for the ctrl_data_records trap family: +// for every .hwpx in samples/corpus_tmp/, insert a footnote and an equation +// at offset 0 of each of the first paragraphs (covers p0 sectiondef hosts, +// table hosts, and plain text paras), then render every page and round-trip +// a snapshot. Any panic = the 'unreachable' WASM trap shape. Exits non-zero +// if anything fails. +use rhwp::wasm_api::HwpDocument; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +fn main() { + let dir = "samples/corpus_tmp"; + let mut files: Vec<_> = std::fs::read_dir(dir) + .expect("read corpus dir") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().map(|x| x == "hwpx").unwrap_or(false)) + .collect(); + files.sort(); + assert!(!files.is_empty(), "no corpus files in {dir}"); + + let mut failures = 0usize; + let mut checks = 0usize; + for path in &files { + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + println!("FAIL read {name}: {e}"); + failures += 1; + continue; + } + }; + let para_count = match catch_unwind(AssertUnwindSafe(|| { + HwpDocument::from_bytes(&bytes) + .map(|d| d.document().sections[0].paragraphs.len()) + })) { + Ok(Ok(n)) => n, + Ok(Err(e)) => { + println!("SKIP parse {name}: {e:?}"); + continue; + } + Err(_) => { + println!("FAIL parse-panic {name}"); + failures += 1; + continue; + } + }; + + for para in 0..para_count.min(4) { + for op in ["footnote", "equation"] { + checks += 1; + let bytes = bytes.clone(); + let ok = catch_unwind(AssertUnwindSafe(|| { + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + let res = match op { + "footnote" => doc.insert_footnote_native(0, para, 0), + _ => doc.insert_equation_native(0, para, 0, "a^2+b^2=c^2", 1200, 0), + }; + res.expect("insert"); + for pg in 0..doc.page_count() { + doc.render_page_svg(pg).expect("render"); + } + let id = doc.save_snapshot(); + doc.restore_snapshot(id).expect("restore"); + })) + .is_ok(); + if !ok { + println!("FAIL {op} {name} p{para}"); + failures += 1; + } + } + } + println!("ok {name} ({} paras probed)", para_count.min(4)); + } + println!("---\n{checks} insert checks over {} docs, {failures} failures", files.len()); + if failures > 0 { + std::process::exit(1); + } +} diff --git a/examples/probe_compose_ir.rs b/examples/probe_compose_ir.rs new file mode 100644 index 0000000000..1d62fc9413 --- /dev/null +++ b/examples/probe_compose_ir.rs @@ -0,0 +1,50 @@ +//! Compare the CharOverlap host paragraph IR between two HWPX files +//! (source vs edited export) to find why reopen drops the compose glyphs. +//! +//! cargo run --release --example probe_compose_ir -- + +use rhwp::document_core::DocumentCore; +use rhwp::model::control::Control; + +fn dump(path: &str) { + let data = std::fs::read(path).expect("read"); + let core = DocumentCore::from_bytes(&data).expect("parse"); + for (si, sec) in core.document().sections.iter().enumerate() { + for (pi, para) in sec.paragraphs.iter().enumerate() { + for ctrl in ¶.controls { + if let Control::Table(t) = ctrl { + for cell in &t.cells { + for (cpi, cp) in cell.paragraphs.iter().enumerate() { + for c in &cp.controls { + if let Control::CharOverlap(co) = c { + println!( + "{path}: s{si} p{pi} cell r{}c{} cp{cpi} chars={:?} border={} exp={} ids={} | text={:?} char_count={} offsets={:?} segs={} char_shapes={:?}", + cell.row, cell.col, co.chars, co.border_type, co.expansion, + co.char_shape_ids.len(), + cp.text, cp.char_count, cp.char_offsets, + cp.line_segs.len(), + cp.char_shapes.iter().map(|r| (r.start_pos, r.char_shape_id)).collect::>(), + ); + } + } + } + } + } + } + for c in ¶.controls { + if let Control::CharOverlap(co) = c { + println!( + "{path}: s{si} p{pi} BODY chars={:?} text={:?} char_count={} offsets={:?}", + co.chars, para.text, para.char_count, para.char_offsets + ); + } + } + } + } +} + +fn main() { + for p in std::env::args().skip(1) { + dump(&p); + } +} diff --git a/examples/probe_secctrl_survival.rs b/examples/probe_secctrl_survival.rs new file mode 100644 index 0000000000..d04372dcb9 --- /dev/null +++ b/examples/probe_secctrl_survival.rs @@ -0,0 +1,114 @@ +//! Probe: edit a cell inside the secPr-hosting first paragraph and check the +//! re-serialized export keeps pageNum/newNum/autoNum/pageHiding controls. +//! +//! cargo run --release --example probe_secctrl_survival -- [...] + +use std::env; +use std::fs; + +use rhwp::document_core::DocumentCore; +use rhwp::model::control::Control; + +fn count(hay: &str, needle: &str) -> usize { + hay.matches(needle).count() +} + +fn masterpage_count(bytes: &[u8]) -> usize { + let cursor = std::io::Cursor::new(bytes); + let zip = zip::ZipArchive::new(cursor).expect("zip"); + (0..zip.len()) + .filter(|&i| { + let mut z = zip.clone(); + z.by_index(i) + .map(|f| f.name().to_ascii_lowercase().contains("masterpage")) + .unwrap_or(false) + }) + .count() +} + +fn section_xml(bytes: &[u8]) -> String { + let cursor = std::io::Cursor::new(bytes); + let mut zip = zip::ZipArchive::new(cursor).expect("zip"); + let names: Vec = (0..zip.len()) + .map(|i| zip.by_index(i).unwrap().name().to_string()) + .filter(|n| n.starts_with("Contents/section") && n.ends_with(".xml")) + .collect(); + let mut out = String::new(); + for n in names { + use std::io::Read; + let mut xml = String::new(); + zip.by_name(&n).unwrap().read_to_string(&mut xml).unwrap(); + out.push_str(&xml); + } + out +} + +fn main() { + for path in env::args().skip(1) { + let data = fs::read(&path).expect("read"); + let mut core = match DocumentCore::from_bytes(&data) { + Ok(c) => c, + Err(e) => { + println!("{path}: PARSE ERR {e:?}"); + continue; + } + }; + // find first table control in section 0 paragraph 0 and its first + // non-empty cell paragraph + let mut target: Option<(usize, usize)> = None; // (ctrl_idx, cell_idx) + { + let para = &core.document().sections[0].paragraphs[0]; + for (ci, ctrl) in para.controls.iter().enumerate() { + if let Control::Table(t) = ctrl { + for (xi, cell) in t.cells.iter().enumerate() { + if cell.paragraphs.iter().any(|p| !p.text.is_empty()) { + target = Some((ci, xi)); + break; + } + } + if target.is_some() { + break; + } + } + } + } + let Some((ctrl_idx, cell_idx)) = target else { + println!("{path}: NO p0 table cell — skip"); + continue; + }; + let cp = vec![(ctrl_idx, cell_idx, 0usize)]; + core.insert_text_in_cell_by_path(0, 0, &cp, 0, "probe ") + .expect("edit"); + let out = core.export_hwpx_native().expect("export"); + + let src = section_xml(&data); + let edited = section_xml(&out); + let keys = [ + " + +use std::env; +use std::fs; + +use rhwp::document_core::DocumentCore; + +fn has_banner(bytes: &[u8], label: &str) { + let cursor = std::io::Cursor::new(bytes); + let mut zip = zip::ZipArchive::new(cursor).expect("zip"); + let mut xml = String::new(); + { + use std::io::Read; + let mut f = zip.by_name("Contents/section0.xml").expect("section0"); + f.read_to_string(&mut xml).expect("utf8"); + } + println!( + "{label}: Ⅳ={} 기획과={} 인력효율화={}", + xml.contains('Ⅳ'), + xml.contains("기획과, 총무과"), + xml.contains("인력 효율화 방안"), + ); +} + +fn visit(shape: &rhwp::model::shape::ShapeObject, pi: usize, label: &str, n_rects: &mut u32, n_tb_chars: &mut usize) { + use rhwp::model::shape::ShapeObject; + match shape { + ShapeObject::Rectangle(r) => { + *n_rects += 1; + let chars: usize = r + .drawing + .text_box + .as_ref() + .map(|tb| tb.paragraphs.iter().map(|pp| pp.text.chars().count()).sum()) + .unwrap_or(0); + *n_tb_chars += chars; + if chars > 0 { + let first: String = r.drawing.text_box.as_ref().unwrap().paragraphs[0] + .text + .chars() + .take(20) + .collect(); + println!(" {label} p{pi} rect tb_chars={chars} first={first:?}"); + } + } + ShapeObject::Group(g) => { + for ch in &g.children { + visit(ch, pi, label, n_rects, n_tb_chars); + } + } + _ => {} + } +} + +fn dump_model(core: &DocumentCore, label: &str) { + use rhwp::model::control::Control; + let sec = &core.document().sections[0]; + let mut n_rects = 0; + let mut n_tb_chars = 0usize; + for (pi, p) in sec.paragraphs.iter().enumerate() { + for c in &p.controls { + if let Control::Shape(shape) = c { + visit(shape, pi, label, &mut n_rects, &mut n_tb_chars); + } + } + } + println!("{label}: paragraphs={} rects={} tb_chars_total={}", sec.paragraphs.len(), n_rects, n_tb_chars); +} + +fn main() { + let path = env::args().nth(1).expect("usage: repro_rect_text_loss "); + let data = fs::read(&path).expect("read"); + + // no-edit round trip + let core = DocumentCore::from_bytes(&data).expect("parse"); + dump_model(&core, "parsed "); + let out = core.export_hwpx_native().expect("export"); + has_banner(&out, "no-edit "); + + // trivial edit then export + let mut core = DocumentCore::from_bytes(&data).expect("parse"); + core.insert_text_native(0, 0, 0, "probe ").expect("insert"); + dump_model(&core, "edited "); + let out = core.export_hwpx_native().expect("export"); + has_banner(&out, "edited "); +} diff --git a/harness/FOCUS_PLAYBOOK.md b/harness/FOCUS_PLAYBOOK.md new file mode 100644 index 0000000000..3a1c2f1031 --- /dev/null +++ b/harness/FOCUS_PLAYBOOK.md @@ -0,0 +1,41 @@ +# Focus Validation Playbook + +This is the user-selected problem set from the June 1 visual review. It is a +working queue, not a replacement for the full corpus gate. + +## Open + +- `http://127.0.0.1:8799/_FOCUS_INDEX.html` +- Each row links to the existing Hancom-left / rhwp-right comparison folder. + +## First Pass + +Start with `quick`: + +```bash +cd /Users/jaehoshin/Desktop/mindlogic/factchat/worktree-rhwp-poc/rhwp +python3 harness/focus_next.py +python3 harness/focus_index.py +bash harness/focus_gate.sh quick +``` + +Skip `giant` until shorter docs stop yielding general fixes. The obvious first +cluster is spacing/table height: + +1. `report_form` and `meeting_summary` for short line-height drift in opposite directions. +2. `accountability_eval` for severe table row-height under-measure. +3. `form_01`, `form_04`, `form_15`, `tiny_03` for short wild-form visual checks. +4. `15_3740450_research_admin_innovation_meeting_template` and `meeting` for image/table pagination. + +## Commit Rule + +`focus_gate.sh` is only a cheap loop. Before committing any renderer change: + +```bash +python3 harness/fingerprint_lint.py +bash harness/gate.sh +``` + +Then re-render the touched target's `rhwp_svg_cur/`, open the comparison, and +commit only if the visual result moves toward Hancom without growing the overfit +baseline. diff --git a/harness/MORNING_STATUS.md b/harness/MORNING_STATUS.md new file mode 100644 index 0000000000..dcc0140b7a --- /dev/null +++ b/harness/MORNING_STATUS.md @@ -0,0 +1,47 @@ +# rhwp fidelity — morning status (2026-06-01, overnight session) + +## TL;DR +You asked: "make this into a ralph/harness that won't hardcode like Codex did." +**Done + committed.** Then "keep going, close the gap" — I probed all 11 DIFF docs +with the new objective metric and **landed 0 engine fixes on purpose**: none has a +safe, structurally-general fix that can be confirmed without your eyes on the page, +and forcing one blind is exactly the Codex/doc-09 failure mode the harness exists to +prevent. The honest gap-closer is a short visual-gated session WITH you — set up below. + +## What shipped (committed LOCAL on mindlogic/feat/trust-lineseg-cache, NOT deployed) +- `34c5ab2c` — **the harness**: `harness/` + - `drift.py` — objective metric: Hancom-PDF-bbox vs rhwp-SVG line gaps (pt). REPLACES + dump-pages' `used/hwp_used` which LIES on TopAndBottom docs (-300..-500px phantoms). + PROVEN: on med_03 it cleanly showed p1-14 byte-perfect, divergence isolated to p16. + - `fingerprint_lint.py` — blocks `text.contains("")` / fixture-named fns + (the Codex hardcode move) mechanically. Exit 1 = no commit. + - `gate.sh` — build + page-count + overflow regression gate across all 74 docs. + - `corpus.tsv` — 10-doc held-out split (fix must help held, not just dev = anti-memorize). + - `ralph.sh` + `PLAYBOOK.md` — the gated loop; **"classify BLOCKED" is a valid outcome.** +- `` — **queue.tsv**: all 11 classified with hard evidence (below). + +## The 11 — why each is blocked (evidence, not guesses) +| doc | gap | blocked because | +|---|---|---| +| med_03 | +1 | p16 has only 29.6px room (body 946.7, used 917.1); heading group 42.6px legit doesn't fit. Root = upstream image-region (p15) accumulation, not an orphan. | +| 09 | +2 | metric-lie + cover float overlap; prior wrong-fix already reverted. High risk. | +| form_07 | +2 | cumulative sub-page drift. para=64 table is NOT over-tall (Hancom bbox = 115pt/row, equal). | +| form_22 | +1 | tiny mixed per-page diffs accumulate; "phantom" p18 holds real 117px content. | +| huge_01/02 | +5/+6 | same cumulative-drift family as form_07. | +| report_form | -1 | uniform -13/-31/-24/-32px per-page SHORTFALL = line-height-base family (highest blast). | +| large_01 | -2 | under-paginates + 7 overflow markers (packs too much → clipping). | +| meeting/15 | +1 | image-region pagination (p3/p4 = 4.5/5.5MB image SVGs); same doc. | +| 13 | +1 | complex table-block break (P7 underfills 264px then 14x3 784px table on P8). | +| accountability_eval | -7 | needs a NEW capability (wide-table width-fit + row-height reflow), not a fix. | + +## Do this first when you wake (the one thing that unblocks everything) +Open **http://localhost:8799/_MORNING_REVIEW.html** — Hancom vs current rhwp at each +divergence boundary. For each, the only question is: *does Hancom merge/split where rhwp +doesn't?* Your visual call on 2-3 of these tells me which have a real fix vs which are +correct-but-different. Then I run the gated loop (`ralph.sh next` → patch → `ralph.sh +verify` → commit) on the ones you greenlight. With your eyes on the gate, these become +landable; without them, committing is gambling on the 60 OK docs. + +## State +Tree CLEAN at the 2 harness commits on `mindlogic/feat/trust-lineseg-cache`. Binary = clean +HEAD. No engine edits. No deploy. 2 old stashes (not mine) still parked — ignore. diff --git a/harness/PLAYBOOK.md b/harness/PLAYBOOK.md new file mode 100644 index 0000000000..eb38376c6d --- /dev/null +++ b/harness/PLAYBOOK.md @@ -0,0 +1,48 @@ +# rhwp fidelity ralph — agent playbook (the anti-hardcode contract) + +You are one iteration of an autonomous loop fixing rhwp page-count/layout drift. +Your job is NOT to make every number move. Your job is to land **general, structural** +fixes and to **correctly classify** docs that can't be generally fixed. A loop that +"fixes" 11/11 by memorizing is a FAILURE. A loop that fixes 3 and classifies 8 with +honest reasons is a SUCCESS. + +## The one rule that matters +**Fix on STRUCTURE, never on document CONTENT.** Allowed discriminators: lineseg +presence, vpos resets, item type (TAC/TopAndBottom/Square), numbering id/level, table +geometry, overflow magnitude, page_break kind — properties *any* document has. FORBIDDEN: +`text.contains("")`, fixture-named fns (`is_sampleNN`, `_hwp`), doc ids. +`fingerprint_lint.py` blocks these mechanically; if it fires, your fix is wrong — redo +it structurally, don't add to the baseline. + +## Per-iteration steps +1. `bash harness/ralph.sh next` → target doc + objective drift table. +2. Read the drift table. The `first_divergent_page` localizes the bug. Confirm the + real cause with an **env-gated `eprintln!` probe** (e.g. `RHWP_*_DIAG`) — never guess. + Build (`CARGO_BUILD_JOBS=1`, ~80s), read the numbers, find the ONE wrong value. +3. **If no general structural cause in ~3 probes → STOP this doc.** Set its queue status + to `blocked` with a one-line reason (metric-only / needs-feature / cumulative-no-single-cause). + This is a valid outcome. Move on. Strip all probes (`grep -rn _DIAG src/` must be clean). +4. Patch the engine (structural only, `// === [Mindlogic patch — ] ===` markers). +5. `bash harness/ralph.sh verify` → runs fingerprint_lint + corpus gate + held-out check. + Must print `ALL-GREEN`. Any RED → `git checkout -- src/`, mark doc blocked, move on. +6. ALL-GREEN → re-render the target, eyeball drift.py again (target improved, gaps now + match Hancom ±2pt). Then `gate.sh --update-baseline` + `git commit` LOCAL. Update queue + status to `done` with before/after page count. +7. NEVER deploy (no WASM build, no rsync, no Vercel). Local commits only. + +## Gotchas (learned, don't relearn) +- Binary is **Linux ELF — docker only**. Native exec fails. `_sweep.sh` is bogus. +- `dump-pages` metric **LIES** on TopAndBottom docs (phantom -300..-500px). Use `drift.py` + (objective Hancom-bbox vs SVG) as the truth signal. dump-pages is fine for per-ITEM probing. +- The cell trust-lineseg-cache is **already implemented** (height_measurer.rs Bucket A). + Don't re-add it. form_07's para=64 table is **not** over-tall (Hancom renders it equally + tall — verified by bbox). See rhwp-dead-leads memory. Don't re-chase either. +- Held-out docs (corpus.tsv `set=held`): do NOT read their oracle or name them in code. + If a fix helps dev but not held, it memorized → reject. +- Build OOMs on multi-job → always `CARGO_BUILD_JOBS=1`. +- 2 pre-existing stashes exist (not loop's) — ignore them. + +## Queue +`harness/queue.tsv` — `pending` → work it; `blocked`/`done` → skip. Categories: +cumulative-drift (hard, likely blocked), metric-lie (fix drift signal first), image-pagination, +under-paginate, needs-feature (blocked — requires new capability like wide-table reflow). diff --git a/harness/__pycache__/review_gallery.cpython-314.pyc b/harness/__pycache__/review_gallery.cpython-314.pyc new file mode 100644 index 0000000000..d0f868fbdb Binary files /dev/null and b/harness/__pycache__/review_gallery.cpython-314.pyc differ diff --git a/harness/audit_review_gallery.py b/harness/audit_review_gallery.py new file mode 100644 index 0000000000..a7786f00e2 --- /dev/null +++ b/harness/audit_review_gallery.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Audit generated fidelity galleries for truncated or stale visual evidence. + +This catches the failure mode from old three-way galleries: a document label can +say Hancom has 17 pages while the HTML only embeds 14 page strips, or generated +PNG strips can have a suspiciously short aspect ratio that makes bottom content +look clipped. +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from PIL import Image + + +DOC_LINK_RE = re.compile(r']*\bsrc="([^"]*page-\d+\.png)"') +COUNT_PATTERNS = ( + re.compile(r"Hancom=(\d+)p?\s+RHWP=(\d+)p?", re.IGNORECASE), + re.compile(r"한컴=(\d+)p?\s*[· ]+\s*ours=(\d+)p?", re.IGNORECASE), + re.compile(r"한컴=(\d+)p?\s*[· ]+\s*upstream=\d+p?\s*[· ]+\s*ours=(\d+)p?", re.IGNORECASE), +) + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="ignore") + + +def declared_total(html: str) -> int | None: + for pattern in COUNT_PATTERNS: + match = pattern.search(html) + if match: + return max(int(match.group(1)), int(match.group(2))) + return None + + +def image_paths(doc_index: Path, html: str) -> list[Path]: + paths: list[Path] = [] + for src in IMG_RE.findall(html): + paths.append((doc_index.parent / src).resolve()) + return paths + + +def suspicious_aspect(path: Path) -> str | None: + if not path.exists(): + return "missing" + with Image.open(path) as image: + width, height = image.size + if width <= 0 or height <= 0: + return f"invalid-size {width}x{height}" + # Old 3-column qlmanage strips such as 2304x1103 are visibly short and often + # hide bottom-edge drift. Current 2-column galleries can be short for valid + # landscape pages, especially wide tables, so only flag very-wide strips. + if width >= 2200 and height / width < 0.55: + return f"suspicious-short {width}x{height}" + return None + + +def doc_indexes(root: Path) -> list[Path]: + index = root / "index.html" + if not index.exists(): + raise SystemExit(f"missing gallery index: {index}") + html = read(index) + links = [root / href / "index.html" for href in DOC_LINK_RE.findall(html)] + if links: + return [path for path in links if path.exists()] + return sorted(path for path in root.glob("*/index.html") if path.parent != root) + + +def audit(root: Path) -> int: + failures = 0 + checked = 0 + for doc_index in doc_indexes(root): + checked += 1 + html = read(doc_index) + imgs = image_paths(doc_index, html) + total = declared_total(html) + if total is not None and len(imgs) < total: + failures += 1 + print( + f"TRUNCATED {doc_index.parent.name}: html embeds {len(imgs)} " + f"page strips but declares {total} pages" + ) + for path in imgs: + issue = suspicious_aspect(path) + if issue: + failures += 1 + print(f"IMAGE {doc_index.parent.name}/{path.name}: {issue}") + if failures: + print(f"\nFAIL: {failures} gallery evidence issue(s) across {checked} doc(s)") + return 1 + print(f"PASS: no gallery truncation/aspect issues across {checked} doc(s)") + return 0 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("gallery_dir", help="directory containing index.html") + args = parser.parse_args() + raise SystemExit(audit(Path(args.gallery_dir))) + + +if __name__ == "__main__": + main() diff --git a/harness/baseline_pc.tsv b/harness/baseline_pc.tsv new file mode 100644 index 0000000000..741fae6805 --- /dev/null +++ b/harness/baseline_pc.tsv @@ -0,0 +1,142 @@ +form_01______________49KB 2 0 +form_02____5______ 1 0 +form_03_signup_consent_form 2 0 +form_04______________15KB 3 0 +form_05________________30KB 1 0 +form_06_____________37KB 3 0 +form_07_______________41KB 12 2 +form_08_PJBL_______44KB 4 0 +form_09___________48KB 1 0 +form_10______________49KB 2 0 +form_11____________48KB 2 0 +form_12_20260415_____53KB 5 0 +form_13_________54KB 1 0 +form_14_________________________________59KB 2 0 +form_15_____________59KB 3 0 +form_16_______________76KB 2 0 +form_17_______________81KB 9 0 +form_18_______260414_86KB 5 0 +form_19_________________88KB 1 0 +form_20______________90KB 8 5 +form_21____________98KB 1 0 +form_22____________________270KB 37 0 +form_23___________________3MB 26 2 +form_24_____4MB 84 4 +form_25__________________6MB 29 0 +form_26___________________15MB 6 4 +form_27_______________58MB 8 2 +huge_01_DNA________33MB 41 2 +huge_02_DNA_______75MB 67 4 +large_01_____________16MB 37 7 +med_01_________225KB 8 3 +med_02____2_4MB 6 0 +med_03____________4MB 47 3 +small_01_____________46KB 4 0 +small_02____________46KB 1 0 +small_03_________73KB 3 0 +small_04____________79KB 1 0 +tiny_01__________17KB 4 1 +tiny_02________16KB 3 0 +tiny_03_______38KB 3 0 +wb01_inherit_form_2pg 2 0 +wb02_teacher_center_2pg 2 0 +wb03_physics_lab_2pg 1 0 +wb04_finance_2pg 2 0 +wb05_govsummary_2pg 2 0 +wb06_grad_report_3pg 3 0 +wb07_future_college_3pg 3 0 +wb08_scholar_proposal_3pg 3 0 +wb09_future_college_final_4pg 4 0 +wb10_pension_ppt_4pg 4 0 +wb11_bastille_3pg 3 0 +wb12_gangjin_meeting_4pg 4 0 +wb13_comphumanities_6pg 6 0 +wb14_writingtask_6pg 6 13 +wb15_capstone_16pg 17 0 +wb16_futuretech_19pg 19 3 +wb17_library_report_13pg 15 5 +wb18_subplan_15pg 17 5 +wb19_plan_report3_9pg 9 2 +wb20_coreboard_15pg 13 1 +wb21_week10_expr_11pg 11 5 +wb22_plan_report6_6pg 6 0 +wb23_polymer_6pg 6 3 +wb24_columbia_14pg 14 3 +wb25_ecolife_8pg 8 0 +wc01_7pg 7 2 +wc02_7pg 7 0 +wc03_11pg 11 2 +wc04_4pg 5 10 +wc05_7pg 7 9 +wc06_9pg 9 0 +wc07_4pg 4 0 +wc08_14pg 14 0 +wc09_3pg 3 0 +wc10_4pg 4 0 +wc11_6pg 6 0 +wc12_10pg 10 0 +wc13_7pg 6 0 +wc14_13pg 12 1 +wc15_7pg 6 0 +wc16_6pg 5 0 +wc17_6pg 5 0 +wc18_10pg 10 2 +wc19_5pg 5 0 +wc20_3pg 3 0 +wc21_7pg 7 0 +wc22_4pg 4 3 +wc23_6pg 5 0 +wc24_4pg 4 0 +wc25_4pg 4 0 +wc26_3pg 3 0 +wc27_13pg 13 0 +wc28_2pg 2 3 +wc29_9pg 9 13 +wc30_3pg 3 0 +wc31_17pg 12 0 +wc32_6pg 6 13 +wc33_2pg 2 1 +wc34_2pg 2 0 +wc35_15pg 16 9 +wc36_5pg 5 4 +wc37_9pg 9 2 +wc38_19pg 19 5 +wc39_14pg 14 0 +wc40_13pg 13 6 +wc41_4pg 4 8 +wc42_9pg 10 0 +wc43_8pg 10 0 +wc44_8pg 9 0 +wc45_8pg 9 0 +wc46_7pg 7 2 +wc47_6pg 6 0 +wc48_18pg 18 0 +wc49_4pg 4 3 +wc50_2pg 2 0 +wc51_4pg 4 1 +wc52_8pg 9 2 +wc53_11pg 11 2 +wc54_7pg 7 0 +wc55_8pg 8 0 +wc56_5pg 5 0 +wc57_2pg 2 0 +wc58_8pg 7 0 +wc59_2pg 2 0 +wc60_12pg 12 1 +wc61_8pg 8 0 +wc62_8pg 9 0 +wc63_2pg 1 3 +wc64_9pg 18 1 +wc65_5pg 5 9 +wc66_3pg 3 0 +wc67_12pg 10 36 +wc68_17pg 17 0 +wc69_9pg 10 0 +wild_01_business_plan_31pg 32 7 +wild_02_paper_fig_10MB 7 2 +wild_03_labor_law_250pg 255 13 +wild_04_glocal_script_10pg 10 0 +wild_05_hangul21_1pg_1MB 1 0 +wild_06_report_najy_10pg 10 0 +wild_07_ethics_debate_13pg 14 0 +wild_08_virtual_company_9pg 9 0 diff --git a/harness/bottom_tail_scan.py b/harness/bottom_tail_scan.py new file mode 100644 index 0000000000..bfaab38405 --- /dev/null +++ b/harness/bottom_tail_scan.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Find near-page-bottom one-line paragraph tails in RHWP dump-pages output. + +This is a diagnostic harness for fidelity work. It does not decide correctness; +it surfaces structural candidates where RHWP packs a short final paragraph into +the last few pixels of a page and the next page begins with a fresh paragraph. +Those are the cases to compare against the Hancom oracle before adding any +pagination rule. +""" +from __future__ import annotations + +import argparse +import re +from dataclasses import dataclass, field +from pathlib import Path + + +DIFF = Path("/tmp/diff") + +PAGE_RE = re.compile(r"^=== 페이지 (?P\d+) .*?===$") +BODY_RE = re.compile(r"body_area: .* h=(?P[0-9.]+)") +COL_RE = re.compile(r"단 (?P\d+) \(items=(?P\d+), used=(?P[0-9.]+)px") +PARA_RE = re.compile( + r"(?PFullParagraph|PartialParagraph)\s+" + r"pi=(?P\d+)\s+" + r"(?:lines=(?P\d+)\.\.(?P\d+)\s+)?" + r"h=(?P[0-9.]+).*?" + r"vpos=(?P[0-9.\-]+(?:\.\.[0-9.\-]+)?)\s+" + r'"(?P.*)"$' +) +TABLE_RE = re.compile(r"Table\s+pi=(?P\d+)") + + +@dataclass +class Item: + kind: str + pi: int + h: float + text: str = "" + vpos: str = "" + start_line: int | None = None + end_line: int | None = None + + @property + def visible(self) -> bool: + return bool(self.text.strip()) and self.text.strip() != "(빈)" + + @property + def line_count(self) -> int: + if self.start_line is not None and self.end_line is not None: + return max(0, self.end_line - self.start_line) + # dump-pages does not print line_count directly. Heights up to about + # 36px are usually single-line body/list/head paragraphs in the staged + # corpus; this scanner deliberately reports "one-line-ish", not truth. + return 1 if self.h <= 36.0 else 2 + + +@dataclass +class Page: + page: int + body_h: float = 0.0 + used: float = 0.0 + items: list[Item] = field(default_factory=list) + + @property + def remaining(self) -> float: + return self.body_h - self.used + + +def clean_text(text: str, limit: int = 90) -> str: + text = " ".join(text.replace("\u00a0", " ").split()) + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def parse_dump(path: Path) -> list[Page]: + pages: list[Page] = [] + current: Page | None = None + for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if m := PAGE_RE.match(line): + current = Page(page=int(m.group("page"))) + pages.append(current) + continue + if current is None: + continue + if m := BODY_RE.search(line): + current.body_h = float(m.group("h")) + continue + if m := COL_RE.search(line): + # Current harness focuses single-column dumps; for multi-column, + # keep the max used column as the page-bottom risk signal. + current.used = max(current.used, float(m.group("used"))) + continue + if m := PARA_RE.search(line): + current.items.append( + Item( + kind=m.group("kind"), + pi=int(m.group("pi")), + h=float(m.group("h")), + text=m.group("text"), + vpos=m.group("vpos"), + start_line=int(m.group("start")) if m.group("start") else None, + end_line=int(m.group("end")) if m.group("end") else None, + ) + ) + continue + if m := TABLE_RE.search(line): + current.items.append(Item(kind="Table", pi=int(m.group("pi")), h=0.0)) + return pages + + +def last_visible_para(page: Page) -> Item | None: + for item in reversed(page.items): + if item.kind in {"FullParagraph", "PartialParagraph"} and item.visible: + return item + return None + + +def first_visible_item(page: Page) -> Item | None: + for item in page.items: + if item.kind == "Table": + return item + if item.kind in {"FullParagraph", "PartialParagraph"} and item.visible: + return item + return None + + +def scan( + pages: list[Page], + max_tail_h: float, + max_remaining: float, + min_used_ratio: float, +) -> list[tuple[Page, Item, Page, Item | None]]: + rows: list[tuple[Page, Item, Page, Item | None]] = [] + for idx, page in enumerate(pages[:-1]): + if page.body_h <= 0.0: + continue + used_ratio = page.used / page.body_h + if used_ratio < min_used_ratio or page.remaining > max_remaining: + continue + tail = last_visible_para(page) + if tail is None: + continue + if tail.h > max_tail_h or tail.line_count != 1: + continue + rows.append((page, tail, pages[idx + 1], first_visible_item(pages[idx + 1]))) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("doc", nargs="?", help="staged doc name under /tmp/diff") + parser.add_argument("--dump", type=Path, help="explicit dump-pages file") + parser.add_argument("--max-tail-h", type=float, default=36.0) + parser.add_argument("--max-remaining", type=float, default=18.0) + parser.add_argument("--min-used-ratio", type=float, default=0.965) + args = parser.parse_args() + + if args.dump: + dump = args.dump + doc = args.doc or dump.parent.name + elif args.doc: + doc = args.doc + dump = DIFF / doc / "dump_pages_patched.txt" + if not dump.exists(): + dump = DIFF / doc / "dump_pages_current.txt" + else: + raise SystemExit("provide DOC or --dump") + + pages = parse_dump(dump) + rows = scan(pages, args.max_tail_h, args.max_remaining, args.min_used_ratio) + + print("doc\tpage\tused\tbody\tremaining\ttail_pi\ttail_h\ttail_vpos\tnext_page\tnext_kind\tnext_pi\ttail_text\tnext_text") + for page, tail, next_page, next_item in rows: + next_kind = next_item.kind if next_item else "" + next_pi = str(next_item.pi) if next_item else "" + next_text = clean_text(next_item.text) if next_item else "" + print( + f"{doc}\t{page.page}\t{page.used:.1f}\t{page.body_h:.1f}\t{page.remaining:.1f}" + f"\t{tail.pi}\t{tail.h:.1f}\t{tail.vpos}\t{next_page.page}\t{next_kind}\t{next_pi}" + f"\t{clean_text(tail.text)}\t{next_text}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/cellbreak_rowspan_scan.py b/harness/cellbreak_rowspan_scan.py new file mode 100644 index 0000000000..833b60adc7 --- /dev/null +++ b/harness/cellbreak_rowspan_scan.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Scan rendered diff dirs for continued CellBreak rowspan table fragments. + +This read-only triage helper looks for the structural class behind +`accountability_eval` page 4: + +* a rendered PartialTable continuation; +* source table has pageBreak="CELL"; +* at least one rowspan cell is carried across the fragment start; +* the source table contains small blank separator rows near the fragment; +* the rendered page is underfilled enough that another short band might be + expected by the oracle. + +It does not judge correctness; use it to find guard docs before renderer edits. +""" + +from __future__ import annotations + +import argparse +import re +import zipfile +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + + +NS = {"hp": "http://www.hancom.co.kr/hwpml/2011/paragraph"} +DIFF = Path("/tmp/diff") + +PAGE_RE = re.compile(r"^=== 페이지 (\d+) ") +BODY_RE = re.compile(r"body_area: .* h=([0-9.]+)") +USED_RE = re.compile(r"used=([0-9.]+)px") +PARTIAL_RE = re.compile( + r"PartialTable\s+pi=(\d+)\s+ci=(\d+)\s+rows=(\d+)\.\.(\d+)\s+cont=(true|false)\s+(\d+)x(\d+)" +) + + +@dataclass(frozen=True) +class Cell: + row: int + col: int + row_span: int + col_span: int + height_hu: int + text: str + + +@dataclass(frozen=True) +class TableInfo: + index: int + rows: int + cols: int + page_break: str + repeat_header: str + cells: tuple[Cell, ...] + + +@dataclass(frozen=True) +class Fragment: + page: int + body_h: float + used_h: float + pi: int + ci: int + start: int + end: int + cont: bool + rows: int + cols: int + + +def find_source(docdir: Path) -> Path | None: + for name in ("source.hwpx", "source_converted.hwpx", "source_fitted.hwpx"): + path = docdir / name + if path.exists(): + return path + return None + + +def hu_to_px(value: int, dpi: float = 96.0) -> float: + return value * dpi / 7200.0 + + +def parse_tables(source: Path) -> list[TableInfo]: + tables: list[TableInfo] = [] + with zipfile.ZipFile(source) as zf: + sections = sorted(name for name in zf.namelist() if name.startswith("Contents/section")) + for section in sections: + root = ET.fromstring(zf.read(section)) + for table in root.findall(".//hp:tbl", NS): + cells: list[Cell] = [] + max_row = -1 + max_col = -1 + for tc in table.findall(".//hp:tc", NS): + addr = tc.find("hp:cellAddr", NS) + span = tc.find("hp:cellSpan", NS) + size = tc.find("hp:cellSz", NS) + if addr is None or span is None or size is None: + continue + row = int(addr.attrib.get("rowAddr", "0")) + col = int(addr.attrib.get("colAddr", "0")) + row_span = int(span.attrib.get("rowSpan", "1")) + col_span = int(span.attrib.get("colSpan", "1")) + max_row = max(max_row, row + row_span - 1) + max_col = max(max_col, col + col_span - 1) + text = "".join(tc.itertext()).strip() + cells.append( + Cell( + row=row, + col=col, + row_span=row_span, + col_span=col_span, + height_hu=int(size.attrib.get("height", "0")), + text=text, + ) + ) + tables.append( + TableInfo( + index=len(tables), + rows=max_row + 1, + cols=max_col + 1, + page_break=table.attrib.get("pageBreak", ""), + repeat_header=table.attrib.get("repeatHeader", ""), + cells=tuple(cells), + ) + ) + return tables + + +def parse_fragments(docdir: Path) -> list[Fragment]: + dump = docdir / "dump_pages_current.txt" + if not dump.exists(): + return [] + page = 0 + body_h = 0.0 + used_h = 0.0 + out: list[Fragment] = [] + for line in dump.read_text(encoding="utf-8", errors="ignore").splitlines(): + if match := PAGE_RE.search(line): + page = int(match.group(1)) + body_h = 0.0 + used_h = 0.0 + continue + if match := BODY_RE.search(line): + body_h = float(match.group(1)) + continue + if match := USED_RE.search(line): + used_h = float(match.group(1)) + if match := PARTIAL_RE.search(line): + out.append( + Fragment( + page=page, + body_h=body_h, + used_h=used_h, + pi=int(match.group(1)), + ci=int(match.group(2)), + start=int(match.group(3)), + end=int(match.group(4)), + cont=match.group(5) == "true", + rows=int(match.group(6)), + cols=int(match.group(7)), + ) + ) + return out + + +def row_height_map(table: TableInfo) -> dict[int, float]: + heights: dict[int, float] = {} + for cell in table.cells: + if cell.row_span == 1: + heights[cell.row] = max(heights.get(cell.row, 0.0), hu_to_px(cell.height_hu)) + return heights + + +def blank_rows(table: TableInfo) -> set[int]: + rows: dict[int, list[Cell]] = {} + for cell in table.cells: + if cell.row_span == 1: + rows.setdefault(cell.row, []).append(cell) + out: set[int] = set() + for row, cells in rows.items(): + if cells and all(not cell.text.strip() for cell in cells): + out.add(row) + return out + + +def carried_rowspans(table: TableInfo, start: int) -> list[Cell]: + return [ + cell + for cell in table.cells + if cell.row_span > 1 and cell.row < start < cell.row + cell.row_span + ] + + +def matching_tables(tables: list[TableInfo], fragment: Fragment) -> list[TableInfo]: + return [table for table in tables if table.rows == fragment.rows and table.cols == fragment.cols] + + +def scan_doc(docdir: Path, min_slack: float) -> list[str]: + source = find_source(docdir) + if source is None: + return [] + fragments = parse_fragments(docdir) + if not fragments: + return [] + tables = parse_tables(source) + rows: list[str] = [] + for fragment in fragments: + if not fragment.cont: + continue + slack = fragment.body_h - fragment.used_h + if slack < min_slack: + continue + for table in matching_tables(tables, fragment): + if table.page_break != "CELL": + continue + carried = carried_rowspans(table, fragment.start) + if not carried: + continue + blanks = blank_rows(table) + nearby_blanks = sorted( + row for row in blanks if fragment.start - 2 <= row <= fragment.end + 2 + ) + row_heights = row_height_map(table) + frag_heights = [ + round(row_heights.get(row, 0.0), 1) + for row in range(fragment.start, min(fragment.end, table.rows)) + ] + rows.append( + "\t".join( + [ + docdir.name, + f"p{fragment.page}", + f"table={table.index}", + f"rows={fragment.start}..{fragment.end}", + f"slack={slack:.1f}", + f"repeatHeader={table.repeat_header}", + f"carried={[(c.row, c.col, c.row_span) for c in carried]}", + f"blank_near={nearby_blanks}", + f"row_h={frag_heights}", + ] + ) + ) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("docdirs", nargs="*", type=Path) + parser.add_argument("--root", type=Path, default=DIFF) + parser.add_argument("--min-slack", type=float, default=35.0) + args = parser.parse_args() + + docdirs = args.docdirs or sorted(path for path in args.root.iterdir() if path.is_dir()) + print( + "doc\tpage\ttable\trows\tslack\trepeatHeader\tcarried\tblank_near\trow_h" + ) + count = 0 + for docdir in docdirs: + for line in scan_doc(docdir, args.min_slack): + print(line) + count += 1 + print(f"# matches={count}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/cellbreak_source_scan.py b/harness/cellbreak_source_scan.py new file mode 100644 index 0000000000..0ac194cf4a --- /dev/null +++ b/harness/cellbreak_source_scan.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Scan HWPX sources for CellBreak rowspan guard candidates. + +This is a source-only prefilter for the `accountability_eval` class. It does +not claim a rendering bug. It finds tables that structurally could exercise a +carried-rowspan continuation: + +* pageBreak="CELL"; +* at least one cell spans multiple rows; +* one or more blank separator rows; +* a possible split row cuts through the rowspan and is near a blank row. + +Rendered `dump_pages_current.txt` evidence is still required before accepting a +renderer patch. +""" +from __future__ import annotations + +import argparse +import zipfile +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + + +NS = {"hp": "http://www.hancom.co.kr/hwpml/2011/paragraph"} + + +@dataclass(frozen=True) +class Cell: + row: int + col: int + row_span: int + col_span: int + height: int + text: str + + +@dataclass(frozen=True) +class Table: + doc: str + path: Path + section: str + index: int + rows: int + cols: int + page_break: str + repeat_header: str + cells: tuple[Cell, ...] + + +def int_attr(elem: ET.Element | None, name: str, default: int = 0) -> int: + if elem is None: + return default + try: + return int(elem.attrib.get(name, str(default))) + except ValueError: + return default + + +def source_paths(root: Path) -> list[Path]: + if root.is_file(): + return [root] + if (root / "source.hwpx").exists(): + return [root / "source.hwpx"] + paths: list[Path] = [] + for docdir in sorted(path for path in root.iterdir() if path.is_dir()): + for name in ("source.hwpx", "source_converted.hwpx", "source_fitted.hwpx", "_doc.hwpx"): + candidate = docdir / name + if candidate.exists(): + paths.append(candidate) + break + return paths + + +def paths_from_file(path: Path) -> list[Path]: + return [ + Path(line.strip()) + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines() + if line.strip() + ] + + +def doc_name(path: Path) -> str: + if path.name == "_doc.hwpx": + return path.parent.name + if path.name.startswith("source"): + return path.parent.name + return path.stem + + +def parse_tables(path: Path) -> list[Table]: + out: list[Table] = [] + try: + with zipfile.ZipFile(path) as zf: + sections = sorted(name for name in zf.namelist() if name.startswith("Contents/section")) + for section in sections: + try: + root = ET.fromstring(zf.read(section)) + except ET.ParseError: + continue + for table in root.findall(".//hp:tbl", NS): + cells: list[Cell] = [] + max_row = -1 + max_col = -1 + for tc in table.findall(".//hp:tc", NS): + addr = tc.find("hp:cellAddr", NS) + span = tc.find("hp:cellSpan", NS) + size = tc.find("hp:cellSz", NS) + row = int_attr(addr, "rowAddr") + col = int_attr(addr, "colAddr") + row_span = int_attr(span, "rowSpan", 1) + col_span = int_attr(span, "colSpan", 1) + max_row = max(max_row, row + row_span - 1) + max_col = max(max_col, col + col_span - 1) + cells.append( + Cell( + row=row, + col=col, + row_span=row_span, + col_span=col_span, + height=int_attr(size, "height"), + text="".join(tc.itertext()).strip(), + ) + ) + out.append( + Table( + doc=doc_name(path), + path=path, + section=section, + index=len(out), + rows=max_row + 1, + cols=max_col + 1, + page_break=table.attrib.get("pageBreak", ""), + repeat_header=table.attrib.get("repeatHeader", ""), + cells=tuple(cells), + ) + ) + except zipfile.BadZipFile: + return [] + return out + + +def blank_rows(table: Table) -> set[int]: + by_row: dict[int, list[Cell]] = {} + for cell in table.cells: + if cell.row_span == 1: + by_row.setdefault(cell.row, []).append(cell) + return {row for row, cells in by_row.items() if cells and all(not cell.text for cell in cells)} + + +def rowspans(table: Table) -> list[Cell]: + return [cell for cell in table.cells if cell.row_span > 1] + + +def candidate_split_rows(table: Table, near: int) -> list[int]: + blanks = blank_rows(table) + splits: set[int] = set() + for cell in rowspans(table): + for split in range(cell.row + 1, cell.row + cell.row_span): + if any(abs(split - blank) <= near for blank in blanks): + splits.add(split) + return sorted(splits) + + +def hu_to_px(value: int, dpi: float = 96.0) -> float: + return value * dpi / 7200.0 + + +def row_height_summary(table: Table, rows: list[int]) -> str: + heights: dict[int, float] = {} + for cell in table.cells: + if cell.row_span == 1: + heights[cell.row] = max(heights.get(cell.row, 0.0), hu_to_px(cell.height)) + return ",".join(f"{row}:{heights.get(row, 0.0):.1f}" for row in rows) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("roots", nargs="*", type=Path) + parser.add_argument("--paths-file", type=Path, help="newline-delimited HWPX source paths") + parser.add_argument("--max-bytes", type=int, default=0, help="skip files larger than this; 0 disables") + parser.add_argument("--near-blank", type=int, default=2) + args = parser.parse_args() + + print("doc\ttable\tshape\tbreak\trepeat\trowspans\tblank_rows\tcandidate_splits\trow_h\tpath") + count = 0 + roots = list(args.roots) + if args.paths_file is not None: + roots.extend(paths_from_file(args.paths_file)) + if not roots: + parser.error("provide at least one root or --paths-file") + for root in roots: + for source in source_paths(root): + if args.max_bytes and source.stat().st_size > args.max_bytes: + continue + for table in parse_tables(source): + if table.page_break != "CELL": + continue + spans = rowspans(table) + blanks = sorted(blank_rows(table)) + splits = candidate_split_rows(table, args.near_blank) + if not spans or not blanks or not splits: + continue + rows = sorted(set(blanks + splits)) + span_desc = ",".join(f"r{cell.row}c{cell.col}x{cell.row_span}" for cell in spans[:8]) + print( + f"{table.doc}\t{table.index}\t{table.rows}x{table.cols}\t" + f"{table.page_break}\t{table.repeat_header}\t{span_desc}\t" + f"{','.join(map(str, blanks[:16]))}\t{','.join(map(str, splits))}\t" + f"{row_height_summary(table, rows)}\t{table.path}" + ) + count += 1 + print(f"# matches={count}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/corpus.tsv b/harness/corpus.tsv new file mode 100644 index 0000000000..0a77005be7 --- /dev/null +++ b/harness/corpus.tsv @@ -0,0 +1,73 @@ +01_3787018_81dong_cafeteria_capsule_coffee_review_20260527 dev 5 +02_3783665_snu_emeritus_professor_regulation held 3 +03_3781727_staff_evaluation_table dev 1 +04_3781571_car_2bu_je_notice dev 2 +05_3781559_medschool_car_2bu_je_plan dev 4 +06_3778860_ehp_change_proposal held 1 +07_3775010_attachment4_org_opinions_combined dev 14 +08_3767791_kei_snu_intl_mou dev 3 +09_3766093_natural_sci_planning_committee_260527 dev 51 +10_3764349_development_plan_v1 dev 3 +11_3764309_meeting4_transcript held 38 +12_3763876_development_plan_v2 dev 3 +13_3763367_k_star_visa_track_plan dev 19 +14_3762372_manual_template held 1 +15_3740450_research_admin_innovation_meeting_template dev 5 +16_3738193_secure_admin_ai_service_plan dev 4 +17_3728967_forest_mou_report held 2 +18_3728528_research_fund_repayment_request dev 1 +19_3728140_snu_finance_regulation_v3 dev 8 +20_3727659_resume_2605_ai held 1 +accountability_eval dev 11 +expense_form dev 1 +form_01______________49KB dev 2 +form_02____5______ dev 1 +form_04______________15KB dev 3 +form_05________________30KB dev 1 +form_06_____________37KB dev 3 +form_07_______________41KB dev 11 +form_08_PJBL_______44KB dev 4 +form_09___________48KB dev 1 +form_10______________49KB dev 2 +form_11____________48KB dev 2 +form_12_20260415_____53KB dev 5 +form_13_________54KB dev 1 +form_14_________________________________59KB dev 2 +form_15_____________59KB dev 3 +form_16_______________76KB dev 2 +form_17_______________81KB dev 9 +form_18_______260414_86KB dev 5 +form_19_________________88KB dev 1 +form_20______________90KB dev 8 +form_21____________98KB dev 1 +form_22____________________270KB dev 37 +form_23___________________3MB dev 26 +form_24_____4MB held 84 +form_25__________________6MB dev 29 +form_26___________________15MB dev 6 +form_27_______________58MB dev 8 +gifted_application dev 1 +huge_01_DNA________33MB dev 41 +huge_02_DNA_______75MB dev 67 +internship_plan dev 11 +lab_report dev 2 +large_01_____________16MB dev 39 +med_01_________225KB held 8 +med_02____2_4MB dev 6 +med_03____________4MB dev 47 +medschool dev 4 +meeting dev 5 +meeting_summary dev 3 +multicultural_plan dev 3 +org_diagnosis held 16 +overseas_training dev 4 +report_form dev 6 +research_form dev 2 +small_01_____________46KB dev 4 +small_02____________46KB dev 1 +small_03_________73KB held 3 +small_04____________79KB dev 1 +tiny_01__________17KB dev 4 +tiny_02________16KB dev 3 +tiny_03_______38KB dev 3 +writing_template dev 2 diff --git a/harness/drift.py b/harness/drift.py new file mode 100755 index 0000000000..fbc6a78e0f --- /dev/null +++ b/harness/drift.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Objective fidelity metric — Hancom PDF text positions vs rhwp SVG text positions. + +This REPLACES dump-pages' `used vs hwp_used` metric, which LIES on TopAndBottom +docs (counts page-anchored frames by vpos → phantom -300..-500px diffs). This +metric reads ACTUAL rendered glyph y-positions from both engines, so it cannot +be fooled by anchor accounting. + +Per page (aligned by index until divergence): + - line count (glyph rows) + - median inter-line gap in pt + - delta(rhwp - hancom) +Reports the FIRST page where the two engines structurally diverge — that localizes +where an extra/missing page creeps in. + +Units: pdftotext -bbox emits PDF points (pt). rhwp SVG is px@96dpi; +pt = px * 0.75. Gaps are compared, so absolute page offset cancels. + +Usage: drift.py # e.g. /tmp/diff/form_07_______________41KB + drift.py --json +""" +import sys, os, re, subprocess, json, statistics, glob + +PX_TO_PT = 0.75 +LINE_TOL_PT = 3.0 # words within this yMin band = same line + + +def hancom_lines_per_page(pdf): + out = subprocess.run(["pdftotext", "-bbox", pdf, "-"], + capture_output=True, text=True).stdout + pages, cur = [], None + for line in out.splitlines(): + if re.search(r']*\by="([\d.]+)"', s)] + out.append(collapse_lines(ys)) + return out + + +def collapse_lines(ys): + """Group near-equal y values into distinct text lines; return sorted centers.""" + if not ys: + return [] + ys = sorted(ys) + lines, cur = [], [ys[0]] + for y in ys[1:]: + if y - cur[-1] <= LINE_TOL_PT: + cur.append(y) + else: + lines.append(sum(cur) / len(cur)) + cur = [y] + lines.append(sum(cur) / len(cur)) + return lines + + +def median_gap(lines): + if len(lines) < 2: + return 0.0 + gaps = [b - a for a, b in zip(lines, lines[1:]) if 0 < b - a < 200] + return statistics.median(gaps) if gaps else 0.0 + + +def main(): + docdir = sys.argv[1].rstrip("/") + as_json = "--json" in sys.argv + pdf = os.path.join(docdir, "hancom.pdf") + if not os.path.exists(pdf): + print(f"NO hancom.pdf in {docdir}", file=sys.stderr); sys.exit(2) + hc = hancom_lines_per_page(pdf) + rh = svg_lines_per_page(docdir) + rows, first_div = [], None + n = max(len(hc), len(rh)) + for i in range(n): + h = hc[i] if i < len(hc) else [] + r = rh[i] if i < len(rh) else [] + hg, rg = median_gap(h), median_gap(r) + diverge = (abs(len(h) - len(r)) > 2) or (abs(hg - rg) > 4 and h and r) + if diverge and first_div is None: + first_div = i + 1 + rows.append(dict(page=i + 1, h_lines=len(h), r_lines=len(r), + h_gap=round(hg, 1), r_gap=round(rg, 1), + gap_delta=round(rg - hg, 1))) + result = dict(doc=os.path.basename(docdir), hancom_pages=len(hc), rhwp_pages=len(rh), + first_divergent_page=first_div, pages=rows) + if as_json: + print(json.dumps(result, ensure_ascii=False)) + return + print(f"== {result['doc']}: hancom={len(hc)}p rhwp={len(rh)}p first_divergence=p{first_div} ==") + print(f"{'pg':>3} {'h_lines':>7} {'r_lines':>7} {'h_gap':>6} {'r_gap':>6} {'Δgap':>6}") + for r in rows: + flag = " <-- DIVERGES" if r["page"] == first_div else "" + print(f"{r['page']:>3} {r['h_lines']:>7} {r['r_lines']:>7} {r['h_gap']:>6} {r['r_gap']:>6} {r['gap_delta']:>6}{flag}") + + +if __name__ == "__main__": + main() diff --git a/harness/embedded_scan_title_diag.py b/harness/embedded_scan_title_diag.py new file mode 100644 index 0000000000..ce3741123a --- /dev/null +++ b/harness/embedded_scan_title_diag.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Detect HWPX embedded scan/title interleaving candidates. + +This is a read-only corpus diagnostic for renderer-fidelity work. It looks for +structural patterns where page/paper-relative scanned page images and 1x3 title +tables are interleaved across nearby paragraphs. That class can require +per-control page ownership; paragraph-level page breaks tend to create blank +pages or reorder titles incorrectly. + +Usage: + python3 harness/embedded_scan_title_diag.py /tmp/diff/wc31_17pg/source.hwpx + python3 harness/embedded_scan_title_diag.py --corpus /tmp/diff --limit 20 +""" + +from __future__ import annotations + +import argparse +import json +import re +import zipfile +from dataclasses import dataclass +from pathlib import Path +from xml.etree import ElementTree as ET + + +NS = { + "hp": "http://www.hancom.co.kr/hwpml/2011/paragraph", + "hc": "http://www.hancom.co.kr/hwpml/2011/core", +} + + +@dataclass +class ControlInfo: + para: int + index: int + kind: str + text: str + detail: str + bin_id: str = "" + + +def route_sequence(controls: list[ControlInfo]) -> list[dict[str, object]]: + """Annotate an interleaved scan/title run with structural ownership hints. + + This is diagnostic-only. It never inspects fixture names and does not depend + on title text. For each title band, the next scan/media control is the + candidate body target a renderer-side router would need to consider. + """ + sequence: list[dict[str, object]] = [] + for i, ctrl in enumerate(controls): + entry: dict[str, object] = { + "para": ctrl.para, + "index": ctrl.index, + "kind": ctrl.kind, + "bin_id": ctrl.bin_id, + "detail": ctrl.detail, + } + if ctrl.kind == "title": + target = next( + ( + c + for c in controls[i + 1 :] + if c.kind in {"scan", "media", "media_table"} + ), + None, + ) + if target is not None: + entry["next_body_kind"] = target.kind + entry["next_body_para"] = target.para + entry["next_body_bin_id"] = target.bin_id + sequence.append(entry) + return sequence + + +def local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def text_of(elem: ET.Element) -> str: + return re.sub(r"\s+", " ", "".join(elem.itertext())).strip() + + +def is_page_scan(pic: ET.Element) -> tuple[bool, str]: + pos = pic.find("hp:pos", NS) + size = pic.find("hp:sz", NS) + img = pic.find("hc:img", NS) + if pos is None or size is None: + return False, "" + treat = pos.get("treatAsChar") == "1" + vert = pos.get("vertRelTo") + horz = pos.get("horzRelTo") + wrap = pic.get("textWrap", "") + width = int(size.get("width") or 0) + height = int(size.get("height") or 0) + # Structural approximation: near body/page width and tall enough to be a + # scanned page region. Uses HWP units; avoids content/title matching. + pageish = ( + not treat + and vert in {"PAPER", "PAGE"} + and horz in {"PAPER", "PAGE", "COLUMN"} + and wrap in {"SQUARE", "TOP_AND_BOTTOM"} + and width >= 43000 + and height >= 56000 + ) + if not pageish: + return False, "" + bin_id = img.get("binaryItemIDRef", "?") if img is not None else "?" + return True, f"bin={bin_id} wrap={wrap} vert={vert} horz={horz} size={width}x{height}" + + +def picture_bin_id(pic: ET.Element) -> str: + img = pic.find("hc:img", NS) + return img.get("binaryItemIDRef", "") if img is not None else "" + + +def is_large_media_picture(pic: ET.Element) -> tuple[bool, str]: + pos = pic.find("hp:pos", NS) + size = pic.find("hp:sz", NS) + if pos is None or size is None: + return False, "" + width = int(size.get("width") or 0) + height = int(size.get("height") or 0) + if width < 43000 or height < 30000: + return False, "" + treat = pos.get("treatAsChar") == "1" + vert = pos.get("vertRelTo") + horz = pos.get("horzRelTo") + wrap = pic.get("textWrap", "") + bin_id = picture_bin_id(pic) or "?" + return True, f"bin={bin_id} tac={treat} wrap={wrap} vert={vert} horz={horz} size={width}x{height}" + + +def is_title_table(tbl: ET.Element) -> tuple[bool, str]: + pos = tbl.find("hp:pos", NS) + size = tbl.find("hp:sz", NS) + row_count = int(tbl.get("rowCnt") or 0) + col_count = int(tbl.get("colCnt") or 0) + if pos is None or size is None: + return False, "" + width = int(size.get("width") or 0) + height = int(size.get("height") or 0) + # Typical evidence/title band shape: one row, three columns, body-wide. + # Deliberately no literal title-text matching. + if row_count == 1 and col_count == 3 and width >= 43000 and height <= 7000: + treat = pos.get("treatAsChar") == "1" + wrap = tbl.get("textWrap", "") + return True, f"tac={treat} wrap={wrap} size={width}x{height}" + return False, "" + + +def is_large_media_table(tbl: ET.Element) -> tuple[bool, str]: + pos = tbl.find("hp:pos", NS) + size = tbl.find("hp:sz", NS) + row_count = int(tbl.get("rowCnt") or 0) + col_count = int(tbl.get("colCnt") or 0) + if pos is None or size is None: + return False, "" + width = int(size.get("width") or 0) + height = int(size.get("height") or 0) + if row_count >= 2 and col_count >= 1 and width >= 43000 and height >= 30000: + treat = pos.get("treatAsChar") == "1" + wrap = tbl.get("textWrap", "") + return True, ( + f"rows={row_count} cols={col_count} tac={treat} " + f"wrap={wrap} size={width}x{height}" + ) + return False, "" + + +def iter_paragraph_controls(section_xml: bytes) -> list[list[ControlInfo]]: + root = ET.fromstring(section_xml) + paragraphs: list[list[ControlInfo]] = [] + # Renderer paragraph indices are section top-level body paragraphs. Nested + # table-cell paragraphs are still used for title text, but they must not + # increment the reported host paragraph index. + for para_idx, para in enumerate(root.findall("hp:p", NS)): + controls: list[ControlInfo] = [] + for elem in para.iter(): + name = local_name(elem.tag) + if name == "pic": + ok, detail = is_page_scan(elem) + if ok: + controls.append( + ControlInfo( + para_idx, + len(controls), + "scan", + "", + detail, + picture_bin_id(elem), + ) + ) + else: + media_ok, media_detail = is_large_media_picture(elem) + if media_ok: + controls.append( + ControlInfo( + para_idx, + len(controls), + "media", + "", + media_detail, + picture_bin_id(elem), + ) + ) + elif name == "tbl": + ok, detail = is_title_table(elem) + if ok: + controls.append( + ControlInfo(para_idx, len(controls), "title", text_of(elem), detail) + ) + else: + media_ok, media_detail = is_large_media_table(elem) + if media_ok: + controls.append( + ControlInfo( + para_idx, + len(controls), + "media_table", + "", + media_detail, + ) + ) + paragraphs.append(controls) + return paragraphs + + +def diagnose(path: Path) -> dict[str, object]: + try: + zf_ctx = zipfile.ZipFile(path) + except zipfile.BadZipFile: + return { + "path": str(path), + "candidate_count": 0, + "candidates": [], + "error": "bad-zip", + } + with zf_ctx as zf: + section_names = sorted( + name + for name in zf.namelist() + if name.startswith("Contents/section") and name.endswith(".xml") + ) + candidates: list[dict[str, object]] = [] + for section_idx, name in enumerate(section_names): + paragraphs = iter_paragraph_controls(zf.read(name)) + for idx, controls in enumerate(paragraphs): + if not controls: + continue + has_scan = any(c.kind == "scan" for c in controls) + has_title = any(c.kind == "title" for c in controls) + window = [ + c + for para_controls in paragraphs[idx : idx + 8] + for c in para_controls + ] + window_has_scan = any(c.kind == "scan" for c in window) + window_has_title = any(c.kind == "title" for c in window) + # Same-paragraph scan+title or nearby interleaving of both classes. + if (has_scan and has_title) or ( + window_has_scan and window_has_title and len(window) >= 3 + ): + compact_window: list[ControlInfo] = [] + seen: set[tuple[int, int, str, str]] = set() + for c in window: + key = (c.para, c.index, c.kind, c.bin_id) + if key in seen: + continue + seen.add(key) + compact_window.append(c) + candidates.append( + { + "section": section_idx, + "start_para": idx, + "controls": [ + { + "para": c.para, + "kind": c.kind, + "text": c.text[:80], + "detail": c.detail, + "bin_id": c.bin_id, + } + for c in window[:12] + ], + "route_sequence": route_sequence(compact_window[:12]), + } + ) + return {"path": str(path), "candidate_count": len(candidates), "candidates": candidates} + + +def corpus_sources(root: Path) -> list[Path]: + return sorted(root.glob("*/source.hwpx")) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", nargs="?", help="single .hwpx source") + parser.add_argument("--corpus", type=Path, help="scan corpus directory") + parser.add_argument("--limit", type=int, default=0, help="limit printed candidates") + parser.add_argument("--json", action="store_true", help="emit JSON") + args = parser.parse_args() + + paths: list[Path] + if args.corpus: + paths = corpus_sources(args.corpus) + elif args.source: + paths = [Path(args.source)] + else: + raise SystemExit("provide a source.hwpx or --corpus") + + results = [diagnose(path) for path in paths if path.exists()] + matches = [r for r in results if r["candidate_count"]] + if args.json: + print(json.dumps(matches, ensure_ascii=False, indent=2)) + return 0 + + print(f"scanned={len(results)} matches={len(matches)}") + printed = 0 + for result in matches: + if args.limit and printed >= args.limit: + break + print(f"\n{result['path']} candidates={result['candidate_count']}") + for cand in result["candidates"][:3]: + print(f" section={cand['section']} start_para={cand['start_para']}") + for ctrl in cand["controls"]: + label = f" p{ctrl['para']:<4} {ctrl['kind']:<5} {ctrl['detail']}" + if ctrl["text"]: + label += f" text={ctrl['text']!r}" + print(label) + printed += 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/fidelity_category_status.py b/harness/fidelity_category_status.py new file mode 100644 index 0000000000..92062302ba --- /dev/null +++ b/harness/fidelity_category_status.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Refresh category-level RHWP fidelity status from current local evidence. + +This is deliberately lightweight. It does not decide that a renderer bug is +fixed; it gives the next long run a current source/oracle/page-count/gallery +map so work does not start from stale review boards. +""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from PIL import Image, ImageChops, ImageStat + + +RHWP = Path(__file__).resolve().parents[1] +DIFF = Path("/tmp/diff") +DEFAULT_DOCS = [ + "wild_02_paper_fig_10MB", + "overseas_training", + "meeting_summary", + "accountability_eval", + "15_3740450_research_admin_innovation_meeting_template", + "20_3727659_resume_2605_ai", + "report_form", +] + + +@dataclass +class DocStatus: + doc: str + source: str + hancom_pages: int | None + rhwp_pages: int | None + dump_ok: bool + gallery: str + gallery_ok: bool | None + visual_worst_page: int | None + visual_mean_diff: float | None + notes: str + + +def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, cwd=RHWP, capture_output=True, text=True, **kwargs) + + +def source_path(doc: str) -> Path | None: + docdir = DIFF / doc + for name in ("source.hwpx", "source.hwp", "source_converted.hwpx"): + path = docdir / name + if path.exists(): + return path + return None + + +def hancom_page_count(doc: str) -> int | None: + pages = sorted((DIFF / doc).glob("hancom_p-*.png")) + if pages: + return len(pages) + index = DIFF / "_hancom_pages.json" + if index.exists(): + try: + data = json.loads(index.read_text(encoding="utf-8")) + value = data.get(doc) + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + except (OSError, json.JSONDecodeError): + pass + return None + + +def dump_pages(doc: str, src: Path) -> tuple[bool, int | None, str]: + rel_src = f"/diff/{doc}/{src.name}" + cmd = [ + "docker", + "compose", + "--env-file", + ".env.docker", + "run", + "--rm", + "-v", + "/tmp/diff:/diff", + "dev", + "/app/target/release/rhwp", + "dump-pages", + rel_src, + ] + result = run(cmd) + combined = f"{result.stdout}\n{result.stderr}" + if result.returncode != 0: + return False, None, combined.strip().splitlines()[-1] if combined.strip() else "dump failed" + match = re.search(r"\((\d+)페이지\)", combined) + pages = int(match.group(1)) if match else None + return True, pages, "" + + +def build_gallery(doc: str, out_root: Path) -> tuple[str, bool, str]: + cmd = [ + "python3", + "harness/review_gallery.py", + str(out_root), + doc, + "--export-current", + ] + result = run(cmd) + if result.returncode != 0: + note = (result.stderr or result.stdout).strip().splitlines() + return str(out_root / "index.html"), False, note[-1] if note else "gallery build failed" + audit = run(["python3", "harness/audit_review_gallery.py", str(out_root)]) + if audit.returncode != 0: + note = (audit.stdout or audit.stderr).strip().splitlines() + return str(out_root / "index.html"), False, note[-1] if note else "gallery audit failed" + return str(out_root / "index.html"), True, "" + + +def gallery_visual_metric(doc: str, out_root: Path) -> tuple[int | None, float | None, str]: + doc_dir = out_root / doc + pages = sorted(doc_dir.glob("page-*.png")) + if not pages: + return None, None, "no gallery page strips" + worst_page: int | None = None + worst_mean = -1.0 + for idx, page in enumerate(pages, 1): + with Image.open(page) as opened: + image = opened.convert("RGB") + width, height = image.size + if width < 4 or height <= 30: + return None, None, f"invalid gallery strip size {width}x{height}" + gap = 12 if width >= 1812 else 0 + column_width = (width - gap) // 2 + left = image.crop((0, 30, column_width, height)) + right = image.crop((column_width + gap, 30, column_width + gap + column_width, height)) + if right.size != left.size: + right = right.resize(left.size) + diff = ImageChops.difference(left, right) + stat = ImageStat.Stat(diff) + mean = sum(stat.mean) / 3.0 + if mean > worst_mean: + worst_mean = mean + worst_page = idx + if worst_page is None: + return None, None, "no gallery metric" + return worst_page, worst_mean, "" + + +def classify(status: DocStatus, visual_threshold: float) -> str: + if status.source == "missing": + return "source_missing" + if not status.dump_ok: + return "dump_failed" + if status.hancom_pages is None: + return "oracle_missing" + if status.rhwp_pages is None: + return "rhwp_count_unknown" + if status.rhwp_pages != status.hancom_pages: + return "page_count_gap" + if status.gallery_ok is False: + return "gallery_failed" + if status.visual_mean_diff is not None and status.visual_mean_diff >= visual_threshold: + return "visual_drift" + return "page_count_clean" + + +def markdown(statuses: list[DocStatus], generated_at: str, visual_threshold: float) -> str: + lines = [ + f"# RHWP Fidelity Category Status -- {generated_at}", + "", + f"Visual drift threshold: mean pixel diff >= {visual_threshold:.1f}.", + "", + "| doc | class | hancom | rhwp | worst visual page | mean diff | source | gallery | notes |", + "|---|---:|---:|---:|---:|---:|---|---|---|", + ] + for item in statuses: + cls = classify(item, visual_threshold) + hancom = "" if item.hancom_pages is None else str(item.hancom_pages) + rhwp = "" if item.rhwp_pages is None else str(item.rhwp_pages) + worst_page = "" if item.visual_worst_page is None else str(item.visual_worst_page) + mean_diff = "" if item.visual_mean_diff is None else f"{item.visual_mean_diff:.2f}" + gallery = item.gallery if item.gallery_ok else "" + source = item.source + notes = item.notes.replace("|", "/") + lines.append( + f"| `{item.doc}` | `{cls}` | {hancom} | {rhwp} | {worst_page} | " + f"{mean_diff} | `{source}` | {gallery} | {notes} |" + ) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("docs", nargs="*", default=DEFAULT_DOCS) + parser.add_argument("--with-gallery", action="store_true") + parser.add_argument( + "--visual-threshold", + type=float, + default=15.0, + help="mean pixel diff threshold for classifying page-count-clean docs as visual_drift", + ) + parser.add_argument( + "--out", + default="work/FIDELITY_CATEGORY_STATUS_2026-06-07.md", + help="markdown summary path, relative to repo unless absolute", + ) + args = parser.parse_args() + + generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + statuses: list[DocStatus] = [] + for doc in args.docs: + src = source_path(doc) + if src is None: + statuses.append( + DocStatus( + doc, + "missing", + hancom_page_count(doc), + None, + False, + "", + None, + None, + None, + "no source.hwpx/source.hwp", + ) + ) + continue + dump_ok, rhwp_pages, dump_note = dump_pages(doc, src) + gallery = "" + gallery_ok: bool | None = None + gallery_note = "" + visual_worst_page: int | None = None + visual_mean_diff: float | None = None + metric_note = "" + if args.with_gallery and dump_ok: + out_root = DIFF / f"_review_category_status_2026-06-07_{doc}" + gallery, gallery_ok, gallery_note = build_gallery(doc, out_root) + if gallery_ok: + visual_worst_page, visual_mean_diff, metric_note = gallery_visual_metric(doc, out_root) + notes = "; ".join(note for note in (dump_note, gallery_note, metric_note) if note) + statuses.append( + DocStatus( + doc=doc, + source=str(src), + hancom_pages=hancom_page_count(doc), + rhwp_pages=rhwp_pages, + dump_ok=dump_ok, + gallery=gallery, + gallery_ok=gallery_ok, + visual_worst_page=visual_worst_page, + visual_mean_diff=visual_mean_diff, + notes=notes, + ) + ) + + out = Path(args.out) + if not out.is_absolute(): + out = RHWP / out + out.write_text(markdown(statuses, generated_at, args.visual_threshold), encoding="utf-8") + print(out) + for item in statuses: + print( + "\t".join( + [ + item.doc, + classify(item, args.visual_threshold), + "" if item.hancom_pages is None else str(item.hancom_pages), + "" if item.rhwp_pages is None else str(item.rhwp_pages), + "" if item.visual_worst_page is None else str(item.visual_worst_page), + "" if item.visual_mean_diff is None else f"{item.visual_mean_diff:.2f}", + item.source, + ] + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/fidelity_queue_builder.py b/harness/fidelity_queue_builder.py new file mode 100644 index 0000000000..6adfd1d1b6 --- /dev/null +++ b/harness/fidelity_queue_builder.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Build a current renderer-fidelity work queue from local evidence. + +This script is intentionally conservative. It does not decide a renderer bug is +fixed, and it does not invent patches. It combines the current visual board with +structural scanner output so a long run can pick the next safe category without +repeating already-rejected one-page guesses. +""" +from __future__ import annotations + +import argparse +import csv +import re +import subprocess +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + + +RHWP = Path(__file__).resolve().parents[1] +DEFAULT_BOARD = RHWP / "work" / "FIDELITY_VISUAL_STATUS_CURRENT_CONTINUE_2026-06-08.md" + + +@dataclass(frozen=True) +class BoardRow: + doc: str + cls: str + hancom: str + rhwp: str + worst_page: str + mean_diff: str + source: str + + +@dataclass(frozen=True) +class Decision: + rank: int + category: str + doc: str + page: str + status: str + reason: str + next_command: str + + +def run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, cwd=RHWP, capture_output=True, text=True) + + +def parse_board(path: Path) -> list[BoardRow]: + rows: list[BoardRow] = [] + if not path.exists(): + raise SystemExit(f"missing board file: {path}") + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.startswith("| `"): + continue + parts = [part.strip() for part in line.strip("|").split("|")] + if len(parts) < 8: + continue + doc = parts[0].strip("`") + cls = parts[1].strip("`") + source = parts[6].strip("`") + rows.append( + BoardRow( + doc=doc, + cls=cls, + hancom=parts[2], + rhwp=parts[3], + worst_page=parts[4], + mean_diff=parts[5], + source=source, + ) + ) + return rows + + +def parse_tsv(text: str) -> list[dict[str, str]]: + lines = [line for line in text.splitlines() if line and not line.startswith("#")] + if not lines: + return [] + return list(csv.DictReader(lines, delimiter="\t")) + + +def scanner_rows(command: list[str]) -> list[dict[str, str]]: + result = run(command) + if result.returncode != 0: + return [] + return parse_tsv(result.stdout) + + +def page_ref(row: BoardRow) -> str: + if row.worst_page: + return f"{row.doc}:{row.worst_page}" + return row.doc + + +def page_number(row: BoardRow) -> str: + return row.worst_page or "" + + +def by_doc(rows: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]: + out: dict[str, list[dict[str, str]]] = {} + for row in rows: + doc = row.get("doc", "") + if not doc: + continue + out.setdefault(doc, []).append(row) + return out + + +def has_independent_docs(rows: list[dict[str, str]], *, exclude_variants: bool = True) -> bool: + docs = {row.get("doc", "") for row in rows if row.get("doc", "")} + if exclude_variants: + docs = {re.sub(r"_fitted.*$", "", doc) for doc in docs} + return len(docs) >= 2 + + +def decide( + board: list[BoardRow], + cellbreak_rows: list[dict[str, str]], + photo_rows: list[dict[str, str]], +) -> list[Decision]: + decisions: list[Decision] = [] + cellbreak_by_doc = by_doc(cellbreak_rows) + photo_by_doc = by_doc(photo_rows) + independent_cellbreak = has_independent_docs(cellbreak_rows) + independent_photo = has_independent_docs(photo_rows) + + for row in sorted( + board, + key=lambda item: ( + item.cls != "page_count_gap", + -(float(item.mean_diff) if item.mean_diff else 0.0), + ), + ): + if row.cls == "page_count_clean": + decisions.append( + Decision( + rank=len(decisions) + 1, + category="guard", + doc=row.doc, + page=page_number(row), + status="guard", + reason="page count and visual score are currently below drift threshold", + next_command=f"python3 harness/fidelity_category_status.py --with-gallery {row.doc}", + ) + ) + continue + + if row.cls == "page_count_gap": + decisions.append( + Decision( + rank=len(decisions) + 1, + category="page-count", + doc=row.doc, + page=page_number(row), + status="patchable", + reason="page-count mismatch outranks visual drift", + next_command=f"RHWP_TABLE_DRIFT=1 /app/target/release/rhwp dump-pages /diff/{row.doc}/source.hwpx", + ) + ) + continue + + if row.doc in photo_by_doc: + status = "needs-more-guards" if not independent_photo else "probe" + reason = ( + "photo-grid source class currently appears in only one independent staged document" + if status == "needs-more-guards" + else "photo-grid class has independent guard coverage" + ) + decisions.append( + Decision( + rank=len(decisions) + 1, + category="photo-grid", + doc=row.doc, + page=page_number(row), + status=status, + reason=reason, + next_command=f"python3 harness/photo_grid_scan.py /tmp/diff --only-mixed && python3 harness/svg_image_ink_probe.py {page_ref(row)}", + ) + ) + continue + + if row.doc in cellbreak_by_doc: + status = "needs-more-guards" if not independent_cellbreak else "probe" + reason = ( + "CellBreak carried-rowspan class currently lacks an independent guard document" + if status == "needs-more-guards" + else "CellBreak carried-rowspan class has independent guard coverage" + ) + decisions.append( + Decision( + rank=len(decisions) + 1, + category="cellbreak-rowspan", + doc=row.doc, + page=page_number(row), + status=status, + reason=reason, + next_command=f"python3 harness/cellbreak_rowspan_scan.py --min-slack 0.0 && python3 harness/table_fragment_diag.py /tmp/diff/{row.doc}", + ) + ) + continue + + if row.cls == "visual_drift": + decisions.append( + Decision( + rank=len(decisions) + 1, + category="table-text-raster", + doc=row.doc, + page=page_number(row), + status="guard-only", + reason=( + "current evidence rejects broad SVG font/stroke/fill/y-shift knobs; " + "use as guard until a backend or cell-composition invariant appears" + ), + next_command=f"python3 harness/raster_backend_probe.py {page_ref(row)} --native --region focus:X0,Y0,X1,Y1", + ) + ) + continue + + decisions.append( + Decision( + rank=len(decisions) + 1, + category="unknown", + doc=row.doc, + page=page_number(row), + status="inspect", + reason=f"unclassified board class {row.cls}", + next_command=f"python3 harness/svg_geometry_probe.py {page_ref(row)}", + ) + ) + + return decisions + + +def markdown( + board: Path, + decisions: list[Decision], + cellbreak_rows: list[dict[str, str]], + photo_rows: list[dict[str, str]], +) -> str: + lines = [ + f"# RHWP Fidelity Queue -- {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + "", + f"Board: `{board}`", + "", + "| rank | category | doc | page | status | reason | next command |", + "|---:|---|---|---:|---|---|---|", + ] + for decision in decisions: + lines.append( + f"| {decision.rank} | `{decision.category}` | `{decision.doc}` | " + f"{decision.page} | `{decision.status}` | {decision.reason} | " + f"`{decision.next_command}` |" + ) + lines.extend( + [ + "", + "## Structural Scanner Summary", + "", + f"- CellBreak carried-rowspan rows: {len(cellbreak_rows)}.", + f"- Photo-grid rows: {len(photo_rows)}.", + "", + "Interpretation:", + "", + "- `patchable`: safe enough to start a structural probe now.", + "- `probe`: run targeted probes before Rust.", + "- `needs-more-guards`: do not patch until another independent doc appears.", + "- `guard-only`: useful for preventing regressions, not current patch source.", + ] + ) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--board", type=Path, default=DEFAULT_BOARD) + parser.add_argument("--out", type=Path, default=RHWP / "work" / "FIDELITY_QUEUE_CURRENT_2026-06-08.md") + args = parser.parse_args() + + board = args.board if args.board.is_absolute() else RHWP / args.board + out = args.out if args.out.is_absolute() else RHWP / args.out + board_rows = parse_board(board) + cellbreak = scanner_rows(["python3", "harness/cellbreak_rowspan_scan.py", "--min-slack", "0.0"]) + photo = scanner_rows(["python3", "harness/photo_grid_scan.py", "/tmp/diff"]) + decisions = decide(board_rows, cellbreak, photo) + out.parent.mkdir(parents=True, exist_ok=True) + text = markdown(board, decisions, cellbreak, photo) + out.write_text(text, encoding="utf-8") + print(out) + for decision in decisions: + print( + f"{decision.rank}\t{decision.category}\t{decision.doc}\t" + f"{decision.page}\t{decision.status}\t{decision.reason}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/fingerprint_baseline.txt b/harness/fingerprint_baseline.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/harness/fingerprint_lint.py b/harness/fingerprint_lint.py new file mode 100755 index 0000000000..a29c18114b --- /dev/null +++ b/harness/fingerprint_lint.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Block content-string ('fingerprint') fixes in the rhwp engine. + +The core anti-hardcode rule: a fix may branch on STRUCTURE (lineseg presence, +vpos resets, item type, table geometry) but NEVER on document CONTENT +(`text.contains("")`, fixture-named fns). The corpus is train+test, so +content-keyed fixes memorize one doc's answer and generalize to nothing. + +Scans the WORKING-TREE diff (unstaged + staged) of src/renderer and +src/document_core for ADDED lines that: + - call .contains("...") / .starts_with("...") / == "..." with a literal + containing non-ASCII (Hangul) or >=6 ASCII letters (doc-text-like), OR + - define a fn whose name looks fixture-specific (is_sampleNN_, _hwp, doc id). +Existing debt is grandfathered via baseline.txt (exact added-line text). + +Exit 1 (blocks commit) on any NEW offender. Exit 0 if clean. + +Usage: fingerprint_lint.py [RHWP_DIR] +""" +import sys, os, re, subprocess + +RHWP = sys.argv[1] if len(sys.argv) > 1 else \ + "/Users/jaehoshin/Desktop/mindlogic/factchat/worktree-rhwp-poc/rhwp" +BASELINE = os.path.join(os.path.dirname(__file__), "fingerprint_baseline.txt") +DIRS = ("src/renderer", "src/document_core") + +CONTENT_CALL = re.compile( + r'\.(?:contains|starts_with|ends_with|find)\("([^"]*)"\)|==\s*"([^"]+)"') +FIXTURE_FN = re.compile( + r'\bfn\s+(is_sample\w+|\w*_hwp\b\w*|\w*fixture\w*|\w*_doc\d+\w*)\s*\(') +HANGUL = re.compile(r'[가-힣]') + + +def looks_like_doctext(s): + if HANGUL.search(s): + return True + return len(re.findall(r'[A-Za-z]', s)) >= 6 + + +def added_lines(): + """Return added (+) lines from working-tree diff against HEAD for DIRS.""" + cmd = ["git", "-C", RHWP, "diff", "HEAD", "--unified=0", "--"] + list(DIRS) + out = subprocess.run(cmd, capture_output=True, text=True).stdout + for ln in out.splitlines(): + if ln.startswith("+") and not ln.startswith("+++"): + yield ln[1:] + + +def main(): + baseline = set() + if os.path.exists(BASELINE): + baseline = {l.rstrip("\n") for l in open(BASELINE, encoding="utf-8")} + offenders = [] + for raw in added_lines(): + line = raw.strip() + if not line or line.startswith("//") or line in baseline: + continue + hit = None + m = CONTENT_CALL.search(line) + if m: + lit = m.group(1) or m.group(2) or "" + if looks_like_doctext(lit): + hit = f'content-string match: "{lit[:40]}"' + if not hit and FIXTURE_FN.search(line): + hit = f'fixture-named fn: {FIXTURE_FN.search(line).group(1)}' + if hit: + offenders.append((hit, line[:100])) + if offenders: + print("FINGERPRINT LINT FAILED — content-keyed fix detected:") + for why, ln in offenders: + print(f" [{why}] {ln}") + print("\nFix must key on STRUCTURE, not document content. " + "If this is intentional grandfathered debt, add the exact added " + "line to harness/fingerprint_baseline.txt.") + sys.exit(1) + print("fingerprint_lint: clean (0 new content-keyed fixes)") + + +if __name__ == "__main__": + main() diff --git a/harness/focus_gate.sh b/harness/focus_gate.sh new file mode 100755 index 0000000000..9f54646bc4 --- /dev/null +++ b/harness/focus_gate.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Focus-corpus page-count + overflow gate. +# +# Usage: +# bash harness/focus_gate.sh quick +# bash harness/focus_gate.sh medium +# bash harness/focus_gate.sh all +# bash harness/focus_gate.sh quick --build +# +# This is intentionally narrower than harness/gate.sh. It does not replace the +# full regression gate before committing; it makes first-pass iteration cheap on +# the user-selected problem set. +set -euo pipefail + +RHWP="${RHWP_ROOT:-/Users/jaehoshin/Desktop/mindlogic/factchat/worktree-rhwp-poc/rhwp}" +DIFF="${DIFF_DIR:-/tmp/diff}" +H="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TIER="${1:-quick}" +BUILD=0 +if [[ "${2:-}" == "--build" || "${1:-}" == "--build" ]]; then + BUILD=1 + [[ "${1:-}" == "--build" ]] && TIER="quick" +fi + +cd "$RHWP" +if [[ "$BUILD" == "1" ]]; then + echo "[focus-gate] building working-tree binary (CARGO_BUILD_JOBS=1)..." + CARGO_BUILD_JOBS=1 docker compose --env-file .env.docker run --rm -e CARGO_BUILD_JOBS=1 dev \ + cargo build --release --bin rhwp +fi + +python3 - "$H/focus_set.tsv" "$TIER" > "$DIFF/_focus_docs.txt" <<'PY' +import csv, sys +manifest, tier = sys.argv[1], sys.argv[2] +allowed = {"quick"} if tier == "quick" else {"medium"} if tier == "medium" else {"quick", "medium", "giant"} +with open(manifest, newline="") as f: + for row in csv.reader(f, delimiter="\t"): + if not row or row[0].startswith("#"): + continue + if row[2] in allowed: + print(row[0]) +PY + +echo "[focus-gate] rendering tier=$TIER docs=$(wc -l < "$DIFF/_focus_docs.txt" | tr -d ' ')" +docker compose --env-file .env.docker run --rm -v "$DIFF":/diff dev bash -c ' + set -euo pipefail + BIN=/app/target/release/rhwp + while IFS= read -r n; do + d="/diff/$n" + src="$d/source.hwpx" + [ -f "$src" ] || src="$d/source_converted.hwpx" + if [ ! -f "$src" ]; then + echo -e "$n\tMISSING\tMISSING" + continue + fi + out=$(mktemp -d) + log=$("$BIN" export-svg "$src" -o "$out" 2>&1 >/dev/null || true) + pages=$(ls "$out"/*.svg 2>/dev/null | wc -l | tr -d " ") + ov=$(printf "%s" "$log" | grep -c LAYOUT_OVERFLOW || true) + rm -rf "$out" + echo -e "$n\t$pages\t$ov" + done < /diff/_focus_docs.txt +' > "$DIFF/_focus_gate_now.tsv" + +python3 - "$H/focus_set.tsv" "$H/baseline_pc.tsv" "$DIFF/_focus_gate_now.tsv" "$TIER" <<'PY' +import csv, sys +manifest, baseline_path, now_path, tier = sys.argv[1:] +allowed = {"quick"} if tier == "quick" else {"medium"} if tier == "medium" else {"quick", "medium", "giant"} +baseline = {} +with open(baseline_path) as f: + for line in f: + if not line.strip(): + continue + doc, pages, ov = line.rstrip("\n").split("\t") + baseline[doc] = (int(pages), int(ov)) +expected = {} +with open(manifest, newline="") as f: + for row in csv.reader(f, delimiter="\t"): + if not row or row[0].startswith("#") or row[2] not in allowed: + continue + expected[row[0]] = {"tier": row[2], "hancom": int(row[3]), "rhwp": int(row[4]), "note": row[5]} +now = {} +with open(now_path) as f: + for line in f: + doc, pages, ov = line.rstrip("\n").split("\t") + if pages != "MISSING": + now[doc] = (int(pages), int(ov)) + +improved = [] +same = [] +regressed = [] +overflow = [] +missing = [] +for doc, meta in expected.items(): + if doc not in now: + missing.append(doc) + continue + pages, ov = now[doc] + base_pages, base_ov = baseline.get(doc, (meta["rhwp"], 0)) + oracle = meta["hancom"] + if abs(pages - oracle) < abs(base_pages - oracle): + improved.append((doc, base_pages, pages, oracle)) + elif abs(pages - oracle) > abs(base_pages - oracle): + regressed.append((doc, base_pages, pages, oracle)) + else: + same.append((doc, pages, oracle)) + if ov > base_ov: + overflow.append((doc, base_ov, ov)) + +print(f"focus tier={tier}: docs={len(expected)} improved={len(improved)} same={len(same)} regressed={len(regressed)} missing={len(missing)} new_overflow={len(overflow)}") +for doc, b, p, h in improved: + print(f" IMPROVE {doc}: {b}->{p} oracle={h}") +for doc, p, h in same: + marker = "OK" if p == h else "DIFF" + print(f" {marker:4} {doc}: {p}/{h}") +for doc, b, p, h in regressed: + print(f" REGRESS {doc}: {b}->{p} oracle={h}") +for doc, b, ov in overflow: + print(f" +OVERFLOW {doc}: {b}->{ov}") +for doc in missing: + print(f" MISSING {doc}") +raise SystemExit(1 if regressed or overflow or missing else 0) +PY diff --git a/harness/focus_index.py b/harness/focus_index.py new file mode 100755 index 0000000000..598f650998 --- /dev/null +++ b/harness/focus_index.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Build /tmp/diff/_FOCUS_INDEX.html for the user-selected problem set.""" +from __future__ import annotations + +import csv +import html +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +DIFF = Path("/tmp/diff") +FOCUS = ROOT / "focus_set.tsv" +OUT = DIFF / "_FOCUS_INDEX.html" +FOCUS_DIR = DIFF / "_focus" + + +def rows(): + with FOCUS.open(newline="") as f: + for row in csv.reader(f, delimiter="\t"): + if not row or row[0].startswith("#"): + continue + doc, kind, tier, hancom, rhwp, note = row + yield doc, kind, tier, int(hancom), int(rhwp), note + + +def main() -> int: + FOCUS_DIR.mkdir(exist_ok=True) + table_rows = [] + counts = {"quick": 0, "medium": 0, "giant": 0} + for doc, kind, tier, hancom, rhwp, note in rows(): + counts[tier] = counts.get(tier, 0) + 1 + delta = rhwp - hancom + status = "OK" if delta == 0 else f"{delta:+d}" + status_class = "ok" if delta == 0 else "bad" + source_dir = DIFF / doc + exists = source_dir.exists() + focus_link = FOCUS_DIR / doc + if exists and not focus_link.exists(): + focus_link.symlink_to(Path("..") / doc, target_is_directory=True) + link = f"_focus/{html.escape(doc)}/" if exists else "#" + table_rows.append( + "" + f"{html.escape(doc)}" + f"{html.escape(kind)}" + f"{html.escape(tier)}" + f"{rhwp}{hancom}" + f"{status}" + f"{html.escape(note)}" + "" + ) + + body = "\n".join(table_rows) + OUT.write_text( + f""" + +rhwp focus validation set + +
+

rhwp focus validation set

+
User-selected problematic docs · quick {counts.get('quick',0)} · medium {counts.get('medium',0)} · giant {counts.get('giant',0)}
+
+
+

Start with quick. Defer giant until the shorter spacing/table fixes stop moving the cluster.

+ + + +{body} + +
dockindtierrhwp pagesHancom pagesdeltanote
+
+""", + encoding="utf-8", + ) + (FOCUS_DIR / "index.html").write_text( + """ + +rhwp focus set + +Open focus index +""", + encoding="utf-8", + ) + print(OUT) + print(FOCUS_DIR / "index.html") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/focus_next.py b/harness/focus_next.py new file mode 100755 index 0000000000..196efdba8a --- /dev/null +++ b/harness/focus_next.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Print the focus-corpus work queue, grouped by tier.""" +from __future__ import annotations + +import csv +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +FOCUS = ROOT / "focus_set.tsv" + + +def rows(): + with FOCUS.open(newline="") as f: + for row in csv.reader(f, delimiter="\t"): + if not row or row[0].startswith("#"): + continue + doc, kind, tier, hancom, rhwp, note = row + yield { + "doc": doc, + "kind": kind, + "tier": tier, + "hancom": int(hancom), + "rhwp": int(rhwp), + "note": note, + } + + +def main() -> int: + groups = {"quick": [], "medium": [], "giant": []} + for row in rows(): + groups.setdefault(row["tier"], []).append(row) + + for tier in ("quick", "medium", "giant"): + print(f"\n== {tier.upper()} ==") + for row in groups.get(tier, []): + delta = row["rhwp"] - row["hancom"] + marker = "OK" if delta == 0 else f"{delta:+d}" + print( + f"{row['doc']}\t{row['kind']}\t{row['rhwp']}/{row['hancom']} pages\t{marker}\t{row['note']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/focus_set.tsv b/harness/focus_set.tsv new file mode 100644 index 0000000000..cb66528d23 --- /dev/null +++ b/harness/focus_set.tsv @@ -0,0 +1,29 @@ +# doc kind tier hancom_pages rhwp_pages note +# tier: +# quick = first pass, manageable visual/debug loop +# medium = useful but likely shared drift/table path or longer visual review +# giant = skip until simple gains are exhausted +15_3740450_research_admin_innovation_meeting_template snu quick 5 6 photo-grid/page-count; manageable SNU regression target +20_3727659_resume_2605_ai snu quick 1 1 one-page visual/overflow sanity check +accountability_eval samples quick 11 4 severe table row-height/line-height under-measure +form_01______________49KB wild quick 2 2 tight spacing/table row visual drift +form_04______________15KB wild quick 3 3 short form visual drift check +form_07_______________41KB wild quick 11 13 page-count drift but still small enough for first pass +form_15_____________59KB wild quick 3 3 short table/form visual drift check +form_17_______________81KB wild quick 9 9 page-count OK; verify visual drift before deeper fixes +med_02____2_4MB wild quick 6 6 line-height/float-flow candidate; manageable page count +meeting wild quick 5 6 image pagination/page-count drift +meeting_summary samples quick 3 3 line-height drift, short doc +overseas_training samples quick 4 4 row-height/line-height drift, short doc +report_form samples quick 6 5 under-paginates; short high-signal doc +tiny_03_______38KB wild quick 3 3 short visual sanity check +05_3781559_medschool_car_2bu_je_plan snu quick 4 4 SNU known-good/visual guard in focus set +form_22____________________270KB wild medium 37 38 longer cumulative drift; defer until quick cluster moves +form_24_____4MB wild medium 84 84 long page-count OK visual drift; expensive review +form_27_______________58MB wild medium 8 8 large file despite short page count; review after quick pass +13_3763367_k_star_visa_track_plan snu medium 19 20 K-STAR table drift/page-count; high-value but complex +09_3766093_natural_sci_planning_committee_260527 snu giant 51 54 long agenda; known trap/intentional short-page risk +huge_01_DNA________33MB wild giant 41 46 giant cumulative drift +huge_02_DNA_______75MB wild giant 67 73 giant cumulative drift +large_01_____________16MB wild giant 39 37 large under-paginate + overflow markers +med_03____________4MB wild giant 47 48 long cumulative drift diff --git a/harness/gate.sh b/harness/gate.sh new file mode 100755 index 0000000000..cc2eb15c34 --- /dev/null +++ b/harness/gate.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Corpus regression gate — build working-tree binary, then page-count + overflow +# sweep across ALL docs. BLOCKS (exit 1) if any doc moved AWAY from its baseline +# page count or gained overflow. Page-count is reliable for REGRESSION detection +# (it's only unreliable as an IMPROVEMENT signal — that's drift.py's job). +# +# gate.sh build + sweep + compare to baseline +# gate.sh --no-build skip rebuild (binary already current) +# gate.sh --update-baseline adopt current as new baseline (after a verified win) +set -uo pipefail +RHWP=/Users/jaehoshin/Desktop/mindlogic/factchat/worktree-rhwp-poc/rhwp +H=/tmp/diff/harness +BASE="$H/baseline_pc.tsv" +cd "$RHWP" + +if [[ "${1:-}" != "--no-build" && "${1:-}" != "--update-baseline" ]]; then + echo "[gate] building working-tree binary (CARGO_BUILD_JOBS=1)..." + CARGO_BUILD_JOBS=1 docker compose --env-file .env.docker run --rm -e CARGO_BUILD_JOBS=1 dev \ + cargo build --release --bin rhwp > "$H/_gate_build.log" 2>&1 + if ! grep -q 'Finished' "$H/_gate_build.log"; then + echo "[gate] BUILD FAILED — see $H/_gate_build.log"; tail -15 "$H/_gate_build.log"; exit 1 + fi +fi + +docker compose --env-file .env.docker run --rm -v /tmp/diff:/diff dev bash -c ' + BIN=/app/target/release/rhwp + for d in /diff/*/; do + n=$(basename "$d"); src="$d/source.hwpx"; [ -f "$src" ] || src="$d/source_converted.hwpx" + [ -f "$src" ] || continue + out=$(mktemp -d) + log=$("$BIN" export-svg "$src" -o "$out" 2>&1 >/dev/null) + pages=$(ls "$out"/*.svg 2>/dev/null | wc -l | tr -d " ") + ov=$(printf "%s" "$log" | grep -c LAYOUT_OVERFLOW || true) + rm -rf "$out"; echo "$n $pages $ov" + done' > "$H/_gate_now.tsv" 2>/dev/null +sort -o "$H/_gate_now.tsv" "$H/_gate_now.tsv" + +if [[ "${1:-}" == "--update-baseline" ]]; then + cp "$H/_gate_now.tsv" "$BASE"; echo "[gate] baseline updated ($(wc -l < "$BASE") docs)"; exit 0 +fi +if [[ ! -f "$BASE" ]]; then + cp "$H/_gate_now.tsv" "$BASE"; echo "[gate] baseline created ($(wc -l < "$BASE") docs); re-run to compare"; exit 0 +fi + +python3 - "$BASE" "$H/_gate_now.tsv" "$H/_hancom_pages_for_gate.json" <<'PY' +import sys,json,os +base={l.split("\t")[0]:l.strip().split("\t")[1:] for l in open(sys.argv[1]) if l.strip()} +now ={l.split("\t")[0]:l.strip().split("\t")[1:] for l in open(sys.argv[2]) if l.strip()} +hc=json.load(open('/tmp/diff/_hancom_pages.json')) if os.path.exists('/tmp/diff/_hancom_pages.json') else {} +regress, improve, ov_new = [], [], [] +for d,(bp,bov) in base.items(): + if d not in now: continue + np,nov=now[d] + h=str(hc.get(d,'')) + if np!=bp: + # moved toward oracle = improve; away = regress + if h and abs(int(np)-int(h))abs(int(bp)-int(h)): regress.append((d,bp,np,h)) + else: regress.append((d,bp,np,h)) # unknown direction = treat as regress (safe) + if int(nov)>int(bov): ov_new.append((d,bov,nov)) +print(f"[gate] docs={len(now)} improved={len(improve)} regressed={len(regress)} new_overflow={len(ov_new)}") +for d,b,n,h in improve: print(f" IMPROVE {d}: {b}->{n} (oracle {h})") +for d,b,n,h in regress: print(f" REGRESS {d}: {b}->{n} (oracle {h})") +for d,b,n in ov_new: print(f" +OVERFLOW {d}: {b}->{n}") +sys.exit(1 if (regress or ov_new) else 0) +PY diff --git a/harness/hancom_pages.json b/harness/hancom_pages.json new file mode 100644 index 0000000000..46fa3785ab --- /dev/null +++ b/harness/hancom_pages.json @@ -0,0 +1,76 @@ +{ +"form_01______________49KB": 2, +"form_11____________48KB": 2, +"multicultural_plan": 3, +"org_diagnosis": 16, +"08_3767791_kei_snu_intl_mou": 3, +"form_20______________90KB": 8, +"huge_01_DNA________33MB": 41, +"med_01_________225KB": 8, +"form_02____5______": 1, +"form_12_20260415_____53KB": 5, +"form_26___________________15MB": 6, +"form_08_PJBL_______44KB": 4, +"form_25__________________6MB": 29, +"form_07_______________41KB": 11, +"expense_form": 1, +"form_05________________30KB": 1, +"small_01_____________46KB": 4, +"11_3764309_meeting4_transcript": 38, +"form_18_______260414_86KB": 5, +"accountability_eval": 11, +"20_3727659_resume_2605_ai": 1, +"overseas_training": 4, +"form_23___________________3MB": 26, +"research_form": 2, +"16_3738193_secure_admin_ai_service_plan": 4, +"14_3762372_manual_template": 1, +"form_21____________98KB": 1, +"form_19_________________88KB": 1, +"form_16_______________76KB": 2, +"lab_report": 2, +"06_3778860_ehp_change_proposal": 1, +"large_01_____________16MB": 39, +"meeting_summary": 3, +"tiny_01__________17KB": 4, +"02_3783665_snu_emeritus_professor_regulation": 3, +"med_02____2_4MB": 6, +"18_3728528_research_fund_repayment_request": 1, +"09_3766093_natural_sci_planning_committee_260527": 51, +"04_3781571_car_2bu_je_notice": 2, +"05_3781559_medschool_car_2bu_je_plan": 4, +"form_14_________________________________59KB": 2, +"medschool": 4, +"meeting": 5, +"gifted_application": 1, +"tiny_02________16KB": 3, +"small_02____________46KB": 1, +"form_04______________15KB": 3, +"form_17_______________81KB": 9, +"form_24_____4MB": 84, +"small_03_________73KB": 3, +"15_3740450_research_admin_innovation_meeting_template": 5, +"form_06_____________37KB": 3, +"small_04____________79KB": 1, +"tiny_03_______38KB": 3, +"form_10______________49KB": 2, +"01_3787018_81dong_cafeteria_capsule_coffee_review_20260527": 5, +"07_3775010_attachment4_org_opinions_combined": 14, +"report_form": 6, +"form_13_________54KB": 1, +"huge_02_DNA_______75MB": 67, +"03_3781727_staff_evaluation_table": 1, +"13_3763367_k_star_visa_track_plan": 19, +"form_09___________48KB": 1, +"form_27_______________58MB": 8, +"internship_plan": 11, +"form_15_____________59KB": 3, +"19_3728140_snu_finance_regulation_v3": 8, +"17_3728967_forest_mou_report": 2, +"10_3764349_development_plan_v1": 3, +"12_3763876_development_plan_v2": 3, +"med_03____________4MB": 47, +"form_22____________________270KB": 37, +"writing_template": 2, +"form_03_signup_consent_form": 2 +} \ No newline at end of file diff --git a/harness/heading_drift_scan.py b/harness/heading_drift_scan.py new file mode 100644 index 0000000000..1c9e9634e9 --- /dev/null +++ b/harness/heading_drift_scan.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Extract RHWP heading-like paragraphs and map them to Hancom pages.""" +from __future__ import annotations + +import argparse +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +DIFF = Path("/tmp/diff") +PAGE_RE = re.compile(r"^=== 페이지 (?P\d+) ") +PARA_RE = re.compile(r'FullParagraph\s+pi=(?P\d+)\s+h=(?P[0-9.]+).*?"(?P.*)"$') + + +@dataclass(frozen=True) +class Hit: + rhwp_page: int + pi: int + text: str + + +def clean(text: str) -> str: + return " ".join(text.replace("\u00a0", " ").split()).strip() + + +def is_heading(text: str) -> bool: + if not text or text == "(빈)": + return False + if len(text) > 80: + return False + patterns = ( + r"^\d+\s+", + r"^\d+\.", + r"^\(\d+\)", + r"^[가-힣]\.", + r"^[IVXⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+[.)]", + r"^□\s*", + r"^󰊱|^󰊲|^󰊳|^󰊴|^󰊵", + ) + return any(re.search(pattern, text) for pattern in patterns) + + +def extract_rhwp_headings(dump: Path) -> list[Hit]: + hits: list[Hit] = [] + page = 0 + for raw in dump.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if m := PAGE_RE.match(line): + page = int(m.group("page")) + continue + if not page: + continue + if m := PARA_RE.search(line): + text = clean(m.group("text")) + if is_heading(text): + hits.append(Hit(page, int(m.group("pi")), text)) + return hits + + +def hancom_text_pages(pdf: Path) -> list[str]: + text = subprocess.check_output( + ["pdftotext", "-layout", str(pdf), "-"], + text=True, + errors="ignore", + ) + return text.split("\f") + + +def find_hancom_pages(pages: list[str], needle: str) -> list[int]: + compact = " ".join(needle.split()) + out: list[int] = [] + for idx, body in enumerate(pages, 1): + body_compact = " ".join(body.split()) + if needle in body or compact in body_compact: + out.append(idx) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("doc") + parser.add_argument("--dump", type=Path) + parser.add_argument("--pdf", type=Path) + parser.add_argument("--page-min", type=int, default=1) + parser.add_argument("--page-max", type=int) + args = parser.parse_args() + + docdir = DIFF / args.doc + dump = args.dump or docdir / "dump_pages_current.txt" + pdf = args.pdf or docdir / "hancom.pdf" + if not dump.exists(): + raise SystemExit(f"missing dump {dump}") + if not pdf.exists(): + raise SystemExit(f"missing Hancom PDF {pdf}") + + hancom_pages = hancom_text_pages(pdf) + print("rhwp_page\tpi\thancom_page\tdelta\ttext") + seen: set[str] = set() + for hit in extract_rhwp_headings(dump): + if hit.rhwp_page < args.page_min: + continue + if args.page_max is not None and hit.rhwp_page > args.page_max: + continue + if hit.text in seen: + continue + seen.add(hit.text) + hp = find_hancom_pages(hancom_pages, hit.text) + if not hp: + continue + print(f"{hit.rhwp_page}\t{hit.pi}\t{hp[0]}\t{hit.rhwp_page - hp[0]}\t{hit.text}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/heading_photo_table_scan.py b/harness/heading_photo_table_scan.py new file mode 100644 index 0000000000..d87a691c73 --- /dev/null +++ b/harness/heading_photo_table_scan.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Scan RHWP dump-pages output for heading + near-full-page photo table shapes.""" +from __future__ import annotations + +import argparse +import re +from dataclasses import dataclass +from pathlib import Path + + +DIFF = Path("/tmp/diff") + + +PAGE_RE = re.compile(r"\n=== 페이지 (\d+) .*?===\n") +PARA_RE = re.compile( + r"FullParagraph\s+pi=(?P\d+)\s+h=(?P[0-9.]+).*?vpos=(?P[^ ]+)\s+\"(?P.*)\"" +) +TABLE_RE = re.compile( + r"Table\s+pi=(?P\d+)\s+ci=(?P\d+)\s+" + r"(?P\d+x\d+)\s+(?P[0-9.]+)x(?P[0-9.]+)px\s+" + r"wrap=(?P\w+)\s+tac=(?P\w+)\s+vpos=(?P[^ ]+)" +) + + +@dataclass(frozen=True) +class PageItem: + kind: str + pi: int + height: float + text: str = "" + shape: str = "" + wrap: str = "" + tac: str = "" + + +def parse_dump(path: Path) -> list[tuple[int, list[PageItem]]]: + text = path.read_text(encoding="utf-8", errors="ignore") + chunks = PAGE_RE.split(text) + pages: list[tuple[int, list[PageItem]]] = [] + for idx in range(1, len(chunks), 2): + page = int(chunks[idx]) + body = chunks[idx + 1] + items: list[PageItem] = [] + for line in body.splitlines(): + if m := PARA_RE.search(line): + items.append( + PageItem( + kind="para", + pi=int(m.group("pi")), + height=float(m.group("h")), + text=m.group("text"), + ) + ) + continue + if m := TABLE_RE.search(line): + items.append( + PageItem( + kind="table", + pi=int(m.group("pi")), + height=float(m.group("h")), + shape=m.group("shape"), + wrap=m.group("wrap"), + tac=m.group("tac"), + ) + ) + pages.append((page, items)) + return pages + + +def is_heading_text(text: str) -> bool: + stripped = text.strip() + if not stripped or stripped == "(빈)": + return False + if len(stripped) > 80: + return False + return bool( + re.match(r"^([가-힣]\.|[0-9]+\.|\([0-9]+\)|[□○󰊱-󰊵])", stripped) + ) + + +def table_rows(shape: str) -> int: + try: + return int(shape.split("x", 1)[0]) + except ValueError: + return 0 + + +def dump_path_for(doc: str, override: str) -> Path: + if override: + path = Path(override) + if path.is_file(): + return path + candidate = DIFF / doc / override + if candidate.exists(): + return candidate + raise SystemExit(f"missing dump override {override!r} for {doc}") + dump = DIFF / doc / "dump_pages_current.txt" + if dump.exists(): + return dump + dump = DIFF / doc / "dump_pages_debug.txt" + if dump.exists(): + return dump + raise SystemExit(f"missing dump_pages_current.txt for {doc}") + + +def scan(doc: str, min_table_height: float, min_rows: int, dump_override: str) -> list[str]: + dump = dump_path_for(doc, dump_override) + rows: list[str] = [] + for page, items in parse_dump(dump): + for idx, item in enumerate(items[:-1]): + nxt = items[idx + 1] + prev = items[idx - 1] if idx > 0 else None + if item.kind != "para" or nxt.kind != "table": + continue + if not is_heading_text(item.text): + continue + if nxt.wrap != "TopAndBottom" or nxt.height < min_table_height: + continue + if table_rows(nxt.shape) < min_rows: + continue + rows.append( + "\t".join( + [ + doc, + str(page), + str(item.pi), + f"{item.height:.1f}", + item.text, + str(nxt.pi), + nxt.shape, + f"{nxt.height:.1f}", + nxt.tac, + prev.kind if prev else "", + str(prev.pi) if prev else "", + f"{prev.height:.1f}" if prev else "", + ] + ) + ) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("docs", nargs="+") + parser.add_argument("--min-table-height", type=float, default=650.0) + parser.add_argument("--min-rows", type=int, default=4) + parser.add_argument("--dump", default="", help="dump filename/path to use instead of dump_pages_current.txt") + args = parser.parse_args() + + print( + "doc\tpage\theading_pi\theading_h\theading_text\ttable_pi\tshape\ttable_h\ttac\tprev_kind\tprev_pi\tprev_h" + ) + for doc in args.docs: + for row in scan(doc, args.min_table_height, args.min_rows, args.dump): + print(row) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/hwpx_para_source_probe.py b/harness/hwpx_para_source_probe.py new file mode 100644 index 0000000000..0fc443a2fd --- /dev/null +++ b/harness/hwpx_para_source_probe.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Inspect HWPX source paragraphs around renderer paragraph indexes. + +This is a read-only triage helper for fidelity work. It prints paragraph text, +line segment geometry, break attributes, and nearby control/table summaries so +renderer dumps can be tied back to structural HWPX metadata without relying on +document text in renderer code. +""" +from __future__ import annotations + +import argparse +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path + + +NS = { + "hp": "http://www.hancom.co.kr/hwpml/2011/paragraph", +} + + +def find_source(path: Path) -> Path: + if path.is_file(): + return path + for name in ("source.hwpx", "source_converted.hwpx", "source_fitted.hwpx"): + candidate = path / name + if candidate.exists(): + return candidate + raise SystemExit(f"no HWPX source found under {path}") + + +def compact(attrs: dict[str, str], keys: tuple[str, ...]) -> str: + return " ".join(f"{key}={attrs[key]}" for key in keys if key in attrs) + + +def table_shape(table: ET.Element) -> tuple[int, int, int]: + max_row = -1 + max_col = -1 + cells = table.findall(".//hp:tc", NS) + for cell in cells: + addr = cell.find("hp:cellAddr", NS) + if addr is None: + continue + max_row = max(max_row, int(addr.attrib.get("rowAddr", "0"))) + max_col = max(max_col, int(addr.attrib.get("colAddr", "0"))) + return max_row + 1, max_col + 1, len(cells) + + +def child_attrs(parent: ET.Element, name: str) -> dict[str, str]: + elem = parent.find(f"hp:{name}", NS) + return dict(elem.attrib) if elem is not None else {} + + +def para_text(para: ET.Element) -> str: + parts: list[str] = [] + for text in para.findall(".//hp:t", NS): + if text.text: + parts.append(text.text) + joined = "".join(parts) + return " ".join(joined.split()) + + +def iter_paragraphs(hwpx: Path) -> list[tuple[str, ET.Element]]: + out: list[tuple[str, ET.Element]] = [] + with zipfile.ZipFile(hwpx) as zf: + sections = sorted(name for name in zf.namelist() if name.startswith("Contents/section")) + for name in sections: + root = ET.fromstring(zf.read(name)) + for para in root.findall("hp:p", NS): + out.append((name, para)) + return out + + +def describe_para(index: int, section: str, para: ET.Element, table_base: int) -> tuple[str, int]: + text = para_text(para) + attrs = compact( + dict(para.attrib), + ( + "id", + "paraPrIDRef", + "styleIDRef", + "pageBreak", + "columnBreak", + "merged", + ), + ) + linesegs = para.findall(".//hp:lineseg", NS) + tables = para.findall("hp:run/hp:tbl", NS) + pics = para.findall(".//hp:pic", NS) + shapes = para.findall(".//hp:container", NS) + para.findall(".//hp:shape", NS) + + lines = [ + f"pi={index} section={section} attrs={attrs} text={text[:120]!r}", + f" counts: lineSegs={len(linesegs)} directTables={len(tables)} pics={len(pics)} shapes={len(shapes)}", + ] + for li, seg in enumerate(linesegs[:5]): + lines.append( + " lineSeg[{li}] ".format(li=li) + + compact( + dict(seg.attrib), + ( + "textpos", + "vertpos", + "vertsize", + "textheight", + "baseline", + "spacing", + "horzpos", + "horzsize", + ), + ) + ) + if len(linesegs) > 5: + lines.append(f" ... {len(linesegs) - 5} more lineSegs") + + table_index = table_base + for ci, table in enumerate(tables): + rows, cols, cell_count = table_shape(table) + size = child_attrs(table, "sz") + lines.append( + f" table[{ci}] globalTable={table_index} rows={rows} cols={cols} cells={cell_count} " + f"attrs={compact(dict(table.attrib), ('textWrap', 'pageBreak', 'repeatHeader', 'cellSpacing', 'borderFillIDRef'))} " + f"size={compact(size, ('width', 'height', 'widthRelTo', 'heightRelTo'))}" + ) + table_index += 1 + return "\n".join(lines), table_index + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("docdir", type=Path) + parser.add_argument("--start", type=int, required=True) + parser.add_argument("--end", type=int, required=True) + args = parser.parse_args() + + source = find_source(args.docdir) + paragraphs = iter_paragraphs(source) + if args.start < 0 or args.end >= len(paragraphs) or args.start > args.end: + raise SystemExit(f"invalid range; found {len(paragraphs)} paragraphs") + + print(f"source: {source}") + table_base = 0 + for index, (section, para) in enumerate(paragraphs): + if args.start <= index <= args.end: + body, next_table = describe_para(index, section, para, table_base) + print(body) + table_base = next_table + else: + table_base += len(para.findall("hp:run/hp:tbl", NS)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/hwpx_row_floor_scan.py b/harness/hwpx_row_floor_scan.py new file mode 100644 index 0000000000..1e6509e887 --- /dev/null +++ b/harness/hwpx_row_floor_scan.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Scan HWPX table rows where declared cell height exceeds saved content height. + +This is a read-only fidelity triage helper. It intentionally reports structural +geometry only: table/page-break attributes, row/column/span shape, declared cell +height, saved line-segment extents, and nested table extents. +""" +from __future__ import annotations + +import argparse +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path + + +NS = { + "hp": "http://www.hancom.co.kr/hwpml/2011/paragraph", +} +DPI = 96.0 + + +def hu_to_px(raw: str | int | None) -> float: + if raw is None: + return 0.0 + return int(raw) * DPI / 7200.0 + + +def find_source(path: Path) -> Path: + if path.is_file(): + return path + for name in ("source.hwpx", "source_converted.hwpx", "source_fitted.hwpx"): + candidate = path / name + if candidate.exists(): + return candidate + raise SystemExit(f"no HWPX source found under {path}") + + +def direct_text(elem: ET.Element) -> str: + return " ".join("".join(elem.itertext()).split()) + + +def line_seg_bottom_px(para: ET.Element) -> float: + bottom = 0.0 + for seg in para.findall(".//hp:lineseg", NS): + attrs = seg.attrib + y = hu_to_px(attrs.get("vertpos")) + h = max( + hu_to_px(attrs.get("vertsize")), + hu_to_px(attrs.get("textheight")), + hu_to_px(attrs.get("spacing")), + ) + bottom = max(bottom, y + h) + return bottom + + +def nested_table_height_px(cell: ET.Element) -> float: + total = 0.0 + for nested in cell.findall(".//hp:tbl", NS): + sz = nested.find("hp:sz", NS) + if sz is not None: + total += hu_to_px(sz.attrib.get("height")) + continue + row_heights = [] + for row in nested.findall("hp:tr", NS): + heights = [] + for nested_cell in row.findall("hp:tc", NS): + cell_sz = nested_cell.find("hp:cellSz", NS) + if cell_sz is not None: + heights.append(hu_to_px(cell_sz.attrib.get("height"))) + if heights: + row_heights.append(max(heights)) + total += sum(row_heights) + return total + + +def direct_cells(row: ET.Element) -> list[ET.Element]: + return row.findall("hp:tc", NS) + + +def cell_addr(cell: ET.Element) -> tuple[int, int]: + addr = cell.find("hp:cellAddr", NS) + if addr is None: + return (0, 0) + return (int(addr.attrib.get("rowAddr", "0")), int(addr.attrib.get("colAddr", "0"))) + + +def scan_source(source: Path, min_slack: float, table_index: int | None) -> None: + print(f"source: {source}") + with zipfile.ZipFile(source) as zf: + tables: list[tuple[str, ET.Element]] = [] + for name in sorted(n for n in zf.namelist() if n.startswith("Contents/section")): + root = ET.fromstring(zf.read(name)) + for table in root.findall(".//hp:tbl", NS): + tables.append((name, table)) + + for ti, (section, table) in enumerate(tables): + if table_index is not None and ti != table_index: + continue + rows = table.findall("hp:tr", NS) + attrs = table.attrib + for ri, row in enumerate(rows): + row_declared = 0.0 + row_content = 0.0 + cell_notes = [] + for cell in sorted(direct_cells(row), key=cell_addr): + span = cell.find("hp:cellSpan", NS) + row_span = int(span.attrib.get("rowSpan", "1")) if span is not None else 1 + col_span = int(span.attrib.get("colSpan", "1")) if span is not None else 1 + if row_span != 1: + continue + cell_sz = cell.find("hp:cellSz", NS) + declared = hu_to_px(cell_sz.attrib.get("height")) if cell_sz is not None else 0.0 + direct_paras = cell.findall("./hp:subList/hp:p", NS) + line_bottom = max((line_seg_bottom_px(p) for p in direct_paras), default=0.0) + nested_h = nested_table_height_px(cell) + content = max(line_bottom, nested_h) + row_declared = max(row_declared, declared) + row_content = max(row_content, content) + _, col = cell_addr(cell) + cell_notes.append( + f"c{col}:decl={declared:.1f} content={content:.1f} " + f"lines={sum(len(p.findall('.//hp:lineseg', NS)) for p in direct_paras)} " + f"nested={nested_h:.1f} span={row_span}x{col_span}" + ) + slack = row_declared - row_content + if row_declared > 0.0 and slack >= min_slack: + text = direct_text(row)[:80] + print( + f"table={ti} section={section} row={ri} " + f"textWrap={attrs.get('textWrap','')} pageBreak={attrs.get('pageBreak','')} " + f"repeatHeader={attrs.get('repeatHeader','')} " + f"decl={row_declared:.1f} content={row_content:.1f} slack={slack:.1f} " + f"text={text!r}" + ) + for note in cell_notes: + print(f" {note}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("docdir", type=Path) + parser.add_argument("--table-index", type=int) + parser.add_argument("--min-slack", type=float, default=60.0) + args = parser.parse_args() + scan_source(find_source(args.docdir), args.min_slack, args.table_index) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/hwpx_table_source_probe.py b/harness/hwpx_table_source_probe.py new file mode 100644 index 0000000000..f18ffb4104 --- /dev/null +++ b/harness/hwpx_table_source_probe.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Inspect HWPX source table/cell/picture attributes for fidelity triage. + +This is read-only and intentionally generic. Use it when visual probes point at +table/image geometry and you need the source table dimensions, cell margins, +picture sizes, crop boxes, and signed object offsets before changing renderer +code. + +Usage: + python3 harness/hwpx_table_source_probe.py /tmp/diff/doc_name --list + python3 harness/hwpx_table_source_probe.py /tmp/diff/doc_name --table-index 4 +""" +from __future__ import annotations + +import argparse +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path + + +NS = { + "hh": "http://www.hancom.co.kr/hwpml/2011/head", + "hp": "http://www.hancom.co.kr/hwpml/2011/paragraph", +} +SIGNED32 = 1 << 32 +SIGNED31 = 1 << 31 + + +def find_source(docdir: Path) -> Path: + if docdir.is_file(): + return docdir + for name in ("source.hwpx", "source_converted.hwpx", "source_fitted.hwpx"): + path = docdir / name + if path.exists(): + return path + raise SystemExit(f"no HWPX source found under {docdir}") + + +def signed_i32(raw: str | None) -> int | None: + if raw is None: + return None + value = int(raw) + return value - SIGNED32 if value >= SIGNED31 else value + + +def px_from_hu(value: int | None, dpi: float) -> str: + if value is None: + return "" + return f"{value * dpi / 7200.0:.1f}px" + + +def tables(hwpx: Path) -> list[tuple[str, ET.Element]]: + out: list[tuple[str, ET.Element]] = [] + with zipfile.ZipFile(hwpx) as zf: + for name in sorted(n for n in zf.namelist() if n.startswith("Contents/section")): + root = ET.fromstring(zf.read(name)) + for table in root.findall(".//hp:tbl", NS): + out.append((name, table)) + return out + + +def border_fills(hwpx: Path) -> list[ET.Element]: + with zipfile.ZipFile(hwpx) as zf: + root = ET.fromstring(zf.read("Contents/header.xml")) + return root.findall(".//hh:borderFill", NS) + + +def table_shape(table: ET.Element) -> tuple[int, int, int]: + cells = table.findall(".//hp:tc", NS) + max_row = -1 + max_col = -1 + for cell in cells: + addr = cell.find("hp:cellAddr", NS) + if addr is None: + continue + max_row = max(max_row, int(addr.attrib.get("rowAddr", "0"))) + max_col = max(max_col, int(addr.attrib.get("colAddr", "0"))) + return max_row + 1, max_col + 1, len(cells) + + +def child_attrs(parent: ET.Element, name: str) -> dict[str, str]: + elem = parent.find(f"hp:{name}", NS) + return dict(elem.attrib) if elem is not None else {} + + +def compact_attrs(attrs: dict[str, str], keys: list[str]) -> str: + return " ".join(f"{key}={attrs[key]}" for key in keys if key in attrs) + + +def print_table_list(items: list[tuple[str, ET.Element]]) -> None: + print("tables:") + for index, (section, table) in enumerate(items): + rows, cols, cell_count = table_shape(table) + size = child_attrs(table, "sz") + print( + f" {index}: {section} rows={rows} cols={cols} cells={cell_count} " + f"attrs={compact_attrs(table.attrib, ['textWrap', 'pageBreak', 'repeatHeader', 'cellSpacing', 'borderFillIDRef'])} " + f"size={compact_attrs(size, ['width', 'height', 'widthRelTo', 'heightRelTo'])}" + ) + + +def print_border_fills(items: list[ET.Element], ids: set[int] | None) -> None: + print("border_fills:") + for idx, border_fill in enumerate(items, start=1): + if ids is not None and idx not in ids: + continue + fill = border_fill.find("hh:fillBrush", NS) + face = border_fill.find("hh:slash", NS) + back = "" + if fill is not None: + win = fill.find(".//hh:winBrush", NS) + if win is not None: + back = compact_attrs(dict(win.attrib), ["faceColor", "hatchColor", "alpha"]) + borders = [] + for name in ("leftBorder", "rightBorder", "topBorder", "bottomBorder"): + elem = border_fill.find(f"hh:{name}", NS) + if elem is None: + continue + borders.append(f"{name}({compact_attrs(dict(elem.attrib), ['type', 'width', 'color'])})") + diagonal = "" + if face is not None: + diagonal = compact_attrs(dict(face.attrib), ["type", "CrookedSlash"]) + print(f" {idx}: back={back} diagonal={diagonal} {' '.join(borders)}") + + +def print_selected_table(index: int, section: str, table: ET.Element, dpi: float) -> None: + rows, cols, cell_count = table_shape(table) + print(f"table_index={index} section={section} rows={rows} cols={cols} cells={cell_count}") + print(f"attrs: {table.attrib}") + for name in ("sz", "inMargin", "outMargin"): + print(f"{name}: {child_attrs(table, name)}") + for cell in table.findall(".//hp:tc", NS): + addr = child_attrs(cell, "cellAddr") + span = child_attrs(cell, "cellSpan") + size = child_attrs(cell, "cellSz") + margin = child_attrs(cell, "cellMargin") + paras = cell.findall(".//hp:p", NS) + line_seg_elems = [seg for para in paras for seg in para.findall(".//hp:lineseg", NS)] + line_segs = len(line_seg_elems) + first_seg = line_seg_elems[0].attrib if line_seg_elems else {} + text = " ".join("".join(cell.itertext()).split())[:80] + print( + "\ncell " + f"addr={addr} span={span} size={size} margin={margin} " + f"borderFillIDRef={cell.attrib.get('borderFillIDRef', '')} " + f"vAlign={cell.attrib.get('vAlign', cell.attrib.get('vertAlign', ''))} " + f"paras={len(paras)} lineSegs={line_segs} " + f"firstLineSeg={compact_attrs(first_seg, ['textpos', 'vertpos', 'vertsize', 'textheight', 'baseline', 'spacing'])} " + f"text={text!r}" + ) + for pic in cell.findall(".//hp:pic", NS): + pic_size = child_attrs(pic, "sz") + pos = child_attrs(pic, "pos") + img_clip = child_attrs(pic, "imgClip") + img_dim = child_attrs(pic, "imgDim") + vo = signed_i32(pos.get("vertOffset")) + ho = signed_i32(pos.get("horzOffset")) + print( + " pic " + f"attrs={compact_attrs(pic.attrib, ['textWrap', 'textFlow', 'zOrder', 'instid'])} " + f"size={pic_size} pos={pos} " + f"signedOffset=(v={vo} {px_from_hu(vo, dpi)}, h={ho} {px_from_hu(ho, dpi)}) " + f"imgClip={img_clip} imgDim={img_dim}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("docdir", type=Path, help="doc directory under /tmp/diff or a .hwpx file") + parser.add_argument("--list", action="store_true", help="list all source tables") + parser.add_argument("--table-index", type=int, help="print one source table in detail") + parser.add_argument("--border-fills", default="", help="comma-separated borderFill IDs to print, or 'all'") + parser.add_argument("--dpi", type=float, default=96.0) + args = parser.parse_args() + + source = find_source(args.docdir) + items = tables(source) + print(f"source: {source}") + if args.list or args.table_index is None: + print_table_list(items) + if args.border_fills: + ids = None + if args.border_fills != "all": + ids = {int(part) for part in args.border_fills.split(",") if part.strip()} + print_border_fills(border_fills(source), ids) + if args.table_index is not None: + if args.table_index < 0 or args.table_index >= len(items): + raise SystemExit(f"table index {args.table_index} out of range; found {len(items)}") + section, table = items[args.table_index] + print_selected_table(args.table_index, section, table, args.dpi) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/look.py b/harness/look.py new file mode 100644 index 0000000000..10e6104b51 --- /dev/null +++ b/harness/look.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""look.py — vision-first rhwp fidelity loop. + +The ONLY oracle is the Hancom render as an image. The ONLY truth is looking at +it. Page-count is a one-line tripwire, nothing more. No pt-gap, no dump-pages +metric — those are lossy proxies that go blind on shapes/boxes/tables/images +(exactly the cases that are hard). + +Pipeline (host-only after the SVG export): + 1. (--export) docker rhwp export-svg → rhwp_svg_cur/*.svg + 2. pdftoppm hancom.pdf → hancom_p-N.png (vector PDF, real fonts) + 3. qlmanage rhwp svg → rhwp_p-N.png (host, real Korean fonts — + NOT the font-starved raster + that made the old Gemini + pass over-flag everything) + 4. stitch hancom | rhwp, one strip per page → look/page-NN.png + 5. tripwire print rhwp vs Hancom page count + 6. (--gemini) Gemini-Flash second opinion per page on the real-font strips + +Usage: + look.py [--export] [--gemini] + e.g. /tmp/diff/report_form (must contain source.hwpx + hancom.pdf) + +After it runs, READ look/page-NN.png yourself for every page. The script's job +is to put Hancom and rhwp eye-to-eye at full legibility; the judgment is yours. +""" +import sys, os, re, glob, subprocess, json, base64, urllib.request, tempfile + +RHWP = "/Users/jaehoshin/Desktop/mindlogic/factchat/worktree-rhwp-poc/rhwp" +DC = ["docker", "compose", "--env-file", ".env.docker", "run", "--rm", + "-v", "/tmp/diff:/diff", "dev"] +WIDTH = 980 # px per page column; both engines normalized to this for honesty + + +def sh(cmd, **kw): + return subprocess.run(cmd, capture_output=True, text=True, **kw) + + +def export_svg(docdir, src): + name = os.path.basename(docdir.rstrip("/")) + rel = f"/diff/{name}/{os.path.basename(src)}" + # clean stale SVGs first — export-svg does NOT delete old pages, so a doc + # that shrank (e.g. 6→5 after a fix) would leave a phantom page-6 behind + # and the page-count tripwire would lie. + for old in glob.glob(os.path.join(docdir, "rhwp_svg_cur", "*.svg")): + os.remove(old) + print(f"[1] export-svg {rel} (docker, current binary)…") + r = sh(DC + ["/app/target/release/rhwp", "export-svg", rel, + "-o", f"/diff/{name}/rhwp_svg_cur"], cwd=RHWP) + if r.returncode: + print(r.stderr[-800:]); sys.exit("export-svg failed") + + +def raster_hancom(docdir): + pdf = os.path.join(docdir, "hancom.pdf") + sh(["pdftoppm", "-r", "120", "-png", pdf, os.path.join(docdir, "hancom_p")]) + return sorted(glob.glob(os.path.join(docdir, "hancom_p-*.png")), + key=lambda p: int(re.search(r"-(\d+)\.png$", p).group(1))) + + +def raster_rhwp(docdir): + svgs = sorted(glob.glob(os.path.join(docdir, "rhwp_svg_cur", "*.svg")), + key=lambda p: int(re.search(r"(\d+)\.svg$", p).group(1))) + out = [] + for i, svg in enumerate(svgs, 1): + png = os.path.join(docdir, f"rhwp_p-{i}.png") + if raster_svg_chromium(svg, png): + out.append(png) + continue + sh(["qlmanage", "-t", "-s", "1400", svg, "-o", docdir]) + produced = os.path.join(docdir, os.path.basename(svg) + ".png") + if os.path.exists(produced): + os.replace(produced, png); out.append(png) + return out + + +def raster_svg_chromium(svg, png): + """Rasterize SVG at its real page aspect ratio. + + macOS qlmanage creates square thumbnails for some SVGs, which makes the + review board look bottom-clipped or vertically distorted. Chromium renders + the SVG in a viewport matching width/height or viewBox. + """ + text = open(svg, encoding="utf-8", errors="ignore").read(2048) + m = re.search(r"]*\bwidth=\"([0-9.]+)\"[^>]*\bheight=\"([0-9.]+)\"", text) + if not m: + m = re.search(r"]*\bviewBox=\"[0-9.]+ [0-9.]+ ([0-9.]+) ([0-9.]+)\"", text) + if not m: + return False + src_w, src_h = float(m.group(1)), float(m.group(2)) + if src_w <= 0 or src_h <= 0: + return False + target_w = 1400 + target_h = max(1, round(src_h * target_w / src_w)) + html = ( + "" + "" + f"" + ) + with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) as f: + f.write(html) + html_path = f.name + js = r""" +const { chromium } = require('playwright'); +const [html, png, w, h] = process.argv.slice(1); +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: Number(w), height: Number(h) }, + deviceScaleFactor: 1, + }); + await page.goto('file://' + html, { waitUntil: 'networkidle' }); + await page.locator('#page').evaluate(img => img.decode ? img.decode() : Promise.resolve()); + const size = await page.evaluate(() => ({ + width: Math.ceil(document.documentElement.scrollWidth), + height: Math.ceil(document.documentElement.scrollHeight), + })); + await page.setViewportSize({ + width: Math.max(Number(w), size.width), + height: Math.max(Number(h), size.height), + }); + await page.screenshot({ path: png, fullPage: true }); + await browser.close(); +})().catch(err => { console.error(err); process.exit(1); }); +""" + try: + r = sh(["node", "-e", js, html_path, png, str(target_w), str(target_h)], cwd=RHWP) + return r.returncode == 0 and os.path.exists(png) + finally: + try: + os.remove(html_path) + except OSError: + pass + + +def stitch(docdir, hancom, rhwp): + from PIL import Image, ImageDraw, ImageFont + os.makedirs(os.path.join(docdir, "look"), exist_ok=True) + n = max(len(hancom), len(rhwp)) + written = [] + for i in range(n): + cols = [] + for label, paths in (("HANCOM", hancom), ("rhwp", rhwp)): + if i < len(paths): + im = Image.open(paths[i]).convert("RGB") + w, h = im.size + im = im.resize((WIDTH, int(h * WIDTH / w))) + else: + im = Image.new("RGB", (WIDTH, 600), "#ffe0e0") # missing page + ImageDraw.Draw(im).text((20, 20), f"(no {label} page {i+1})", + fill="red") + cols.append((label, im)) + H = max(im.size[1] for _, im in cols) + 30 + strip = Image.new("RGB", (WIDTH * 2 + 12, H), "#888") + for j, (label, im) in enumerate(cols): + canvas = Image.new("RGB", (WIDTH, H), "white") + canvas.paste(im, (0, 30)) + ImageDraw.Draw(canvas).text((8, 8), f"{label} page {i+1}", fill="#000") + strip.paste(canvas, (j * (WIDTH + 12), 0)) + p = os.path.join(docdir, "look", f"page-{i+1:02d}.png") + strip.save(p); written.append(p) + return written + + +def gemini(strip_paths): + key = None + for line in open("/Users/jaehoshin/Desktop/mindlogic/factchat/" + "mindlogic_factchat_server/.env"): + m = re.match(r"GEMINI_API_KEY=(.+)", line.strip()) + if m: key = m.group(1).strip().strip('"\''); break + if not key: + print("no GEMINI_API_KEY"); return + url = ("https://generativelanguage.googleapis.com/v1beta/models/" + f"gemini-flash-latest:generateContent?key={key}") + prompt = ("Left = HANCOM (ground truth). Right = rhwp (candidate renderer) of " + "the SAME page. Both use real fonts. List ONLY layout/spacing " + "differences a human would notice: missing/extra vertical gaps " + "(esp. around shaded section-header boxes), compressed line spacing, " + "content that overflows the page, or content present in one but not " + "the other. Ignore tiny sub-pixel glyph/anti-aliasing differences. " + "Reply 'MATCH' if visually equivalent, else terse bullets.") + for p in strip_paths: + b64 = base64.b64encode(open(p, "rb").read()).decode() + body = {"contents": [{"parts": [ + {"text": prompt}, + {"inline_data": {"mime_type": "image/png", "data": b64}}]}], + "generationConfig": {"temperature": 1.0}} + req = urllib.request.Request(url, json.dumps(body).encode(), + {"Content-Type": "application/json"}) + try: + r = json.load(urllib.request.urlopen(req, timeout=90)) + txt = r["candidates"][0]["content"]["parts"][0]["text"].strip() + except Exception as e: + txt = f"(gemini error: {e})" + print(f"\n── {os.path.basename(p)} ──\n{txt}") + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + docdir = os.path.abspath(sys.argv[1]) + src = next((os.path.join(docdir, f) for f in ("source.hwpx", "source.hwp") + if os.path.exists(os.path.join(docdir, f))), None) + if "--export" in sys.argv: + export_svg(docdir, src) + print("[2] raster Hancom…"); hancom = raster_hancom(docdir) + print("[3] raster rhwp…"); rhwp = raster_rhwp(docdir) + print("[4] stitch…"); strips = stitch(docdir, hancom, rhwp) + flag = " ⚠️ PAGE COUNT DIFFERS" if len(hancom) != len(rhwp) else " ✓" + print(f"\n[5] TRIPWIRE Hancom={len(hancom)} rhwp={len(rhwp)}{flag}") + print(" look/ → " + " ".join(os.path.basename(s) for s in strips)) + print(" READ every page-NN.png yourself. That is the truth signal.") + if "--gemini" in sys.argv: + print("\n[6] Gemini second opinion (real-font strips):"); gemini(strips) + + +if __name__ == "__main__": + main() diff --git a/harness/page_heading_map.py b/harness/page_heading_map.py new file mode 100644 index 0000000000..5c317b64e1 --- /dev/null +++ b/harness/page_heading_map.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Map semantic headings to Hancom PDF pages and RHWP dump pages. + +This is a lightweight drift diagnostic for renderer fidelity work. It lets a +guard doc prove whether RHWP is merely visually different on the same page or +semantically ahead/behind Hancom by whole pages. +""" +from __future__ import annotations + +import argparse +import re +import subprocess +from pathlib import Path + + +DIFF = Path("/tmp/diff") + + +def read_needles(args: argparse.Namespace) -> list[str]: + needles: list[str] = [] + if args.needles_file: + needles.extend( + line.strip() + for line in args.needles_file.read_text(encoding="utf-8", errors="ignore").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + needles.extend(args.needle) + if not needles: + raise SystemExit("provide at least one --needle or --needles-file line") + return needles + + +def hancom_pages(pdf: Path, needle: str) -> list[int]: + if not pdf.exists(): + return [] + text = subprocess.check_output( + ["pdftotext", "-layout", str(pdf), "-"], + text=True, + errors="ignore", + ) + return [page for page, body in enumerate(text.split("\f"), 1) if needle in body] + + +def rhwp_page_bodies(dump: Path) -> list[tuple[int, str]]: + if not dump.exists(): + return [] + text = dump.read_text(encoding="utf-8", errors="ignore") + chunks = re.split(r"\n=== 페이지 (\d+) .*?===\n", text) + pages: list[tuple[int, str]] = [] + for idx in range(1, len(chunks), 2): + pages.append((int(chunks[idx]), chunks[idx + 1])) + return pages + + +def rhwp_pages(pages: list[tuple[int, str]], needle: str) -> list[int]: + normalized = needle.replace(" ", " ").replace("8 ", "8. ") + compact = " ".join(needle.split()) + out: list[int] = [] + for page, body in pages: + body_compact = " ".join(body.split()) + if needle in body or normalized in body or compact in body_compact: + out.append(page) + return out + + +def fmt_pages(pages: list[int]) -> str: + return ",".join(str(page) for page in pages[:12]) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("doc", help="staged doc name under /tmp/diff") + parser.add_argument("--needle", action="append", default=[], help="heading/text to locate") + parser.add_argument("--needles-file", type=Path, help="newline-delimited headings") + parser.add_argument("--pdf", type=Path, help="override Hancom PDF path") + parser.add_argument("--dump", type=Path, help="override RHWP dump-pages path") + args = parser.parse_args() + + docdir = DIFF / args.doc + pdf = args.pdf or docdir / "hancom.pdf" + dump = args.dump or docdir / "dump_pages_current.txt" + needles = read_needles(args) + rhwp_bodies = rhwp_page_bodies(dump) + + print("needle\thancom_pages\trhwp_pages\tfirst_delta") + for needle in needles: + hp = hancom_pages(pdf, needle) + rp = rhwp_pages(rhwp_bodies, needle) + delta = "" + if hp and rp: + delta = str(rp[0] - hp[0]) + print(f"{needle}\t{fmt_pages(hp)}\t{fmt_pages(rp)}\t{delta}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/photo_grid_scan.py b/harness/photo_grid_scan.py new file mode 100644 index 0000000000..67cefc56b6 --- /dev/null +++ b/harness/photo_grid_scan.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Scan staged HWPX docs for photo-grid table classes. + +This is a read-only guard-finder for renderer fidelity work. It looks for +tables with multiple picture cells, mixed treat-as-character/non-TAC pictures, +and negative paragraph-relative offsets. Those are structural clues for the +meeting-template photo-grid fixes; renderer code must not key on filenames or +visible picture placeholder text. +""" +from __future__ import annotations + +import argparse +import zipfile +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + + +DIFF = Path("/tmp/diff") +NS = { + "hp": "http://www.hancom.co.kr/hwpml/2011/paragraph", +} +SIGNED32 = 1 << 32 +SIGNED31 = 1 << 31 + + +@dataclass(frozen=True) +class PictureCell: + row: int + col: int + row_span: int + col_span: int + treat_as_char: bool + vert_offset: int + horz_offset: int + width: int + height: int + + +@dataclass(frozen=True) +class TableHit: + doc: str + path: Path + section: str + table_index: int + rows: int + cols: int + text_wrap: str + page_break: str + repeat_header: str + pic_cells: tuple[PictureCell, ...] + + +def signed_i32(raw: str | None) -> int: + if raw is None: + return 0 + value = int(raw) + return value - SIGNED32 if value >= SIGNED31 else value + + +def int_attr(elem: ET.Element | None, name: str, default: int = 0) -> int: + if elem is None: + return default + raw = elem.attrib.get(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +def child(parent: ET.Element, name: str) -> ET.Element | None: + return parent.find(f"hp:{name}", NS) + + +def source_paths(root: Path) -> list[Path]: + if root.is_file(): + return [root] + for name in ("source.hwpx", "source_converted.hwpx", "source_fitted.hwpx"): + candidate = root / name + if candidate.exists(): + return [candidate] + paths: list[Path] = [] + for docdir in sorted(path for path in root.iterdir() if path.is_dir()): + for name in ("source.hwpx", "source_converted.hwpx", "source_fitted.hwpx"): + candidate = docdir / name + if candidate.exists(): + paths.append(candidate) + break + return paths + + +def paths_from_file(path: Path) -> list[Path]: + return [ + Path(line.strip()) + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines() + if line.strip() + ] + + +def table_shape(table: ET.Element) -> tuple[int, int]: + max_row = -1 + max_col = -1 + for cell in table.findall(".//hp:tc", NS): + addr = child(cell, "cellAddr") + if addr is None: + continue + max_row = max(max_row, int_attr(addr, "rowAddr")) + max_col = max(max_col, int_attr(addr, "colAddr")) + return max_row + 1, max_col + 1 + + +def picture_cells(table: ET.Element) -> tuple[PictureCell, ...]: + out: list[PictureCell] = [] + for cell in table.findall(".//hp:tc", NS): + pics = cell.findall(".//hp:pic", NS) + if not pics: + continue + addr = child(cell, "cellAddr") + span = child(cell, "cellSpan") + row = int_attr(addr, "rowAddr") + col = int_attr(addr, "colAddr") + row_span = int_attr(span, "rowSpan", 1) + col_span = int_attr(span, "colSpan", 1) + for pic in pics: + pos = child(pic, "pos") + size = child(pic, "sz") + out.append( + PictureCell( + row=row, + col=col, + row_span=row_span, + col_span=col_span, + treat_as_char=(pos is not None and pos.attrib.get("treatAsChar") == "1"), + vert_offset=signed_i32(pos.attrib.get("vertOffset") if pos is not None else None), + horz_offset=signed_i32(pos.attrib.get("horzOffset") if pos is not None else None), + width=int_attr(size, "width"), + height=int_attr(size, "height"), + ) + ) + return tuple(out) + + +def scan_one(source: Path) -> list[TableHit]: + doc = source.parent.name + hits: list[TableHit] = [] + try: + with zipfile.ZipFile(source) as zf: + table_index = 0 + for name in sorted(n for n in zf.namelist() if n.startswith("Contents/section")): + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for table in root.findall(".//hp:tbl", NS): + rows, cols = table_shape(table) + pics = picture_cells(table) + if len(pics) >= 4 and cols >= 2: + hits.append( + TableHit( + doc=doc, + path=source, + section=name, + table_index=table_index, + rows=rows, + cols=cols, + text_wrap=table.attrib.get("textWrap", ""), + page_break=table.attrib.get("pageBreak", ""), + repeat_header=table.attrib.get("repeatHeader", ""), + pic_cells=pics, + ) + ) + table_index += 1 + except zipfile.BadZipFile: + return [] + return hits + + +def has_mixed_row(hit: TableHit) -> bool: + by_row: dict[int, set[bool]] = {} + for pic in hit.pic_cells: + by_row.setdefault(pic.row, set()).add(pic.treat_as_char) + return any(len(values) > 1 for values in by_row.values()) + + +def fmt_px(hwpunit: int, dpi: float) -> str: + return f"{hwpunit * dpi / 7200.0:.1f}" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", nargs="?", type=Path, default=None) + parser.add_argument("--paths-file", type=Path, help="newline-delimited HWPX source paths") + parser.add_argument("--max-bytes", type=int, default=0, help="skip files larger than this; 0 disables") + parser.add_argument("--only-mixed", action="store_true") + parser.add_argument("--dpi", type=float, default=96.0) + args = parser.parse_args() + + print( + "doc\ttable\tshape\twrap\tbreak\trepeat\tpics\ttac\tnon_tac\t" + "mixed_row\tneg_offsets\trows\tpath" + ) + count = 0 + roots = [] + if args.root is not None: + roots.extend(source_paths(args.root)) + if args.paths_file is not None: + roots.extend(paths_from_file(args.paths_file)) + if not roots: + roots.extend(source_paths(DIFF)) + for source in roots: + if args.max_bytes and source.stat().st_size > args.max_bytes: + continue + for hit in scan_one(source): + mixed = has_mixed_row(hit) + if args.only_mixed and not mixed: + continue + tac = sum(1 for pic in hit.pic_cells if pic.treat_as_char) + non_tac = len(hit.pic_cells) - tac + neg = [ + f"r{pic.row}c{pic.col}:v{fmt_px(pic.vert_offset, args.dpi)}" + for pic in hit.pic_cells + if pic.vert_offset < 0 or pic.horz_offset < 0 + ] + rows = ",".join( + f"r{pic.row}c{pic.col}{'T' if pic.treat_as_char else 'N'}" + for pic in hit.pic_cells + ) + print( + f"{hit.doc}\t{hit.table_index}\t{hit.rows}x{hit.cols}\t" + f"{hit.text_wrap}\t{hit.page_break}\t{hit.repeat_header}\t" + f"{len(hit.pic_cells)}\t{tac}\t{non_tac}\t{int(mixed)}\t" + f"{';'.join(neg)}\t{rows}\t{hit.path}" + ) + count += 1 + print(f"# matches={count}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/queue.tsv b/harness/queue.tsv new file mode 100644 index 0000000000..7b6a658f4e --- /dev/null +++ b/harness/queue.tsv @@ -0,0 +1,15 @@ +# doc rhwp hancom category status notes +# status: pending | blocked | done category drives strategy +huge_01_DNA________33MB 41 41 cumulative-drift done FIXED 8ca24c17 near-empty vpos-reset orphan 46->41 +huge_02_DNA_______75MB 67 67 cumulative-drift done FIXED 8ca24c17 73->67 +med_03____________4MB 47 47 metric-lie done FIXED 8ca24c17 48->47 +15_3740450_research_admin_innovation_meeting_template 5 5 image-pagination done FIXED cb43772c photo-cell row-height 6->5 +meeting 5 5 image-pagination done FIXED cb43772c 6->5 +09_3766093_natural_sci_planning_committee_260527 51 51 metric-lie+cover done page-count 53->51 (8ca24c17). RESIDUAL render-only: cover 보고사항 float overlap (separate from page-count; needs a VISUAL gate, tractable next target) +form_07_______________41KB 12 11 cache-divergence blocked 2026-06-02 PRIOR DIAGNOSIS DISPROVEN. The "single table pi=89 with two disagreeing heights 397.2 vs 409.0 (Δ11.8px), reconcile measure-vs-cut" theory was WRONG. Enhanced TABLE_DRIFT probe with ctrl_idx revealed: pi=89 has TWO DISTINCT tables — ci=0 (rows [28.9,170.5,28.9,168.9]=397.2) and ci=1 (rows [29.4,173.7,29.4,176.4]=409.0). Both measured CONSISTENTLY (each cut_sum==mt_sum, diff=0). There is NO measurement inconsistency. Real bug (verified by look.py reading p9-12 vs Hancom): rhwp over-paginates by packing too little onto p10. Table mapping — pi=87(우선순위 326.8), pi=89ci0(진단목표 group-A 401.5), pi=89ci1(진단목표 group-B 409.0), pi=93(간호수행 group-A 375.0), pi=94(간호수행 group-B 375.0). Hancom: p9=[pi87+pi89ci0], p10=[pi89ci1 + pi93], p11=[pi94 + 참고문헌] = 11pp. rhwp: p9 matches, but p10=[pi89ci1 ONLY] (wastes bottom half) → pi93 pushed to p11 → pi94 to p12 → 12pp. Between pi89ci1 and pi93 sit empty spacer paras pi=90(vpos 64000=page bottom),pi91(30957),pi92(33157): rhwp breaks the page there (vpos reset / cache-trusted bottom positioning) where Hancom packs pi93's 375px table onto p10. This is the cache-divergence / TopAndBottom-positioning cluster (same family as large_01/report_form): rhwp trusts the encoded linesegs that place the empty paras at page bottom, Hancom re-flows. NOT a clean structural fix — needs cache-staleness/forward-pack logic, high-blast. is_hwp3_variant=false so the Task#1007 cross-para reset block doesn't apply; break likely Bucket C hwpx_cross_para_reset_breaks or page-fill. NEXT: probe whether collapsing trailing empty spacer paras before a fitting table is a narrow discriminator. +form_22____________________270KB 37 37 intra-para-vpos-reset done FIXED 2026-06-02 38->37 (=oracle), gate ALL-GREEN (improved=1 regressed=0 new_overflow=0, held-out OK), visually validated p28/p29 match Hancom. Root: typeset_table_paragraph flush (typeset.rs:3151) counted the post-vpos-reset trailing line in height_for_fit, forcing the whole single-TAC host para (table pi=465 358.5px, fits the 368px p28 slack) to p29 over an 8px trailing reset-line. Fix: flush_height = pre-reset height (fmt.line_advances_sum(0..reset_line)) when single-TAC + col_count==1 + a lineseg resets to vpos 0. Structural, no fingerprint. See single-col-vpos-reset-418-tradeoff memory. +large_01_____________16MB 37 39 cache-divergence blocked PROVEN trust-cache path (measure_table_impl never fires); cached linesegs UNDER-state grouped-diagram height. cache-fidelity not geometry. +report_form 5 6 cache-divergence blocked under-paginate; cache-staleness known-stuck. See report-form-sb-zeroing memory. +13_3763367_k_star_visa_track_plan 19 19 sub-line-orphan done FIXED 2026-06-02 20->19 (=oracle), gate ALL-GREEN (improved=2 w/ form_22, regressed=0, held-out OK), visually validated p11 = whole 서식1 심사평가표 on one page matching Hancom. Root: sec2 서식1 table pi=1 (4row, eff=mt=cut=900, NO inflation) overflowed avail_for_rows 896.9 by exactly 3.1px, orphaning the 221.7px signature row (25% of avail) to p12; Hancom fits the form (header30+table900=930<933.5 body) on ONE page. The last-row orphan guard (typeset.rs:~4299, 48f310ab) absorbed ≤3.0px rounding spills — missed by 0.1px and the row wasn't tight-fit (a/b arms). Fix: widened arm (c) 3.0->4.0px (still deep sub-line, 0.45% avail). NOTE: a prior comment WRONGLY cited doc13 as a "keep on next page like Hancom" case — disproven, Hancom fits it on one page; comment corrected. +accountability_eval 4 11 needs-feature blocked wide-table reflow: Hancom width-fits+re-wraps; rhwp draws literal. Needs NEW width-fit capability not a fix. +form_03_signup_consent_form 2 2 lineseg-less-no-oracle done 2026-06-02 RESOLVED (bookkeeping). Source (samples_wild/form_03_signup_consent_form.hwpx) is a LINESEG-LESS HWPX (grep ' target doc + drift table + queue note +# 2. probe + patch the engine (structural only) +# 3. run `ralph.sh verify` -> must print ALL-GREEN or it will not commit +# 4. on ALL-GREEN: commit LOCAL; on any RED: `git checkout -- src/`, mark blocked +set -uo pipefail +RHWP=/Users/jaehoshin/Desktop/mindlogic/factchat/worktree-rhwp-poc/rhwp +H=/tmp/diff/harness +cmd="${1:-next}" + +case "$cmd" in +next) + # first pending doc in queue + doc=$(awk -F'\t' '!/^#/ && $5=="pending"{print $1; exit}' "$H/queue.tsv") + [ -z "$doc" ] && { echo "QUEUE EMPTY — all docs done or blocked."; exit 0; } + echo "=== NEXT TARGET: $doc ===" + grep -P "^$doc\t" "$H/queue.tsv" + echo "--- objective drift (Hancom bbox vs rhwp SVG) ---" + python3 "$H/drift.py" "/tmp/diff/$doc" 2>&1 | head -40 + echo "--- rule: structural fix only. fingerprint_lint + gate + held-out must pass. ---" + ;; +verify) + echo "[1/3] fingerprint lint..." + python3 "$H/fingerprint_lint.py" "$RHWP" || { echo "RED: fingerprint"; exit 1; } + echo "[2/3] corpus regression gate (build + sweep)..." + bash "$H/gate.sh" || { echo "RED: regression/overflow"; exit 1; } + echo "[3/3] held-out drift check (dev improved, held not worse)..." + # held docs must not gain a first-divergence they didn't have; cheap proxy: + # their page count (in _gate_now.tsv) must equal oracle. + python3 - "$H/corpus.tsv" "$H/_gate_now.tsv" <<'PY' +import sys +held={l.split('\t')[0] for l in open(sys.argv[1]) if '\theld\t' in l} +now={l.split('\t')[0]:l.strip().split('\t')[1] for l in open(sys.argv[2]) if l.strip()} +import json; hc=json.load(open('/tmp/diff/_hancom_pages.json')) +bad=[d for d in held if d in now and str(now[d])!=str(hc.get(d))] +if bad: + print("RED: held-out regressed:",bad); sys.exit(1) +print("held-out OK") +PY + echo "ALL-GREEN" + ;; +*) echo "usage: ralph.sh [next|verify]"; exit 2;; +esac diff --git a/harness/raster_backend_probe.py b/harness/raster_backend_probe.py new file mode 100644 index 0000000000..6b77f66498 --- /dev/null +++ b/harness/raster_backend_probe.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Compare raster backends for one RHWP page against Hancom. + +Late-stage table/text drift can come from three different layers: + +1. layout/render-tree geometry; +2. SVG text/line emission; +3. the raster backend used to turn RHWP output into pixels. + +This probe keeps Hancom as oracle and compares the current browser-rasterized +SVG candidate with an optional native-Skia PNG candidate when a native-Skia +binary is available. It is read-only unless `--native` writes PNGs under the +requested output directory. +""" +from __future__ import annotations + +import argparse +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageChops, ImageStat + +import review_gallery +from svg_geometry_probe import ( + hancom_png_path, + parse_ref, + rhwp_png_path, + rhwp_svg_path, + svg_size, +) +from svg_region_probe import Region, parse_region, svg_to_pixel_box + + +RHWP = Path(__file__).resolve().parents[1] +DIFF = Path("/tmp/diff") + + +@dataclass(frozen=True) +class Candidate: + name: str + path: Path + note: str = "" + + +def run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, cwd=RHWP, capture_output=True, text=True) + + +def fit_to_match(image: Image.Image, size: tuple[int, int]) -> Image.Image: + if image.size == size: + return image + return image.resize(size) + + +def luma(red: int, green: int, blue: int) -> float: + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + + +def luma_series(image: Image.Image) -> list[float]: + return [luma(red, green, blue) for red, green, blue in image.getdata()] + + +def dark_counts(image: Image.Image, thresholds: list[int]) -> dict[int, int]: + values = luma_series(image) + return {threshold: sum(1 for value in values if value < threshold) for threshold in thresholds} + + +def percentile(values: list[float], pct: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + index = round((len(ordered) - 1) * pct) + return ordered[index] + + +def luma_summary(image: Image.Image) -> tuple[float, float, float, float]: + values = luma_series(image) + return ( + percentile(values, 0.01), + percentile(values, 0.05), + percentile(values, 0.10), + sum(values) / max(1, len(values)), + ) + + +def metrics(left: Image.Image, right: Image.Image, dark_threshold: int, nonwhite_threshold: int) -> tuple[float, int, int, float]: + candidate = fit_to_match(right, left.size) + diff = ImageChops.difference(left, candidate) + stat = ImageStat.Stat(diff) + mean = sum(stat.mean) / 3.0 + dark = 0 + nonwhite = 0 + luma_total = 0.0 + for red, green, blue in candidate.getdata(): + value = luma(red, green, blue) + luma_total += value + if value < dark_threshold: + dark += 1 + if max(255 - red, 255 - green, 255 - blue) > nonwhite_threshold: + nonwhite += 1 + return mean, dark, nonwhite, luma_total / max(1, candidate.width * candidate.height) + + +def crop(path: Path, box: tuple[int, int, int, int]) -> Image.Image: + with Image.open(path) as opened: + return opened.convert("RGB").crop(box) + + +def full_image(path: Path) -> Image.Image: + with Image.open(path) as opened: + return opened.convert("RGB") + + +def parse_thresholds(value: str) -> list[int]: + thresholds = [] + for piece in value.split(","): + piece = piece.strip() + if not piece: + continue + threshold = int(piece) + if threshold < 0 or threshold > 255: + raise argparse.ArgumentTypeError(f"threshold must be 0..255: {threshold}") + thresholds.append(threshold) + if not thresholds: + raise argparse.ArgumentTypeError("at least one threshold is required") + return thresholds + + +def native_png_candidate( + ref_doc: str, + page: int, + out_dir: Path, + font_paths: list[Path], +) -> Candidate: + docdir = DIFF / ref_doc + src = review_gallery.source_path(docdir) + if src is None: + return Candidate("native-skia", Path(), "missing source") + out_dir.mkdir(parents=True, exist_ok=True) + rel_src = f"/diff/{ref_doc}/{src.name}" + rel_out = f"/diff/{out_dir.relative_to(DIFF)}" + cmd = [ + "docker", + "compose", + "--env-file", + ".env.docker", + "run", + "--rm", + "-v", + "/tmp/diff:/diff", + ] + for font_path in font_paths: + if font_path.is_absolute(): + cmd.extend(["-v", f"{font_path}:{font_path}:ro"]) + cmd.extend( + [ + "dev", + "/app/target/release/rhwp", + "export-png", + rel_src, + "-o", + rel_out, + "-p", + str(page - 1), + ] + ) + for font_path in font_paths: + cmd.extend(["--font-path", str(font_path)]) + result = run(cmd) + output_lines = (result.stderr + "\n" + result.stdout).strip().splitlines() + if result.returncode != 0: + return Candidate("native-skia", Path(), output_lines[-1] if output_lines else "native export failed") + candidates = sorted(out_dir.glob("*.png")) + if not candidates: + note = output_lines[-1] if output_lines else "native export wrote no PNG" + return Candidate("native-skia", Path(), note) + note = "" + if font_paths: + note = "font_paths=" + ",".join(str(path) for path in font_paths) + return Candidate("native-skia", candidates[-1], note) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("ref", type=parse_ref, help="DOC:PAGE") + parser.add_argument("--region", action="append", type=parse_region, default=[]) + parser.add_argument("--export-current", action="store_true") + parser.add_argument("--native", action="store_true", help="try native-Skia export-png via Docker") + parser.add_argument( + "--native-font-path", + action="append", + type=Path, + default=[], + help="pass --font-path to native-Skia export-png; may be repeated", + ) + parser.add_argument("--out-dir", type=Path, default=DIFF / "_raster_backend_probe") + parser.add_argument("--dark-threshold", type=int, default=96) + parser.add_argument("--nonwhite-threshold", type=int, default=8) + parser.add_argument( + "--threshold-sweep", + action="store_true", + help="print Hancom/candidate dark-pixel counts across luma thresholds", + ) + parser.add_argument( + "--sweep-thresholds", + type=parse_thresholds, + default=parse_thresholds("64,96,128,160,192,224,240"), + help="comma-separated luma thresholds for --threshold-sweep", + ) + args = parser.parse_args() + + docdir = DIFF / args.ref.doc + if args.export_current: + review_gallery.export_current(docdir) + + svg_path = rhwp_svg_path(args.ref) + svg_w, svg_h = svg_size(svg_path.read_text(encoding="utf-8", errors="ignore")) + hancom = hancom_png_path(args.ref) + browser_svg = rhwp_png_path(args.ref) + candidates = [Candidate("browser-svg", browser_svg)] + if args.native: + candidates.append( + native_png_candidate( + args.ref.doc, + args.ref.page, + args.out_dir / args.ref.doc, + args.native_font_path, + ) + ) + + regions = args.region or [Region("full", 0.0, 0.0, svg_w, svg_h)] + print("doc\tpage\tregion\tcandidate\tmean_diff\tdark\tnonwhite\tmean_luma\tnote\tpng") + for region in regions: + with Image.open(hancom) as opened: + hancom_image = opened.convert("RGB") + box = svg_to_pixel_box(region, hancom_image, svg_w, svg_h) + left = crop(hancom, box) + for candidate in candidates: + if not candidate.path.is_file(): + print( + f"{args.ref.doc}\t{args.ref.page}\t{region.name}\t{candidate.name}\t" + f"\t\t\t\t{candidate.note}\t" + ) + continue + right_full = full_image(candidate.path) + cbox = svg_to_pixel_box(region, right_full, svg_w, svg_h) + right = right_full.crop(cbox) + mean, dark, nonwhite, luma = metrics( + left, + right, + args.dark_threshold, + args.nonwhite_threshold, + ) + print( + f"{args.ref.doc}\t{args.ref.page}\t{region.name}\t{candidate.name}\t" + f"{mean:.2f}\t{dark}\t{nonwhite}\t{luma:.1f}\t{candidate.note}\t{candidate.path}" + ) + if args.threshold_sweep: + hancom_counts = dark_counts(left, args.sweep_thresholds) + candidate_counts = dark_counts(right, args.sweep_thresholds) + hancom_l01, hancom_l05, hancom_l10, hancom_avg = luma_summary(left) + candidate_l01, candidate_l05, candidate_l10, candidate_avg = luma_summary(right) + print( + "# sweep\t" + f"region={region.name}\tcandidate={candidate.name}\t" + f"hancom_luma=p01:{hancom_l01:.1f},p05:{hancom_l05:.1f},p10:{hancom_l10:.1f},avg:{hancom_avg:.1f}\t" + f"candidate_luma=p01:{candidate_l01:.1f},p05:{candidate_l05:.1f},p10:{candidate_l10:.1f},avg:{candidate_avg:.1f}" + ) + for threshold in args.sweep_thresholds: + hancom_dark = hancom_counts[threshold] + candidate_dark = candidate_counts[threshold] + ratio = candidate_dark / hancom_dark if hancom_dark else 0.0 + print( + "# sweep\t" + f"threshold={threshold}\thancom_dark={hancom_dark}\t" + f"candidate_dark={candidate_dark}\tratio={ratio:.3f}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/review_gallery.py b/harness/review_gallery.py new file mode 100755 index 0000000000..1cbde1f907 --- /dev/null +++ b/harness/review_gallery.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Build a non-cropping visual review gallery for fidelity work. + +This is the tracked replacement for one-off /tmp/diff/_gallery3.py scripts. +It intentionally uses the same truth model as harness/look.py: + + left = Hancom PDF raster, oracle + right = current rhwp SVG raster, candidate + +The generated HTML labels git HEAD and dirty state so stale boards are obvious. +By default every page is shown. Use --max-pages only when the user explicitly +wants to skip huge documents. + +Usage: + python3 harness/review_gallery.py /tmp/diff/_review_live wc35_15pg --export-current + python3 harness/review_gallery.py /tmp/diff/_review_live --all --max-pages 12 +""" +from __future__ import annotations + +import argparse +import base64 +import glob +import html +import os +import re +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path + +from PIL import Image, ImageDraw + + +RHWP = Path(__file__).resolve().parents[1] +DIFF = Path("/tmp/diff") +COLUMN_WIDTH = 900 +GAP = 12 +LABEL_H = 30 +SVG_OPEN_RE = re.compile(r"(]*>)", re.I) +FONT_FAMILY_RE = re.compile(r'font-family="([^"]+)"') +FONT_ENTRY_RE = re.compile( + r"\{\s*name:\s*'([^']+)'\s*,\s*file:\s*'([^']+)'\s*(?:,\s*format:\s*'([^']+)')?", + re.S, +) +_FONT_FACE_CSS_CACHE: dict[tuple[str, ...], str] = {} + + +def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) + + +def git_value(args: list[str], fallback: str = "unknown") -> str: + result = run(["git", *args], cwd=RHWP) + value = result.stdout.strip() + return value or fallback + + +def natural_key(path: Path) -> int: + match = re.search(r"(\d+)(?=\.[^.]+$)", path.name) + return int(match.group(1)) if match else 1 + + +def svg_pages(docdir: Path, subdir: str = "rhwp_svg_cur") -> list[Path]: + return sorted((docdir / subdir).glob("*.svg"), key=natural_key) + + +def source_path(docdir: Path) -> Path | None: + for name in ("source.hwpx", "source.hwp", "source_converted.hwpx"): + path = docdir / name + if path.exists(): + return path + return None + + +def export_current(docdir: Path) -> None: + src = source_path(docdir) + if src is None: + raise SystemExit(f"no source.hwpx/source.hwp in {docdir}") + out = docdir / "rhwp_svg_cur" + out.mkdir(exist_ok=True) + for old in out.glob("*.svg"): + old.unlink() + rel_src = f"/diff/{docdir.name}/{src.name}" + rel_out = f"/diff/{docdir.name}/rhwp_svg_cur" + cmd = [ + "docker", + "compose", + "--env-file", + ".env.docker", + "run", + "--rm", + "-v", + "/tmp/diff:/diff", + "dev", + os.environ.get("RHWP_BIN", "/app/target/release/rhwp"), + "export-svg", + rel_src, + "-o", + rel_out, + ] + result = run(cmd, cwd=RHWP) + if result.returncode != 0: + sys.stderr.write(result.stderr[-1200:]) + raise SystemExit(f"export-svg failed for {docdir.name}") + + +def raster_hancom(docdir: Path) -> list[Path]: + pdf = docdir / "hancom.pdf" + if not pdf.exists(): + return [] + existing = sorted(docdir.glob("hancom_p-*.png"), key=natural_key) + if existing: + return existing + result = run(["pdftoppm", "-r", "120", "-png", str(pdf), str(docdir / "hancom_p")]) + if result.returncode != 0: + sys.stderr.write(result.stderr[-1200:]) + raise SystemExit(f"pdftoppm failed for {docdir.name}") + return sorted(docdir.glob("hancom_p-*.png"), key=natural_key) + + +def svg_size(svg: Path) -> tuple[float, float]: + head = svg.read_text(encoding="utf-8", errors="ignore")[:1600] + match = re.search(r']*\bwidth="([0-9.]+)"[^>]*\bheight="([0-9.]+)"', head) + if match: + return float(match.group(1)), float(match.group(2)) + match = re.search(r']*\bviewBox="[0-9.]+ [0-9.]+ ([0-9.]+) ([0-9.]+)"', head) + if match: + return float(match.group(1)), float(match.group(2)) + return 794.0, 1123.0 + + +def doc_has_landscape_svg(docdir: Path) -> bool: + for svg in svg_pages(docdir): + width, height = svg_size(svg) + if width > height: + return True + return False + + +def font_mime(path: Path) -> str: + suffix = path.suffix.lower() + if suffix == ".woff2": + return "font/woff2" + if suffix == ".woff": + return "font/woff" + if suffix == ".ttf": + return "font/ttf" + if suffix == ".otf": + return "font/otf" + return "application/octet-stream" + + +def font_format(path: Path, declared: str | None = None) -> str: + if declared: + return declared + suffix = path.suffix.lower() + if suffix == ".woff2": + return "woff2" + if suffix == ".woff": + return "woff" + if suffix == ".ttf": + return "truetype" + if suffix == ".otf": + return "opentype" + return "opentype" + + +def css_escape(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def font_registry() -> dict[str, tuple[Path, str | None]]: + source = RHWP / "rhwp-studio" / "src" / "core" / "font-loader.ts" + text = source.read_text(encoding="utf-8", errors="ignore") + registry: dict[str, tuple[Path, str | None]] = {} + for name, file_name, declared_format in FONT_ENTRY_RE.findall(text): + if not file_name.startswith("fonts/"): + continue + path = RHWP / "web" / file_name + if path.exists(): + registry[name] = (path, declared_format or None) + return registry + + +def svg_font_families(svg_text: str) -> set[str]: + families: set[str] = set() + for match in FONT_FAMILY_RE.finditer(svg_text): + value = html.unescape(match.group(1)) + for part in value.split(","): + family = part.strip().strip("'\"") + if family and family not in {"serif", "sans-serif", "monospace"}: + families.add(family) + return families + + +def review_font_css(families: set[str]) -> str: + available = font_registry() + matched = tuple(sorted(family for family in families if family in available)) + if not matched: + return "" + cached = _FONT_FACE_CSS_CACHE.get(matched) + if cached is not None: + return cached + lines: list[str] = [] + for family in matched: + path, declared_format = available[family] + data = base64.b64encode(path.read_bytes()).decode("ascii") + lines.append( + "@font-face{" + f'font-family:"{css_escape(family)}";' + f'src:url("data:{font_mime(path)};base64,{data}") format("{font_format(path, declared_format)}");' + "font-style:normal;" + "font-display:block;" + "}" + ) + css = "\n".join(lines) + _FONT_FACE_CSS_CACHE[matched] = css + return css + + +def inject_review_fonts(svg: Path, temp_dir: Path) -> Path: + svg_text = svg.read_text(encoding="utf-8", errors="ignore") + css = review_font_css(svg_font_families(svg_text)) + if not css: + return svg + style = f"\n" + if "" in svg_text: + patched = svg_text.replace("", f"{style}", 1) + else: + patched = SVG_OPEN_RE.sub(lambda match: match.group(1) + f"\n{style}\n", svg_text, count=1) + temp_svg = temp_dir / svg.name + temp_svg.write_text(patched, encoding="utf-8") + return temp_svg + + +def raster_svg(svg: Path, png: Path) -> None: + width, height = svg_size(svg) + target_w = 1400 + target_h = max(1, round(height * target_w / width)) + page_html = ( + "" + "" + "" + ) + js = r""" +const { chromium } = require('playwright'); +const [html, png, w, h] = process.argv.slice(1); +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: Number(w), height: Number(h) }, + deviceScaleFactor: 1, + }); + await page.goto('file://' + html, { waitUntil: 'networkidle' }); + await page.locator('#page').evaluate(img => img.decode ? img.decode() : Promise.resolve()); + const size = await page.evaluate(() => ({ + width: Math.ceil(document.documentElement.scrollWidth), + height: Math.ceil(document.documentElement.scrollHeight), + })); + await page.setViewportSize({ + width: Math.max(Number(w), size.width), + height: Math.max(Number(h), size.height), + }); + await page.screenshot({ path: png, fullPage: true }); + await browser.close(); +})().catch(err => { console.error(err); process.exit(1); }); +""" + with tempfile.TemporaryDirectory(prefix="rhwp-review-svg-") as temp_name: + temp_dir = Path(temp_name) + raster_svg_path = inject_review_fonts(svg, temp_dir) + page_html = page_html.replace("__SVG_URL__", f"file://{raster_svg_path.resolve()}").replace( + "__TARGET_W__", str(target_w) + ) + with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) as handle: + handle.write(page_html) + html_path = handle.name + try: + result = run(["node", "-e", js, html_path, str(png), str(target_w), str(target_h)], cwd=RHWP) + if result.returncode != 0 or not png.exists(): + sys.stderr.write(result.stderr[-1200:]) + raise SystemExit(f"Chromium SVG raster failed for {svg}") + finally: + try: + os.remove(html_path) + except OSError: + pass + + +def raster_rhwp(docdir: Path) -> list[Path]: + pages = svg_pages(docdir) + out: list[Path] = [] + for i, svg in enumerate(pages, 1): + png = docdir / f"rhwp_review_p-{i}.png" + raster_svg(svg, png) + out.append(png) + return out + + +def fit_width(image: Image.Image, width: int) -> Image.Image: + src_w, src_h = image.size + return image.resize((width, max(1, round(src_h * width / src_w)))) + + +def missing_page(label: str, page: int) -> Image.Image: + image = Image.new("RGB", (COLUMN_WIDTH, 500), "#ffe0e0") + ImageDraw.Draw(image).text((20, 20), f"(no {label} page {page})", fill="red") + return image + + +def stitch_doc(outdir: Path, docdir: Path, hancom: list[Path], rhwp: list[Path], max_pages: int) -> list[str]: + doc_out = outdir / docdir.name + doc_out.mkdir(parents=True, exist_ok=True) + page_count = max(len(hancom), len(rhwp)) + shown = page_count if max_pages <= 0 else min(page_count, max_pages) + rows: list[str] = [] + for i in range(shown): + images: list[tuple[str, Image.Image]] = [] + for label, paths in (("HANCOM", hancom), ("RHWP current", rhwp)): + if i < len(paths) and paths[i].exists(): + image = fit_width(Image.open(paths[i]).convert("RGB"), COLUMN_WIDTH) + else: + image = missing_page(label, i + 1) + images.append((label, image)) + height = max(image.height for _, image in images) + LABEL_H + strip = Image.new("RGB", (COLUMN_WIDTH * 2 + GAP, height), "#888") + for col, (label, image) in enumerate(images): + canvas = Image.new("RGB", (COLUMN_WIDTH, height), "white") + canvas.paste(image, (0, LABEL_H)) + ImageDraw.Draw(canvas).text((8, 8), f"{label} page {i + 1}", fill="#000") + strip.paste(canvas, (col * (COLUMN_WIDTH + GAP), 0)) + rel = f"{docdir.name}/page-{i + 1:02d}.png" + strip.save(outdir / rel) + rows.append(rel) + return rows + + +def build_doc(outdir: Path, docdir: Path, max_pages: int, export: bool) -> tuple[str, int, int, int, list[str]]: + if export: + export_current(docdir) + hancom = raster_hancom(docdir) + rhwp = raster_rhwp(docdir) + rows = stitch_doc(outdir, docdir, hancom, rhwp, max_pages) + return docdir.name, len(hancom), len(rhwp), max(len(hancom), len(rhwp)), rows + + +def write_doc_html(outdir: Path, name: str, hancom_pages: int, rhwp_pages: int, total_pages: int, rows: list[str]) -> None: + truncated = "" + if len(rows) < total_pages: + truncated = f" (showing {len(rows)}/{total_pages} pages)" + body = "\n".join( + f'

page {i + 1}

' + f'' + for i, row in enumerate(rows) + ) + page = f"""{html.escape(name)} + +

{html.escape(name)} Hancom={hancom_pages}p RHWP={rhwp_pages}p{truncated}

+

index | Click any strip to open the raw full-height PNG.

+{body} +""" + (outdir / name / "index.html").write_text(page, encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("out_dir") + parser.add_argument("docs", nargs="*") + parser.add_argument("--all", action="store_true", help="include every /tmp/diff doc with source + Hancom PDF") + parser.add_argument("--export-current", action="store_true", help="regenerate rhwp_svg_cur with the current binary") + parser.add_argument("--max-pages", type=int, default=0, help="0 means all pages") + parser.add_argument("--skip-landscape", action="store_true", help="skip docs whose current RHWP SVG pages are landscape") + args = parser.parse_args() + + outdir = Path(args.out_dir) + outdir.mkdir(parents=True, exist_ok=True) + if args.all: + docs = sorted( + d.name for d in DIFF.iterdir() + if d.is_dir() and (d / "hancom.pdf").exists() and source_path(d) is not None + ) + else: + docs = args.docs + if not docs: + raise SystemExit("no docs requested") + + head = git_value(["rev-parse", "--short", "HEAD"]) + dirty = "yes" if git_value(["status", "--porcelain"], "") else "no" + generated_at = datetime.now().isoformat(timespec="seconds") + + results = [] + skipped = [] + for doc in docs: + docdir = DIFF / doc + if not docdir.is_dir(): + print(f"skip missing doc: {doc}", file=sys.stderr) + continue + export_for_build = args.export_current + if args.skip_landscape and args.export_current: + export_current(docdir) + export_for_build = False + if args.skip_landscape and doc_has_landscape_svg(docdir): + print(f"skip landscape doc: {doc}", file=sys.stderr) + skipped.append(doc) + continue + print(f"building {doc}...", flush=True) + name, hancom_pages, rhwp_pages, total_pages, rows = build_doc( + outdir, docdir, args.max_pages, export_for_build + ) + write_doc_html(outdir, name, hancom_pages, rhwp_pages, total_pages, rows) + results.append((name, hancom_pages, rhwp_pages, total_pages, len(rows))) + + rows_html = [] + for name, hancom_pages, rhwp_pages, total_pages, shown_pages in results: + match = "yes" if hancom_pages == rhwp_pages else "no" + shown = f"{shown_pages}/{total_pages}" + rows_html.append( + f'{html.escape(name)}' + f"{hancom_pages}{rhwp_pages}{match}{shown}" + ) + skipped_html = "" + if skipped: + skipped_html = "

Skipped landscape docs

    " + "".join( + f"
  • {html.escape(name)}
  • " for name in skipped + ) + "
" + index = f"""RHWP review gallery + +

RHWP review gallery

+

Generated {html.escape(generated_at)} from git HEAD {html.escape(head)}; dirty worktree: {dirty}.

+

Left = Hancom oracle. Right = current RHWP. Strips are full-height PNGs; no CSS crop is applied.

+{skipped_html} + +{''.join(rows_html)} +
docHancomRHWPpage matchshown
+""" + (outdir / "index.html").write_text(index, encoding="utf-8") + print(f"\nGALLERY: {outdir / 'index.html'} ({len(results)} docs)") + + +if __name__ == "__main__": + main() diff --git a/harness/review_page_pairs.py b/harness/review_page_pairs.py new file mode 100644 index 0000000000..3d004b175c --- /dev/null +++ b/harness/review_page_pairs.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Build Hancom/RHWP side-by-side strips for semantically aligned page pairs.""" +from __future__ import annotations + +import argparse +import html +from pathlib import Path + +from PIL import Image, ImageDraw + +import review_gallery + + +DIFF = Path("/tmp/diff") +GAP = review_gallery.GAP +LABEL_H = review_gallery.LABEL_H +COLUMN_WIDTH = review_gallery.COLUMN_WIDTH + + +def parse_pairs(raw: str) -> list[tuple[int, int]]: + pairs: list[tuple[int, int]] = [] + for part in raw.split(","): + part = part.strip() + if not part: + continue + if ":" not in part: + raise argparse.ArgumentTypeError(f"pair must be HANCOM:RHWP, got {part!r}") + left, right = part.split(":", 1) + pairs.append((int(left), int(right))) + if not pairs: + raise argparse.ArgumentTypeError("no page pairs supplied") + return pairs + + +def page_path(paths: list[Path], page: int) -> Path | None: + idx = page - 1 + if 0 <= idx < len(paths): + return paths[idx] + return None + + +def image_or_missing(path: Path | None, label: str, page: int) -> Image.Image: + if path is not None and path.exists(): + return review_gallery.fit_width(Image.open(path).convert("RGB"), COLUMN_WIDTH) + return review_gallery.missing_page(label, page) + + +def build(outdir: Path, doc: str, pairs: list[tuple[int, int]], export_current: bool) -> None: + docdir = DIFF / doc + if export_current: + review_gallery.export_current(docdir) + hancom = review_gallery.raster_hancom(docdir) + svg_pages = review_gallery.svg_pages(docdir) + rhwp_pages: dict[int, Path] = {} + for _, rhwp_page in pairs: + svg = page_path(svg_pages, rhwp_page) + if svg is None: + continue + png = docdir / f"rhwp_review_p-{rhwp_page}.png" + review_gallery.raster_svg(svg, png) + rhwp_pages[rhwp_page] = png + + doc_out = outdir / doc + doc_out.mkdir(parents=True, exist_ok=True) + rows: list[tuple[int, int, str]] = [] + for hancom_page, rhwp_page in pairs: + left = image_or_missing(page_path(hancom, hancom_page), "HANCOM", hancom_page) + right = image_or_missing(rhwp_pages.get(rhwp_page), "RHWP current", rhwp_page) + height = max(left.height, right.height) + LABEL_H + strip = Image.new("RGB", (COLUMN_WIDTH * 2 + GAP, height), "#888") + for col, (label, page, image) in enumerate( + (("HANCOM", hancom_page, left), ("RHWP current", rhwp_page, right)) + ): + canvas = Image.new("RGB", (COLUMN_WIDTH, height), "white") + canvas.paste(image, (0, LABEL_H)) + ImageDraw.Draw(canvas).text((8, 8), f"{label} page {page}", fill="#000") + strip.paste(canvas, (col * (COLUMN_WIDTH + GAP), 0)) + rel = f"hancom-{hancom_page:03d}_rhwp-{rhwp_page:03d}.png" + strip.save(doc_out / rel) + rows.append((hancom_page, rhwp_page, rel)) + + body = "\n".join( + ( + f"

Hancom {hancom_page} / RHWP {rhwp_page}

" + f'' + ) + for hancom_page, rhwp_page, rel in rows + ) + (doc_out / "index.html").write_text( + f"""{html.escape(doc)} + +

{html.escape(doc)} paired page review

+

index

+{body} +""", + encoding="utf-8", + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("out_dir", type=Path) + parser.add_argument("doc") + parser.add_argument("--pairs", required=True, type=parse_pairs) + parser.add_argument("--export-current", action="store_true") + args = parser.parse_args() + + args.out_dir.mkdir(parents=True, exist_ok=True) + build(args.out_dir, args.doc, args.pairs, args.export_current) + (args.out_dir / "index.html").write_text( + f"""paired page review + +

paired page review

+ +""", + encoding="utf-8", + ) + print(args.out_dir / "index.html") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/review_pages.py b/harness/review_pages.py new file mode 100644 index 0000000000..6476854e63 --- /dev/null +++ b/harness/review_pages.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Build a small Hancom/RHWP side-by-side review for selected pages. + +Use this for large guard docs where `review_gallery.py --max-pages N` is too +slow. It reuses the same raster/stitch primitives, but only for explicit page +numbers. +""" +from __future__ import annotations + +import argparse +import html +from pathlib import Path + +from PIL import Image, ImageDraw + +import review_gallery + + +DIFF = Path("/tmp/diff") +COLUMN_WIDTH = review_gallery.COLUMN_WIDTH +GAP = review_gallery.GAP +LABEL_H = review_gallery.LABEL_H + + +def parse_pages(raw: str) -> list[int]: + pages: list[int] = [] + for part in raw.split(","): + part = part.strip() + if not part: + continue + if "-" in part: + start_raw, end_raw = part.split("-", 1) + start = int(start_raw) + end = int(end_raw) + pages.extend(range(start, end + 1)) + else: + pages.append(int(part)) + out = sorted(set(page for page in pages if page > 0)) + if not out: + raise argparse.ArgumentTypeError("no positive pages supplied") + return out + + +def image_or_missing(path: Path | None, label: str, page: int) -> Image.Image: + if path is not None and path.exists(): + return review_gallery.fit_width(Image.open(path).convert("RGB"), COLUMN_WIDTH) + return review_gallery.missing_page(label, page) + + +def page_path(paths: list[Path], page: int) -> Path | None: + idx = page - 1 + if 0 <= idx < len(paths): + return paths[idx] + return None + + +def build(outdir: Path, doc: str, pages: list[int], export_current: bool) -> None: + docdir = DIFF / doc + if export_current: + review_gallery.export_current(docdir) + hancom = review_gallery.raster_hancom(docdir) + svg_pages = review_gallery.svg_pages(docdir) + rhwp_pages: dict[int, Path] = {} + for page in pages: + svg = page_path(svg_pages, page) + if svg is None: + continue + png = docdir / f"rhwp_review_p-{page}.png" + review_gallery.raster_svg(svg, png) + rhwp_pages[page] = png + + doc_out = outdir / doc + doc_out.mkdir(parents=True, exist_ok=True) + rows: list[tuple[int, str]] = [] + for page in pages: + left = image_or_missing(page_path(hancom, page), "HANCOM", page) + right = image_or_missing(rhwp_pages.get(page), "RHWP current", page) + height = max(left.height, right.height) + LABEL_H + strip = Image.new("RGB", (COLUMN_WIDTH * 2 + GAP, height), "#888") + for col, (label, image) in enumerate((("HANCOM", left), ("RHWP current", right))): + canvas = Image.new("RGB", (COLUMN_WIDTH, height), "white") + canvas.paste(image, (0, LABEL_H)) + ImageDraw.Draw(canvas).text((8, 8), f"{label} page {page}", fill="#000") + strip.paste(canvas, (col * (COLUMN_WIDTH + GAP), 0)) + rel = f"page-{page:03d}.png" + strip.save(doc_out / rel) + rows.append((page, rel)) + + body = "\n".join( + f'

page {page}

' + for page, rel in rows + ) + (doc_out / "index.html").write_text( + f"""{html.escape(doc)} + +

{html.escape(doc)} selected pages

+

index

+{body} +""", + encoding="utf-8", + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("out_dir", type=Path) + parser.add_argument("doc") + parser.add_argument("--pages", required=True, type=parse_pages) + parser.add_argument("--export-current", action="store_true") + args = parser.parse_args() + + outdir = args.out_dir + outdir.mkdir(parents=True, exist_ok=True) + build(outdir, args.doc, args.pages, args.export_current) + (outdir / "index.html").write_text( + f"""selected page review + +

selected page review

+ +""", + encoding="utf-8", + ) + print(outdir / "index.html") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/svg_cell_text_probe.py b/harness/svg_cell_text_probe.py new file mode 100644 index 0000000000..05edd70a9c --- /dev/null +++ b/harness/svg_cell_text_probe.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Inspect RHWP SVG table-cell text placement. + +This read-only probe associates `` groups with +their clip rectangles and summarizes text baselines inside each cell. It helps +separate missing text from table-cell baseline/clip/position issues before +touching renderer code. + +Usage: + python3 harness/svg_cell_text_probe.py overseas_training:1 --y-range 160,1038 +""" +from __future__ import annotations + +import argparse +import html +import re +from collections import Counter +from dataclasses import dataclass + +from svg_geometry_probe import parse_ref, rhwp_svg_path + + +CLIP_RE = re.compile( + r'cell-clip-[^"]+)">\s*' + r'[^>]*)/>\s*', + re.DOTALL, +) +GROUP_RE = re.compile( + r'cell-clip-[^)]+)\)">(?P.*?)', + re.DOTALL, +) +TEXT_RE = re.compile(r"[^>]*)>(?P.*?)
", re.DOTALL) +ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)=(?:\"([^\"]*)\"|'([^']*)')") + + +@dataclass(frozen=True) +class Rect: + x: float + y: float + width: float + height: float + + @property + def x1(self) -> float: + return self.x + self.width + + @property + def y1(self) -> float: + return self.y + self.height + + +@dataclass(frozen=True) +class TextRun: + x: float + y: float + font_size: float + font_family: str + font_weight: str + text: str + + +def attr_map(raw: str) -> dict[str, str]: + out: dict[str, str] = {} + for key, double, single in ATTR_RE.findall(raw): + out[key] = html.unescape(double or single) + return out + + +def parse_float(raw: str | None, default: float = 0.0) -> float: + if raw is None: + return default + try: + return float(raw) + except ValueError: + return default + + +def parse_range(raw: str) -> tuple[float, float]: + parts = raw.split(",") + if len(parts) != 2: + raise argparse.ArgumentTypeError("expected y0,y1") + try: + y0, y1 = (float(part) for part in parts) + except ValueError as exc: + raise argparse.ArgumentTypeError("invalid y range") from exc + if y1 <= y0: + raise argparse.ArgumentTypeError("expected y1 > y0") + return y0, y1 + + +def primary_font(font_family: str) -> str: + return font_family.split(",", 1)[0].strip().strip("'\"") or "-" + + +def parse_clips(svg: str) -> dict[str, Rect]: + clips: dict[str, Rect] = {} + for match in CLIP_RE.finditer(svg): + attrs = attr_map(match.group("attrs")) + clips[match.group("id")] = Rect( + x=parse_float(attrs.get("x")), + y=parse_float(attrs.get("y")), + width=parse_float(attrs.get("width")), + height=parse_float(attrs.get("height")), + ) + return clips + + +def parse_text_runs(raw: str) -> list[TextRun]: + runs: list[TextRun] = [] + for match in TEXT_RE.finditer(raw): + attrs = attr_map(match.group("attrs")) + text = html.unescape(re.sub(r"<[^>]+>", "", match.group("body"))).strip() + if not text: + continue + runs.append( + TextRun( + x=parse_float(attrs.get("x")), + y=parse_float(attrs.get("y")), + font_size=parse_float(attrs.get("font-size")), + font_family=attrs.get("font-family", ""), + font_weight=attrs.get("font-weight", "normal") or "normal", + text=text, + ) + ) + return runs + + +def line_bands(runs: list[TextRun], bucket: float) -> list[tuple[float, int]]: + counts: Counter[float] = Counter() + for run in runs: + counts[round(run.y / bucket) * bucket] += len(run.text) + return sorted(counts.items()) + + +def summarize_cell(clip_id: str, rect: Rect, runs: list[TextRun], edge_threshold: float) -> str: + chars = sum(len(run.text) for run in runs) + if not runs: + return ( + f"{clip_id}\t{rect.x:.1f}\t{rect.y:.1f}\t{rect.width:.1f}\t{rect.height:.1f}\t" + "0\t0\t-\t-\t-\t-\t-\t-" + ) + min_y = min(run.y for run in runs) + max_y = max(run.y for run in runs) + min_x = min(run.x for run in runs) + max_x = max(run.x for run in runs) + top_pad = min_y - rect.y + bottom_pad = rect.y1 - max_y + left_pad = min_x - rect.x + right_pad = rect.x1 - max_x + y_pct = (min_y - rect.y) * 100.0 / max(rect.height, 0.001) + bands = ";".join(f"{y:.1f}:{count}" for y, count in line_bands(runs, 2.0)) + fonts = Counter(primary_font(run.font_family) for run in runs) + sizes = Counter(round(run.font_size, 2) for run in runs) + flags: list[str] = [] + if top_pad < edge_threshold: + flags.append("near_top") + if bottom_pad < edge_threshold: + flags.append("near_bottom") + if left_pad < -0.5 or right_pad < -0.5: + flags.append("x_outside") + if min_y < rect.y - 0.5 or max_y > rect.y1 + 0.5: + flags.append("y_outside") + return ( + f"{clip_id}\t{rect.x:.1f}\t{rect.y:.1f}\t{rect.width:.1f}\t{rect.height:.1f}\t" + f"{len(runs)}\t{chars}\t{top_pad:.1f}\t{bottom_pad:.1f}\t{y_pct:.1f}\t" + f"{fonts.most_common(1)[0][0]}\t{sizes.most_common(1)[0][0]:g}\t{bands}\t" + f"{','.join(flags) if flags else '-'}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("ref", type=parse_ref, help="DOC:PAGE reference") + parser.add_argument("--y-range", type=parse_range, default=None) + parser.add_argument("--min-chars", type=int, default=1) + parser.add_argument("--edge-threshold", type=float, default=2.0) + args = parser.parse_args() + + svg = rhwp_svg_path(args.ref).read_text(encoding="utf-8", errors="ignore") + clips = parse_clips(svg) + print( + "clip\tx\ty\tw\th\ttext_runs\tchars\ttop_pad\tbottom_pad\tfirst_y_pct\t" + "font\tfont_size\ty_bands\tflags" + ) + for match in GROUP_RE.finditer(svg): + clip_id = match.group("id") + rect = clips.get(clip_id) + if rect is None: + continue + if args.y_range is not None: + y0, y1 = args.y_range + if rect.y1 < y0 or rect.y > y1: + continue + runs = parse_text_runs(match.group("body")) + if sum(len(run.text) for run in runs) < args.min_chars: + continue + print(summarize_cell(clip_id, rect, runs, args.edge_threshold)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/svg_component_probe.py b/harness/svg_component_probe.py new file mode 100644 index 0000000000..82e4f39267 --- /dev/null +++ b/harness/svg_component_probe.py @@ -0,0 +1,749 @@ +#!/usr/bin/env python3 +"""Probe SVG paint components against Hancom oracle pages. + +This is a fast, renderer-read-only harness for fidelity triage. It mutates +temporary copies of the current RHWP SVG page, rasters them, and reports whether +text, ruling strokes, font family, weight, or paint scale dominate the visual +diff before we spend time on Rust patches. + +Usage: + python3 harness/svg_component_probe.py overseas_training:1 accountability_eval:4 + python3 harness/svg_component_probe.py --export-current --keep accountability_eval:4 +""" +from __future__ import annotations + +import argparse +import csv +import fnmatch +import html +import re +import shutil +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageChops, ImageStat + +import review_gallery + + +RHWP = Path(__file__).resolve().parents[1] +DIFF = Path("/tmp/diff") +GALLERY_LABEL_H = 30 +GALLERY_GAP = 12 + +TEXT_RE = re.compile(r"]*>.*?
", re.DOTALL) +LINE_OR_RECT_RE = re.compile(r"<(?:line|rect)\b[^>]*/?>", re.DOTALL) +STROKE_WIDTH_RE = re.compile(r'(stroke-width=")([0-9.]+)(")') +FONT_FAMILY_RE = re.compile(r'(font-family=")([^"]+)(")') +FONT_WEIGHT_RE = re.compile(r'(font-weight=")([^"]+)(")') +FONT_SIZE_RE = re.compile(r'(font-size=")([0-9.]+)(")') +TEXT_LENGTH_ATTR_RE = re.compile(r'\s+textLength="[^"]+"\s+lengthAdjust="[^"]+"') +FILL_OR_STROKE_BLACK_RE = re.compile(r'(?:fill|stroke)="(?:#000000|#000|black|rgb\(0,\s*0,\s*0\))"', re.I) +CELL_CLIP_RE = re.compile( + r'[^>]*)/>' +) +CLIPPED_IMAGE_RE = re.compile( + r'(?P.*?)', + re.DOTALL, +) +IMAGE_TAG_RE = re.compile(r"]*>", re.DOTALL) +SVG_ATTR_RE = re.compile(r'([a-zA-Z_:][-a-zA-Z0-9_:.]*)="([^"]*)"') +TEXT_TAG_RE = re.compile(r"[^>]*)>(?P.*?)
", re.DOTALL) +ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)=(?:\"([^\"]*)\"|'([^']*)')") + + +@dataclass(frozen=True) +class PageRef: + doc: str + page: int + + +@dataclass(frozen=True) +class Variant: + name: str + kind: str + value: str + + +VARIANTS = [ + Variant("base", "base", ""), + Variant("hide_text", "hide_text", ""), + Variant("hide_lines", "hide_lines", ""), + Variant("hide_text_gyeonggi", "hide_text_family", "경기천년"), + Variant("hide_text_noto", "hide_text_family", "Noto Sans KR"), + Variant("hide_text_malgun", "hide_text_family", "Malgun Gothic|맑은 고딕"), + Variant("thin_stroke_x0.50", "thin_stroke_factor", "0.50"), + Variant("thin_stroke_x0.75", "thin_stroke_factor", "0.75"), + Variant("thin_stroke_x1.00", "thin_stroke_factor", "1.00"), + Variant("thin_stroke_x1.50", "thin_stroke_factor", "1.50"), + Variant("thin_stroke_x2.00", "thin_stroke_factor", "2.00"), + Variant("thin_stroke_x3.00", "thin_stroke_factor", "3.00"), + Variant("stroke_eq_0.07_x2", "stroke_width_eq_factor", "0.07=2.0"), + Variant("stroke_eq_0.07_x3", "stroke_width_eq_factor", "0.07=3.0"), + Variant("stroke_eq_0.0875_x2", "stroke_width_eq_factor", "0.0875=2.0"), + Variant("stroke_eq_0.105_x2", "stroke_width_eq_factor", "0.105=2.0"), + Variant("stroke_eq_0.1575_x2", "stroke_width_eq_factor", "0.1575=2.0"), + Variant("stroke_min_0.12", "stroke_min_width", "0.12"), + Variant("stroke_min_0.16", "stroke_min_width", "0.16"), + Variant("stroke_min_0.20", "stroke_min_width", "0.20"), + Variant("stroke_min_0.30", "stroke_min_width", "0.30"), + Variant("stroke_min_0.50", "stroke_min_width", "0.50"), + Variant("stroke_eq_0.07_x5", "stroke_width_eq_factor", "0.07=5.0"), + Variant("stroke_eq_0.07_x7", "stroke_width_eq_factor", "0.07=7.0"), + Variant("font_Apple_SD_Gothic_Neo", "font_family", "Apple SD Gothic Neo"), + Variant("font_Noto_Sans_KR", "font_family", "Noto Sans KR"), + Variant("font_Pretendard", "font_family", "Pretendard"), + Variant("font_Nanum_Gothic", "font_family", "Nanum Gothic"), + Variant("font_AppleGothic", "font_family", "AppleGothic"), + Variant("weight_500", "font_weight", "500"), + Variant("weight_600", "font_weight", "600"), + Variant("weight_gyeonggi_500", "font_family_weight", "경기천년=500"), + Variant("weight_gyeonggi_600", "font_family_weight", "경기천년=600"), + Variant("font_size_x0.90", "font_size_factor", "0.90"), + Variant("font_size_x1.00", "font_size_factor", "1.00"), + Variant("font_size_x1.10", "font_size_factor", "1.10"), + Variant("font_size_gyeonggi_x0.90", "font_family_size_factor", "경기천년=0.90"), + Variant("font_size_gyeonggi_x1.10", "font_family_size_factor", "경기천년=1.10"), + Variant("font_size_noto_x0.90", "font_family_size_factor", "Noto Sans KR=0.90"), + Variant("font_size_noto_x1.10", "font_family_size_factor", "Noto Sans KR=1.10"), + Variant("font_family_bold_attr", "font_family_bold_attr", ""), + Variant("remove_text_length", "remove_text_length", ""), + Variant("clamp_cell_image_top", "clamp_cell_image_top", ""), + Variant("shift_crop_y_15", "shift_crop_y", "15"), + Variant("image_height_x1.05", "image_height_factor", "1.05"), + Variant("image_height_x1.08", "image_height_factor", "1.08"), + Variant("image_height_x1.12", "image_height_factor", "1.12"), + Variant("image_y_m12", "image_y_shift", "-12"), + Variant("image_y_m6", "image_y_shift", "-6"), + Variant("image_y_p6", "image_y_shift", "6"), + Variant("image_y_p12", "image_y_shift", "12"), + Variant("image_filter_b95_c110", "image_css_filter", "brightness(0.95) contrast(1.10)"), + Variant("image_filter_b90_c115", "image_css_filter", "brightness(0.90) contrast(1.15)"), + Variant("image_filter_b85_c120", "image_css_filter", "brightness(0.85) contrast(1.20)"), + Variant("shift_y_after_430_p12", "shift_y_after", "430=12"), + Variant("shift_y_after_430_m12", "shift_y_after", "430=-12"), + Variant("text_stroke_0.15", "text_stroke", "0.15"), + Variant("text_stroke_0.25", "text_stroke", "0.25"), + Variant("text_y_m4", "text_y_shift", "-4"), + Variant("text_y_m2", "text_y_shift", "-2"), + Variant("text_y_p2", "text_y_shift", "2"), + Variant("text_y_p4", "text_y_shift", "4"), + Variant("normalize_line_advances_1.00", "normalize_line_advances", "1.00"), + Variant("normalize_line_advances_1.10", "normalize_line_advances", "1.10"), + Variant("normalize_line_advances_1.18", "normalize_line_advances", "1.18"), +] + + +def parse_ref(raw: str) -> PageRef: + if ":" not in raw: + raise argparse.ArgumentTypeError(f"expected DOC:PAGE, got {raw!r}") + doc, page_raw = raw.rsplit(":", 1) + if not doc: + raise argparse.ArgumentTypeError("doc name is empty") + try: + page = int(page_raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid page number in {raw!r}") from exc + if page <= 0: + raise argparse.ArgumentTypeError("page must be positive") + return PageRef(doc=doc, page=page) + + +def docdir(ref: PageRef) -> Path: + path = DIFF / ref.doc + if not path.exists(): + raise SystemExit(f"missing /tmp/diff docdir: {path}") + return path + + +def rhwp_svg_path(ref: PageRef) -> Path: + directory = docdir(ref) / "rhwp_svg_cur" + candidates = [ + directory / f"source_{ref.page:03d}.svg", + directory / f"source_{ref.page}.svg", + directory / f"page_{ref.page:03d}.svg", + directory / f"page_{ref.page}.svg", + ] + for path in candidates: + if path.exists(): + return path + raise SystemExit(f"missing RHWP SVG for {ref.doc} page {ref.page} under {directory}") + + +def hancom_png_path(ref: PageRef) -> Path: + directory = docdir(ref) + candidates = [ + directory / f"hancom_p-{ref.page}.png", + directory / f"hancom_p-{ref.page:02d}.png", + directory / f"hancom_p-{ref.page:03d}.png", + ] + for path in candidates: + if path.exists(): + return path + generated = review_gallery.raster_hancom(directory) + for path in generated: + if review_gallery.natural_key(path) == ref.page: + return path + raise SystemExit(f"missing Hancom PNG for {ref.doc} page {ref.page}") + + +def ensure_current_svg(ref: PageRef, export_current: bool) -> None: + if export_current: + review_gallery.export_current(docdir(ref)) + else: + rhwp_svg_path(ref) + + +def change_thin_strokes(svg: str, factor: float) -> str: + def replace_tag(match: re.Match[str]) -> str: + tag = match.group(0) + if not FILL_OR_STROKE_BLACK_RE.search(tag): + return tag + + def replace_width(width_match: re.Match[str]) -> str: + width = float(width_match.group(2)) + if width > 1.0: + return width_match.group(0) + return f'{width_match.group(1)}{width * factor:.4g}{width_match.group(3)}' + + return STROKE_WIDTH_RE.sub(replace_width, tag) + + return LINE_OR_RECT_RE.sub(replace_tag, svg) + + +def split_width_value(raw: str) -> tuple[float, float]: + if "=" not in raw: + raise ValueError(f"expected WIDTH=VALUE, got {raw!r}") + left, right = raw.rsplit("=", 1) + return float(left), float(right) + + +def change_stroke_width_eq(svg: str, target: float, factor: float) -> str: + def replace_tag(match: re.Match[str]) -> str: + tag = match.group(0) + if not FILL_OR_STROKE_BLACK_RE.search(tag): + return tag + + def replace_width(width_match: re.Match[str]) -> str: + width = float(width_match.group(2)) + if abs(width - target) > 0.002: + return width_match.group(0) + return f'{width_match.group(1)}{width * factor:.4g}{width_match.group(3)}' + + return STROKE_WIDTH_RE.sub(replace_width, tag) + + return LINE_OR_RECT_RE.sub(replace_tag, svg) + + +def change_stroke_min_width(svg: str, min_width: float) -> str: + def replace_tag(match: re.Match[str]) -> str: + tag = match.group(0) + if not FILL_OR_STROKE_BLACK_RE.search(tag): + return tag + + def replace_width(width_match: re.Match[str]) -> str: + width = float(width_match.group(2)) + if width >= min_width: + return width_match.group(0) + return f'{width_match.group(1)}{min_width:.4g}{width_match.group(3)}' + + return STROKE_WIDTH_RE.sub(replace_width, tag) + + return LINE_OR_RECT_RE.sub(replace_tag, svg) + + +def family_patterns(raw: str) -> list[str]: + return [part for part in raw.split("|") if part] + + +def text_tag_has_family(tag: str, patterns: list[str]) -> bool: + match = FONT_FAMILY_RE.search(tag) + if not match: + return False + family = match.group(2) + return any(pattern in family for pattern in patterns) + + +def split_family_value(raw: str) -> tuple[list[str], str]: + if "=" not in raw: + raise ValueError(f"expected FAMILY=VALUE, got {raw!r}") + family, value = raw.rsplit("=", 1) + return family_patterns(family), value + + +def change_family_font_size(tag: str, factor: float) -> str: + def replace_size(match: re.Match[str]) -> str: + return f'{match.group(1)}{float(match.group(2)) * factor:.4g}{match.group(3)}' + + return FONT_SIZE_RE.sub(replace_size, tag) + + +def change_family_font_weight(tag: str, weight: str) -> str: + if FONT_WEIGHT_RE.search(tag): + return FONT_WEIGHT_RE.sub(lambda m: f'{m.group(1)}{weight}{m.group(3)}', tag) + return tag.replace(" str: + def replace_tag(match: re.Match[str]) -> str: + tag = match.group(0) + if FONT_WEIGHT_RE.search(tag): + return tag + family = FONT_FAMILY_RE.search(tag) + if not family: + return tag + primary = family.group(2).split(",", 1)[0].strip("'\"") + lower = primary.lower() + if "bold" not in lower and "extra" not in lower and "굵" not in primary: + return tag + return tag.replace(" dict[str, str]: + return {match.group(1): match.group(2) for match in SVG_ATTR_RE.finditer(raw)} + + +def replace_svg_attr(tag: str, name: str, value: float) -> str: + pattern = re.compile(rf'({re.escape(name)}=")([^"]*)(")') + replacement = rf"\g<1>{value:.12g}\3" + if pattern.search(tag): + return pattern.sub(replacement, tag, count=1) + return tag.replace(" str: + clip_top: dict[str, float] = {} + for match in CELL_CLIP_RE.finditer(svg): + attrs = svg_attrs(match.group("attrs")) + try: + clip_top[match.group("id")] = float(attrs["y"]) + except (KeyError, ValueError): + continue + + def replace_group(match: re.Match[str]) -> str: + top = clip_top.get(match.group("clip")) + if top is None: + return match.group(0) + + def replace_image(image_match: re.Match[str]) -> str: + tag = image_match.group(0) + attrs = svg_attrs(tag) + try: + y = float(attrs["y"]) + except (KeyError, ValueError): + return tag + if y >= top: + return tag + return replace_svg_attr(tag, "y", top) + + body = IMAGE_TAG_RE.sub(replace_image, match.group("body")) + return f'{body}' + + return CLIPPED_IMAGE_RE.sub(replace_group, svg) + + +def shift_crop_y(svg: str, dy: float) -> str: + crop_ids = set(re.findall(r'', svg)) + if not crop_ids: + return svg + + def shift_rect(match: re.Match[str]) -> str: + tag = match.group(0) + attrs = svg_attrs(tag) + try: + y = float(attrs["y"]) + except (KeyError, ValueError): + return tag + return replace_svg_attr(tag, "y", y + dy) + + def shift_group(match: re.Match[str]) -> str: + clip_id = match.group("clip") + if clip_id not in crop_ids: + return match.group(0) + + def shift_image(image_match: re.Match[str]) -> str: + tag = image_match.group(0) + attrs = svg_attrs(tag) + try: + y = float(attrs["y"]) + except (KeyError, ValueError): + return tag + return replace_svg_attr(tag, "y", y + dy) + + body = IMAGE_TAG_RE.sub(shift_image, match.group("body")) + return f'{body}' + + svg = re.sub( + r']*/>', + lambda m: re.sub(r"]*/>", shift_rect, m.group(0)), + svg, + ) + return re.sub( + r'(?P.*?)', + shift_group, + svg, + flags=re.DOTALL, + ) + + +def scale_image_height(svg: str, factor: float) -> str: + def replace_image(match: re.Match[str]) -> str: + tag = match.group(0) + attrs = svg_attrs(tag) + try: + height = float(attrs["height"]) + except (KeyError, ValueError): + return tag + return replace_svg_attr(tag, "height", height * factor) + + return IMAGE_TAG_RE.sub(replace_image, svg) + + +def shift_image_y(svg: str, dy: float) -> str: + def replace_image(match: re.Match[str]) -> str: + tag = match.group(0) + attrs = svg_attrs(tag) + try: + y = float(attrs["y"]) + except (KeyError, ValueError): + return tag + return replace_svg_attr(tag, "y", y + dy) + + return IMAGE_TAG_RE.sub(replace_image, svg) + + +def add_image_css_filter(svg: str, filter_value: str) -> str: + def replace_image(match: re.Match[str]) -> str: + tag = match.group(0) + if " filter=" in tag: + return tag + return tag.replace(" tuple[float, float]: + if "=" not in raw: + raise ValueError(f"expected THRESHOLD=SHIFT, got {raw!r}") + left, right = raw.rsplit("=", 1) + return float(left), float(right) + + +def shift_y_after(svg: str, threshold: float, dy: float) -> str: + y_attr_re = re.compile(r'\by="([^"]+)"') + y1_attr_re = re.compile(r'\by1="([^"]+)"') + y2_attr_re = re.compile(r'\by2="([^"]+)"') + + def shift_attr(tag: str, pattern: re.Pattern[str], attr: str) -> str: + match = pattern.search(tag) + if not match: + return tag + try: + y = float(match.group(1)) + except ValueError: + return tag + if y < threshold: + return tag + return replace_svg_attr(tag, attr, y + dy) + + def replace_tag(match: re.Match[str]) -> str: + tag = match.group(0) + if tag.startswith("]*>", replace_tag, svg) + + +def add_text_stroke(svg: str, width: float) -> str: + def replace_text(match: re.Match[str]) -> str: + tag = match.group(0) + if 'stroke="' in tag: + return tag + fill = re.search(r'fill="([^"]+)"', tag) + color = fill.group(1) if fill else "#000000" + return tag.replace( + " str: + def replace_text(match: re.Match[str]) -> str: + tag = match.group(0) + attrs = svg_attrs(tag) + try: + y = float(attrs["y"]) + except (KeyError, ValueError): + return tag + return replace_svg_attr(tag, "y", y + dy) + + return TEXT_RE.sub(replace_text, svg) + + +def attr_map(raw: str) -> dict[str, str]: + attrs: dict[str, str] = {} + for key, double, single in ATTR_RE.findall(raw): + attrs[key] = html.unescape(double or single) + return attrs + + +def parse_float(raw: str | None) -> float: + if raw is None: + return 0.0 + try: + return float(raw) + except ValueError: + return 0.0 + + +def normalize_line_advances(svg: str, step_font_ratio: float) -> str: + matches = list(TEXT_TAG_RE.finditer(svg)) + if not matches: + return svg + tags = [] + groups: dict[float, list[int]] = {} + for idx, match in enumerate(matches): + attrs = attr_map(match.group("attrs")) + body = html.unescape(re.sub(r"<[^>]+>", "", match.group("body"))).strip() + x = parse_float(attrs.get("x")) + y = parse_float(attrs.get("y")) + font_size = parse_float(attrs.get("font-size")) + tags.append((match, attrs, body, x, y, font_size)) + if body and x > 0.0 and y > 0.0 and font_size > 0.0 and len(body) == 1: + groups.setdefault(round(y), []).append(idx) + + new_tags: dict[int, str] = {} + for indices in groups.values(): + if len(indices) < 8: + continue + indices = sorted(indices, key=lambda i: tags[i][3]) + xs = [tags[i][3] for i in indices] + steps = [b - a for a, b in zip(xs, xs[1:]) if b > a] + if not steps: + continue + font_size = sorted(tags[i][5] for i in indices)[len(indices) // 2] + current_median = sorted(steps)[len(steps) // 2] + target_step = font_size * step_font_ratio + # Only normalize visibly expanded lines; leave dense numeric/compressed rows alone. + if current_median <= target_step + 0.5: + continue + x0 = xs[0] + for pos, idx in enumerate(indices): + match, _attrs, _body, _x, _y, _font_size = tags[idx] + target_x = x0 + pos * target_step + tag = match.group(0) + tag = re.sub(r'x="[^"]+"', f'x="{target_x:.4f}"', tag, count=1) + new_tags[idx] = tag + + if not new_tags: + return svg + out = [] + last = 0 + for idx, (match, *_rest) in enumerate(tags): + out.append(svg[last : match.start()]) + out.append(new_tags.get(idx, match.group(0))) + last = match.end() + out.append(svg[last:]) + return "".join(out) + + +def apply_variant(svg: str, variant: Variant) -> str: + if variant.kind == "base": + return svg + if variant.kind == "hide_text": + return TEXT_RE.sub("", svg) + if variant.kind == "hide_text_family": + patterns = family_patterns(variant.value) + return TEXT_RE.sub(lambda m: "" if text_tag_has_family(m.group(0), patterns) else m.group(0), svg) + if variant.kind == "hide_lines": + return LINE_OR_RECT_RE.sub("", svg) + if variant.kind == "thin_stroke_factor": + return change_thin_strokes(svg, float(variant.value)) + if variant.kind == "stroke_width_eq_factor": + target, factor = split_width_value(variant.value) + return change_stroke_width_eq(svg, target, factor) + if variant.kind == "stroke_min_width": + return change_stroke_min_width(svg, float(variant.value)) + if variant.kind == "font_family": + return FONT_FAMILY_RE.sub(lambda m: f'{m.group(1)}{variant.value}{m.group(3)}', svg) + if variant.kind == "font_weight": + if FONT_WEIGHT_RE.search(svg): + return FONT_WEIGHT_RE.sub(lambda m: f'{m.group(1)}{variant.value}{m.group(3)}', svg) + return TEXT_RE.sub(lambda m: m.group(0).replace(" str: + return f'{match.group(1)}{float(match.group(2)) * factor:.4g}{match.group(3)}' + + return FONT_SIZE_RE.sub(replace_size, svg) + if variant.kind == "font_family_size_factor": + patterns, value = split_family_value(variant.value) + factor = float(value) + return TEXT_RE.sub( + lambda m: change_family_font_size(m.group(0), factor) + if text_tag_has_family(m.group(0), patterns) + else m.group(0), + svg, + ) + if variant.kind == "font_family_bold_attr": + return add_bold_attr_for_bold_named_faces(svg) + if variant.kind == "remove_text_length": + return TEXT_LENGTH_ATTR_RE.sub("", svg) + if variant.kind == "clamp_cell_image_top": + return clamp_cell_image_top(svg) + if variant.kind == "shift_crop_y": + return shift_crop_y(svg, float(variant.value)) + if variant.kind == "image_height_factor": + return scale_image_height(svg, float(variant.value)) + if variant.kind == "image_y_shift": + return shift_image_y(svg, float(variant.value)) + if variant.kind == "image_css_filter": + return add_image_css_filter(svg, variant.value) + if variant.kind == "shift_y_after": + threshold, dy = split_threshold_shift(variant.value) + return shift_y_after(svg, threshold, dy) + if variant.kind == "text_stroke": + return add_text_stroke(svg, float(variant.value)) + if variant.kind == "text_y_shift": + return shift_text_y(svg, float(variant.value)) + if variant.kind == "normalize_line_advances": + return normalize_line_advances(svg, float(variant.value)) + raise AssertionError(f"unknown variant kind: {variant.kind}") + + +def fit_to_match(image: Image.Image, size: tuple[int, int]) -> Image.Image: + if image.size == size: + return image + return image.resize(size) + + +def mean_diff(left: Path, right: Path) -> float: + with Image.open(left) as opened_left, Image.open(right) as opened_right: + oracle = opened_left.convert("RGB") + candidate = fit_to_match(opened_right.convert("RGB"), oracle.size) + diff = ImageChops.difference(oracle, candidate) + stat = ImageStat.Stat(diff) + return sum(stat.mean) / 3.0 + + +def dark_pixels(path: Path, threshold: int = 96) -> int: + with Image.open(path) as opened: + rgba = opened.convert("RGBA") + dark = 0 + for red, green, blue, alpha in rgba.getdata(): + if alpha == 0: + continue + luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue + if luma < threshold: + dark += 1 + return dark + + +def probe_one( + ref: PageRef, out_dir: Path, keep: bool, variants: list[Variant] +) -> list[dict[str, str]]: + source_svg = rhwp_svg_path(ref) + hancom_png = hancom_png_path(ref) + svg_text = source_svg.read_text(encoding="utf-8", errors="ignore") + rows: list[dict[str, str]] = [] + ref_dir = out_dir / f"{ref.doc}_p{ref.page}" + ref_dir.mkdir(parents=True, exist_ok=True) + + for variant in variants: + variant_svg = ref_dir / f"{variant.name}.svg" + variant_png = ref_dir / f"{variant.name}.png" + variant_svg.write_text(apply_variant(svg_text, variant), encoding="utf-8") + review_gallery.raster_svg(variant_svg, variant_png) + rows.append( + { + "doc": ref.doc, + "page": str(ref.page), + "variant": variant.name, + "mean_diff": f"{mean_diff(hancom_png, variant_png):.2f}", + "dark_pixels": str(dark_pixels(variant_png)), + "png": str(variant_png) if keep else "", + } + ) + + return rows + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("refs", nargs="+", type=parse_ref, help="one or more DOC:PAGE references") + parser.add_argument("--export-current", action="store_true", help="refresh rhwp_svg_cur before probing") + parser.add_argument("--keep", action="store_true", help="keep probe SVG/PNG artifacts and print paths") + parser.add_argument("--out-dir", default="", help="artifact directory; default is a temp dir unless --keep") + parser.add_argument( + "--variant", + action="append", + default=[], + help="variant name or shell-style pattern to run; may be repeated", + ) + args = parser.parse_args() + + for ref in args.refs: + ensure_current_svg(ref, args.export_current) + + temp_dir: tempfile.TemporaryDirectory[str] | None = None + if args.out_dir: + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + elif args.keep: + out_dir = DIFF / "_svg_component_probe" + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + else: + temp_dir = tempfile.TemporaryDirectory(prefix="rhwp-svg-probe-") + out_dir = Path(temp_dir.name) + + try: + variants = VARIANTS + if args.variant: + variants = [ + variant + for variant in VARIANTS + if any(fnmatch.fnmatch(variant.name, pattern) for pattern in args.variant) + ] + if not variants: + raise SystemExit(f"no variants matched: {', '.join(args.variant)}") + rows: list[dict[str, str]] = [] + for ref in args.refs: + rows.extend(probe_one(ref, out_dir, keep=args.keep or bool(args.out_dir), variants=variants)) + writer = csv.DictWriter( + sys.stdout, + fieldnames=["doc", "page", "variant", "mean_diff", "dark_pixels", "png"], + delimiter="\t", + lineterminator="\n", + ) + writer.writeheader() + writer.writerows(rows) + finally: + if temp_dir is not None: + temp_dir.cleanup() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/svg_font_face_probe.py b/harness/svg_font_face_probe.py new file mode 100644 index 0000000000..4633f685b6 --- /dev/null +++ b/harness/svg_font_face_probe.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Probe explicit SVG @font-face mappings against Hancom oracle pages. + +This is harness-only evidence gathering. It injects data-URI @font-face rules +into temporary copies of current RHWP SVG pages, rasters them with Playwright, +and reports whether a concrete font file improves visual parity before any +renderer/runtime font policy is changed. +""" +from __future__ import annotations + +import argparse +import base64 +import csv +import fnmatch +import re +import shutil +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageChops, ImageStat + +import review_gallery + + +RHWP = Path(__file__).resolve().parents[1] +DIFF = Path("/tmp/diff") + +SVG_OPEN_RE = re.compile(r"(]*>)", re.I) +TEXT_RE = re.compile(r"]*>.*?", re.DOTALL) +FONT_FAMILY_RE = re.compile(r'(font-family=")([^"]+)(")') + + +@dataclass(frozen=True) +class PageRef: + doc: str + page: int + + +@dataclass(frozen=True) +class FontRule: + family: str + font_path: Path + weight: str = "normal" + + +@dataclass(frozen=True) +class Variant: + name: str + rules: tuple[FontRule, ...] + rewrite_family: str = "" + + +def bundled_font(name: str) -> Path: + for root in (RHWP / "rhwp-studio" / "dist" / "fonts", RHWP / "web" / "fonts"): + path = root / name + if path.exists(): + return path + raise SystemExit(f"missing bundled font: {name}") + + +def default_variants() -> list[Variant]: + malgun_aliases = ("맑은 고딕", "Malgun Gothic") + serif_aliases = ( + "바탕", + "Batang", + "휴먼명조", + "Nanum Myeongjo", + "Noto Serif KR", + "AppleMyungjo", + ) + gyeonggi_serif_aliases = ("경기천년바탕 Bold",) + gyeonggi_sans_aliases = ("경기천년제목 Medium", "경기천년제목V Bold") + + def rules(families: tuple[str, ...], font_name: str, weight: str = "normal") -> tuple[FontRule, ...]: + font_path = bundled_font(font_name) + return tuple(FontRule(family=family, font_path=font_path, weight=weight) for family in families) + + def combined(*groups: tuple[FontRule, ...]) -> tuple[FontRule, ...]: + merged: list[FontRule] = [] + for group in groups: + merged.extend(group) + return tuple(merged) + + return [ + Variant("base", ()), + Variant("malgun_noto_regular", rules(malgun_aliases, "NotoSansKR-Regular.woff2")), + Variant("malgun_noto_bold", rules(malgun_aliases, "NotoSansKR-Bold.woff2", "700")), + Variant("malgun_pretendard_regular", rules(malgun_aliases, "Pretendard-Regular.woff2")), + Variant("malgun_pretendard_medium", rules(malgun_aliases, "Pretendard-Medium.woff2", "500")), + Variant("malgun_nanum_regular", rules(malgun_aliases, "NanumGothic-Regular.woff2")), + Variant("malgun_spoqa_regular", rules(malgun_aliases, "SpoqaHanSans-Regular.woff2")), + Variant("serif_noto_regular", rules(serif_aliases, "NotoSerifKR-Regular.woff2")), + Variant("serif_nanum_regular", rules(serif_aliases, "NanumMyeongjo-Regular.woff2")), + Variant( + "open_korean_baseline", + combined( + rules(malgun_aliases, "NotoSansKR-Regular.woff2"), + rules(serif_aliases, "NotoSerifKR-Regular.woff2"), + rules(gyeonggi_serif_aliases, "NotoSerifKR-Bold.woff2", "700"), + rules(gyeonggi_sans_aliases, "NotoSansKR-Bold.woff2", "700"), + ), + ), + Variant( + "open_korean_nanum", + combined( + rules(malgun_aliases, "NanumGothic-Regular.woff2"), + rules(serif_aliases, "NanumMyeongjo-Regular.woff2"), + rules(gyeonggi_serif_aliases, "NanumMyeongjo-Bold.woff2", "700"), + rules(gyeonggi_sans_aliases, "NanumGothic-Bold.woff2", "700"), + ), + ), + ] + + +def parse_ref(raw: str) -> PageRef: + if ":" not in raw: + raise argparse.ArgumentTypeError(f"expected DOC:PAGE, got {raw!r}") + doc, page_raw = raw.rsplit(":", 1) + if not doc: + raise argparse.ArgumentTypeError("doc name is empty") + try: + page = int(page_raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid page number in {raw!r}") from exc + if page <= 0: + raise argparse.ArgumentTypeError("page must be positive") + return PageRef(doc=doc, page=page) + + +def docdir(ref: PageRef) -> Path: + path = DIFF / ref.doc + if not path.exists(): + raise SystemExit(f"missing /tmp/diff docdir: {path}") + return path + + +def rhwp_svg_path(ref: PageRef) -> Path: + directory = docdir(ref) / "rhwp_svg_cur" + candidates = [ + directory / f"source_{ref.page:03d}.svg", + directory / f"source_{ref.page}.svg", + directory / f"page_{ref.page:03d}.svg", + directory / f"page_{ref.page}.svg", + ] + for path in candidates: + if path.exists(): + return path + raise SystemExit(f"missing RHWP SVG for {ref.doc} page {ref.page} under {directory}") + + +def hancom_png_path(ref: PageRef) -> Path: + directory = docdir(ref) + candidates = [ + directory / f"hancom_p-{ref.page}.png", + directory / f"hancom_p-{ref.page:02d}.png", + directory / f"hancom_p-{ref.page:03d}.png", + ] + for path in candidates: + if path.exists(): + return path + generated = review_gallery.raster_hancom(directory) + for path in generated: + if review_gallery.natural_key(path) == ref.page: + return path + raise SystemExit(f"missing Hancom PNG for {ref.doc} page {ref.page}") + + +def ensure_current_svg(ref: PageRef, export_current: bool) -> None: + if export_current: + review_gallery.export_current(docdir(ref)) + else: + rhwp_svg_path(ref) + + +def font_mime(path: Path) -> str: + suffix = path.suffix.lower() + if suffix == ".woff2": + return "font/woff2" + if suffix == ".woff": + return "font/woff" + if suffix in {".otf", ".ttf"}: + return "font/opentype" + return "application/octet-stream" + + +def font_format(path: Path) -> str: + suffix = path.suffix.lower() + if suffix == ".woff2": + return "woff2" + if suffix == ".woff": + return "woff" + if suffix == ".ttf": + return "truetype" + return "opentype" + + +def css_escape(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def font_face_css(rules: tuple[FontRule, ...]) -> str: + lines: list[str] = [] + for rule in rules: + data = base64.b64encode(rule.font_path.read_bytes()).decode("ascii") + lines.append( + "@font-face{" + f'font-family:"{css_escape(rule.family)}";' + f"font-weight:{rule.weight};" + "font-style:normal;" + f'src:url("data:{font_mime(rule.font_path)};base64,{data}") format("{font_format(rule.font_path)}");' + "}" + ) + return "\n".join(lines) + + +def inject_style(svg: str, css: str) -> str: + if not css: + return svg + style = f"\n" + if "" in svg: + return svg.replace("", f"\n", 1) + return SVG_OPEN_RE.sub(lambda match: match.group(1) + "\n" + style, svg, count=1) + + +def rewrite_text_family(svg: str, family: str) -> str: + if not family: + return svg + return FONT_FAMILY_RE.sub(lambda match: f'{match.group(1)}{family}{match.group(3)}', svg) + + +def apply_variant(svg: str, variant: Variant) -> str: + return inject_style(rewrite_text_family(svg, variant.rewrite_family), font_face_css(variant.rules)) + + +def filter_variants(variants: list[Variant], patterns: list[str] | None) -> list[Variant]: + if not patterns: + return variants + selected = [ + variant + for variant in variants + if any(fnmatch.fnmatchcase(variant.name, pattern) for pattern in patterns) + ] + if not selected: + available = ", ".join(variant.name for variant in variants) + raise SystemExit( + f"no font-face variants matched {patterns!r}; available variants: {available}" + ) + return selected + + +def fit_to_match(image: Image.Image, size: tuple[int, int]) -> Image.Image: + if image.size == size: + return image + return image.resize(size) + + +def mean_diff(left: Path, right: Path) -> float: + with Image.open(left) as opened_left, Image.open(right) as opened_right: + oracle = opened_left.convert("RGB") + candidate = fit_to_match(opened_right.convert("RGB"), oracle.size) + diff = ImageChops.difference(oracle, candidate) + stat = ImageStat.Stat(diff) + return sum(stat.mean) / 3.0 + + +def dark_pixels(path: Path, threshold: int = 96) -> int: + with Image.open(path) as opened: + rgba = opened.convert("RGBA") + dark = 0 + for red, green, blue, alpha in rgba.getdata(): + if alpha == 0: + continue + luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue + if luma < threshold: + dark += 1 + return dark + + +def text_family_summary(svg: str) -> str: + families: set[str] = set() + for text_tag in TEXT_RE.findall(svg): + match = FONT_FAMILY_RE.search(text_tag) + if not match: + continue + family = ( + match.group(2) + .replace("'", "'") + .replace(""", '"') + .replace("&", "&") + ) + families.add(family) + return " | ".join(sorted(families)) + + +def text_count(svg: str) -> int: + return len(TEXT_RE.findall(svg)) + + +def probe_one(ref: PageRef, variants: list[Variant], out_dir: Path, keep: bool) -> list[dict[str, str]]: + source_svg = rhwp_svg_path(ref) + hancom_png = hancom_png_path(ref) + svg_text = source_svg.read_text(encoding="utf-8", errors="ignore") + ref_dir = out_dir / f"{ref.doc}_p{ref.page}" + ref_dir.mkdir(parents=True, exist_ok=True) + rows: list[dict[str, str]] = [] + + for variant in variants: + variant_svg = ref_dir / f"{variant.name}.svg" + variant_png = ref_dir / f"{variant.name}.png" + variant_svg.write_text(apply_variant(svg_text, variant), encoding="utf-8") + review_gallery.raster_svg(variant_svg, variant_png) + rows.append( + { + "doc": ref.doc, + "page": str(ref.page), + "variant": variant.name, + "mean_diff": f"{mean_diff(hancom_png, variant_png):.2f}", + "dark_pixels": str(dark_pixels(variant_png)), + "text_nodes": str(text_count(svg_text)), + "families": text_family_summary(svg_text), + "png": str(variant_png) if keep else "", + } + ) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("refs", nargs="+", type=parse_ref, help="one or more DOC:PAGE references") + parser.add_argument("--export-current", action="store_true", help="refresh rhwp_svg_cur before probing") + parser.add_argument("--keep", action="store_true", help="keep probe SVG/PNG artifacts and print paths") + parser.add_argument("--out-dir", default="", help="artifact directory; default is a temp dir unless --keep") + parser.add_argument( + "--variant", + action="append", + default=None, + help="variant glob to run; repeatable, e.g. --variant base --variant 'open_korean_*'", + ) + args = parser.parse_args() + + for ref in args.refs: + ensure_current_svg(ref, args.export_current) + + temp_dir: tempfile.TemporaryDirectory[str] | None = None + if args.out_dir: + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + elif args.keep: + out_dir = DIFF / "_svg_font_face_probe" + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + else: + temp_dir = tempfile.TemporaryDirectory(prefix="rhwp-svg-font-face-probe-") + out_dir = Path(temp_dir.name) + + try: + rows: list[dict[str, str]] = [] + variants = filter_variants(default_variants(), args.variant) + for ref in args.refs: + rows.extend(probe_one(ref, variants, out_dir, keep=args.keep or bool(args.out_dir))) + writer = csv.DictWriter( + sys.stdout, + fieldnames=[ + "doc", + "page", + "variant", + "mean_diff", + "dark_pixels", + "text_nodes", + "families", + "png", + ], + delimiter="\t", + lineterminator="\n", + ) + writer.writeheader() + writer.writerows(rows) + finally: + if temp_dir is not None: + temp_dir.cleanup() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/svg_font_resource_audit.py b/harness/svg_font_resource_audit.py new file mode 100644 index 0000000000..2ad68ccdfa --- /dev/null +++ b/harness/svg_font_resource_audit.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Audit rendered SVG font families against bundled/system font resources. + +This is a read-only fidelity triage helper. It answers a narrow question for +the text/raster category: are high-drift pages asking the renderer/browser for +font faces we actually provide, or are they falling through to platform +substitution? + +Usage: + python3 harness/svg_font_resource_audit.py overseas_training:1 meeting_summary:1 +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +DIFF = Path("/tmp/diff") +FONT_LOADER = REPO / "rhwp-studio/src/core/font-loader.ts" +FONT_ATTR_RE = re.compile(r'font-family="([^"]+)"') +FONT_LIST_RE = re.compile( + r"\{\s*name:\s*'([^']+)'\s*,\s*file:\s*([^,}\n]+)", re.MULTILINE +) +LOCAL_FONT_ROOTS = [ + REPO / "rhwp-studio/dist", + REPO / "web", +] +SYSTEM_FONT_DIRS = [ + Path("/System/Library/Fonts"), + Path("/System/Library/Fonts/Supplemental"), + Path("/Library/Fonts"), + Path.home() / "Library/Fonts", +] + + +@dataclass(frozen=True) +class FontResource: + status: str + detail: str + + +def parse_ref(ref: str) -> tuple[str, int]: + if ":" not in ref: + raise SystemExit(f"expected DOC:PAGE, got {ref!r}") + doc, page_s = ref.rsplit(":", 1) + return doc, int(page_s) + + +def svg_path(doc: str, page: int) -> Path: + return DIFF / doc / "rhwp_svg_cur" / f"source_{page:03d}.svg" + + +def ref_from_svg_path(path: Path) -> tuple[str, int]: + try: + relative = path.relative_to(DIFF) + except ValueError as exc: + raise SystemExit(f"SVG path is outside {DIFF}: {path}") from exc + doc = relative.parts[0] + match = re.fullmatch(r"source_(\d+)\.svg", path.name) + if not match: + raise SystemExit(f"unexpected SVG filename: {path}") + return doc, int(match.group(1)) + + +def iter_all_svg_paths() -> list[Path]: + return sorted(DIFF.glob("*/rhwp_svg_cur/source_*.svg")) + + +def primary_font(family: str) -> str: + return family.split(",", 1)[0].strip().strip("'\"") + + +def local_font_exists(relative_file: str) -> bool: + return any((root / relative_file).exists() for root in LOCAL_FONT_ROOTS) + + +def load_font_registry() -> dict[str, FontResource]: + if not FONT_LOADER.exists(): + return {} + text = FONT_LOADER.read_text(encoding="utf-8") + registry: dict[str, FontResource] = {} + for name, file_expr in FONT_LIST_RE.findall(text): + file_expr = file_expr.strip() + if file_expr.startswith("'") and file_expr.endswith("'"): + relative_file = file_expr.strip("'") + if local_font_exists(relative_file): + resource = FontResource("bundled", relative_file) + else: + resource = FontResource("bundled-missing", relative_file) + elif file_expr.startswith("CDN_"): + resource = FontResource("cdn", file_expr) + else: + resource = FontResource("registered", file_expr) + registry[name] = resource + return registry + + +def system_font_names() -> set[str]: + names: set[str] = set() + for base in SYSTEM_FONT_DIRS: + if not base.exists(): + continue + for path in base.rglob("*"): + if path.suffix.lower() not in {".ttf", ".otf", ".ttc"}: + continue + stem = path.stem + names.add(stem) + names.add(stem.replace("-", " ")) + # fc-match is not always installed on macOS, but use it when present. + try: + out = subprocess.run( + ["fc-list", ":", "family"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ).stdout + except FileNotFoundError: + out = "" + for line in out.splitlines(): + for name in line.split(","): + if name.strip(): + names.add(name.strip()) + return names + + +def classify( + font: str, registry: dict[str, FontResource], system_fonts: set[str] +) -> FontResource: + if not font: + return FontResource("empty", "-") + if font in registry: + return registry[font] + if font in system_fonts: + return FontResource("system", "local-system") + if any(font.startswith(prefix) for prefix in ("경기천년", "한컴 ", "휴먼")): + return FontResource("missing-office-face", "-") + if font.startswith("HY"): + return FontResource("missing-hy-face", "-") + return FontResource("missing-or-platform-fallback", "-") + + +def audit_ref( + ref: str, registry: dict[str, FontResource], system_fonts: set[str] +) -> list[str]: + doc, page = parse_ref(ref) + path = svg_path(doc, page) + if not path.exists(): + raise SystemExit(f"missing SVG for {ref}: {path}") + return audit_svg(doc, page, path, registry, system_fonts) + + +def audit_svg( + doc: str, + page: int, + path: Path, + registry: dict[str, FontResource], + system_fonts: set[str], +) -> list[str]: + svg = path.read_text(encoding="utf-8") + fonts = Counter(primary_font(value) for value in FONT_ATTR_RE.findall(svg)) + rows = [] + for font, runs in fonts.most_common(): + resource = classify(font, registry, system_fonts) + rows.append( + "\t".join( + [ + doc, + str(page), + font or "-", + str(runs), + resource.status, + resource.detail, + ] + ) + ) + return rows + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("refs", nargs="*", help="DOC:PAGE references") + parser.add_argument( + "--all", + action="store_true", + help="audit every /tmp/diff/*/rhwp_svg_cur/source_*.svg page", + ) + parser.add_argument( + "--summary", + action="store_true", + help="append status/font totals after detailed rows", + ) + args = parser.parse_args() + if not args.refs and not args.all: + parser.error("provide at least one DOC:PAGE ref or --all") + + registry = load_font_registry() + system_fonts = system_font_names() + print("doc\tpage\tprimary_font\ttext_runs\tresource_status\tresource_detail") + rows: list[str] = [] + if args.all: + for path in iter_all_svg_paths(): + doc, page = ref_from_svg_path(path) + rows.extend(audit_svg(doc, page, path, registry, system_fonts)) + for ref in args.refs: + rows.extend(audit_ref(ref, registry, system_fonts)) + for row in rows: + print(row) + + if args.summary: + status_runs: Counter[str] = Counter() + font_runs: Counter[tuple[str, str]] = Counter() + font_pages: Counter[tuple[str, str]] = Counter() + for row in rows: + doc, page, font, runs_s, status, _detail = row.split("\t") + runs = int(runs_s) + key = (status, font) + status_runs[status] += runs + font_runs[key] += runs + font_pages[key] += 1 + print() + print("summary_status\ttext_runs") + for status, runs in status_runs.most_common(): + print(f"{status}\t{runs}") + print() + print("summary_font\tresource_status\ttext_runs\tpages") + for (status, font), runs in font_runs.most_common(): + print(f"{font}\t{status}\t{runs}\t{font_pages[(status, font)]}") + + +if __name__ == "__main__": + main() diff --git a/harness/svg_geometry_probe.py b/harness/svg_geometry_probe.py new file mode 100644 index 0000000000..96817cd9c3 --- /dev/null +++ b/harness/svg_geometry_probe.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Compare coarse RHWP SVG geometry against Hancom/RHWP raster bands. + +The component probe answers "which paint class matters?". This script answers +"where are the big blocks?". It avoids fixture-specific logic by reporting +SVG element bands and image-derived ink bands in normalized page coordinates. + +Usage: + python3 harness/svg_geometry_probe.py 15_3740450_research_admin_innovation_meeting_template:3 +""" +from __future__ import annotations + +import argparse +import re +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image + +import review_gallery + + +DIFF = Path("/tmp/diff") +ATTR_RE = re.compile(r'([A-Za-z_:][-A-Za-z0-9_:.]*)="([^"]*)"') +SVG_SIZE_RE = re.compile(r']*\bwidth="([0-9.]+)"[^>]*\bheight="([0-9.]+)"') +VIEWBOX_RE = re.compile(r']*\bviewBox="[0-9.]+ [0-9.]+ ([0-9.]+) ([0-9.]+)"') + + +@dataclass(frozen=True) +class PageRef: + doc: str + page: int + + +@dataclass(frozen=True) +class Box: + kind: str + x0: float + y0: float + x1: float + y1: float + note: str = "" + + @property + def width(self) -> float: + return self.x1 - self.x0 + + @property + def height(self) -> float: + return self.y1 - self.y0 + + +def parse_ref(raw: str) -> PageRef: + if ":" not in raw: + raise argparse.ArgumentTypeError(f"expected DOC:PAGE, got {raw!r}") + doc, page_raw = raw.rsplit(":", 1) + try: + page = int(page_raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid page in {raw!r}") from exc + if not doc or page <= 0: + raise argparse.ArgumentTypeError(f"invalid DOC:PAGE reference {raw!r}") + return PageRef(doc, page) + + +def docdir(ref: PageRef) -> Path: + path = DIFF / ref.doc + if not path.exists(): + raise SystemExit(f"missing /tmp/diff docdir: {path}") + return path + + +def rhwp_svg_path(ref: PageRef) -> Path: + directory = docdir(ref) / "rhwp_svg_cur" + for name in ( + f"source_{ref.page:03d}.svg", + f"source_{ref.page}.svg", + f"page_{ref.page:03d}.svg", + f"page_{ref.page}.svg", + ): + path = directory / name + if path.exists(): + return path + raise SystemExit(f"missing RHWP SVG for {ref.doc} page {ref.page}") + + +def hancom_png_path(ref: PageRef) -> Path: + directory = docdir(ref) + for name in (f"hancom_p-{ref.page}.png", f"hancom_p-{ref.page:02d}.png", f"hancom_p-{ref.page:03d}.png"): + path = directory / name + if path.exists(): + return path + generated = review_gallery.raster_hancom(directory) + for path in generated: + if review_gallery.natural_key(path) == ref.page: + return path + raise SystemExit(f"missing Hancom PNG for {ref.doc} page {ref.page}") + + +def rhwp_png_path(ref: PageRef) -> Path: + directory = docdir(ref) + for name in ( + f"rhwp_review_p-{ref.page}.png", + f"rhwp_review_p-{ref.page:02d}.png", + f"rhwp_p-{ref.page}.png", + f"rhwp_p-{ref.page:02d}.png", + ): + path = directory / name + if path.exists(): + return path + svg = rhwp_svg_path(ref) + out = directory / f"rhwp_review_p-{ref.page}.png" + review_gallery.raster_svg(svg, out) + return out + + +def svg_size(svg_text: str) -> tuple[float, float]: + if match := SVG_SIZE_RE.search(svg_text[:2000]): + return float(match.group(1)), float(match.group(2)) + if match := VIEWBOX_RE.search(svg_text[:2000]): + return float(match.group(1)), float(match.group(2)) + return 794.0, 1123.0 + + +def attrs(raw_tag: str) -> dict[str, str]: + return {key: value for key, value in ATTR_RE.findall(raw_tag)} + + +def tag_name(elem: ET.Element) -> str: + if "}" in elem.tag: + return elem.tag.rsplit("}", 1)[1] + return elem.tag + + +def text_content(elem: ET.Element) -> str: + return "".join(elem.itertext()).strip() + + +def collect_svg_boxes(svg: Path) -> tuple[float, float, list[Box]]: + svg_text = svg.read_text(encoding="utf-8", errors="ignore") + width, height = svg_size(svg_text) + root = ET.fromstring(svg_text) + boxes: list[Box] = [] + + def walk(elem: ET.Element, in_defs: bool = False) -> None: + name = tag_name(elem) + now_in_defs = in_defs or name in {"defs", "clipPath"} + if not now_in_defs: + box = element_box(elem) + if box is not None: + boxes.append(box) + for child in elem: + walk(child, now_in_defs) + + walk(root) + return width, height, boxes + + +def float_attr(elem: ET.Element, name: str, default: float = 0.0) -> float: + raw = elem.attrib.get(name) + if raw is None: + return default + try: + return float(raw) + except ValueError: + return default + + +def element_box(elem: ET.Element) -> Box | None: + name = tag_name(elem) + if name == "rect": + x = float_attr(elem, "x") + y = float_attr(elem, "y") + w = float_attr(elem, "width") + h = float_attr(elem, "height") + fill = elem.attrib.get("fill", "") + if w <= 0 or h <= 0: + return None + if x == 0.0 and y == 0.0 and fill.lower() in {"#ffffff", "#fff", "white"}: + return None + return Box("rect", x, y, x + w, y + h, fill) + if name == "image": + x = float_attr(elem, "x") + y = float_attr(elem, "y") + w = float_attr(elem, "width") + h = float_attr(elem, "height") + if w <= 0 or h <= 0: + return None + return Box("image", x, y, x + w, y + h) + if name == "line": + x1 = float_attr(elem, "x1") + y1 = float_attr(elem, "y1") + x2 = float_attr(elem, "x2") + y2 = float_attr(elem, "y2") + stroke_width = float_attr(elem, "stroke-width", 0.0) + pad = max(0.5, stroke_width / 2.0) + return Box("line", min(x1, x2), min(y1, y2) - pad, max(x1, x2), max(y1, y2) + pad, elem.attrib.get("stroke", "")) + if name == "text": + text = text_content(elem) + if not text: + return None + x = float_attr(elem, "x") + y = float_attr(elem, "y") + size = float_attr(elem, "font-size", 10.0) + text_length = float_attr(elem, "textLength", max(size * 0.55 * len(text), size * 0.5)) + return Box("text", x, y - size, x + text_length, y + size * 0.25, text[:20]) + return None + + +def merge_ranges(ranges: list[tuple[float, float]], gap: float) -> list[tuple[float, float]]: + if not ranges: + return [] + merged: list[tuple[float, float]] = [] + cur_start, cur_end = sorted(ranges)[0] + for start, end in sorted(ranges)[1:]: + if start <= cur_end + gap: + cur_end = max(cur_end, end) + else: + merged.append((cur_start, cur_end)) + cur_start, cur_end = start, end + merged.append((cur_start, cur_end)) + return merged + + +def svg_kind_bands(boxes: list[Box], kind: str, min_height: float, gap: float) -> list[tuple[float, float]]: + ranges = [(box.y0, box.y1) for box in boxes if box.kind == kind and box.height >= min_height] + return merge_ranges(ranges, gap) + + +def raster_bands(path: Path, svg_height: float, threshold: int, min_rows: int, gap_rows: int) -> list[tuple[float, float, int]]: + with Image.open(path) as opened: + image = opened.convert("RGB") + width, height = image.size + active: list[tuple[int, int]] = [] + in_band = False + start = 0 + for y in range(height): + row_active = 0 + for x in range(width): + red, green, blue = image.getpixel((x, y)) + if max(red, green, blue) < threshold: + row_active += 1 + if row_active >= max(1, width // 200): + if not in_band: + start = y + in_band = True + elif in_band: + active.append((start, y)) + in_band = False + if in_band: + active.append((start, height)) + + merged_px = merge_ranges([(float(a), float(b)) for a, b in active], float(gap_rows)) + out: list[tuple[float, float, int]] = [] + for y0, y1 in merged_px: + if y1 - y0 < min_rows: + continue + out.append((y0 * svg_height / height, y1 * svg_height / height, round(y1 - y0))) + return out + + +def print_bands(label: str, bands: list[tuple[float, float] | tuple[float, float, int]], max_bands: int) -> None: + print(f"{label}:") + if not bands: + print(" (none)") + return + for band in bands[:max_bands]: + y0, y1 = band[0], band[1] + extra = f" px_rows={band[2]}" if len(band) > 2 else "" + print(f" y={y0:7.1f}..{y1:7.1f} h={y1 - y0:6.1f}{extra}") + if len(bands) > max_bands: + print(f" ... {len(bands) - max_bands} more") + + +def print_svg_summary(boxes: list[Box], max_boxes: int) -> None: + print("svg boxes:") + for kind in ("image", "rect", "line", "text"): + selected = [box for box in boxes if box.kind == kind] + print(f" {kind}: {len(selected)}") + if kind in {"image", "rect"}: + for box in sorted(selected, key=lambda b: (b.y0, b.x0))[:max_boxes]: + print( + f" y={box.y0:7.1f}..{box.y1:7.1f} x={box.x0:7.1f}..{box.x1:7.1f} " + f"w={box.width:6.1f} h={box.height:6.1f} {box.note}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("refs", nargs="+", type=parse_ref) + parser.add_argument("--threshold", type=int, default=245, help="raster non-white threshold") + parser.add_argument("--min-raster-rows", type=int, default=6) + parser.add_argument("--raster-gap-rows", type=int, default=8) + parser.add_argument("--svg-gap", type=float, default=8.0) + parser.add_argument("--max-bands", type=int, default=30) + parser.add_argument("--max-boxes", type=int, default=20) + args = parser.parse_args() + + for ref in args.refs: + svg = rhwp_svg_path(ref) + svg_width, svg_height, boxes = collect_svg_boxes(svg) + print(f"== {ref.doc} page {ref.page} ==") + print(f"svg={svg} size={svg_width:.1f}x{svg_height:.1f}") + print_svg_summary(boxes, args.max_boxes) + print_bands("svg image bands", svg_kind_bands(boxes, "image", 1.0, args.svg_gap), args.max_bands) + print_bands("svg rect bands", svg_kind_bands(boxes, "rect", 2.0, args.svg_gap), args.max_bands) + print_bands("svg line bands", svg_kind_bands(boxes, "line", 0.0, args.svg_gap), args.max_bands) + print_bands("svg text bands", svg_kind_bands(boxes, "text", 1.0, args.svg_gap), args.max_bands) + print_bands( + "hancom raster bands", + raster_bands(hancom_png_path(ref), svg_height, args.threshold, args.min_raster_rows, args.raster_gap_rows), + args.max_bands, + ) + print_bands( + "rhwp raster bands", + raster_bands(rhwp_png_path(ref), svg_height, args.threshold, args.min_raster_rows, args.raster_gap_rows), + args.max_bands, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/svg_image_ink_probe.py b/harness/svg_image_ink_probe.py new file mode 100644 index 0000000000..a2d684d18d --- /dev/null +++ b/harness/svg_image_ink_probe.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Inspect embedded SVG image boxes and their actual non-white ink bounds. + +Use this after geometry probes show an image/table mismatch but box movement +probes fail. It decodes data URI images from an RHWP SVG page and reports the +local ink bounds inside each image, plus page-coordinate ink bounds after +scaling into the emitted SVG image rectangle. + +Usage: + python3 harness/svg_image_ink_probe.py 15_3740450_research_admin_innovation_meeting_template:3 +""" +from __future__ import annotations + +import argparse +import base64 +import io +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image + + +DIFF = Path("/tmp/diff") +IMAGE_TAG_RE = re.compile(r"]*/?>", re.DOTALL) +ATTR_RE = re.compile(r'([A-Za-z_:][-A-Za-z0-9_:.]*)="([^"]*)"') + + +@dataclass(frozen=True) +class PageRef: + doc: str + page: int + + +@dataclass(frozen=True) +class ImageProbe: + index: int + x: float + y: float + width: float + height: float + px_width: int + px_height: int + ink_box: tuple[int, int, int, int] | None + dark_box: tuple[int, int, int, int] | None + nonwhite_ratio: float + dark_ratio: float + page_ink_box: tuple[float, float, float, float] | None + page_dark_box: tuple[float, float, float, float] | None + + +def parse_ref(raw: str) -> PageRef: + if ":" not in raw: + raise argparse.ArgumentTypeError(f"expected DOC:PAGE, got {raw!r}") + doc, page_raw = raw.rsplit(":", 1) + try: + page = int(page_raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid page in {raw!r}") from exc + if not doc or page <= 0: + raise argparse.ArgumentTypeError(f"invalid DOC:PAGE reference {raw!r}") + return PageRef(doc, page) + + +def svg_path(ref: PageRef) -> Path: + directory = DIFF / ref.doc / "rhwp_svg_cur" + for name in ( + f"source_{ref.page:03d}.svg", + f"source_{ref.page}.svg", + f"page_{ref.page:03d}.svg", + f"page_{ref.page}.svg", + ): + path = directory / name + if path.exists(): + return path + raise SystemExit(f"missing RHWP SVG for {ref.doc} page {ref.page}") + + +def attrs(tag: str) -> dict[str, str]: + return {key: value for key, value in ATTR_RE.findall(tag)} + + +def float_attr(values: dict[str, str], name: str) -> float: + try: + return float(values[name]) + except (KeyError, ValueError): + return 0.0 + + +def image_data(values: dict[str, str]) -> bytes | None: + href = values.get("href") or values.get("xlink:href") + if not href or not href.startswith("data:"): + return None + if "," not in href: + return None + header, payload = href.split(",", 1) + if ";base64" not in header: + return None + try: + return base64.b64decode(payload, validate=False) + except ValueError: + return None + + +def pixel_bounds( + image: Image.Image, + *, + nonwhite_threshold: int, + dark_threshold: int, +) -> tuple[tuple[int, int, int, int] | None, tuple[int, int, int, int] | None, float, float]: + rgba = image.convert("RGBA") + width, height = rgba.size + ink_min_x = width + ink_min_y = height + ink_max_x = -1 + ink_max_y = -1 + dark_min_x = width + dark_min_y = height + dark_max_x = -1 + dark_max_y = -1 + ink_count = 0 + dark_count = 0 + for y in range(height): + for x in range(width): + red, green, blue, alpha = rgba.getpixel((x, y)) + if alpha == 0: + continue + luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue + if luma < nonwhite_threshold: + ink_count += 1 + ink_min_x = min(ink_min_x, x) + ink_min_y = min(ink_min_y, y) + ink_max_x = max(ink_max_x, x + 1) + ink_max_y = max(ink_max_y, y + 1) + if luma < dark_threshold: + dark_count += 1 + dark_min_x = min(dark_min_x, x) + dark_min_y = min(dark_min_y, y) + dark_max_x = max(dark_max_x, x + 1) + dark_max_y = max(dark_max_y, y + 1) + area = max(1, width * height) + ink_box = None if ink_max_x < 0 else (ink_min_x, ink_min_y, ink_max_x, ink_max_y) + dark_box = None if dark_max_x < 0 else (dark_min_x, dark_min_y, dark_max_x, dark_max_y) + return ink_box, dark_box, ink_count / area, dark_count / area + + +def to_page_box( + local: tuple[int, int, int, int] | None, + *, + image_px: tuple[int, int], + svg_box: tuple[float, float, float, float], +) -> tuple[float, float, float, float] | None: + if local is None: + return None + px_w, px_h = image_px + x, y, width, height = svg_box + sx = width / max(1, px_w) + sy = height / max(1, px_h) + x0, y0, x1, y1 = local + return (x + x0 * sx, y + y0 * sy, x + x1 * sx, y + y1 * sy) + + +def probes_for_svg(path: Path, nonwhite_threshold: int, dark_threshold: int) -> list[ImageProbe]: + svg = path.read_text(encoding="utf-8", errors="ignore") + probes: list[ImageProbe] = [] + for index, match in enumerate(IMAGE_TAG_RE.finditer(svg), 1): + values = attrs(match.group(0)) + data = image_data(values) + if data is None: + continue + try: + with Image.open(io.BytesIO(data)) as opened: + image = opened.convert("RGBA") + except OSError: + continue + x = float_attr(values, "x") + y = float_attr(values, "y") + width = float_attr(values, "width") + height = float_attr(values, "height") + ink_box, dark_box, nonwhite_ratio, dark_ratio = pixel_bounds( + image, + nonwhite_threshold=nonwhite_threshold, + dark_threshold=dark_threshold, + ) + probes.append( + ImageProbe( + index=index, + x=x, + y=y, + width=width, + height=height, + px_width=image.width, + px_height=image.height, + ink_box=ink_box, + dark_box=dark_box, + nonwhite_ratio=nonwhite_ratio, + dark_ratio=dark_ratio, + page_ink_box=to_page_box( + ink_box, + image_px=(image.width, image.height), + svg_box=(x, y, width, height), + ), + page_dark_box=to_page_box( + dark_box, + image_px=(image.width, image.height), + svg_box=(x, y, width, height), + ), + ) + ) + return probes + + +def fmt_box(box: tuple[float, float, float, float] | tuple[int, int, int, int] | None) -> str: + if box is None: + return "" + return ",".join(f"{value:.1f}" if isinstance(value, float) else str(value) for value in box) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("refs", nargs="+", type=parse_ref) + parser.add_argument("--nonwhite-threshold", type=int, default=248) + parser.add_argument("--dark-threshold", type=int, default=96) + args = parser.parse_args() + + print( + "\t".join( + [ + "doc", + "page", + "image", + "svg_box", + "px_size", + "ink_box_px", + "ink_box_page", + "ink_ratio", + "dark_box_px", + "dark_box_page", + "dark_ratio", + ] + ) + ) + for ref in args.refs: + for probe in probes_for_svg(svg_path(ref), args.nonwhite_threshold, args.dark_threshold): + print( + "\t".join( + [ + ref.doc, + str(ref.page), + str(probe.index), + fmt_box((probe.x, probe.y, probe.x + probe.width, probe.y + probe.height)), + f"{probe.px_width}x{probe.px_height}", + fmt_box(probe.ink_box), + fmt_box(probe.page_ink_box), + f"{probe.nonwhite_ratio:.4f}", + fmt_box(probe.dark_box), + fmt_box(probe.page_dark_box), + f"{probe.dark_ratio:.4f}", + ] + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/svg_line_region_probe.py b/harness/svg_line_region_probe.py new file mode 100644 index 0000000000..6fd6b37a0f --- /dev/null +++ b/harness/svg_line_region_probe.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Probe SVG line/rect variants inside a page region against Hancom. + +This is for table-heavy pages where component probes say ruling/geometry may +matter. It rewrites only `` and border-like `` elements inside a +Y-region, rasters the variants, and prints mean pixel diff. + +Usage: + python3 harness/svg_line_region_probe.py 15_3740450_research_admin_innovation_meeting_template:3 --y-min 430 --y-max 990 --keep +""" +from __future__ import annotations + +import argparse +import re +import shutil +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageChops, ImageStat + +import review_gallery + + +DIFF = Path("/tmp/diff") +LINE_RE = re.compile(r"]*/?>", re.DOTALL) +RECT_RE = re.compile(r"]*/?>", re.DOTALL) +ATTR_RE = re.compile(r'([A-Za-z_:][-A-Za-z0-9_:.]*)="([^"]*)"') +STROKE_WIDTH_RE = re.compile(r'(stroke-width=")([0-9.]+)(")') + + +@dataclass(frozen=True) +class PageRef: + doc: str + page: int + + +@dataclass(frozen=True) +class Element: + tag: str + kind: str + x0: float + y0: float + x1: float + y1: float + horizontal: bool + vertical: bool + + +def parse_ref(raw: str) -> PageRef: + if ":" not in raw: + raise argparse.ArgumentTypeError(f"expected DOC:PAGE, got {raw!r}") + doc, page_raw = raw.rsplit(":", 1) + try: + page = int(page_raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid page in {raw!r}") from exc + if not doc or page <= 0: + raise argparse.ArgumentTypeError(f"invalid DOC:PAGE reference {raw!r}") + return PageRef(doc, page) + + +def docdir(ref: PageRef) -> Path: + path = DIFF / ref.doc + if not path.exists(): + raise SystemExit(f"missing /tmp/diff docdir: {path}") + return path + + +def svg_path(ref: PageRef) -> Path: + directory = docdir(ref) / "rhwp_svg_cur" + for name in ( + f"source_{ref.page:03d}.svg", + f"source_{ref.page}.svg", + f"page_{ref.page:03d}.svg", + f"page_{ref.page}.svg", + ): + path = directory / name + if path.exists(): + return path + raise SystemExit(f"missing RHWP SVG for {ref.doc} page {ref.page}") + + +def hancom_png_path(ref: PageRef) -> Path: + directory = docdir(ref) + for name in (f"hancom_p-{ref.page}.png", f"hancom_p-{ref.page:02d}.png", f"hancom_p-{ref.page:03d}.png"): + path = directory / name + if path.exists(): + return path + generated = review_gallery.raster_hancom(directory) + for path in generated: + if review_gallery.natural_key(path) == ref.page: + return path + raise SystemExit(f"missing Hancom PNG for {ref.doc} page {ref.page}") + + +def attrs(tag: str) -> dict[str, str]: + return {key: value for key, value in ATTR_RE.findall(tag)} + + +def fattr(values: dict[str, str], name: str, default: float = 0.0) -> float: + try: + return float(values.get(name, default)) + except ValueError: + return default + + +def element_from_tag(tag: str, kind: str) -> Element | None: + values = attrs(tag) + if kind == "line": + x1 = fattr(values, "x1") + y1 = fattr(values, "y1") + x2 = fattr(values, "x2") + y2 = fattr(values, "y2") + horizontal = abs(y1 - y2) <= 0.1 and abs(x2 - x1) > 2.0 + vertical = abs(x1 - x2) <= 0.1 and abs(y2 - y1) > 2.0 + return Element(tag, kind, min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2), horizontal, vertical) + if kind == "rect": + x = fattr(values, "x") + y = fattr(values, "y") + w = fattr(values, "width") + h = fattr(values, "height") + if w <= 0.0 or h <= 0.0: + return None + fill = values.get("fill", "") + stroke = values.get("stroke", "") + if fill.lower() in {"#ffffff", "#fff", "white"} and not stroke: + return None + return Element(tag, kind, x, y, x + w, y + h, w >= h, h >= w) + return None + + +def in_region(element: Element, y_min: float, y_max: float) -> bool: + return element.y1 >= y_min and element.y0 <= y_max + + +def set_stroke_width(tag: str, factor: float) -> str: + def repl(match: re.Match[str]) -> str: + return f"{match.group(1)}{float(match.group(2)) * factor:.4g}{match.group(3)}" + + if STROKE_WIDTH_RE.search(tag): + return STROKE_WIDTH_RE.sub(repl, tag) + if tag.endswith("/>"): + return tag[:-2] + f' stroke-width="{factor:.4g}"/>' + return tag + + +def rewrite_elements(svg: str, variant: str, y_min: float, y_max: float) -> str: + def rewrite(kind: str, pattern: re.Pattern[str], text: str) -> str: + def repl(match: re.Match[str]) -> str: + tag = match.group(0) + element = element_from_tag(tag, kind) + if element is None or not in_region(element, y_min, y_max): + return tag + if variant == "hide_all": + return "" + if variant == "hide_horizontal" and element.horizontal: + return "" + if variant == "hide_vertical" and element.vertical: + return "" + if variant == "thin_x0.50": + return set_stroke_width(tag, 0.50) + if variant == "thin_x0.75": + return set_stroke_width(tag, 0.75) + if variant == "thick_x1.50": + return set_stroke_width(tag, 1.50) + if variant == "thick_x2.00": + return set_stroke_width(tag, 2.00) + return tag + + return pattern.sub(repl, text) + + svg = rewrite("line", LINE_RE, svg) + svg = rewrite("rect", RECT_RE, svg) + return svg + + +def mean_diff(hancom: Path, candidate: Path) -> float: + with Image.open(hancom) as opened_left, Image.open(candidate) as opened_right: + left = opened_left.convert("RGB") + right = opened_right.convert("RGB").resize(left.size) + diff = ImageChops.difference(left, right) + stat = ImageStat.Stat(diff) + return sum(stat.mean) / 3.0 + + +def element_summary(svg: str, y_min: float, y_max: float) -> dict[str, int]: + counts = {"line": 0, "hline": 0, "vline": 0, "rect": 0} + for kind, pattern in (("line", LINE_RE), ("rect", RECT_RE)): + for match in pattern.finditer(svg): + element = element_from_tag(match.group(0), kind) + if element is None or not in_region(element, y_min, y_max): + continue + counts[kind] += 1 + if element.horizontal: + counts["hline"] += 1 + if element.vertical: + counts["vline"] += 1 + return counts + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("refs", nargs="+", type=parse_ref) + parser.add_argument("--y-min", type=float, required=True) + parser.add_argument("--y-max", type=float, required=True) + parser.add_argument("--keep", action="store_true") + parser.add_argument("--out-dir", default="") + args = parser.parse_args() + + variants = [ + "base", + "hide_all", + "hide_horizontal", + "hide_vertical", + "thin_x0.50", + "thin_x0.75", + "thick_x1.50", + "thick_x2.00", + ] + temp: tempfile.TemporaryDirectory[str] | None = None + if args.out_dir: + out_root = Path(args.out_dir) + out_root.mkdir(parents=True, exist_ok=True) + elif args.keep: + out_root = DIFF / "_svg_line_region_probe" + if out_root.exists(): + shutil.rmtree(out_root) + out_root.mkdir(parents=True) + else: + temp = tempfile.TemporaryDirectory(prefix="rhwp-line-probe-") + out_root = Path(temp.name) + + try: + print("doc\tpage\tvariant\tmean_diff\tline\thline\tvline\trect\tpng") + for ref in args.refs: + source = svg_path(ref) + hancom = hancom_png_path(ref) + svg = source.read_text(encoding="utf-8", errors="ignore") + counts = element_summary(svg, args.y_min, args.y_max) + ref_out = out_root / f"{ref.doc}_p{ref.page}" + ref_out.mkdir(parents=True, exist_ok=True) + for variant in variants: + out_svg = ref_out / f"{variant}.svg" + out_png = ref_out / f"{variant}.png" + out_svg.write_text( + svg if variant == "base" else rewrite_elements(svg, variant, args.y_min, args.y_max), + encoding="utf-8", + ) + review_gallery.raster_svg(out_svg, out_png) + print( + "\t".join( + [ + ref.doc, + str(ref.page), + variant, + f"{mean_diff(hancom, out_png):.2f}", + str(counts["line"]), + str(counts["hline"]), + str(counts["vline"]), + str(counts["rect"]), + str(out_png) if args.keep or args.out_dir else "", + ] + ) + ) + finally: + if temp is not None: + temp.cleanup() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/svg_region_probe.py b/harness/svg_region_probe.py new file mode 100644 index 0000000000..b9bb14cfe7 --- /dev/null +++ b/harness/svg_region_probe.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Measure Hancom/RHWP visual drift inside named page regions. + +Whole-page mean diff is too blunt for late-stage renderer fidelity work: image +areas, table rulings, and dense Korean text can swamp each other. This probe +keeps the same oracle model as `review_gallery.py`, but reports per-region +diff, ink density, and vertical ink bands in SVG page coordinates. + +Usage: + python3 harness/svg_region_probe.py overseas_training:1 \ + --region title:56,98,726,146 --region header:56,164,737,214 + + python3 harness/svg_region_probe.py overseas_training:1 \ + --candidate-png /tmp/diff/_probe_variant/base.png \ + --region header:56,164,737,214 +""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageChops, ImageStat + +import review_gallery +from svg_geometry_probe import hancom_png_path, parse_ref, rhwp_png_path, rhwp_svg_path, svg_size + + +@dataclass(frozen=True) +class Region: + name: str + x0: float + y0: float + x1: float + y1: float + + +def parse_region(raw: str) -> Region: + if ":" not in raw: + raise argparse.ArgumentTypeError(f"expected NAME:x0,y0,x1,y1, got {raw!r}") + name, coords_raw = raw.split(":", 1) + if not name: + raise argparse.ArgumentTypeError("region name is empty") + parts = coords_raw.split(",") + if len(parts) != 4: + raise argparse.ArgumentTypeError(f"expected four coordinates in {raw!r}") + try: + x0, y0, x1, y1 = (float(part) for part in parts) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid coordinate in {raw!r}") from exc + if x1 <= x0 or y1 <= y0: + raise argparse.ArgumentTypeError(f"invalid region bounds in {raw!r}") + return Region(name=name, x0=x0, y0=y0, x1=x1, y1=y1) + + +def svg_to_pixel_box(region: Region, image: Image.Image, svg_w: float, svg_h: float) -> tuple[int, int, int, int]: + width, height = image.size + x0 = max(0, min(width, round(region.x0 * width / svg_w))) + y0 = max(0, min(height, round(region.y0 * height / svg_h))) + x1 = max(0, min(width, round(region.x1 * width / svg_w))) + y1 = max(0, min(height, round(region.y1 * height / svg_h))) + if x1 <= x0: + x1 = min(width, x0 + 1) + if y1 <= y0: + y1 = min(height, y0 + 1) + return x0, y0, x1, y1 + + +def crop_region(path: Path, region: Region, svg_w: float, svg_h: float) -> Image.Image: + with Image.open(path) as opened: + image = opened.convert("RGB") + return image.crop(svg_to_pixel_box(region, image, svg_w, svg_h)) + + +def fit_to_match(image: Image.Image, size: tuple[int, int]) -> Image.Image: + if image.size == size: + return image + return image.resize(size) + + +def mean_diff(left: Image.Image, right: Image.Image) -> float: + candidate = fit_to_match(right, left.size) + diff = ImageChops.difference(left, candidate) + stat = ImageStat.Stat(diff) + return sum(stat.mean) / 3.0 + + +def dark_pixels(image: Image.Image, threshold: int) -> int: + count = 0 + for red, green, blue in image.getdata(): + luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue + if luma < threshold: + count += 1 + return count + + +def nonwhite_pixels(image: Image.Image, threshold: int) -> int: + count = 0 + for red, green, blue in image.getdata(): + if max(255 - red, 255 - green, 255 - blue) > threshold: + count += 1 + return count + + +def mean_luma(image: Image.Image) -> float: + total = 0.0 + count = 0 + for red, green, blue in image.getdata(): + total += 0.2126 * red + 0.7152 * green + 0.0722 * blue + count += 1 + return total / max(1, count) + + +def ink_bands(image: Image.Image, svg_y0: float, svg_y1: float, threshold: int, min_rows: int, gap_rows: int) -> str: + width, height = image.size + active: list[tuple[int, int]] = [] + in_band = False + start = 0 + min_active = max(1, width // 200) + for y in range(height): + row_active = 0 + for x in range(width): + red, green, blue = image.getpixel((x, y)) + if max(red, green, blue) < threshold: + row_active += 1 + if row_active >= min_active: + if not in_band: + start = y + in_band = True + elif in_band: + active.append((start, y)) + in_band = False + if in_band: + active.append((start, height)) + + merged: list[tuple[int, int]] = [] + for y0, y1 in active: + if merged and y0 <= merged[-1][1] + gap_rows: + merged[-1] = (merged[-1][0], max(merged[-1][1], y1)) + else: + merged.append((y0, y1)) + + out: list[str] = [] + region_h = svg_y1 - svg_y0 + for y0, y1 in merged: + if y1 - y0 < min_rows: + continue + band0 = svg_y0 + y0 * region_h / max(1, height) + band1 = svg_y0 + y1 * region_h / max(1, height) + out.append(f"{band0:.1f}..{band1:.1f}") + return ";".join(out) if out else "-" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("ref", type=parse_ref, help="DOC:PAGE reference") + parser.add_argument("--region", action="append", type=parse_region, required=True) + parser.add_argument("--export-current", action="store_true") + parser.add_argument("--candidate-png", default="", help="override RHWP candidate PNG") + parser.add_argument("--threshold", type=int, default=96) + parser.add_argument("--nonwhite-threshold", type=int, default=8) + parser.add_argument("--min-rows", type=int, default=2) + parser.add_argument("--gap-rows", type=int, default=2) + args = parser.parse_args() + + if args.export_current: + review_gallery.export_current(Path("/tmp/diff") / args.ref.doc) + + svg = rhwp_svg_path(args.ref) + svg_w, svg_h = svg_size(svg.read_text(encoding="utf-8", errors="ignore")) + hancom = hancom_png_path(args.ref) + rhwp = Path(args.candidate_png) if args.candidate_png else rhwp_png_path(args.ref) + if not rhwp.exists(): + raise SystemExit(f"missing candidate PNG: {rhwp}") + + print( + "doc\tpage\tregion\tmean_diff\thancom_dark\trhwp_dark\tdark_delta_pct\t" + "hancom_nonwhite\trhwp_nonwhite\tnonwhite_delta_pct\t" + "hancom_luma\trhwp_luma\tluma_delta\thancom_bands\trhwp_bands" + ) + for region in args.region: + left = crop_region(hancom, region, svg_w, svg_h) + right = crop_region(rhwp, region, svg_w, svg_h) + right_fit = fit_to_match(right, left.size) + left_dark = dark_pixels(left, args.threshold) + right_dark = dark_pixels(right_fit, args.threshold) + denom = max(1, left_dark) + delta_pct = (right_dark - left_dark) * 100.0 / denom + left_nonwhite = nonwhite_pixels(left, args.nonwhite_threshold) + right_nonwhite = nonwhite_pixels(right_fit, args.nonwhite_threshold) + nonwhite_delta_pct = (right_nonwhite - left_nonwhite) * 100.0 / max(1, left_nonwhite) + left_luma = mean_luma(left) + right_luma = mean_luma(right_fit) + print( + f"{args.ref.doc}\t{args.ref.page}\t{region.name}\t" + f"{mean_diff(left, right):.2f}\t{left_dark}\t{right_dark}\t{delta_pct:+.1f}\t" + f"{left_nonwhite}\t{right_nonwhite}\t{nonwhite_delta_pct:+.1f}\t" + f"{left_luma:.1f}\t{right_luma:.1f}\t{right_luma - left_luma:+.1f}\t" + f"{ink_bands(left, region.y0, region.y1, args.threshold, args.min_rows, args.gap_rows)}\t" + f"{ink_bands(right_fit, region.y0, region.y1, args.threshold, args.min_rows, args.gap_rows)}" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/svg_text_advance_probe.py b/harness/svg_text_advance_probe.py new file mode 100644 index 0000000000..2158126e75 --- /dev/null +++ b/harness/svg_text_advance_probe.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Summarize per-character SVG advances by text line. + +RHWP's SVG backend emits one element per visible character on many +Korean table pages. This probe groups text elements by y coordinate and reports +their x-step distribution so text/raster drift can be classified as composed +advance / justification instead of a vague font or antialias issue. +""" +from __future__ import annotations + +import argparse +import html +import re +from collections import defaultdict +from dataclasses import dataclass +from statistics import median + +from svg_geometry_probe import parse_ref, rhwp_svg_path + + +TEXT_RE = re.compile(r"[^>]*)>(?P.*?)", re.DOTALL) +ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)=(?:\"([^\"]*)\"|'([^']*)')") + + +@dataclass(frozen=True) +class TextPoint: + x: float + y: float + font_size: float + font_family: str + text: str + + +def attr_map(raw: str) -> dict[str, str]: + attrs: dict[str, str] = {} + for key, double, single in ATTR_RE.findall(raw): + attrs[key] = html.unescape(double or single) + return attrs + + +def parse_float(raw: str | None) -> float: + if raw is None: + return 0.0 + try: + return float(raw) + except ValueError: + return 0.0 + + +def primary_font(family: str) -> str: + return family.split(",", 1)[0].strip().strip("'\"") or "-" + + +def parse_points(svg: str) -> list[TextPoint]: + points: list[TextPoint] = [] + for match in TEXT_RE.finditer(svg): + attrs = attr_map(match.group("attrs")) + text = html.unescape(re.sub(r"<[^>]+>", "", match.group("body"))).strip() + if not text: + continue + points.append( + TextPoint( + x=parse_float(attrs.get("x")), + y=parse_float(attrs.get("y")), + font_size=parse_float(attrs.get("font-size")), + font_family=primary_font(attrs.get("font-family", "")), + text=text, + ) + ) + return points + + +def bucket_y(y: float, bucket: float) -> float: + return round(y / bucket) * bucket + + +def line_stats(points: list[TextPoint], y_bucket: float) -> list[tuple[float, list[TextPoint]]]: + groups: dict[float, list[TextPoint]] = defaultdict(list) + for point in points: + groups[bucket_y(point.y, y_bucket)].append(point) + return sorted((y, sorted(line, key=lambda p: p.x)) for y, line in groups.items()) + + +def fmt(value: float) -> str: + return f"{value:.2f}" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("ref", type=parse_ref, help="DOC:PAGE") + parser.add_argument("--y-min", type=float, default=0.0) + parser.add_argument("--y-max", type=float, default=99999.0) + parser.add_argument("--min-chars", type=int, default=8) + parser.add_argument("--y-bucket", type=float, default=1.0) + parser.add_argument("--top", type=int, default=40) + args = parser.parse_args() + + svg = rhwp_svg_path(args.ref).read_text(encoding="utf-8", errors="ignore") + points = [ + p + for p in parse_points(svg) + if args.y_min <= p.y <= args.y_max and p.x > 0.0 and p.font_size > 0.0 + ] + rows = [] + for y, line in line_stats(points, args.y_bucket): + if len(line) < args.min_chars: + continue + steps = [ + line[idx + 1].x - line[idx].x + for idx in range(len(line) - 1) + if line[idx + 1].x > line[idx].x + ] + if not steps: + continue + font_size = median([p.font_size for p in line]) + step_median = median(steps) + step_max = max(steps) + ratio = step_median / font_size if font_size else 0.0 + text = "".join(p.text for p in line) + fonts = sorted({p.font_family for p in line}) + rows.append((ratio, y, line, steps, font_size, step_median, step_max, text, fonts)) + + rows.sort(reverse=True, key=lambda r: r[0]) + print("doc\tpage\ty\tchars\tfont_size\tmedian_step\tmax_step\tstep/font\tfonts\ttext") + for ratio, y, line, _steps, font_size, step_median, step_max, text, fonts in rows[: args.top]: + print( + f"{args.ref.doc}\t{args.ref.page}\t{fmt(y)}\t{len(line)}\t" + f"{fmt(font_size)}\t{fmt(step_median)}\t{fmt(step_max)}\t" + f"{fmt(ratio)}\t{','.join(fonts[:3])}\t{text[:120]!r}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/svg_text_region_probe.py b/harness/svg_text_region_probe.py new file mode 100644 index 0000000000..fd14d27ba5 --- /dev/null +++ b/harness/svg_text_region_probe.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Summarize RHWP SVG text runs inside named page regions. + +Use this after pixel-region probes point at sparse table text. It is read-only: +it reports whether a region's SVG has the expected text density, font families, +font sizes, weights, and vertical text bands before changing renderer code. + +Usage: + python3 harness/svg_text_region_probe.py overseas_training:1 \ + --region header:56,164,737,214 --region body_top:56,214,737,503 +""" +from __future__ import annotations + +import argparse +import html +import re +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path + +from svg_geometry_probe import parse_ref, rhwp_svg_path, svg_size + + +TEXT_RE = re.compile(r"[^>]*)>(?P.*?)", re.DOTALL) +ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)=(?:\"([^\"]*)\"|'([^']*)')") + + +@dataclass(frozen=True) +class Region: + name: str + x0: float + y0: float + x1: float + y1: float + + +@dataclass(frozen=True) +class TextRun: + x: float + y: float + font_family: str + font_size: float + font_weight: str + text: str + + +def parse_region(raw: str) -> Region: + if ":" not in raw: + raise argparse.ArgumentTypeError(f"expected NAME:x0,y0,x1,y1, got {raw!r}") + name, coords_raw = raw.split(":", 1) + parts = coords_raw.split(",") + if len(parts) != 4: + raise argparse.ArgumentTypeError(f"expected four coordinates in {raw!r}") + try: + x0, y0, x1, y1 = (float(part) for part in parts) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid coordinate in {raw!r}") from exc + if not name or x1 <= x0 or y1 <= y0: + raise argparse.ArgumentTypeError(f"invalid region {raw!r}") + return Region(name=name, x0=x0, y0=y0, x1=x1, y1=y1) + + +def attr_map(raw: str) -> dict[str, str]: + attrs: dict[str, str] = {} + for key, double, single in ATTR_RE.findall(raw): + attrs[key] = html.unescape(double or single) + return attrs + + +def parse_float(raw: str | None, default: float = 0.0) -> float: + if raw is None: + return default + try: + return float(raw) + except ValueError: + return default + + +def primary_font(font_family: str) -> str: + return font_family.split(",", 1)[0].strip().strip("'\"") or "-" + + +def text_runs(svg: str) -> list[TextRun]: + runs: list[TextRun] = [] + for match in TEXT_RE.finditer(svg): + attrs = attr_map(match.group("attrs")) + text = html.unescape(re.sub(r"<[^>]+>", "", match.group("body"))).strip() + if not text: + continue + runs.append( + TextRun( + x=parse_float(attrs.get("x")), + y=parse_float(attrs.get("y")), + font_family=attrs.get("font-family", ""), + font_size=parse_float(attrs.get("font-size")), + font_weight=attrs.get("font-weight", "normal") or "normal", + text=text, + ) + ) + return runs + + +def in_region(run: TextRun, region: Region) -> bool: + return region.x0 <= run.x <= region.x1 and region.y0 <= run.y <= region.y1 + + +def band_key(y: float, bucket: float) -> float: + return round(y / bucket) * bucket + + +def summarize_region(doc: str, page: int, region: Region, runs: list[TextRun], band_bucket: float) -> None: + selected = [run for run in runs if in_region(run, region)] + chars = sum(len(run.text) for run in selected) + fonts = Counter(primary_font(run.font_family) for run in selected) + sizes = Counter(round(run.font_size, 2) for run in selected) + weights = Counter(run.font_weight for run in selected) + bands: dict[float, int] = defaultdict(int) + for run in selected: + bands[band_key(run.y, band_bucket)] += len(run.text) + font_summary = ";".join(f"{name}:{count}" for name, count in fonts.most_common(5)) or "-" + size_summary = ";".join(f"{size:g}:{count}" for size, count in sizes.most_common(5)) or "-" + weight_summary = ";".join(f"{weight}:{count}" for weight, count in weights.most_common(5)) or "-" + band_summary = ";".join(f"{y:.1f}:{count}" for y, count in sorted(bands.items())) or "-" + sample = "".join(run.text for run in selected[:24]) + print( + f"{doc}\t{page}\t{region.name}\t{len(selected)}\t{chars}\t" + f"{font_summary}\t{size_summary}\t{weight_summary}\t{band_summary}\t{sample[:80]!r}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("ref", type=parse_ref, help="DOC:PAGE reference") + parser.add_argument("--region", action="append", type=parse_region, required=True) + parser.add_argument("--band-bucket", type=float, default=2.0) + args = parser.parse_args() + + svg_path = rhwp_svg_path(args.ref) + svg = svg_path.read_text(encoding="utf-8", errors="ignore") + svg_size(svg) + runs = text_runs(svg) + print("doc\tpage\tregion\ttext_runs\tchars\tfonts\tfont_sizes\tweights\ty_bands\tsample") + for region in args.region: + summarize_region(args.ref.doc, args.ref.page, region, runs, args.band_bucket) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/table_boundary_pair_probe.py b/harness/table_boundary_pair_probe.py new file mode 100644 index 0000000000..9992398e4e --- /dev/null +++ b/harness/table_boundary_pair_probe.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Compare long horizontal table-rule bands for Hancom:RHWP semantic page pairs.""" +from __future__ import annotations + +import argparse +from pathlib import Path + +from PIL import Image + +import review_gallery +import table_boundary_probe + + +DIFF = Path("/tmp/diff") + + +def parse_pair(raw: str) -> tuple[int, int]: + if ":" not in raw: + raise argparse.ArgumentTypeError(f"expected HANCOM:RHWP page pair, got {raw!r}") + left, right = raw.split(":", 1) + return int(left), int(right) + + +def page_path(paths: list[Path], page: int) -> Path: + idx = page - 1 + if idx < 0 or idx >= len(paths): + raise SystemExit(f"missing page {page}") + return paths[idx] + + +def print_gaps(label: str, bands: list[tuple[float, float, int]]) -> None: + ys = [start for start, _, _ in bands] + if len(ys) < 2: + return + print(f"{label}_gaps " + " ".join(f"{ys[i + 1] - ys[i]:.1f}" for i in range(len(ys) - 1))) + + +def probe(doc: str, hancom_page: int, rhwp_page: int, min_dark: int, min_width_ratio: float) -> None: + docdir = DIFF / doc + if not docdir.exists(): + raise SystemExit(f"missing docdir {docdir}") + hancom_paths = review_gallery.raster_hancom(docdir) + hancom = page_path(hancom_paths, hancom_page) + + svg_paths = review_gallery.svg_pages(docdir) + svg = page_path(svg_paths, rhwp_page) + rhwp_png = docdir / f"rhwp_pair_boundary_p-{rhwp_page}.png" + review_gallery.raster_svg(svg, rhwp_png) + _, svg_h = review_gallery.svg_size(svg) + + hancom_bands = table_boundary_probe.scale_bands( + table_boundary_probe.horizontal_bands(hancom, min_dark, min_width_ratio), + hancom, + svg_h, + ) + rhwp_bands = table_boundary_probe.scale_bands( + table_boundary_probe.horizontal_bands(rhwp_png, min_dark, min_width_ratio), + rhwp_png, + svg_h, + ) + + print(f"doc={doc} hancom_page={hancom_page} rhwp_page={rhwp_page} svg_h={svg_h:.1f}") + table_boundary_probe.print_bands("hancom", hancom_bands) + print_gaps("hancom", hancom_bands) + table_boundary_probe.print_bands("rhwp", rhwp_bands) + print_gaps("rhwp", rhwp_bands) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("doc") + parser.add_argument("--pair", required=True, type=parse_pair) + parser.add_argument("--min-dark", type=int, default=260) + parser.add_argument("--min-width-ratio", type=float, default=0.18) + args = parser.parse_args() + probe(args.doc, args.pair[0], args.pair[1], args.min_dark, args.min_width_ratio) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/table_boundary_probe.py b/harness/table_boundary_probe.py new file mode 100644 index 0000000000..63157c88b2 --- /dev/null +++ b/harness/table_boundary_probe.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Compare table horizontal-rule boundaries between Hancom and RHWP pages. + +This is a read-only fidelity harness. It detects long dark horizontal bands in +the oracle/rhwp rasters and prints them in SVG page coordinates so row-density +differences are visible without hand-measuring screenshots. +""" +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image + +import review_gallery + + +RHWP = Path(__file__).resolve().parents[1] +DIFF = Path("/tmp/diff") +SVG_LINE_RE = re.compile(r"]*>", re.I) +ATTR_RE = re.compile(r'([a-zA-Z_:][\w:.-]*)="([^"]*)"') + + +@dataclass(frozen=True) +class PageRef: + doc: str + page: int + + +def parse_ref(raw: str) -> PageRef: + if ":" not in raw: + raise argparse.ArgumentTypeError(f"expected DOC:PAGE, got {raw!r}") + doc, page_raw = raw.rsplit(":", 1) + return PageRef(doc=doc, page=int(page_raw)) + + +def docdir(ref: PageRef) -> Path: + path = DIFF / ref.doc + if not path.exists(): + raise SystemExit(f"missing /tmp/diff docdir: {path}") + return path + + +def rhwp_svg_path(ref: PageRef) -> Path: + directory = docdir(ref) / "rhwp_svg_cur" + candidates = [ + directory / f"source_{ref.page:03d}.svg", + directory / f"source_{ref.page}.svg", + directory / f"page_{ref.page:03d}.svg", + directory / f"page_{ref.page}.svg", + ] + for path in candidates: + if path.exists(): + return path + raise SystemExit(f"missing RHWP SVG for {ref.doc} page {ref.page} under {directory}") + + +def hancom_png_path(ref: PageRef) -> Path: + directory = docdir(ref) + candidates = [ + directory / f"hancom_p-{ref.page}.png", + directory / f"hancom_p-{ref.page:02d}.png", + directory / f"hancom_p-{ref.page:03d}.png", + ] + for path in candidates: + if path.exists(): + return path + generated = review_gallery.raster_hancom(directory) + for path in generated: + if review_gallery.natural_key(path) == ref.page: + return path + raise SystemExit(f"missing Hancom PNG for {ref.doc} page {ref.page}") + + +def rhwp_png_path(ref: PageRef, svg: Path) -> Path: + out = docdir(ref) / f"rhwp_boundary_p-{ref.page}.png" + review_gallery.raster_svg(svg, out) + return out + + +def dark_luma(pixel: tuple[int, ...]) -> bool: + if len(pixel) == 4 and pixel[3] == 0: + return False + red, green, blue = pixel[:3] + return 0.2126 * red + 0.7152 * green + 0.0722 * blue < 96 + + +def horizontal_bands(path: Path, min_dark: int, min_width_ratio: float) -> list[tuple[int, int, int]]: + with Image.open(path) as opened: + image = opened.convert("RGBA") + width, height = image.size + threshold = max(min_dark, int(width * min_width_ratio)) + rows: list[tuple[int, int]] = [] + for y in range(height): + count = 0 + for x in range(width): + if dark_luma(image.getpixel((x, y))): + count += 1 + if count >= threshold: + rows.append((y, count)) + + bands: list[tuple[int, int, int]] = [] + if not rows: + return bands + start_y = prev_y = rows[0][0] + max_count = rows[0][1] + for y, count in rows[1:]: + if y <= prev_y + 1: + prev_y = y + max_count = max(max_count, count) + continue + bands.append((start_y, prev_y, max_count)) + start_y = prev_y = y + max_count = count + bands.append((start_y, prev_y, max_count)) + return bands + + +def scale_bands( + bands: list[tuple[int, int, int]], image_path: Path, svg_height: float +) -> list[tuple[float, float, int]]: + with Image.open(image_path) as opened: + _, image_h = opened.size + scale = svg_height / image_h + return [(start * scale, end * scale, count) for start, end, count in bands] + + +def svg_horizontal_lines(svg: Path, min_len: float) -> list[tuple[float, float, float]]: + lines: list[tuple[float, float, float]] = [] + text = svg.read_text(encoding="utf-8", errors="ignore") + for match in SVG_LINE_RE.finditer(text): + attrs = dict(ATTR_RE.findall(match.group(0))) + try: + x1 = float(attrs.get("x1", "0")) + x2 = float(attrs.get("x2", "0")) + y1 = float(attrs.get("y1", "0")) + y2 = float(attrs.get("y2", "0")) + except ValueError: + continue + if abs(y1 - y2) <= 0.2 and abs(x2 - x1) >= min_len: + lines.append((min(y1, y2), min(x1, x2), max(x1, x2))) + return sorted(lines) + + +def print_bands(label: str, bands: list[tuple[float, float, int]]) -> None: + print(f"{label}_bands count={len(bands)}") + for start, end, count in bands: + print(f" y={start:7.1f}..{end:7.1f} h={end - start:5.1f} dark={count}") + + +def print_gaps(label: str, ys: list[float]) -> None: + if len(ys) < 2: + return + gaps = [ys[i + 1] - ys[i] for i in range(len(ys) - 1)] + print(f"{label}_gaps count={len(gaps)}") + print(" " + " ".join(f"{gap:.1f}" for gap in gaps)) + + +def probe(ref: PageRef, min_dark: int, min_width_ratio: float, svg_line_min_len: float) -> None: + svg = rhwp_svg_path(ref) + svg_width, svg_height = review_gallery.svg_size(svg) + hancom = hancom_png_path(ref) + rhwp = rhwp_png_path(ref, svg) + hancom_bands = scale_bands(horizontal_bands(hancom, min_dark, min_width_ratio), hancom, svg_height) + rhwp_bands = scale_bands(horizontal_bands(rhwp, min_dark, min_width_ratio), rhwp, svg_height) + svg_lines = svg_horizontal_lines(svg, svg_line_min_len) + + print(f"ref={ref.doc}:{ref.page} svg_size={svg_width:.1f}x{svg_height:.1f}") + print(f"hancom_png={hancom}") + print(f"rhwp_png={rhwp}") + print_bands("hancom", hancom_bands) + print_gaps("hancom", [start for start, _, _ in hancom_bands]) + print_bands("rhwp", rhwp_bands) + print_gaps("rhwp", [start for start, _, _ in rhwp_bands]) + print(f"svg_horizontal_lines count={len(svg_lines)}") + for y, x1, x2 in svg_lines: + print(f" y={y:7.1f} x={x1:7.1f}..{x2:7.1f} w={x2 - x1:7.1f}") + print_gaps("svg_line", [y for y, _, _ in svg_lines]) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("ref", type=parse_ref) + parser.add_argument("--min-dark", type=int, default=260) + parser.add_argument("--min-width-ratio", type=float, default=0.18) + parser.add_argument("--svg-line-min-len", type=float, default=350.0) + args = parser.parse_args() + probe(args.ref, args.min_dark, args.min_width_ratio, args.svg_line_min_len) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/table_fragment_diag.py b/harness/table_fragment_diag.py new file mode 100644 index 0000000000..bbdd79059f --- /dev/null +++ b/harness/table_fragment_diag.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""Diagnose RHWP partial-table fragments against HWPX row/span structure. + +Usage: + python3 harness/table_fragment_diag.py /tmp/diff/accountability_eval_fitted_oracle + +The script is intentionally read-only. It answers the question that comes up +when a table page-count matches but visual row bands drift: + +* which rows RHWP placed on each page; +* which source rows start inside that fragment; +* which row-spanning cells are being carried from earlier rows; +* what horizontal table bands the current SVG emitted. +""" + +from __future__ import annotations + +import argparse +import collections +import math +import re +import subprocess +import tempfile +import zipfile +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + + +NS = {"hp": "http://www.hancom.co.kr/hwpml/2011/paragraph"} +PAGE_RE = re.compile(r"^=== 페이지 (\d+) ") +PARTIAL_RE = re.compile(r"PartialTable\s+pi=(\d+)\s+ci=(\d+)\s+rows=(\d+)\.\.(\d+)") +LINE_RE = re.compile( + r"]*x1=\"([0-9.]+)\"[^>]*y1=\"([0-9.]+)\"[^>]*x2=\"([0-9.]+)\"[^>]*y2=\"([0-9.]+)\"" +) +TEXT_RE = re.compile(r"]*\by=\"([0-9.]+)\"[^>]*>(.*?)", re.DOTALL) +TAG_RE = re.compile(r"<[^>]+>") +FONT_SIZE_RE = re.compile(r'font-size="([^"]+)"') +FONT_FAMILY_RE = re.compile(r'font-family="([^"]+)"') +FONT_WEIGHT_RE = re.compile(r'font-weight="([^"]+)"') + + +@dataclass(frozen=True) +class Cell: + row: int + col: int + row_span: int + col_span: int + height_hu: int + line_segs: int + text: str + + +@dataclass(frozen=True) +class Fragment: + page: int + pi: int + ci: int + start: int + end: int + + +def hu_to_px(value: int, dpi: float = 96.0) -> float: + return value * dpi / 7200.0 + + +def find_source(docdir: Path) -> Path: + for name in ("source.hwpx", "source_converted.hwpx", "source_fitted.hwpx"): + path = docdir / name + if path.exists(): + return path + raise SystemExit(f"no HWPX source found under {docdir}") + + +def parse_table(hwpx: Path, table_index: int) -> tuple[ET.Element, list[Cell]]: + with zipfile.ZipFile(hwpx) as zf: + sections = sorted(name for name in zf.namelist() if name.startswith("Contents/section")) + tables: list[ET.Element] = [] + for name in sections: + root = ET.fromstring(zf.read(name)) + tables.extend(root.findall(".//hp:tbl", NS)) + if table_index >= len(tables): + raise SystemExit(f"table index {table_index} out of range; found {len(tables)} table(s)") + table = tables[table_index] + cells: list[Cell] = [] + for tc in table.findall(".//hp:tc", NS): + addr = tc.find("hp:cellAddr", NS) + span = tc.find("hp:cellSpan", NS) + size = tc.find("hp:cellSz", NS) + if addr is None or span is None or size is None: + continue + text = " ".join("".join(tc.itertext()).split()) + line_segs = len(tc.findall(".//hp:lineseg", NS)) + cells.append( + Cell( + row=int(addr.attrib["rowAddr"]), + col=int(addr.attrib["colAddr"]), + row_span=int(span.attrib.get("rowSpan", "1")), + col_span=int(span.attrib.get("colSpan", "1")), + height_hu=int(size.attrib.get("height", "0")), + line_segs=line_segs, + text=text[:28], + ) + ) + return table, sorted(cells, key=lambda c: (c.row, c.col)) + + +def parse_fragments(docdir: Path) -> list[Fragment]: + candidates = [ + docdir / "dump_pages_current.txt", + docdir / "dump_pages_after_repeat_header.txt", + docdir / "dump_pages.txt", + ] + dump = next((path for path in candidates if path.exists()), None) + if dump is None: + return [] + page = 0 + fragments: list[Fragment] = [] + for line in dump.read_text(encoding="utf-8", errors="ignore").splitlines(): + if match := PAGE_RE.search(line): + page = int(match.group(1)) + continue + if match := PARTIAL_RE.search(line): + fragments.append( + Fragment( + page=page, + pi=int(match.group(1)), + ci=int(match.group(2)), + start=int(match.group(3)), + end=int(match.group(4)), + ) + ) + return fragments + + +def svg_horizontal_bands(docdir: Path, page: int) -> list[float]: + svg = docdir / "rhwp_svg_cur" / f"source_{page:03d}.svg" + if not svg.exists(): + return [] + ys: list[float] = [] + for match in LINE_RE.finditer(svg.read_text(encoding="utf-8", errors="ignore")): + x1, y1, x2, y2 = map(float, match.groups()) + if abs(y1 - y2) <= 0.05 and abs(x2 - x1) >= 200.0: + ys.append(round(y1, 1)) + return sorted(set(ys)) + + +def svg_text_stats(docdir: Path, page: int) -> tuple[int, list[float]]: + svg = docdir / "rhwp_svg_cur" / f"source_{page:03d}.svg" + if not svg.exists(): + return 0, [] + ys: list[float] = [] + for y_raw, body in TEXT_RE.findall(svg.read_text(encoding="utf-8", errors="ignore")): + text = TAG_RE.sub("", body).strip() + if text: + ys.append(round(float(y_raw), 1)) + return len(ys), sorted(set(ys)) + + +def svg_font_stats(docdir: Path, page: int) -> dict[str, list[tuple[str, int]]]: + svg = docdir / "rhwp_svg_cur" / f"source_{page:03d}.svg" + if not svg.exists(): + return {} + text = svg.read_text(encoding="utf-8", errors="ignore") + return { + "font_size": collections.Counter(FONT_SIZE_RE.findall(text)).most_common(4), + "font_family": collections.Counter(FONT_FAMILY_RE.findall(text)).most_common(2), + "font_weight": collections.Counter(FONT_WEIGHT_RE.findall(text)).most_common(4), + } + + +def local_font_hint(font_stats: dict[str, list[tuple[str, int]]]) -> str | None: + families = font_stats.get("font_family") or [] + if not families: + return None + primary = families[0][0].split(",")[0].strip().strip("'").strip('"') + if primary in {"맑은 고딕", "Malgun Gothic"}: + mac_candidates = [ + Path("/Library/Fonts/Malgun Gothic.ttf"), + Path("/Library/Fonts/malgun.ttf"), + Path.home() / "Library/Fonts/Malgun Gothic.ttf", + Path.home() / "Library/Fonts/malgun.ttf", + ] + if not any(path.exists() for path in mac_candidates): + return "primary Malgun font not found locally; browser/PDF fallback may be thinner" + return None + + +def parse_pages(raw: str) -> list[int]: + pages: list[int] = [] + for part in raw.split(","): + part = part.strip() + if not part: + continue + if "-" in part: + start_raw, end_raw = part.split("-", 1) + start = int(start_raw) + end = int(end_raw) + step = 1 if end >= start else -1 + pages.extend(range(start, end + step, step)) + else: + pages.append(int(part)) + return pages + + +def row_height_px(cells: list[Cell]) -> dict[int, float]: + heights: dict[int, float] = {} + for cell in cells: + if cell.row_span != 1: + continue + heights[cell.row] = max(heights.get(cell.row, 0.0), hu_to_px(cell.height_hu)) + return heights + + +def image_dark_pixels(path: Path, threshold: int) -> tuple[int, tuple[int, int]]: + try: + from PIL import Image + except ImportError as exc: + raise SystemExit("Pillow is required for --font-audit") from exc + with Image.open(path) as image: + rgba = image.convert("RGBA") + width, height = rgba.size + dark = 0 + for red, green, blue, alpha in rgba.getdata(): + if alpha == 0: + continue + # Luma keeps colored borders out of the "black text ink" signal + # unless they are also visually dark. + luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue + if luma < threshold: + dark += 1 + return dark, (width, height) + + +def svg_size(svg: Path) -> tuple[float, float]: + head = svg.read_text(encoding="utf-8", errors="ignore")[:1600] + match = re.search(r']*\bwidth="([0-9.]+)"[^>]*\bheight="([0-9.]+)"', head) + if match: + return float(match.group(1)), float(match.group(2)) + match = re.search(r']*\bviewBox="[0-9.]+ [0-9.]+ ([0-9.]+) ([0-9.]+)"', head) + if match: + return float(match.group(1)), float(match.group(2)) + return 794.0, 1123.0 + + +def raster_svg(svg: Path, png: Path) -> None: + width, height = svg_size(svg) + target_w = 1400 + target_h = max(1, round(height * target_w / width)) + page_html = ( + "" + "" + f"" + ) + js = r""" +const { chromium } = require('playwright'); +const [html, png, w, h] = process.argv.slice(1); +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: Number(w), height: Number(h) }, + deviceScaleFactor: 1, + }); + await page.goto('file://' + html, { waitUntil: 'networkidle' }); + await page.locator('#page').evaluate(img => img.decode ? img.decode() : Promise.resolve()); + const size = await page.evaluate(() => ({ + width: Math.ceil(document.documentElement.scrollWidth), + height: Math.ceil(document.documentElement.scrollHeight), + })); + await page.setViewportSize({ + width: Math.max(Number(w), size.width), + height: Math.max(Number(h), size.height), + }); + await page.screenshot({ path: png, fullPage: true }); + await browser.close(); +})().catch(err => { console.error(err); process.exit(1); }); +""" + with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) as handle: + handle.write(page_html) + html_path = handle.name + try: + result = subprocess.run( + ["node", "-e", js, html_path, str(png), str(target_w), str(target_h)], + check=False, + cwd=Path(__file__).resolve().parents[1], + text=True, + capture_output=True, + ) + if result.returncode != 0 or not png.exists(): + raise SystemExit(f"Chromium SVG raster failed for {svg}: {result.stderr[-1200:]}") + finally: + Path(html_path).unlink(missing_ok=True) + + +def rhwp_png_path(docdir: Path, page: int) -> Path | None: + candidates = [ + docdir / f"rhwp_review_p-{page}.png", + docdir / f"rhwp_review_p-{page:02d}.png", + docdir / f"rhwp_p-{page}.png", + docdir / f"rhwp_p-{page:02d}.png", + docdir / "look" / f"page-{page:02d}.png", + docdir / "look" / f"page-{page}.png", + ] + return next((path for path in candidates if path.exists()), None) + + +def hancom_png_path(docdir: Path, page: int) -> Path: + candidates = [ + docdir / f"hancom_p-{page}.png", + docdir / f"hancom_p-{page:02d}.png", + ] + return next((path for path in candidates if path.exists()), candidates[0]) + + +def print_font_audit(docdir: Path, pages: list[int], threshold: int) -> None: + print("font_audit:") + for page in pages: + hancom = hancom_png_path(docdir, page) + rhwp = rhwp_png_path(docdir, page) + if not hancom.exists() or rhwp is None: + print( + f" p{page:02d}: missing_images " + f"hancom={hancom.exists()} rhwp={rhwp is not None}" + ) + continue + hancom_dark, hancom_size = image_dark_pixels(hancom, threshold) + rhwp_dark, rhwp_size = image_dark_pixels(rhwp, threshold) + ratio = rhwp_dark / hancom_dark if hancom_dark else math.nan + font_stats = svg_font_stats(docdir, page) + family = (font_stats.get("font_family") or [("", 0)])[0][0] + weights = font_stats.get("font_weight", []) + sizes = font_stats.get("font_size", []) + print( + f" p{page:02d}: " + f"hancom_dark={hancom_dark} rhwp_dark={rhwp_dark} " + f"ratio={ratio:.2f} " + f"hancom_size={hancom_size[0]}x{hancom_size[1]} " + f"rhwp_size={rhwp_size[0]}x{rhwp_size[1]} " + f"font_family={family!r} font_size={sizes[:2]} " + f"font_weight={weights[:2]}" + ) + hint = local_font_hint(font_stats) + if hint: + print(f" font_hint={hint}") + + +def source_svg_path(docdir: Path, page: int) -> Path | None: + candidates = [ + docdir / "rhwp_svg_cur" / f"source_{page:03d}.svg", + docdir / "rhwp_svg_fresh" / f"source_{page:03d}.svg", + ] + return next((path for path in candidates if path.exists()), None) + + +def write_weight_probe_svg(source: Path, dest: Path, weight: str) -> None: + text = source.read_text(encoding="utf-8", errors="ignore") + style = f"" + patched = re.sub(r"(]*>)", r"\1" + style, text, count=1) + dest.write_text(patched, encoding="utf-8") + + +def print_weight_probe(docdir: Path, pages: list[int], weight: str, threshold: int) -> None: + outdir = docdir / f"font_probe_weight_{weight}" + outdir.mkdir(exist_ok=True) + print(f"weight_probe: weight={weight} outdir={outdir}") + for page in pages: + hancom = hancom_png_path(docdir, page) + source_svg = source_svg_path(docdir, page) + if not hancom.exists() or source_svg is None: + print( + f" p{page:02d}: missing_inputs " + f"hancom={hancom.exists()} svg={source_svg is not None}" + ) + continue + probe_svg = outdir / f"source_{page:03d}_weight_{weight}.svg" + probe_png = outdir / f"source_{page:03d}_weight_{weight}.png" + write_weight_probe_svg(source_svg, probe_svg, weight) + raster_svg(probe_svg, probe_png) + hancom_dark, _ = image_dark_pixels(hancom, threshold) + probe_dark, probe_size = image_dark_pixels(probe_png, threshold) + ratio = probe_dark / hancom_dark if hancom_dark else math.nan + print( + f" p{page:02d}: hancom_dark={hancom_dark} " + f"probe_dark={probe_dark} ratio={ratio:.2f} " + f"probe_size={probe_size[0]}x{probe_size[1]} png={probe_png}" + ) + + +def summarize_fragment(fragment: Fragment, cells: list[Cell]) -> None: + starts = [cell for cell in cells if fragment.start <= cell.row < fragment.end] + carried = [ + cell + for cell in cells + if cell.row < fragment.start < cell.row + max(cell.row_span, 1) + ] + row_heights = row_height_px(cells) + fragment_heights = [ + round(row_heights.get(row, 0.0), 1) for row in range(fragment.start, fragment.end) + ] + fragment_height_sum = round(sum(fragment_heights), 1) + print( + f"page {fragment.page:02d}: rows={fragment.start}..{fragment.end} " + f"starts={len(starts)} carried={len(carried)} " + f"saved_line_segs={sum(cell.line_segs for cell in starts)} " + f"source_row_h_sum={fragment_height_sum}" + ) + if fragment_heights: + print(f" source_row_h={fragment_heights}") + if carried: + print(" carried:") + for cell in carried[:12]: + print( + f" r{cell.row}c{cell.col} span={cell.row_span} " + f"h={cell.height_hu} segs={cell.line_segs} text={cell.text!r}" + ) + boundary_rows = sorted({fragment.start, fragment.end - 1}) + for row in boundary_rows: + row_cells = [cell for cell in starts if cell.row == row] + if row_cells: + preview = ", ".join( + f"c{cell.col}/rs{cell.row_span}/h{cell.height_hu}/segs{cell.line_segs}/{cell.text!r}" + for cell in row_cells[:8] + ) + print(f" row {row}: {preview}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("docdir", type=Path) + parser.add_argument("--table-index", type=int, default=0) + parser.add_argument( + "--font-audit", + action="store_true", + help="also compare Hancom/RHWP dark-pixel density and SVG font metadata", + ) + parser.add_argument( + "--pages", + default="1,5,11", + help="page list/range for --font-audit, e.g. 1,5,11 or 1-11", + ) + parser.add_argument( + "--dark-threshold", + type=int, + default=96, + help="luma threshold used by --font-audit dark-pixel counts", + ) + parser.add_argument( + "--weight-probe", + metavar="WEIGHT", + help="write/rasterize SVG copies with non-bold text forced to this CSS weight", + ) + args = parser.parse_args() + + source = find_source(args.docdir) + table, cells = parse_table(source, args.table_index) + fragments = parse_fragments(args.docdir) + print(f"source: {source}") + print( + "table: " + f"repeatHeader={table.attrib.get('repeatHeader')} " + f"pageBreak={table.attrib.get('pageBreak')} " + f"rows={table.attrib.get('rowCnt')} cols={table.attrib.get('colCnt')} " + f"cells={len(cells)}" + ) + if not fragments: + print("no dump_pages_current.txt-style fragments found") + return 1 + for fragment in fragments: + summarize_fragment(fragment, cells) + bands = svg_horizontal_bands(args.docdir, fragment.page) + if bands: + print(f" svg_h_bands={bands[:18]}") + text_count, text_ys = svg_text_stats(args.docdir, fragment.page) + if text_count: + print(f" svg_text_runs={text_count} svg_text_y={text_ys[:18]}") + font_stats = svg_font_stats(args.docdir, fragment.page) + if font_stats: + print( + " svg_fonts=" + f"size={font_stats.get('font_size', [])} " + f"weight={font_stats.get('font_weight', [])}" + ) + hint = local_font_hint(font_stats) + if hint: + print(f" font_hint={hint}") + if args.font_audit: + print_font_audit(args.docdir, parse_pages(args.pages), args.dark_threshold) + if args.weight_probe: + print_weight_probe( + args.docdir, + parse_pages(args.pages), + args.weight_probe, + args.dark_threshold, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/webhwp_oracle_pdf.py b/harness/webhwp_oracle_pdf.py new file mode 100644 index 0000000000..a9c9c8dab7 --- /dev/null +++ b/harness/webhwp_oracle_pdf.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Generate Hancom/WebHWP PDF oracles for staged /tmp/diff documents. + +This drives the existing WebHWP test iframe on the licensed `webhwp` host. It +copies a staged source into Tomcat's test samples directory, opens it through +the deployed `hwpctrlframe.html`, asks the frame for `requestRender`, and writes +the returned PDF bytes as `hancom.pdf` in the local doc directory. + +The script intentionally uses the same WebHWP JS path that FactChat uses for +preview/export verification instead of inventing a separate conversion API. +""" +from __future__ import annotations + +import argparse +import base64 +import json +import subprocess +import sys +import time +import uuid +from dataclasses import dataclass +from pathlib import Path + + +RHWP = Path(__file__).resolve().parents[1] +DIFF = Path("/tmp/diff") +DEFAULT_REMOTE_SAMPLES = "/home/rocky/tomcat/webapps/test/samples" +DEFAULT_FRAME_URL = "https://webhwp.mindlogic.ai/test/resources/hwpctrlframe.html" + + +@dataclass(frozen=True) +class ExportResult: + pdf: Path + remote_name: str + size_bytes: int + elapsed_sec: float + + +def run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run(cmd, cwd=RHWP, capture_output=True, text=True) + if check and result.returncode != 0: + sys.stderr.write(result.stderr[-2000:]) + raise SystemExit(f"command failed: {' '.join(cmd)}") + return result + + +def source_path(docdir: Path) -> Path: + for name in ("source.hwpx", "source.hwp", "source_converted.hwpx", "source_fitted.hwpx"): + path = docdir / name + if path.exists(): + return path + raise SystemExit(f"no source file found in {docdir}") + + +def ensure_node_deps() -> None: + script = "require('playwright');" + result = run(["node", "-e", script], check=False) + if result.returncode != 0: + raise SystemExit("node cannot require('playwright'); install/use the repo's Playwright environment") + + +def upload_source(host: str, source: Path, remote_samples: str) -> str: + suffix = source.suffix.lower() or ".hwpx" + remote_name = f"codex-oracle-{uuid.uuid4().hex}{suffix}" + remote_path = f"{host}:{remote_samples.rstrip('/')}/{remote_name}" + run(["scp", "-q", str(source), remote_path]) + return remote_name + + +def cleanup_remote(host: str, remote_samples: str, remote_name: str) -> None: + remote = f"{remote_samples.rstrip('/')}/{remote_name}" + run(["ssh", host, "rm", "-f", remote], check=False) + + +def playwright_script() -> str: + return r""" +const { chromium } = require('playwright'); +const fs = require('fs'); + +const [frameUrl, openUrl, format, timeoutMsRaw, methodRaw] = process.argv.slice(1); +const timeoutMs = Number(timeoutMsRaw || 90000); +const method = methodRaw || 'download'; + +function waitForMessage(page, predicate, timeoutMs) { + return page.evaluate(({ timeoutMs, predicateSource }) => { + const predicate = new Function('message', `return (${predicateSource})(message);`); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + window.removeEventListener('message', onMessage); + reject(new Error('timeout_waiting_for_message')); + }, timeoutMs); + function onMessage(event) { + const message = event.data || {}; + let ok = false; + try { ok = predicate(message); } catch (err) { ok = false; } + if (!ok) return; + clearTimeout(timer); + window.removeEventListener('message', onMessage); + resolve(message); + } + window.addEventListener('message', onMessage); + }); + }, { timeoutMs, predicateSource: predicate.toString() }); +} + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: 1280, height: 900 }, + acceptDownloads: true, + }); + page.setDefaultTimeout(timeoutMs); + await page.setContent(` + `); + + const frameHandle = await page.waitForSelector('#hwp', { timeout: timeoutMs }); + await waitForMessage(page, message => message.type === 'editorReady', timeoutMs); + await page.evaluate(({ openUrl, format }) => { + const iframe = document.getElementById('hwp'); + iframe.contentWindow.postMessage({ + type: 'openFile', + url: openUrl, + format: format || 'HWPX', + }, '*'); + }, { openUrl, format }); + const opened = await waitForMessage(page, message => message.type === 'fileOpened', timeoutMs); + const openOk = opened.result === true || (opened.result && opened.result.result === true); + if (!openOk) { + throw new Error(`open_failed:${JSON.stringify(opened)}`); + } + const requestId = `render-${Date.now()}-${Math.random().toString(36).slice(2)}`; + let rendered = null; + if (method === 'download') { + const downloadPromise = page.waitForEvent('download', { timeout: timeoutMs }); + await page.evaluate(() => { + const iframe = document.getElementById('hwp'); + iframe.contentWindow.postMessage({ + type: 'saveAs', + filename: 'hancom.pdf', + format: 'PDF', + }, '*'); + }); + const download = await downloadPromise; + const downloadPath = await download.path(); + if (!downloadPath) { + throw new Error('download_missing_path'); + } + const bytes = fs.readFileSync(downloadPath); + rendered = { + base64: bytes.toString('base64'), + sizeBytes: bytes.length, + }; + } else { + await page.evaluate((requestId) => { + const iframe = document.getElementById('hwp'); + iframe.contentWindow.postMessage({ + type: 'requestRender', + requestId, + filename: 'hancom.pdf', + }, '*'); + }, requestId); + rendered = await waitForMessage( + page, + message => message.type === 'renderReady' && message.requestId === requestId, + timeoutMs + ); + } + await browser.close(); + if (rendered.error) { + throw new Error(`render_failed:${rendered.error}`); + } + if (!rendered.base64) { + throw new Error('render_missing_base64'); + } + process.stdout.write(JSON.stringify({ + base64: rendered.base64, + sizeBytes: rendered.sizeBytes || 0, + })); +})().catch(async err => { + console.error(err && err.stack || err); + process.exit(1); +}); +""" + + +def export_pdf( + docdir: Path, + *, + host: str, + public_base: str, + frame_url: str, + remote_samples: str, + timeout_ms: int, + keep_remote: bool, +) -> ExportResult: + source = source_path(docdir) + remote_name = upload_source(host, source, remote_samples) + public_url = f"{public_base.rstrip('/')}/{remote_name}" + started = time.monotonic() + try: + result = run( + [ + "node", + "-e", + playwright_script(), + frame_url, + public_url, + "HWPX" if source.suffix.lower() == ".hwpx" else "HWP", + str(timeout_ms), + "download", + ] + ) + payload = json.loads(result.stdout) + data = base64.b64decode(payload["base64"], validate=False) + if not data.startswith(b"%PDF"): + raise SystemExit(f"WebHWP render did not return PDF bytes for {docdir.name}") + pdf = docdir / "hancom.pdf" + pdf.write_bytes(data) + return ExportResult( + pdf=pdf, + remote_name=remote_name, + size_bytes=len(data), + elapsed_sec=time.monotonic() - started, + ) + finally: + if not keep_remote: + cleanup_remote(host, remote_samples, remote_name) + + +def parse_doc(raw: str) -> Path: + path = Path(raw) + if not path.is_absolute(): + path = DIFF / raw + if path.is_file(): + return path.parent + if not path.exists(): + raise argparse.ArgumentTypeError(f"missing doc directory: {path}") + return path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("docs", nargs="+", type=parse_doc) + parser.add_argument("--host", default="webhwp") + parser.add_argument("--remote-samples", default=DEFAULT_REMOTE_SAMPLES) + parser.add_argument("--public-base", default="https://webhwp.mindlogic.ai/test/samples") + parser.add_argument("--frame-url", default=DEFAULT_FRAME_URL) + parser.add_argument("--timeout-ms", type=int, default=120000) + parser.add_argument("--keep-remote", action="store_true") + args = parser.parse_args() + + ensure_node_deps() + for docdir in args.docs: + result = export_pdf( + docdir, + host=args.host, + public_base=args.public_base, + frame_url=args.frame_url, + remote_samples=args.remote_samples, + timeout_ms=args.timeout_ms, + keep_remote=args.keep_remote, + ) + print( + f"{docdir.name}\t{result.pdf}\t{result.size_bytes} bytes\t" + f"{result.elapsed_sec:.1f}s\tremote={result.remote_name}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/rhwp-studio/index.html b/rhwp-studio/index.html index e6fc1b4a85..43baf0bea1 100644 --- a/rhwp-studio/index.html +++ b/rhwp-studio/index.html @@ -8,6 +8,59 @@ rhwp-studio + +
@@ -17,9 +70,21 @@