Files
FoFreeXit/crates/ff-engine/tests/edit_roundtrip.rs
T
adminandClaude Fable 5 1285a8d6ae Phase 4 Iteration 2: sửa text GIỮ NGUYÊN FONT (chuẩn Foxit) + UX edit mượt
Engine:
- fontmatch.rs mới: làm sạch tên PostScript/subset, kiểm coverage cmap bằng
  ttf-parser, match font hệ thống CÙNG HỌ (bảng Windows/macOS, Liberation
  metric-compatible + fc-match trên Linux, alias base-14 Helvetica→Arial...)
- edit.rs: SetText quyết định 3 tầng — (1) sửa TẠI CHỖ giữ nguyên font gốc
  (charset-subset / base-14+ASCII / cmap phủ đủ, gồm font nhúng + tiếng Việt);
  (2) thiếu glyph → thay font cùng họ đúng đậm/nghiêng; (3) mới đến fallback.
  Đổi cỡ chữ = scale matrix neo baseline, không đụng font; fix bug phóng đại
  kép khi text có matrix scale. ObjectInfo thêm font_family/bold/italic/embedded.
  SetText/AddText nhận font_family + bold/italic dạng Option (None = giữ nguyên)
- annot.rs: fallback Linux có đủ biến thể đậm/nghiêng (DejaVu/Liberation)

UI (chuẩn trải nghiệm Foxit):
- Double-click sửa CẢ DÒNG (gộp run cùng baseline), commit 1 batch = 1 undo
- Ô sửa WYSIWYG: đúng font/cỡ/màu/đậm-nghiêng của run gốc khi gõ
- Toolbar Format: dropdown font "(giữ nguyên: X)", nút B/I theo kiểu thật,
  đổi cỡ/màu/kiểu gửi đúng field đó (null = giữ nguyên → không đổi font)
- Kéo di chuyển/resize có phản hồi trực tiếp (live), resize neo góc trên-trái
- Dọn file tạm ff_edit_* khi thoát/lưu (command edit_cleanup)

Test: 14 test edit (7 mới: giữ font ASCII/base-14, giữ font NHÚNG tiếng Việt,
match cùng họ, cỡ chữ neo + hồi quy matrix, bold override, gộp dòng) + 3 unit
fontmatch. Toàn bộ suite engine ngoài qpdf: 50/50 xanh (chạy trên Linux).

Docs: 12 (iteration 2), 13 (E17–E25), 14 MỚI — phân tích định hướng/công nghệ/
tiến độ so với tham vọng thay Foxit, gap còn lại + giải pháp; README cập nhật.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
2026-07-14 16:58:22 +00:00

