Sửa được text nằm trong Form XObject (file Canva/Illustrator) + CI chạy test
Build Windows portable / build (push) Canceled after 0s

File Canva gói toàn bộ nội dung trang vào 1 Form XObject nên chế độ sửa
không thấy gì để sửa. Engine giờ liệt kê PHẲNG đệ quy vào form
(FPDFFormObj_CountObjects/GetObject qua pdfium-render), mỗi mục có path
[i,j,...] + tích ma trận form để quy toạ độ/cỡ chữ về trang; index của
ObjectInfo/EditOp = vị trí trong danh sách phẳng (trang không form thì
trùng index trang — tương thích ngược, UI không đổi cách gửi op).

- SetText in-place/substitute + ReflowText + Delete hoạt động với run trong
  form; xoá trong form = set_text chuỗi rỗng (FPDFFormObj_RemoveObject chưa
  mở ở wrapper cho pdfium_7543), object mới vẽ ở cấp trang với matrix đã
  compose.
- Đã xác minh trước: PDFium regenerate stream form khi con bị sửa
  (ProcessForm / HasDirtyStreams) — canary test round-trip kèm theo.
- ObjectInfo thêm nested/expanded; UI bỏ khung form phủ trang + chặn kéo
  run trong form (Transform chưa hỗ trợ).
- 4 test integration mới với fixture Form XObject tự dựng bằng lopdf
  (dev-dependency mới); CI thêm bước cargo test -p ff-engine trước build
  (máy dev không có Rust — CI là nơi duy nhất chạy test), cache thêm target/.
