diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index d5fc5b3..926152e 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -957,17 +957,37 @@ pub fn apply_edits( // Nới DỌC rộng hơn ngang: dấu tiếng Việt (ngã của Ễ…) // nhiều file (Canva) vẽ bằng op riêng nằm TRÊN cap — // tâm ngoài bbox +2pt → sót lại dấu lơ lửng sau sửa. - // 8pt vẫn nhỏ hơn nhịp dòng (≥12pt) nên không nuốt - // nhầm dòng bên cạnh. + // NHƯNG vùng nở 8pt chỉ được nuốt op NHỎ (mảnh dấu + // thấp hơn hẳn chữ): với giãn dòng sát (~1.25×) tâm + // run của DÒNG KỀ cũng lọt vào bbox+8pt — nuốt nhầm + // cả dòng trên làm khối vẽ lại trồi lên 1 dòng, đè + // chữ (bug "lưu xong đè text khác"). Op cao cỡ chữ + // thường phải có tâm trong bbox LÕI mới bị nuốt. let pad_x = 2.0; let pad_y = 8.0; + let h_med = { + let mut hs: Vec = idxs + .iter() + .filter_map(|&i| text_rect(i)) + .map(|r| r.top - r.bottom) + .filter(|h| *h > 0.1) + .collect(); + hs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + if hs.is_empty() { 0.0 } else { hs[hs.len() / 2] } + }; for i in 0..entries.len() as u16 { if idxs.contains(&i) { continue; } let Some(r) = text_rect(i) else { continue }; let (cx, cy) = ((r.left + r.right) / 2.0, (r.bottom + r.top) / 2.0); - if cx >= bb.left - pad_x + let in_core = cx >= bb.left + && cx <= bb.right + && cy >= bb.bottom + && cy <= bb.top; + let small = (r.top - r.bottom) < (h_med * 0.6).max(2.0); + if (in_core || small) + && cx >= bb.left - pad_x && cx <= bb.right + pad_x && cy >= bb.bottom - pad_y && cy <= bb.top + pad_y diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index 1c11dee..dad8fc4 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -789,6 +789,84 @@ fn reflow_expands_indices_to_cover_block() { assert!(!norm.contains("Mảnh một"), "run đã đưa phải bị thay: {norm:?}"); } +/// Hồi quy bug "lưu xong đè text khác": vùng nở dọc 8pt (bắt dấu tiếng Việt +/// vẽ rời) KHÔNG được nuốt run cao cỡ chữ của DÒNG KỀ TRÊN khi giãn dòng sát +/// (~1.0–1.25×) — nuốt nhầm làm dòng trên bị xoá, first_baseline trồi lên và +/// khối mới vẽ đè lên vị trí dòng trên. +#[test] +fn reflow_tight_leading_does_not_swallow_line_above() { + let pdf = pdfium(); + let fx = tmp("ff_swallow_fx.pdf"); + let out = tmp("ff_swallow_out.pdf"); + let mk = |y: f32, s: &str| EditOp::AddText { + x: 60.0, + y, + text: s.into(), + font_size: 14.0, + color: [0, 0, 0, 255], + font_family: None, + bold: false, + italic: false, + }; + // Giãn dòng 13pt < 1.0× cỡ chữ (sát hơn cả file Word thật) — tâm mực của + // dòng trên lọt hẳn vào bbox khối + 8pt. + ff_engine::apply_edits( + &pdf, + &sample(), + 0, + &[ + mk(427.0, "Dòng trên phải sống sót"), + mk(414.0, "Dòng sửa thứ nhất trong khối"), + mk(401.0, "Dòng sửa thứ hai trong khối"), + ], + &fx, + None, + ) + .expect("fixture 3 dòng sát nhau"); + let b = find_text_index(&pdf, &fx, "Dòng sửa thứ nhất"); + let c = find_text_index(&pdf, &fx, "Dòng sửa thứ hai"); + + ff_engine::apply_edits( + &pdf, + &fx, + 0, + &[EditOp::ReflowText { + indices: vec![b, c], + text: "Nội dung mới dòng một\nnội dung mới dòng hai".into(), + rich: None, + }], + &out, + None, + ) + .expect("reflow khối 2 dòng dưới"); + + let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); + let norm = text.split_whitespace().collect::>().join(" "); + assert!( + norm.contains("Dòng trên phải sống sót"), + "dòng kề trên bị nuốt nhầm vào khối reflow: {norm:?}" + ); + assert!(norm.contains("Nội dung mới dòng một"), "text mới thiếu: {norm:?}"); + + // Dòng mới thứ nhất phải nằm ở vị trí dòng B cũ (~414), KHÔNG trồi lên + // vị trí dòng trên (~428): mực của nó không được chạm mực dòng trên. + let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list"); + let above = objs + .iter() + .find(|o| o.text.as_deref().map_or(false, |t| t.contains("Dòng trên phải"))) + .expect("dòng trên còn đó"); + let new1 = objs + .iter() + .find(|o| o.text.as_deref().map_or(false, |t| t.contains("Nội dung mới dòng một"))) + .expect("dòng mới 1"); + assert!( + new1.rect.top <= above.rect.bottom + 1.5, + "dòng mới trồi lên đè dòng trên: new1.top={} vs above.bottom={}", + new1.rect.top, + above.rect.bottom + ); +} + /// Khối gốc CĂN GIỮA (dòng ngắn thụt vào, tâm trùng nhau) → các dòng mới cũng /// đặt căn giữa theo tâm khối (không dồn hết về lề trái). #[test] diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index b1fb4ab..d52c128 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -528,7 +528,14 @@ các mảnh sau commit). chế độ sửa ảnh chưa load, CSS `:has` cho height auto làm slot cao ≈ 0 → cuộn ban đầu lệch cả cột, listener cuộn tự chuyển nhầm trang active. - Kiểm đè chữ trên build thật: dòng lẫn style sau commit tăng dần tuyệt đối - (worst overlap 0pt) — fix mép mực OK. Lưu ý: file test của user - (Ban_dich_tieng_Viet_...) đã NHIỄM sẵn mảnh "Path=/" lạc chỗ từ lần lưu - bằng build lỗi cũ — mảnh đó được reflow bảo tồn như marker, KHÔNG phải bug - mới; đo kiểm phải loại các mảnh giữ-nguyên-vị-trí. + (worst overlap 0pt) — fix mép mực NGANG đạt. +- ĐÍNH CHÍNH + bug thứ 2 (đè DỌC): ban đầu tưởng file test của user "nhiễm" + mảnh `Path=/` lạc chỗ — SAI. File gốc sạch: `Path=/` là ĐẦU BULLET hợp lệ + của dòng "Path=/ khiến refresh token…" nằm NGAY TRÊN khối sửa. Thủ phạm là + vòng nở dọc `pad_y = 8` (sinh ra để bắt dấu tiếng Việt vẽ rời): với giãn + dòng sát (~13–14pt) tâm mực run của DÒNG KỀ TRÊN lọt vào bbox+8pt → cả câu + bị nuốt (xoá), baseline của nó thành `first_baseline` → khối vẽ lại trồi + lên 1 dòng đè chữ, còn sót mảnh mono `Path=/` (tâm cao hơn ngưỡng chút). + Fix: vùng nở 8pt chỉ nuốt op NHỎ (cao < 0.6 × median cao run trong khối — + mảnh dấu); op cao cỡ chữ phải có tâm trong bbox LÕI. Test hồi quy: + `reflow_tight_leading_does_not_swallow_line_above` (giãn dòng 13pt).