Sửa edit inline với PDF Word-export: khung theo dòng, sửa cả đoạn giữ xuống dòng, hết chồng chữ
Chẩn đoán trên file thật (Word xuất): tiêu đề 2 dòng bị cắt thành 41 run per-word/per-char xen run rỗng kích thước 0, cỡ 22.5pt + 20pt trộn nhau, bbox chữ có dấu (Ạ, Ợ) tụt thấp hơn hẳn chữ thường. UI (main.js): - clusterTextLines: gom run thành DÒNG theo GIAO DỌC ≥50% chiều cao nhỏ (miễn nhiễm dấu tiếng Việt) + tách cột theo hở ngang; gắn run rỗng vào dòng chứa nó. Overlay vẽ 1 khung/DÒNG (file thật: 231 ô vụn → 18 khung); chọn/kéo/xoá/đổi thuộc tính tác động CẢ DÒNG (batch ops, editSelRuns) - paragraphLines mới trên nền cluster: dòng kề nhau (hở ≤1.3× chiều cao), giao ngang ≥30%, cỡ chữ ≤1.8× (tiêu đề 22.5/20pt vẫn 1 đoạn), khoá nhịp ±35% - Đúp LUÔN mở ô sửa CẢ ĐOẠN, mỗi dòng gốc = 1 dòng textarea (giữ và sửa được cả xuống dòng); đúp trúng KHE giữa các từ vẫn mở (hit-test theo điểm trên overlay); click/chọn chữ trong ô sửa di chuyển con trỏ, KHÔNG thoát edit (stopPropagation + guard .edit-inline ở onEditStageClick); ô che kín chữ cũ, line-height đúng nhịp dòng, scrollIntoView Engine (edit.rs ReflowText): - TỰ NỞ danh sách run: mọi text object có tâm trong union bbox (+2pt) của các run được đưa đều bị thay — không bao giờ sót run rỗng/lệch bbox → hết cảnh chữ mới chồng chữ cũ - Phát hiện khối CĂN GIỮA (tâm mọi dòng ≈ tâm khối, có dòng thụt trái) → dòng mới đặt x = tâm khối − w/2 (đo hmtx) — tiêu đề căn giữa giữ căn giữa Kiểm chứng với file thật (cục bộ): 39 run tiêu đề xoá sạch 0 mảnh sót, 2 dòng mới căn giữa lệch tâm trang <5pt, extract sạch chữ cũ; grouping đúng khi đúp mọi từ của cả 2 dòng lẫn header 8pt per-char. Test: 2 test hồi quy mới (nở indices bắt run giữa bị sót; giữ căn giữa đo bằng fixture tự căn) → edit 19/19, toàn bộ engine 75/75 ngoài qpdf-fixture. Docs: 12 (mục vá theo phản hồi), 13 (E34–E38). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
+105
-10
@@ -277,6 +277,8 @@ struct ReflowPlan {
|
||||
/// Khối: lề trái + bề rộng bẻ dòng (điểm PDF).
|
||||
left: f32,
|
||||
width: f32,
|
||||
/// Khối gốc căn giữa → dòng mới đặt x = tâm khối − w/2.
|
||||
centered: bool,
|
||||
/// Baseline dòng đầu (y của gốc text) + khoảng cách baseline giữa các dòng.
|
||||
first_baseline: f32,
|
||||
line_advance: f32,
|
||||
@@ -447,11 +449,11 @@ pub fn apply_edits(
|
||||
left: f32,
|
||||
right: f32,
|
||||
}
|
||||
let mut geos: Vec<RunGeo> = Vec::new();
|
||||
let mut old_text_all = String::new();
|
||||
for &i in &idxs {
|
||||
let geo_of = |i: u16| -> Result<Option<RunGeo>, EngineError> {
|
||||
let obj = page.objects().get(i as usize).map_err(err)?;
|
||||
let Some(t) = obj.as_text_object() else { continue };
|
||||
if obj.as_text_object().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let m = obj.matrix().map_err(err)?;
|
||||
let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect {
|
||||
left: m.e(),
|
||||
@@ -459,14 +461,72 @@ pub fn apply_edits(
|
||||
right: m.e(),
|
||||
top: m.f(),
|
||||
});
|
||||
old_text_all.push_str(&t.text());
|
||||
old_text_all.push(' ');
|
||||
geos.push(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right });
|
||||
Ok(Some(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right }))
|
||||
};
|
||||
let mut geos: Vec<RunGeo> = Vec::new();
|
||||
for &i in &idxs {
|
||||
if let Some(g) = geo_of(i)? {
|
||||
geos.push(g);
|
||||
}
|
||||
}
|
||||
if geos.is_empty() {
|
||||
continue;
|
||||
}
|
||||
idxs = geos.iter().map(|g| g.idx).collect();
|
||||
|
||||
// NỞ danh sách run theo bbox khối (an toàn — chống sót): PDF từ
|
||||
// Word hay cắt 1 dòng thành run theo TỪNG TỪ/KÝ TỰ xen run rỗng,
|
||||
// bbox chữ có dấu tụt thấp — UI có thể sót vài run. Mọi text
|
||||
// object có TÂM nằm trong bbox khối (nới 2pt) đều thuộc khối →
|
||||
// đưa hết vào để xoá sạch, không còn chữ cũ đè dưới chữ mới.
|
||||
{
|
||||
let mut bb = Rect {
|
||||
left: f32::INFINITY,
|
||||
bottom: f32::INFINITY,
|
||||
right: f32::NEG_INFINITY,
|
||||
top: f32::NEG_INFINITY,
|
||||
};
|
||||
for &i in &idxs {
|
||||
let obj = page.objects().get(i as usize).map_err(err)?;
|
||||
if obj.as_text_object().is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(q) = obj.bounds() {
|
||||
let r = quad_to_rect(&q);
|
||||
bb.left = bb.left.min(r.left);
|
||||
bb.bottom = bb.bottom.min(r.bottom);
|
||||
bb.right = bb.right.max(r.right);
|
||||
bb.top = bb.top.max(r.top);
|
||||
}
|
||||
}
|
||||
let pad = 2.0;
|
||||
for i in 0..obj_count as u16 {
|
||||
if idxs.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
let obj = page.objects().get(i as usize).map_err(err)?;
|
||||
if obj.as_text_object().is_none() {
|
||||
continue;
|
||||
}
|
||||
let (cx, cy) = match obj.bounds() {
|
||||
Ok(q) => {
|
||||
let r = quad_to_rect(&q);
|
||||
((r.left + r.right) / 2.0, (r.bottom + r.top) / 2.0)
|
||||
}
|
||||
Err(_) => continue,
|
||||
};
|
||||
if cx >= bb.left - pad
|
||||
&& cx <= bb.right + pad
|
||||
&& cy >= bb.bottom - pad
|
||||
&& cy <= bb.top + pad
|
||||
{
|
||||
idxs.push(i);
|
||||
if let Some(g) = geo_of(i)? {
|
||||
geos.push(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
idxs.sort_unstable();
|
||||
}
|
||||
// Run neo: baseline cao nhất, trái nhất.
|
||||
let anchor_idx = geos
|
||||
.iter()
|
||||
@@ -508,6 +568,33 @@ pub fn apply_edits(
|
||||
let right = geos.iter().map(|g| g.right).fold(f32::NEG_INFINITY, f32::max);
|
||||
let width = (right - left).max(20.0);
|
||||
|
||||
// Phát hiện CĂN GIỮA (tiêu đề...): mọi dòng gốc có tâm ≈ tâm
|
||||
// khối và có ít nhất 1 dòng thụt vào so với lề trái → các dòng
|
||||
// mới cũng đặt căn giữa (giữ bố cục như Foxit).
|
||||
let block_center = (left + right) / 2.0;
|
||||
let centered = {
|
||||
let tol = (width * 0.02).max(2.0);
|
||||
let mut any_indented = false;
|
||||
let mut all_centered = true;
|
||||
for &bl in &baselines {
|
||||
let (mut l, mut r) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
for g in geos.iter().filter(|g| (g.f - bl).abs() <= 1.0) {
|
||||
l = l.min(g.left);
|
||||
r = r.max(g.right);
|
||||
}
|
||||
if !l.is_finite() || r - l < 1.0 {
|
||||
continue;
|
||||
}
|
||||
if ((l + r) / 2.0 - block_center).abs() > tol {
|
||||
all_centered = false;
|
||||
}
|
||||
if l - left > tol {
|
||||
any_indented = true;
|
||||
}
|
||||
}
|
||||
all_centered && any_indented
|
||||
};
|
||||
|
||||
// Thang font cho các dòng mới (giữ font như SetText):
|
||||
// (1) font NHÚNG parse được + phủ đủ glyph → nhúng lại chính bytes đó
|
||||
// (same glyphs — trông y hệt);
|
||||
@@ -546,6 +633,7 @@ pub fn apply_edits(
|
||||
measure_bytes,
|
||||
left,
|
||||
width,
|
||||
centered,
|
||||
first_baseline,
|
||||
line_advance,
|
||||
linear: (m.a(), m.b(), m.c(), m.d()),
|
||||
@@ -841,6 +929,13 @@ pub fn apply_edits(
|
||||
continue; // đoạn trống vẫn chiếm 1 nhịp baseline
|
||||
}
|
||||
let y = plan.first_baseline - (i as f32) * plan.line_advance;
|
||||
// Khối căn giữa → từng dòng mới cũng căn giữa theo tâm khối.
|
||||
let x = if plan.centered {
|
||||
let w: f32 = line.chars().map(&measure).sum();
|
||||
(plan.left + plan.width / 2.0 - w / 2.0).max(plan.left)
|
||||
} else {
|
||||
plan.left
|
||||
};
|
||||
let mut obj = page
|
||||
.objects_mut()
|
||||
.create_text_object(
|
||||
@@ -851,8 +946,8 @@ pub fn apply_edits(
|
||||
PdfPoints::new(plan.tf),
|
||||
)
|
||||
.map_err(err)?;
|
||||
// Giữ scale/nghiêng của run neo; đặt gốc dòng tại (left, y).
|
||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, plan.left, y))
|
||||
// Giữ scale/nghiêng của run neo; đặt gốc dòng tại (x, y).
|
||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
|
||||
.map_err(err)?;
|
||||
obj.set_fill_color(plan.color).map_err(err)?;
|
||||
}
|
||||
|
||||
@@ -740,6 +740,138 @@ the declared standard Helvetica base font untouched.";
|
||||
assert!(norm.contains("standard Helvetica base font untouched"), "round-trip: {norm:?}");
|
||||
}
|
||||
|
||||
/// Engine tự NỞ danh sách run theo bbox khối: dòng bị cắt làm 3 run, UI chỉ
|
||||
/// gửi run ĐẦU + CUỐI (sót run giữa — như run rỗng/lệch bbox của Word) —
|
||||
/// run giữa vẫn bị thay sạch, không còn chữ cũ đè dưới chữ mới.
|
||||
#[test]
|
||||
fn reflow_expands_indices_to_cover_block() {
|
||||
let pdf = pdfium();
|
||||
let fx = tmp("ff_reflow_exp_fx.pdf");
|
||||
let out = tmp("ff_reflow_exp_out.pdf");
|
||||
let mk = |x: f32, s: &str| EditOp::AddText {
|
||||
x,
|
||||
y: 400.0,
|
||||
text: s.into(),
|
||||
font_size: 14.0,
|
||||
color: [0, 0, 0, 255],
|
||||
font_family: None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
};
|
||||
// 1 dòng thị giác bị cắt thành 3 run sát nhau (kiểu Word).
|
||||
ff_engine::apply_edits(
|
||||
&pdf,
|
||||
&sample(),
|
||||
0,
|
||||
&[mk(60.0, "Mảnh một"), mk(125.0, "mảnh hai"), mk(188.0, "mảnh ba.")],
|
||||
&fx,
|
||||
None,
|
||||
)
|
||||
.expect("fixture 3 mảnh");
|
||||
let first = find_text_index(&pdf, &fx, "Mảnh một");
|
||||
let third = find_text_index(&pdf, &fx, "mảnh ba");
|
||||
|
||||
ff_engine::apply_edits(
|
||||
&pdf,
|
||||
&fx,
|
||||
0,
|
||||
&[EditOp::ReflowText { indices: vec![first, third], text: "Dòng đã thay hoàn toàn".into() }],
|
||||
&out,
|
||||
None,
|
||||
)
|
||||
.expect("reflow partial");
|
||||
|
||||
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||
let norm = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(norm.contains("Dòng đã thay hoàn toàn"), "text mới: {norm:?}");
|
||||
assert!(!norm.contains("mảnh hai"), "run GIỮA bị sót phải được nở vào và xoá: {norm:?}");
|
||||
assert!(!norm.contains("Mảnh một"), "run đã đưa phải bị thay: {norm:?}");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn reflow_preserves_centered_alignment() {
|
||||
let pdf = pdfium();
|
||||
let probe = tmp("ff_center_probe.pdf");
|
||||
let fx = tmp("ff_center_fx.pdf");
|
||||
let out = tmp("ff_center_out.pdf");
|
||||
let mk = |x: f32, y: f32, s: &str| EditOp::AddText {
|
||||
x,
|
||||
y,
|
||||
text: s.into(),
|
||||
font_size: 16.0,
|
||||
color: [0, 0, 0, 255],
|
||||
font_family: None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
};
|
||||
// Đo bề rộng thật 2 dòng để dựng fixture căn giữa quanh tâm 300.
|
||||
ff_engine::apply_edits(
|
||||
&pdf,
|
||||
&sample(),
|
||||
0,
|
||||
&[mk(10.0, 500.0, "TIÊU ĐỀ DÀI Ở DÒNG TRÊN"), mk(10.0, 460.0, "DÒNG DƯỚI NGẮN")],
|
||||
&probe,
|
||||
None,
|
||||
)
|
||||
.expect("probe");
|
||||
let objs = ff_engine::list_objects(&pdf, &probe, 0, None).expect("list probe");
|
||||
let w = |needle: &str| {
|
||||
let o = objs
|
||||
.iter()
|
||||
.find(|o| o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
|
||||
.expect(needle);
|
||||
o.rect.right - o.rect.left
|
||||
};
|
||||
let (w1, w2) = (w("DÒNG TRÊN"), w("DÒNG DƯỚI"));
|
||||
let cx = 300.0;
|
||||
ff_engine::apply_edits(
|
||||
&pdf,
|
||||
&sample(),
|
||||
0,
|
||||
&[
|
||||
mk(cx - w1 / 2.0, 500.0, "TIÊU ĐỀ DÀI Ở DÒNG TRÊN"),
|
||||
mk(cx - w2 / 2.0, 480.0, "DÒNG DƯỚI NGẮN"),
|
||||
],
|
||||
&fx,
|
||||
None,
|
||||
)
|
||||
.expect("fixture centered");
|
||||
let idxs: Vec<u16> = ff_engine::list_objects(&pdf, &fx, 0, None)
|
||||
.expect("list fx")
|
||||
.into_iter()
|
||||
.filter(|o| o.text.as_deref().map(|t| t.contains("DÒNG")).unwrap_or(false))
|
||||
.map(|o| o.index)
|
||||
.collect();
|
||||
assert_eq!(idxs.len(), 2);
|
||||
|
||||
ff_engine::apply_edits(
|
||||
&pdf,
|
||||
&fx,
|
||||
0,
|
||||
&[EditOp::ReflowText {
|
||||
indices: idxs,
|
||||
text: "TIÊU ĐỀ MỚI DÀI HƠN CHÚT\nNGẮN".into(),
|
||||
}],
|
||||
&out,
|
||||
None,
|
||||
)
|
||||
.expect("reflow centered");
|
||||
|
||||
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
|
||||
let mut checked = 0;
|
||||
for o in &after {
|
||||
let Some(t) = &o.text else { continue };
|
||||
if t.contains("TIÊU ĐỀ MỚI") || t.trim() == "NGẮN" {
|
||||
let c = (o.rect.left + o.rect.right) / 2.0;
|
||||
assert!((c - cx).abs() < 6.0, "dòng mới phải căn giữa quanh {cx}: center={c} text={t:?}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(checked, 2, "phải kiểm được 2 dòng mới");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_image_adds_image_object() {
|
||||
let pdf = pdfium();
|
||||
|
||||
Reference in New Issue
Block a user