Engine:
- EditOp::ReflowText { indices, text }: engine tự suy hình học từ các run
(lề trái/bề rộng từ bounds, baseline từ matrix.f gom cụm ±1pt, khoảng cách
dòng = median hiệu baseline); bẻ dòng greedy đo bằng hmtx của font thật
(fontmatch::wrap_lines + char_advance, ttf-parser); \n = ngắt dòng cứng,
từ quá dài cắt theo ký tự, dòng rỗng giữ nhịp baseline
- Giữ font theo thang 4 mức nhất quán với iteration 2: (1) font nhúng parse
được + phủ đủ glyph → nhúng lại chính bytes đó; (2) family base-14 + ASCII
→ font chuẩn PDF qua FPDFText_LoadStandardFont (BaseFont giữ tên chuẩn,
không nhúng, file không phình; đo bằng font metric-compatible); (3) font
hệ thống cùng họ; (4) fallback. FontLoad enum Bytes/Builtin cho pha nạp font
- Dòng mới giữ phần tuyến tính matrix của run neo (scale/nghiêng), Tf =
unscaled run neo → cỡ hiển thị không đổi
UI + Tauri:
- paragraphLines: gom đoạn nhiều dòng (baseline cách đều ±25%, cỡ chữ tương
đồng, giao ngang ≥30%); double-click đoạn ≥2 dòng → textarea sửa CẢ ĐOẠN
WYSIWYG (đúng font/cỡ/màu/kiểu + line-height đúng nhịp baseline)
- Enter = xuống dòng cứng · Ctrl+Enter/bấm ra ngoài = áp dụng · Esc = huỷ;
1 commit = 1 op reflowText = 1 nấc undo
- DTO reflowText (indices) trong Tauri
Test: 3 integration mới (wrap giữ font nhúng + tiếng Việt + baseline đều;
hard break; base-14 giữ BaseFont Helvetica chuẩn) + 3 unit wrap_lines.
Toàn bộ suite engine ngoài qpdf: 52/52 xanh.
Docs: 12 (Iteration 3 + giới hạn v1), 13 (E26–E30), 14 (mục 3.1 → đã giải,
cập nhật thứ tự việc tiếp theo), README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
779 lines
27 KiB
Rust
779 lines
27 KiB
Rust
//! 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:?}");
|
||
}
|
||
|
||
/// Dựng "đoạn văn" 3 dòng bằng font nhúng (AddText) tại x=60, baseline cách 15pt.
|
||
fn make_paragraph_fixture(pdf: &pdfium_render::prelude::Pdfium, out: &std::path::Path) {
|
||
let mk = |x: f32, y: f32, s: &str| EditOp::AddText {
|
||
x,
|
||
y,
|
||
text: s.into(),
|
||
font_size: 12.0,
|
||
color: [0, 0, 0, 255],
|
||
font_family: None,
|
||
bold: false,
|
||
italic: false,
|
||
};
|
||
ff_engine::apply_edits(
|
||
pdf,
|
||
&sample(),
|
||
0,
|
||
&[
|
||
mk(60.0, 500.0, "Dòng một của đoạn văn mẫu"),
|
||
mk(60.0, 485.0, "dòng hai nối tiếp nội dung"),
|
||
mk(60.0, 470.0, "dòng ba kết thúc đoạn."),
|
||
],
|
||
out,
|
||
None,
|
||
)
|
||
.expect("dựng đoạn 3 dòng");
|
||
}
|
||
|
||
/// Index (và ObjectInfo) các run thuộc đoạn fixture (baseline 470–500).
|
||
fn paragraph_runs(pdf: &pdfium_render::prelude::Pdfium, path: &std::path::Path) -> Vec<ff_engine::ObjectInfo> {
|
||
ff_engine::list_objects(pdf, path, 0, None)
|
||
.expect("list")
|
||
.into_iter()
|
||
.filter(|o| {
|
||
o.kind == ObjectKind::Text && o.rect.bottom > 455.0 && o.rect.bottom < 505.0 && o.rect.left > 50.0
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// REFLOW (iteration 3): text dài hơn hẳn → tự bẻ thành NHIỀU dòng hơn, mọi
|
||
/// dòng nằm trong bề rộng khối, GIỮ NGUYÊN font nhúng + baseline spacing 15pt.
|
||
#[test]
|
||
fn reflow_wraps_and_keeps_embedded_font() {
|
||
let pdf = pdfium();
|
||
let fx = tmp("ff_reflow_fx.pdf");
|
||
let out = tmp("ff_reflow_out.pdf");
|
||
make_paragraph_fixture(&pdf, &fx);
|
||
let runs = paragraph_runs(&pdf, &fx);
|
||
assert_eq!(runs.len(), 3, "fixture phải có 3 dòng");
|
||
let font_before = runs[0].font_name.clone().expect("font fixture");
|
||
let left = runs.iter().map(|r| r.rect.left).fold(f32::INFINITY, f32::min);
|
||
let right = runs.iter().map(|r| r.rect.right).fold(f32::NEG_INFINITY, f32::max);
|
||
let width = right - left;
|
||
|
||
let long_text = "Nội dung hoàn toàn mới và dài hơn hẳn bản gốc, đủ nhiều từ tiếng Việt \
|
||
để buộc thuật toán reflow phải bẻ lại thành nhiều dòng khác nhau trong đúng bề rộng \
|
||
của khối đoạn văn ban đầu, giữ nguyên phông chữ nhúng và khoảng cách dòng.";
|
||
ff_engine::apply_edits(
|
||
&pdf,
|
||
&fx,
|
||
0,
|
||
&[EditOp::ReflowText { indices: runs.iter().map(|r| r.index).collect(), text: long_text.into() }],
|
||
&out,
|
||
None,
|
||
)
|
||
.expect("reflow");
|
||
|
||
// Run mới = mọi text object chứa mảnh của text dài (reflow kéo dài xuống dưới
|
||
// ngoài cửa sổ baseline của fixture nên không dùng paragraph_runs).
|
||
let new_runs: Vec<_> = ff_engine::list_objects(&pdf, &out, 0, None)
|
||
.expect("list out")
|
||
.into_iter()
|
||
.filter(|o| {
|
||
o.kind == ObjectKind::Text
|
||
&& o.text.as_deref().map(|t| long_text.contains(t.trim()) && !t.trim().is_empty()).unwrap_or(false)
|
||
})
|
||
.collect();
|
||
assert!(new_runs.len() > 3, "text dài phải bẻ thành >3 dòng, được {}", new_runs.len());
|
||
for r in &new_runs {
|
||
assert_eq!(r.font_name.as_deref(), Some(font_before.as_str()), "reflow phải giữ font nhúng");
|
||
assert!(r.rect.left >= left - 2.0, "dòng không được tràn trái");
|
||
assert!(r.rect.right <= left + width + width * 0.05 + 3.0, "dòng không được tràn phải: right={} limit={}", r.rect.right, left + width);
|
||
}
|
||
// Baseline cách đều 15pt.
|
||
let mut bottoms: Vec<f32> = new_runs.iter().map(|r| r.rect.bottom).collect();
|
||
bottoms.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||
for w in bottoms.windows(2) {
|
||
let d = w[0] - w[1];
|
||
assert!((d - 15.0).abs() < 2.0, "khoảng baseline phải ~15pt, được {d}");
|
||
}
|
||
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||
let norm = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
assert!(norm.contains("thuật toán reflow"), "nội dung round-trip: {norm:?}");
|
||
assert!(!norm.contains("Dòng một của đoạn"), "text cũ phải biến mất");
|
||
}
|
||
|
||
/// `\n` trong text reflow = ngắt dòng cứng (đoạn mới).
|
||
#[test]
|
||
fn reflow_hard_break_creates_new_line() {
|
||
let pdf = pdfium();
|
||
let fx = tmp("ff_reflow_hb_fx.pdf");
|
||
let out = tmp("ff_reflow_hb_out.pdf");
|
||
make_paragraph_fixture(&pdf, &fx);
|
||
let runs = paragraph_runs(&pdf, &fx);
|
||
|
||
ff_engine::apply_edits(
|
||
&pdf,
|
||
&fx,
|
||
0,
|
||
&[EditOp::ReflowText {
|
||
indices: runs.iter().map(|r| r.index).collect(),
|
||
text: "Đoạn một ngắn.\nĐoạn hai riêng.".into(),
|
||
}],
|
||
&out,
|
||
None,
|
||
)
|
||
.expect("reflow hard break");
|
||
|
||
let new_runs: Vec<_> = ff_engine::list_objects(&pdf, &out, 0, None)
|
||
.expect("list out")
|
||
.into_iter()
|
||
.filter(|o| o.text.as_deref().map(|t| t.contains("Đoạn")).unwrap_or(false))
|
||
.collect();
|
||
assert_eq!(new_runs.len(), 2, "2 đoạn = 2 dòng riêng");
|
||
let d = (new_runs[0].rect.bottom - new_runs[1].rect.bottom).abs();
|
||
assert!((d - 15.0).abs() < 2.0, "2 dòng cách đúng nhịp baseline: {d}");
|
||
}
|
||
|
||
/// Reflow trên font base-14 (Helvetica, không nhúng) + text ASCII: các dòng
|
||
/// mới dùng font CHUẨN PDF — BaseFont vẫn là Helvetica, file không phình.
|
||
#[test]
|
||
fn reflow_base14_ascii_keeps_standard_font() {
|
||
let pdf = pdfium();
|
||
let input = sample();
|
||
let out = tmp("ff_reflow_b14.pdf");
|
||
let idx = find_text_index(&pdf, &input, "Page one");
|
||
|
||
let long_ascii = "This replacement paragraph is intentionally much longer than the \
|
||
original single line so the reflow engine must wrap it into several lines while keeping \
|
||
the declared standard Helvetica base font untouched.";
|
||
ff_engine::apply_edits(
|
||
&pdf,
|
||
&input,
|
||
0,
|
||
&[EditOp::ReflowText { indices: vec![idx], text: long_ascii.into() }],
|
||
&out,
|
||
None,
|
||
)
|
||
.expect("reflow base14");
|
||
|
||
let new_runs: Vec<_> = ff_engine::list_objects(&pdf, &out, 0, None)
|
||
.expect("list out")
|
||
.into_iter()
|
||
.filter(|o| o.text.as_deref().map(|t| long_ascii.contains(t.trim()) && !t.trim().is_empty()).unwrap_or(false))
|
||
.collect();
|
||
assert!(new_runs.len() >= 2, "text dài phải bẻ ≥2 dòng, được {}", new_runs.len());
|
||
for r in &new_runs {
|
||
let f = r.font_name.clone().unwrap_or_default();
|
||
assert!(f.contains("Helvetica"), "BaseFont phải giữ Helvetica chuẩn, được {f:?}");
|
||
}
|
||
// Extract chèn ngắt dòng tại điểm wrap → chuẩn hoá khoảng trắng trước khi so.
|
||
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||
let norm = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
assert!(norm.contains("standard Helvetica base font untouched"), "round-trip: {norm:?}");
|
||
}
|
||
|
||
#[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");
|
||
}
|