Chi tiết: docs/12-phase4-summary.md (Iteration 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 01:41:21 +07:00
co-authored by Claude Fable 5
parent 68883b9e75
commit e66252681c
8 changed files with 523 additions and 62 deletions
+4
View File
@@ -25,3 +25,7 @@ der = "0.7"
spki = "0.7"
signature = "2"
const-oid = { version = "0.9", features = ["db"] }
[dev-dependencies]
# Dựng PDF fixture (Form XObject...) trong integration test.
lopdf = "0.36"
+270 -58
View File
@@ -72,11 +72,19 @@ impl ObjectKind {
/// Thông tin 1 page object trả về cho UI (để vẽ overlay & sửa).
#[derive(Clone, Debug)]
pub struct ObjectInfo {
/// Index trong danh sách object của trang (0-based, theo thứ tự vẽ/z-order).
/// Vị trí trong DANH SÁCH PHẲNG object của trang (0-based, thứ tự vẽ,
/// gồm cả object nằm trong Form XObject). Trang không có form → trùng
/// index trang như cũ. EditOp cũng đánh địa chỉ theo index này.
pub index: u16,
pub kind: ObjectKind,
/// Khung bao (AABB) theo điểm PDF.
/// Khung bao (AABB) theo điểm PDF — object trong form đã quy về TRANG.
pub rect: Rect,
/// Object nằm BÊN TRONG một Form XObject (file Canva/Illustrator...).
/// Sửa text được (in-place / reflow); di chuyển/resize chưa hỗ trợ.
pub nested: bool,
/// Chỉ với kind = XObjectForm: các object con đã được liệt kê riêng
/// (UI bỏ khung của form để khỏi che con).
pub expanded: bool,
/// Chỉ với text: nội dung hiện tại.
pub text: Option<String>,
/// Chỉ với text: tên font gốc trong PDF (có thể kèm prefix subset).
@@ -146,6 +154,150 @@ fn quad_to_rect(q: &PdfQuadPoints) -> Rect {
}
}
// ---- Object trong Form XObject (Phase 4 iteration 4) ----
//
// File Canva/Illustrator/InDesign hay GÓI toàn bộ nội dung trang vào 1 Form
// XObject — trang chỉ có 1 object "form", chữ nằm bên trong. Để sửa được:
// duyệt PHẲNG đệ quy vào form (FPDFFormObj_CountObjects/GetObject qua
// pdfium-render), mỗi mục có "path" ([i] = cấp trang, [i,j,...] = con trong
// form) + tích ma trận form tổ tiên để quy toạ độ về trang. `index` trong
// ObjectInfo/EditOp = vị trí trong danh sách phẳng này — trang không có form
// thì trùng index trang như cũ (tương thích ngược).
/// Ma trận PDF (a,b,c,d,e,f) dạng giá trị thuần — quy ước hàng: p' = p·M.
type Mat6 = (f32, f32, f32, f32, f32, f32);
const MAT_ID: Mat6 = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
fn mat_of(m: &PdfMatrix) -> Mat6 {
(m.a(), m.b(), m.c(), m.d(), m.e(), m.f())
}
/// K = M rồi N (p·K = (p·M)·N).
fn mat_mul(m: Mat6, n: Mat6) -> Mat6 {
(
m.0 * n.0 + m.1 * n.2,
m.0 * n.1 + m.1 * n.3,
m.2 * n.0 + m.3 * n.2,
m.2 * n.1 + m.3 * n.3,
m.4 * n.0 + m.5 * n.2 + n.4,
m.4 * n.1 + m.5 * n.3 + n.5,
)
}
fn mat_apply(m: Mat6, x: f32, y: f32) -> (f32, f32) {
(m.0 * x + m.2 * y + m.4, m.1 * x + m.3 * y + m.5)
}
/// AABB của rect sau biến đổi (4 góc → bao).
fn mat_rect(m: Mat6, r: &Rect) -> Rect {
let pts = [
mat_apply(m, r.left, r.bottom),
mat_apply(m, r.right, r.bottom),
mat_apply(m, r.left, r.top),
mat_apply(m, r.right, r.top),
];
Rect {
left: pts.iter().map(|p| p.0).fold(f32::INFINITY, f32::min),
bottom: pts.iter().map(|p| p.1).fold(f32::INFINITY, f32::min),
right: pts.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max),
top: pts.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max),
}
}
/// Hệ số scale DỌC của ma trận (độ dài ảnh của vector đơn vị Y) — dùng quy
/// đổi cỡ chữ hiển thị của object trong form về không gian trang.
fn mat_vscale(m: Mat6) -> f32 {
(m.2 * m.2 + m.3 * m.3).sqrt().max(0.01)
}
/// 1 mục trong danh sách phẳng object của trang (gồm cả con trong form).
struct FlatEntry {
/// Địa chỉ: [i] = object cấp trang; [i, j, ...] = con lồng trong form.
path: Vec<u16>,
/// Tích ma trận các form tổ tiên (đưa toạ độ trong form về trang).
acc: Mat6,
kind: ObjectKind,
}
/// Chặn duyệt form quá sâu/quá nhiều (file lỗi/đệ quy).
const FORM_WALK_MAX_DEPTH: usize = 3;
const FORM_WALK_MAX_ENTRIES: usize = 4000;
/// Danh sách phẳng object của trang theo thứ tự vẽ, đi sâu vào Form XObject.
/// Thứ tự này là HỢP ĐỒNG giữa `list_objects` và `apply_edits` — index trong
/// EditOp tra vào đúng danh sách này.
fn collect_flat(page: &PdfPage<'_>) -> Vec<FlatEntry> {
fn walk_form(
form: &PdfPageXObjectFormObject<'_>,
base: &[u16],
acc: Mat6,
depth: usize,
out: &mut Vec<FlatEntry>,
) {
for j in 0..form.len() {
if out.len() >= FORM_WALK_MAX_ENTRIES {
return;
}
let Ok(child) = form.get(j) else { continue };
let mut path = base.to_vec();
path.push(j as u16);
out.push(FlatEntry {
path: path.clone(),
acc,
kind: ObjectKind::from_pdfium(child.object_type()),
});
if depth < FORM_WALK_MAX_DEPTH {
if let PdfPageObject::XObjectForm(f2) = &child {
let m = child.matrix().map(|m| mat_of(&m)).unwrap_or(MAT_ID);
walk_form(f2, &path, mat_mul(m, acc), depth + 1, out);
}
}
}
}
let mut out = Vec::new();
let n = page.objects().len();
for i in 0..n {
if out.len() >= FORM_WALK_MAX_ENTRIES {
break;
}
let Ok(obj) = page.objects().get(i) else { continue };
let path = vec![i as u16];
out.push(FlatEntry {
path: path.clone(),
acc: MAT_ID,
kind: ObjectKind::from_pdfium(obj.object_type()),
});
if let PdfPageObject::XObjectForm(f) = &obj {
let m = obj.matrix().map(|m| mat_of(&m)).unwrap_or(MAT_ID);
walk_form(f, &path, m, 1, &mut out);
}
}
out
}
/// Lấy object theo path (đi sâu vào form theo từng nấc).
fn object_at_path<'a>(page: &PdfPage<'a>, path: &[u16]) -> Result<PdfPageObject<'a>, EngineError> {
let err = |e: PdfiumError| EngineError::Pdfium(format!("lấy object theo path: {e}"));
let mut obj = page
.objects()
.get(path[0] as usize)
.map_err(err)?;
for &j in &path[1..] {
let child = match &obj {
PdfPageObject::XObjectForm(f) => f.get(j as usize).map_err(err)?,
_ => {
return Err(EngineError::Pdfium(
"path object lồng không trỏ vào form".into(),
))
}
};
obj = child;
}
Ok(obj)
}
/// Kiểu chữ (đậm/nghiêng) của 1 text object, tổng hợp từ weight, italic-angle
/// và tên font. Family lấy từ `name()` (BaseFont khai báo trong PDF) — với
/// font KHÔNG nhúng, `family()` trả tên font stub nội bộ của PDFium ("Chrom
@@ -197,21 +349,27 @@ pub fn list_objects(
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
// Danh sách PHẲNG (đi sâu vào Form XObject) — index = vị trí trong list.
let entries = collect_flat(&page);
// Form nào có con được liệt kê → expanded (UI bỏ khung của form đó).
let mut parents_with_children: HashSet<Vec<u16>> = HashSet::new();
for e in &entries {
if e.path.len() > 1 {
parents_with_children.insert(e.path[..e.path.len() - 1].to_vec());
}
}
let mut out = Vec::new();
// Cache kiểu chữ theo tên font: fallback đậm/nghiêng phải đọc bytes font
// nhúng — file Word xuất hay có 200+ run chung vài font, không đọc lại.
let mut style_cache: HashMap<String, (String, bool, bool)> = HashMap::new();
for (i, object) in page.objects().iter().enumerate() {
let kind = ObjectKind::from_pdfium(object.object_type());
let rect = object
.bounds()
.map(|q| quad_to_rect(&q))
.unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
for (flat, entry) in entries.iter().enumerate() {
let mut info = ObjectInfo {
index: i as u16,
kind,
rect,
index: flat as u16,
kind: entry.kind,
rect: Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 },
nested: entry.path.len() > 1,
expanded: parents_with_children.contains(&entry.path),
text: None,
font_name: None,
font_family: None,
@@ -221,6 +379,14 @@ pub fn list_objects(
font_size: None,
color: None,
};
// Không resolve được vẫn giữ 1 chỗ trong list — index phải khớp vị trí.
let Ok(object) = object_at_path(&page, &entry.path) else {
out.push(info);
continue;
};
if let Ok(q) = object.bounds() {
info.rect = mat_rect(entry.acc, &quad_to_rect(&q));
}
if let Some(t) = object.as_text_object() {
let raw_name = t.font().name();
// Tên rỗng không cache được (nhiều font khác nhau cùng key "").
@@ -242,7 +408,8 @@ pub fn list_objects(
info.font_bold = Some(bold);
info.font_italic = Some(italic);
info.font_embedded = t.font().is_embedded().ok();
info.font_size = Some(t.scaled_font_size().value);
// Cỡ hiển thị = cỡ đã scale của run × scale dọc của form chứa nó.
info.font_size = Some(t.scaled_font_size().value * mat_vscale(entry.acc));
info.color = t
.fill_color()
.ok()
@@ -433,6 +600,10 @@ pub fn apply_edits(
let mut add_text_keys: HashMap<usize, FontKey> = HashMap::new();
let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new();
let mut font_needed: HashMap<FontKey, FontLoad> = HashMap::new();
// Danh sách PHẲNG object (gồm cả con trong Form XObject) — index của mọi
// op tra vào đây; dùng chung cho cả pha (A) lẫn (C). Probe font ở pha A
// chỉ thêm+xoá object nháp ở CUỐI trang nên không làm lệch index.
let entries: Vec<FlatEntry>;
{
let mut page = document
.pages()
@@ -440,8 +611,8 @@ pub fn apply_edits(
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
// Pha A chỉ đọc + probe font (tạo/xoá object nháp) — không đụng content.
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
let obj_count = page.objects().len();
let valid = |i: u16| (i as usize) < obj_count;
entries = collect_flat(&page);
let valid = |i: u16| (i as usize) < entries.len();
for (opi, op) in ops.iter().enumerate() {
match op {
@@ -449,7 +620,7 @@ pub fn apply_edits(
if !valid(*index) {
continue;
}
let obj = page.objects().get(*index as usize).map_err(err)?;
let obj = object_at_path(&page, &entries[*index as usize].path)?;
let t = match obj.as_text_object() {
Some(t) => t,
None => continue, // không phải text → bỏ qua op
@@ -530,21 +701,27 @@ pub fn apply_edits(
tf: f32,
scaled: f32,
}
// Mọi toạ độ/cỡ quy về KHÔNG GIAN TRANG (run trong form
// nhân thêm ma trận form) — layout khối tính đúng chỗ.
let geo_of = |page: &PdfPage<'_>, i: u16| -> Result<Option<RunGeo>, EngineError> {
let obj = page.objects().get(i as usize).map_err(err)?;
let entry = &entries[i as usize];
let obj = object_at_path(page, &entry.path)?;
let Some(t) = obj.as_text_object() else {
return Ok(None);
};
let vs = mat_vscale(entry.acc);
let (tf, scaled) =
(t.unscaled_font_size().value, t.scaled_font_size().value);
(t.unscaled_font_size().value, t.scaled_font_size().value * vs);
let m = obj.matrix().map_err(err)?;
let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect {
let (_, fy) = mat_apply(entry.acc, m.e(), m.f());
let b_raw = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect {
left: m.e(),
bottom: m.f(),
right: m.e(),
top: m.f(),
});
Ok(Some(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right, tf, scaled }))
let b = mat_rect(entry.acc, &b_raw);
Ok(Some(RunGeo { idx: i, f: fy, left: b.left, right: b.right, tf, scaled }))
};
let mut geos: Vec<RunGeo> = Vec::new();
for &i in &idxs {
@@ -562,6 +739,15 @@ pub fn apply_edits(
// 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.
{
// Rect trang của 1 entry text (None nếu không phải text).
let text_rect = |i: u16| -> Option<Rect> {
let entry = &entries[i as usize];
if entry.kind != ObjectKind::Text {
return None;
}
let obj = object_at_path(&page, &entry.path).ok()?;
obj.bounds().ok().map(|q| mat_rect(entry.acc, &quad_to_rect(&q)))
};
let mut bb = Rect {
left: f32::INFINITY,
bottom: f32::INFINITY,
@@ -569,12 +755,7 @@ pub fn apply_edits(
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);
if let Some(r) = text_rect(i) {
bb.left = bb.left.min(r.left);
bb.bottom = bb.bottom.min(r.bottom);
bb.right = bb.right.max(r.right);
@@ -582,21 +763,12 @@ pub fn apply_edits(
}
}
let pad = 2.0;
for i in 0..obj_count as u16 {
for i in 0..entries.len() 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,
};
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
&& cx <= bb.right + pad
&& cy >= bb.bottom - pad
@@ -621,19 +793,24 @@ pub fn apply_edits(
.map(|g| g.idx)
.unwrap_or(idxs[0]);
// Chụp mọi thuộc tính của run neo trong 1 block để nhả mượn
// trang (probe font phía dưới cần &mut page).
let (family, bold, italic, m, unscaled, scaled, color, font_embedded, orig_token, font_data) = {
let anchor = page.objects().get(anchor_idx as usize).map_err(err)?;
// trang (probe font phía dưới cần &mut page). Ma trận/cỡ
// COMPOSE với ma trận form (run neo có thể nằm trong form;
// dòng mới vẽ ở CẤP TRANG nên cần toạ độ trang).
let (family, bold, italic, mc, unscaled, scaled, color, font_embedded, orig_token, font_data) = {
let entry = &entries[anchor_idx as usize];
let anchor = object_at_path(&page, &entry.path)?;
let t = anchor.as_text_object().expect("anchor là text");
let (family, bold, italic) = text_object_style(t);
let m = anchor.matrix().map_err(err)?;
let mc = mat_mul(mat_of(&m), entry.acc);
let tok = t.font().token();
let unscaled = t.unscaled_font_size().value;
let scaled = t.scaled_font_size().value.max(1.0);
let scaled =
(t.scaled_font_size().value * mat_vscale(entry.acc)).max(1.0);
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
let embedded = t.font().is_embedded().unwrap_or(false);
let data = t.font().data().ok();
(family, bold, italic, m, unscaled, scaled, color, embedded, tok, data)
(family, bold, italic, mc, unscaled, scaled, color, embedded, tok, data)
};
// Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance =
@@ -756,7 +933,7 @@ pub fn apply_edits(
centered,
first_baseline,
line_advance,
linear: (m.a(), m.b(), m.c(), m.d()),
linear: (mc.0, mc.1, mc.2, mc.3),
tf: unscaled.max(1.0),
scaled,
line_styles,
@@ -790,16 +967,18 @@ pub fn apply_edits(
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
let obj_count = page.objects().len();
let valid = |i: u16| (i as usize) < obj_count;
let valid = |i: u16| (i as usize) < entries.len();
let is_nested = |i: u16| entries[i as usize].path.len() > 1;
// (1) Transform in-place (không đổi cấu trúc/index).
// (1) Transform in-place (không đổi cấu trúc/index). Object TRONG form
// chưa hỗ trợ di chuyển/resize (dx/dy tính theo trang còn matrix con
// theo không gian form) — bỏ qua, UI cũng đã chặn kéo.
for op in ops {
if let EditOp::Transform { index, dx, dy, sx, sy } = op {
if !valid(*index) {
if !valid(*index) || is_nested(*index) {
continue;
}
let mut obj = page.objects().get(*index as usize).map_err(err)?;
let mut obj = object_at_path(&page, &entries[*index as usize].path)?;
if (*sx - 1.0).abs() > f32::EPSILON || (*sy - 1.0).abs() > f32::EPSILON {
let b = obj.bounds().map_err(err)?;
let (x0, y0) = (b.left().value, b.bottom().value);
@@ -823,9 +1002,12 @@ pub fn apply_edits(
if !matches!(set_text_modes.get(&opi), Some(SetTextMode::InPlace)) {
continue;
}
let mut obj = page.objects().get(*index as usize).map_err(err)?;
let entry = &entries[*index as usize];
let mut obj = object_at_path(&page, &entry.path)?;
// Cỡ hiển thị so theo KHÔNG GIAN TRANG (nhân scale form) — target
// từ UI cũng theo trang; hệ số k không đổi khi áp trong form.
let (old_text, scaled) = match obj.as_text_object() {
Some(t) => (t.text(), t.scaled_font_size().value),
Some(t) => (t.text(), t.scaled_font_size().value * mat_vscale(entry.acc)),
None => continue,
};
if *text != old_text {
@@ -873,20 +1055,25 @@ pub fn apply_edits(
if !valid(idx) || captured.contains_key(&idx) {
continue;
}
let obj = page.objects().get(idx as usize).map_err(err)?;
let entry = &entries[idx as usize];
let obj = object_at_path(&page, &entry.path)?;
// Rect + matrix COMPOSE về trang: bản thay thế được tạo ở cấp
// trang nên phải mang sẵn toạ độ trang (kể cả khi bản gốc trong form).
let rect = obj
.bounds()
.map(|q| quad_to_rect(&q))
.map(|q| mat_rect(entry.acc, &quad_to_rect(&q)))
.unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
let (matrix, unscaled, scaled, color) = if let Some(t) = obj.as_text_object() {
let mc = mat_mul(mat_of(&obj.matrix().map_err(err)?), entry.acc);
let matrix = PdfMatrix::new(mc.0, mc.1, mc.2, mc.3, mc.4, mc.5);
let vs = mat_vscale(entry.acc);
let (unscaled, scaled, color) = if let Some(t) = obj.as_text_object() {
(
obj.matrix().map_err(err)?,
t.unscaled_font_size().value,
t.scaled_font_size().value,
t.scaled_font_size().value * vs,
t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)),
)
} else {
(obj.matrix().map_err(err)?, 0.0, 0.0, PdfColor::new(0, 0, 0, 255))
(0.0, 0.0, PdfColor::new(0, 0, 0, 255))
};
captured.insert(
idx,
@@ -917,7 +1104,32 @@ pub fn apply_edits(
to_remove.retain(|i| valid(*i));
to_remove.sort_unstable();
to_remove.dedup();
for idx in to_remove.into_iter().rev() {
// Con TRONG form: chưa rút object ra được (FPDFFormObj_RemoveObject
// chưa mở ở wrapper) → "xoá" = set text RỖNG — glyph biến mất, PDFium
// regenerate stream form khi lưu (ProcessForm → HasDirtyStreams).
// Làm TRƯỚC khi xoá object cấp trang để path (nấc đầu = index trang)
// còn đúng. Con không phải text (ảnh/path trong form): v1 bỏ qua.
for &i in to_remove.iter().filter(|&&i| is_nested(i)) {
let entry = &entries[i as usize];
if entry.kind != ObjectKind::Text {
continue;
}
let mut obj = object_at_path(&page, &entry.path)?;
if let Some(t) = obj.as_text_object_mut() {
if t.set_text("").is_err() {
let _ = t.set_text(" ");
}
}
}
// Cấp trang: xoá thật theo index trang GIẢM DẦN.
let mut page_idxs: Vec<u16> = to_remove
.iter()
.filter(|&&i| !is_nested(i))
.map(|&i| entries[i as usize].path[0])
.collect();
page_idxs.sort_unstable();
page_idxs.dedup();
for idx in page_idxs.into_iter().rev() {
let removed = page.objects_mut().remove_object_at_index(idx as usize).map_err(err)?;
// BẪY PDFium: object vừa tách khỏi trang bị đánh dấu "unowned" → Drop
// gọi FPDFPageObj_Destroy, mà destroy object vốn thuộc document gây
+154
View File
@@ -1015,3 +1015,157 @@ fn add_image_adds_image_object() {
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");
}
// ---- Phase 4 iteration 4: sửa text NẰM TRONG Form XObject (file Canva/
// Illustrator gói cả trang vào form — trước đây không thấy gì để sửa) ----
/// Dựng PDF 1 trang mà TOÀN BỘ chữ nằm trong 1 Form XObject (như Canva xuất):
/// trang chỉ có 1 object "form", bên trong có 1 text run Helvetica 24pt.
fn build_form_xobject_pdf(path: &std::path::Path) {
use lopdf::{dictionary, Document, Object, Stream};
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let form_id = doc.add_object(Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font_id } },
},
b"BT /F1 24 Tf 72 700 Td (Hello inside form) Tj ET".to_vec(),
));
let content_id = doc.add_object(Stream::new(dictionary! {}, b"q /Fx1 Do Q".to_vec()));
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Resources" => dictionary! { "XObject" => dictionary! { "Fx1" => form_id } },
"Contents" => content_id,
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}),
);
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
doc.save(path).expect("lưu fixture form xobject");
}
fn form_fixture(name: &str) -> PathBuf {
let p = tmp(name);
build_form_xobject_pdf(&p);
p
}
#[test]
fn form_xobject_children_are_listed() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_list.pdf");
let objs = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
// Form cấp trang phải được đánh dấu expanded, con text phải lộ ra.
let form = objs.iter().find(|o| o.kind == ObjectKind::XObjectForm).expect("phải thấy form");
assert!(form.expanded, "form có con phải expanded: {form:?}");
let text = objs
.iter()
.find(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains("Hello inside form")).unwrap_or(false))
.unwrap_or_else(|| panic!("phải thấy text trong form: {objs:?}"));
assert!(text.nested, "text trong form phải nested: {text:?}");
// Toạ độ đã quy về trang: baseline 700, cỡ 24 → bbox quanh (72, ~694..~724).
assert!(text.rect.left > 60.0 && text.rect.left < 84.0, "left ~72: {:?}", text.rect);
assert!(text.rect.bottom > 680.0 && text.rect.top < 740.0, "bbox quanh y=700: {:?}", text.rect);
assert!((text.font_size.unwrap_or(0.0) - 24.0).abs() < 2.0, "cỡ hiển thị ~24: {:?}", text.font_size);
}
// Canary quan trọng nhất: sửa IN-PLACE con trong form rồi lưu — PDFium phải
// regenerate content stream của form (ProcessForm → HasDirtyStreams).
#[test]
fn form_xobject_settext_inplace_roundtrip() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_settext.pdf");
let out = tmp("ff_edit_formx_settext_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Edited in form!".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
)
.expect("apply_edits SetText trong form");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Edited in form!"), "text mới phải có mặt: {text:?}");
assert!(!text.contains("Hello inside form"), "text cũ trong form phải biến mất: {text:?}");
}
// Reflow đoạn trong form: run cũ bị làm rỗng (không rút được object khỏi
// form), dòng mới vẽ ở cấp trang với toạ độ đã compose ma trận form.
#[test]
fn form_xobject_reflow_roundtrip() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_reflow.pdf");
let out = tmp("ff_edit_formx_reflow_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::ReflowText {
indices: vec![idx],
text: "Đoạn văn tiếng Việt thay thế trong form, đủ dài để chắc chắn phải bẻ xuống dòng mới".into(),
}],
&out,
None,
)
.expect("apply_edits ReflowText trong form");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Đoạn văn tiếng Việt thay thế"), "text mới phải có mặt: {text:?}");
assert!(!text.contains("Hello inside form"), "text cũ phải biến mất: {text:?}");
// Dòng mới phải nằm quanh vị trí khối cũ (không rơi về góc trang).
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let new_runs: Vec<_> = objs
.iter()
.filter(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains("Đoạn")).unwrap_or(false))
.collect();
assert!(!new_runs.is_empty(), "phải có run mới: {objs:?}");
for r in &new_runs {
assert!(r.rect.left > 50.0 && r.rect.top < 740.0 && r.rect.bottom > 500.0,
"dòng mới phải quanh khối cũ: {:?}", r.rect);
}
}
#[test]
fn form_xobject_delete_clears_text() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_delete.pdf");
let out = tmp("ff_edit_formx_delete_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form");
ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: idx }], &out, None)
.expect("apply_edits Delete trong form");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(!text.contains("Hello inside form"), "text trong form phải biến mất sau xoá: {text:?}");
}