614 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Round-trip test cho chỉnh sửa nội dung (Phase 4 — moat chính): liệt kê
//! object, sửa text (gồm tiếng Việt), xoá, di chuyển, thêm chữ/ảnh. Đường GHI
//! file rủi ro nhất nên thao tác rồi đọc lại để so khớp.
//!
//! Lưu ý bẫy mutex PDFium (đã ghi memory): mọi assert chạy SAU khi ff_engine
//! đã trả về (doc đã drop), nên panic assert không poison mutex toàn cục.
use std::path::PathBuf;
use ff_engine::{EditOp, ObjectKind};
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
fn sample() -> PathBuf {
workspace_root().join("corpus").join("sample-multipage.pdf")
}
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&p);
p
}
/// Index của text object đầu tiên trên trang 0 có nội dung chứa `needle`.
fn find_text_index(pdf: &pdfium_render::prelude::Pdfium, input: &std::path::Path, needle: &str) -> u16 {
let objs = ff_engine::list_objects(pdf, input, 0, None).expect("list_objects");
objs.into_iter()
.find(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
.unwrap_or_else(|| panic!("không tìm thấy text object chứa {needle:?}"))
.index
}
#[test]
fn list_objects_finds_page_text() {
let pdf = pdfium();
let objs = ff_engine::list_objects(&pdf, &sample(), 0, None).expect("list_objects");
assert!(!objs.is_empty(), "trang 0 phải có object");
let has_text = objs
.iter()
.any(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains("Page one") || t.contains("FoFreeXit")).unwrap_or(false));
assert!(has_text, "phải thấy text object của trang 1: {objs:?}");
}
#[test]
fn set_text_replaces_run_content() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_settext.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Edited line ABC".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
)
.expect("apply_edits SetText");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Edited line ABC"), "phải có text mới: {text:?}");
assert!(!text.contains("Page one content alpha"), "text cũ phải biến mất: {text:?}");
}
#[test]
fn set_text_vietnamese_round_trips() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_vi.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let vi = "Sửa: nội dung Tiếng Việt";
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: vi.into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
)
.expect("apply_edits SetText VI");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Tiếng Việt"), "tiếng Việt phải đúng dấu: {text:?}");
}
#[test]
fn delete_object_reduces_count() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_delete.pdf");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list").len();
assert!(before >= 1);
ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: 0 }], &out, None)
.expect("apply_edits Delete");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out").len();
assert_eq!(after, before - 1, "xoá 1 object phải giảm đúng 1");
}
#[test]
fn transform_translate_moves_object() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_move.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
let orig_left = before.iter().find(|o| o.index == idx).expect("obj").rect.left;
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::Transform { index: idx, dx: 50.0, dy: 0.0, sx: 1.0, sy: 1.0 }],
&out,
None,
)
.expect("apply_edits Transform");
// Object đã dịch giữ nguyên thứ tự index (không xoá/thêm) → so cùng index.
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let new_left = after.iter().find(|o| o.index == idx).expect("obj out").rect.left;
assert!((new_left - (orig_left + 50.0)).abs() < 2.0, "left phải dịch ~+50: {orig_left} -> {new_left}");
}
#[test]
fn add_text_appears_in_extract() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_addtext.pdf");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddText {
x: 100.0,
y: 100.0,
text: "ADDEDXYZ".into(),
font_size: 14.0,
color: [10, 20, 30, 255],
font_family: None,
bold: false,
italic: false,
}],
&out,
None,
)
.expect("apply_edits AddText");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("ADDEDXYZ"), "text thêm mới phải xuất hiện: {text:?}");
}
/// CHUẨN FOXIT (iteration 2): sửa text ASCII trên font base-14 (Helvetica,
/// không nhúng) phải GIỮ NGUYÊN BaseFont — không đổi font. Text mới cố ý dùng
/// ký tự ngoài text cũ để không "ăn may" qua luật charset-subset.
#[test]
fn set_text_keeps_original_font() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_keepfont.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
let font_before = before
.iter()
.find(|o| o.index == idx)
.and_then(|o| o.font_name.clone())
.expect("font gốc");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Fixed by editor 2026 JQXZ!".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
)
.expect("apply_edits SetText");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let edited = after
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("JQXZ")).unwrap_or(false))
.expect("run đã sửa");
assert_eq!(
edited.font_name.as_deref(),
Some(font_before.as_str()),
"sửa text ASCII phải GIỮ NGUYÊN font (chuẩn Foxit)"
);
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Fixed by editor"), "text mới phải có mặt: {text:?}");
}
/// Tiếng Việt trên font base-14 (KHÔNG có glyph Việt ở bất kỳ đâu để giữ):
/// phải thay bằng font hệ thống CÙNG HỌ metric-compatible (Helvetica→Arial/
/// LiberationSans), tuyệt đối không rơi bừa về font generic.
#[test]
fn vietnamese_on_base14_uses_matched_family() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_vnmatch.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Thay thế hoàn chỉnh".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
)
.expect("apply_edits VI");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let edited = after
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("hoàn chỉnh")).unwrap_or(false))
.expect("run đã sửa");
let font = edited.font_name.clone().unwrap_or_default();
#[cfg(not(any(windows, target_os = "macos")))]
assert!(
font.contains("Liberation"),
"Helvetica+VI phải match LiberationSans (metric-compatible), được {font:?}"
);
#[cfg(windows)]
assert!(font.to_lowercase().contains("arial"), "Helvetica+VI phải match Arial, được {font:?}");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("hoàn chỉnh"), "tiếng Việt đúng dấu: {text:?}");
}
/// Case quan trọng nhất với tài liệu Việt thực tế: font NHÚNG đầy đủ glyph →
/// sửa sang nội dung Việt hoàn toàn khác phải GIỮ NGUYÊN font nhúng (sửa tại
/// chỗ, không tạo lại bằng font khác).
#[test]
fn set_text_preserves_embedded_font_vietnamese() {
let pdf = pdfium();
let input = sample();
let step1 = tmp("ff_edit_emb1.pdf");
let step2 = tmp("ff_edit_emb2.pdf");
// B1: tạo run với font nhúng FULL (AddText nhúng nguyên font hệ thống).
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddText {
x: 60.0,
y: 300.0,
text: "Chào FoFreeXit".into(),
font_size: 18.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
}],
&step1,
None,
)
.expect("add embedded run");
let idx = find_text_index(&pdf, &step1, "Chào");
let mid = ff_engine::list_objects(&pdf, &step1, 0, None).expect("list mid");
let run = mid.iter().find(|o| o.index == idx).expect("run");
assert_eq!(run.font_embedded, Some(true), "fixture phải là font nhúng");
let font_before = run.font_name.clone().expect("tên font nhúng");
// B2: sửa sang câu Việt khác hẳn → phải giữ nguyên font nhúng.
ff_engine::apply_edits(
&pdf,
&step1,
0,
&[EditOp::SetText {
index: idx,
text: "Đã kiểm định — sửa giữ font nhúng".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
}],
&step2,
None,
)
.expect("edit embedded run");
let after = ff_engine::list_objects(&pdf, &step2, 0, None).expect("list out");
let edited = after
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("kiểm định")).unwrap_or(false))
.expect("run đã sửa");
assert_eq!(
edited.font_name.as_deref(),
Some(font_before.as_str()),
"font NHÚNG phải được giữ nguyên khi sửa tiếng Việt"
);
let text = ff_engine::extract_text(&pdf, &step2, 0, None).expect("extract");
assert!(text.contains("sửa giữ font nhúng"), "text round-trip: {text:?}");
}
/// Đổi CỠ CHỮ (không đổi nội dung) phải giữ nguyên font + text, cỡ mới đúng
/// theo nghĩa hiển thị và vị trí neo (left) không trôi.
#[test]
fn font_size_change_keeps_font_and_anchors() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_size.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
let orig = before.iter().find(|o| o.index == idx).expect("obj").clone();
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: orig.text.clone().unwrap_or_default(),
font_size: Some(30.0),
color: None,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
)
.expect("apply_edits size");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let got = after.iter().find(|o| o.index == idx).expect("obj out");
assert_eq!(got.font_name, orig.font_name, "đổi cỡ không được đổi font");
assert_eq!(got.text, orig.text, "đổi cỡ không được đổi nội dung");
let sz = got.font_size.expect("size");
assert!((sz - 30.0).abs() < 0.5, "cỡ hiển thị phải ≈30, được {sz}");
assert!(
(got.rect.left - orig.rect.left).abs() < 2.0,
"điểm neo trái không được trôi: {} -> {}",
orig.rect.left,
got.rect.left
);
}
/// Hồi quy bug phóng đại kép: text có matrix scale (Tf nhỏ × matrix lớn) —
/// đặt cỡ hiển thị 20pt phải ra đúng ~20pt, không nhân đôi theo matrix.
#[test]
fn font_size_change_respects_matrix_scale() {
let pdf = pdfium();
let input = sample();
let step1 = tmp("ff_edit_mtx1.pdf");
let step2 = tmp("ff_edit_mtx2.pdf");
let step3 = tmp("ff_edit_mtx3.pdf");
// Tạo run 16pt rồi scale ×2 qua Transform → cỡ hiển thị 32pt, matrix scale 2.
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddText {
x: 80.0,
y: 200.0,
text: "MATRIXCASE".into(),
font_size: 16.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
}],
&step1,
None,
)
.expect("add");
let idx = find_text_index(&pdf, &step1, "MATRIXCASE");
ff_engine::apply_edits(
&pdf,
&step1,
0,
&[EditOp::Transform { index: idx, dx: 0.0, dy: 0.0, sx: 2.0, sy: 2.0 }],
&step2,
None,
)
.expect("scale");
let mid = ff_engine::list_objects(&pdf, &step2, 0, None).expect("list mid");
let scaled = mid.iter().find(|o| o.index == idx).and_then(|o| o.font_size).expect("size mid");
assert!((scaled - 32.0).abs() < 1.0, "sau scale ×2 phải ~32pt, được {scaled}");
// Đặt cỡ hiển thị 20 — phải ra ~20 (bug cũ: tạo Tf=20 rồi nhân matrix 2 → 40).
ff_engine::apply_edits(
&pdf,
&step2,
0,
&[EditOp::SetText {
index: idx,
text: "MATRIXCASE".into(),
font_size: Some(20.0),
color: None,
font_family: None,
bold: None,
italic: None,
}],
&step3,
None,
)
.expect("resize");
let fin = ff_engine::list_objects(&pdf, &step3, 0, None).expect("list fin");
let got = fin.iter().find(|o| o.index == idx).and_then(|o| o.font_size).expect("size fin");
assert!((got - 20.0).abs() < 0.5, "cỡ hiển thị phải ≈20, được {got}");
}
/// Ép ĐẬM qua override: font phải đổi sang biến thể đậm (khác font gốc) nhưng
/// nội dung (kể cả tiếng Việt) round-trip nguyên vẹn.
#[test]
fn bold_override_substitutes_font_and_keeps_text() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_bold.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
let font_before = before
.iter()
.find(|o| o.index == idx)
.and_then(|o| o.font_name.clone())
.expect("font gốc");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Chữ đậm kiểm thử".into(),
font_size: None,
color: None,
font_family: None,
bold: Some(true),
italic: None,
}],
&out,
None,
)
.expect("apply_edits bold");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let edited = after
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("đậm")).unwrap_or(false))
.expect("run đã sửa");
assert_ne!(
edited.font_name.as_deref(),
Some(font_before.as_str()),
"ép đậm phải chuyển sang biến thể font khác"
);
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Chữ đậm kiểm thử"), "text round-trip: {text:?}");
}
/// Luồng UI "sửa cả dòng": 1 batch = SetText(run đầu, text gộp) + Delete(các
/// run còn lại). Chốt hành vi: text mới có mặt, các run kia biến mất, tổng
/// object giảm đúng số run bị gộp.
#[test]
fn line_merge_batch_set_text_plus_delete() {
let pdf = pdfium();
let input = sample();
let step1 = tmp("ff_edit_line1.pdf");
let step2 = tmp("ff_edit_line2.pdf");
// Dựng "1 dòng bị cắt làm 2 run" bằng 2 AddText cạnh nhau.
ff_engine::apply_edits(
&pdf,
&input,
0,
&[
EditOp::AddText {
x: 60.0,
y: 260.0,
text: "Nửa đầu".into(),
font_size: 14.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
},
EditOp::AddText {
x: 130.0,
y: 260.0,
text: "nửa sau".into(),
font_size: 14.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
},
],
&step1,
None,
)
.expect("dựng 2 run");
let objs = ff_engine::list_objects(&pdf, &step1, 0, None).expect("list");
let first = find_text_index(&pdf, &step1, "Nửa đầu");
let second = find_text_index(&pdf, &step1, "nửa sau");
let count_before = objs.len();
ff_engine::apply_edits(
&pdf,
&step1,
0,
&[
EditOp::SetText {
index: first,
text: "Cả dòng đã gộp và sửa".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
},
EditOp::Delete { index: second },
],
&step2,
None,
)
.expect("batch gộp dòng");
let after = ff_engine::list_objects(&pdf, &step2, 0, None).expect("list out");
assert_eq!(after.len(), count_before - 1, "gộp 2 run còn 1 → tổng giảm 1");
let text = ff_engine::extract_text(&pdf, &step2, 0, None).expect("extract");
assert!(text.contains("Cả dòng đã gộp và sửa"), "text gộp phải có: {text:?}");
assert!(!text.contains("nửa sau"), "run bị gộp phải biến mất: {text:?}");
}
#[test]
fn add_image_adds_image_object() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_addimg.pdf");
// Tạo 1 PNG đỏ 24x24 làm fixture.
let png = tmp("ff_edit_fixture.png");
let mut img = image::RgbImage::new(24, 24);
for p in img.pixels_mut() {
*p = image::Rgb([220, 30, 30]);
}
img.save(&png).expect("save png");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list").len();
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddImage {
x: 50.0,
y: 50.0,
width_pt: 80.0,
height_pt: 60.0,
image_path: png.to_string_lossy().into_owned(),
}],
&out,
None,
)
.expect("apply_edits AddImage");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
assert_eq!(after.len(), before + 1, "thêm ảnh phải tăng 1 object");
assert!(after.iter().any(|o| o.kind == ObjectKind::Image), "phải có object kind Image");
}