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
+6
View File
@@ -29,6 +29,7 @@ jobs:
path: | path: |
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
target
app/src-tauri/target app/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
@@ -40,6 +41,11 @@ jobs:
shell: pwsh shell: pwsh
run: powershell -ExecutionPolicy Bypass -File scripts/fetch-qpdf.ps1 run: powershell -ExecutionPolicy Bypass -File scripts/fetch-qpdf.ps1
# Máy dev không có Rust — CI là nơi duy nhất chạy test engine.
# Fail test = fail build, không publish bản hỏng.
- name: Run engine tests
run: cargo test -p ff-engine
- name: Build release - name: Build release
working-directory: app/src-tauri working-directory: app/src-tauri
run: cargo build --release run: cargo build --release
+1
View File
@@ -42,6 +42,7 @@
- [x] **Iteration 2 — GIỮ FONT khi sửa (chuẩn Foxit)**: sửa tại chỗ giữ nguyên font gốc/nhúng (kể cả tiếng Việt); thiếu glyph mới thay font CÙNG HỌ (`fontmatch.rs`); đổi cỡ/màu không đụng font (fix bug phóng đại kép); sửa CẢ DÒNG (gộp run); WYSIWYG khi gõ; B/I + đổi font family; kéo-thả live; dọn file tạm - [x] **Iteration 2 — GIỮ FONT khi sửa (chuẩn Foxit)**: sửa tại chỗ giữ nguyên font gốc/nhúng (kể cả tiếng Việt); thiếu glyph mới thay font CÙNG HỌ (`fontmatch.rs`); đổi cỡ/màu không đụng font (fix bug phóng đại kép); sửa CẢ DÒNG (gộp run); WYSIWYG khi gõ; B/I + đổi font family; kéo-thả live; dọn file tạm
- [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng - [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng
- [x] **Đúp chuột từ viewer thường (chuẩn Foxit)**: đang xem, đúp vào chữ → tự vào chế độ sửa đúng trang + mở ngay ô sửa tại đoạn đó; đúp vùng trống không làm gì - [x] **Đúp chuột từ viewer thường (chuẩn Foxit)**: đang xem, đúp vào chữ → tự vào chế độ sửa đúng trang + mở ngay ô sửa tại đoạn đó; đúp vùng trống không làm gì
- [x] **Iteration 4 — Sửa text TRONG Form XObject**: file Canva/Illustrator gói cả trang vào form giờ sửa được (liệt kê đệ quy + quy toạ độ về trang; xoá trong form = set text rỗng; CI chạy `cargo test` trước khi build)
- [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md) - [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md)
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30) - **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30)
- [x] **Phase 5 — Bảo mật** ✅ Iteration 1 + 2 hoàn tất (62/62 test engine xanh) - [x] **Phase 5 — Bảo mật** ✅ Iteration 1 + 2 hoàn tất (62/62 test engine xanh)
+6
View File
@@ -663,6 +663,10 @@ struct ObjectInfoDto {
index: u16, index: u16,
kind: String, kind: String,
rect: RectDto, rect: RectDto,
/// Object nằm trong Form XObject (sửa text OK, kéo/resize chưa hỗ trợ).
nested: bool,
/// Form đã liệt kê con riêng — UI bỏ khung của form này.
expanded: bool,
text: Option<String>, text: Option<String>,
font_name: Option<String>, font_name: Option<String>,
font_family: Option<String>, font_family: Option<String>,
@@ -685,6 +689,8 @@ fn edit_list_objects(path: String, page: u16, password: Option<String>) -> Resul
index: o.index, index: o.index,
kind: o.kind.as_str().to_string(), kind: o.kind.as_str().to_string(),
rect: RectDto { left: o.rect.left, bottom: o.rect.bottom, right: o.rect.right, top: o.rect.top }, rect: RectDto { left: o.rect.left, bottom: o.rect.bottom, right: o.rect.right, top: o.rect.top },
nested: o.nested,
expanded: o.expanded,
text: o.text, text: o.text,
font_name: o.font_name, font_name: o.font_name,
font_family: o.font_family, font_family: o.font_family,
+9 -1
View File
@@ -2226,6 +2226,10 @@ function buildEditOverlay() {
// Ảnh/form: khung theo từng object như cũ (có resize). // Ảnh/form: khung theo từng object như cũ (có resize).
state.editObjects.forEach((o) => { state.editObjects.forEach((o) => {
if (o.kind !== "image" && o.kind !== "form") return; if (o.kind !== "image" && o.kind !== "form") return;
// Form đã liệt kê con riêng (file Canva gói cả trang vào form): khung của
// form phủ kín trang sẽ che hết con → bỏ. Ảnh/form TRONG form: chưa sửa
// được (di chuyển/thay cần địa chỉ lồng) → cũng bỏ khung.
if (o.expanded || o.nested) return;
const box = document.createElement("div"); const box = document.createElement("div");
box.className = "edit-box kind-" + o.kind; box.className = "edit-box kind-" + o.kind;
box.dataset.index = String(o.index); box.dataset.index = String(o.index);
@@ -2979,8 +2983,12 @@ function promptAddText(pdfX, pdfY) {
function onEditBoxMouseDown(e, o, runs) { function onEditBoxMouseDown(e, o, runs) {
if (state.editArm) return; if (state.editArm) return;
if (e.target.classList.contains("ed-handle")) return; if (e.target.classList.contains("ed-handle")) return;
const runIndices = (runs && runs.length ? runs : [o]).map((r) => (typeof r === "number" ? r : r.index)); const runObjs = runs && runs.length ? runs : [o];
const runIndices = runObjs.map((r) => (typeof r === "number" ? r : r.index));
selectEditObject(o.index, runIndices); selectEditObject(o.index, runIndices);
// Run TRONG Form XObject (file Canva...): sửa text được nhưng chưa hỗ trợ
// kéo di chuyển/resize — chọn thôi, không bắt đầu drag.
if (runObjs.some((r) => r && typeof r === "object" && r.nested)) return;
const box = e.currentTarget; const box = e.currentTarget;
const startX = e.clientX, startY = e.clientY; const startX = e.clientX, startY = e.clientY;
const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top); const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top);
+4
View File
@@ -25,3 +25,7 @@ der = "0.7"
spki = "0.7" spki = "0.7"
signature = "2" signature = "2"
const-oid = { version = "0.9", features = ["db"] } 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). /// Thông tin 1 page object trả về cho UI (để vẽ overlay & sửa).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ObjectInfo { 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 index: u16,
pub kind: ObjectKind, 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, 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. /// Chỉ với text: nội dung hiện tại.
pub text: Option<String>, pub text: Option<String>,
/// Chỉ với text: tên font gốc trong PDF (có thể kèm prefix subset). /// 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 /// 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 /// 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 /// 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) .get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?; .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(); let mut out = Vec::new();
// Cache kiểu chữ theo tên font: fallback đậm/nghiêng phải đọc bytes font // 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. // 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(); let mut style_cache: HashMap<String, (String, bool, bool)> = HashMap::new();
for (i, object) in page.objects().iter().enumerate() { for (flat, entry) in entries.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 });
let mut info = ObjectInfo { let mut info = ObjectInfo {
index: i as u16, index: flat as u16,
kind, kind: entry.kind,
rect, 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, text: None,
font_name: None, font_name: None,
font_family: None, font_family: None,
@@ -221,6 +379,14 @@ pub fn list_objects(
font_size: None, font_size: None,
color: 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() { if let Some(t) = object.as_text_object() {
let raw_name = t.font().name(); let raw_name = t.font().name();
// Tên rỗng không cache được (nhiều font khác nhau cùng key ""). // 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_bold = Some(bold);
info.font_italic = Some(italic); info.font_italic = Some(italic);
info.font_embedded = t.font().is_embedded().ok(); 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 info.color = t
.fill_color() .fill_color()
.ok() .ok()
@@ -433,6 +600,10 @@ pub fn apply_edits(
let mut add_text_keys: HashMap<usize, FontKey> = HashMap::new(); let mut add_text_keys: HashMap<usize, FontKey> = HashMap::new();
let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new(); let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new();
let mut font_needed: HashMap<FontKey, FontLoad> = 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 let mut page = document
.pages() .pages()
@@ -440,8 +611,8 @@ pub fn apply_edits(
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?; .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. // Pha A chỉ đọc + probe font (tạo/xoá object nháp) — không đụng content.
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual); page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
let obj_count = page.objects().len(); entries = collect_flat(&page);
let valid = |i: u16| (i as usize) < obj_count; let valid = |i: u16| (i as usize) < entries.len();
for (opi, op) in ops.iter().enumerate() { for (opi, op) in ops.iter().enumerate() {
match op { match op {
@@ -449,7 +620,7 @@ pub fn apply_edits(
if !valid(*index) { if !valid(*index) {
continue; 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() { let t = match obj.as_text_object() {
Some(t) => t, Some(t) => t,
None => continue, // không phải text → bỏ qua op None => continue, // không phải text → bỏ qua op
@@ -530,21 +701,27 @@ pub fn apply_edits(
tf: f32, tf: f32,
scaled: 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 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 { let Some(t) = obj.as_text_object() else {
return Ok(None); return Ok(None);
}; };
let vs = mat_vscale(entry.acc);
let (tf, scaled) = 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 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(), left: m.e(),
bottom: m.f(), bottom: m.f(),
right: m.e(), right: m.e(),
top: m.f(), 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(); let mut geos: Vec<RunGeo> = Vec::new();
for &i in &idxs { 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 → // 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. // đư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 { let mut bb = Rect {
left: f32::INFINITY, left: f32::INFINITY,
bottom: f32::INFINITY, bottom: f32::INFINITY,
@@ -569,12 +755,7 @@ pub fn apply_edits(
top: f32::NEG_INFINITY, top: f32::NEG_INFINITY,
}; };
for &i in &idxs { for &i in &idxs {
let obj = page.objects().get(i as usize).map_err(err)?; if let Some(r) = text_rect(i) {
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.left = bb.left.min(r.left);
bb.bottom = bb.bottom.min(r.bottom); bb.bottom = bb.bottom.min(r.bottom);
bb.right = bb.right.max(r.right); bb.right = bb.right.max(r.right);
@@ -582,21 +763,12 @@ pub fn apply_edits(
} }
} }
let pad = 2.0; let pad = 2.0;
for i in 0..obj_count as u16 { for i in 0..entries.len() as u16 {
if idxs.contains(&i) { if idxs.contains(&i) {
continue; continue;
} }
let obj = page.objects().get(i as usize).map_err(err)?; let Some(r) = text_rect(i) else { continue };
if obj.as_text_object().is_none() { let (cx, cy) = ((r.left + r.right) / 2.0, (r.bottom + r.top) / 2.0);
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 if cx >= bb.left - pad
&& cx <= bb.right + pad && cx <= bb.right + pad
&& cy >= bb.bottom - pad && cy >= bb.bottom - pad
@@ -621,19 +793,24 @@ pub fn apply_edits(
.map(|g| g.idx) .map(|g| g.idx)
.unwrap_or(idxs[0]); .unwrap_or(idxs[0]);
// Chụp mọi thuộc tính của run neo trong 1 block để nhả mượn // 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). // trang (probe font phía dưới cần &mut page). Ma trận/cỡ
let (family, bold, italic, m, unscaled, scaled, color, font_embedded, orig_token, font_data) = { // COMPOSE với ma trận form (run neo có thể nằm trong form;
let anchor = page.objects().get(anchor_idx as usize).map_err(err)?; // 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 t = anchor.as_text_object().expect("anchor là text");
let (family, bold, italic) = text_object_style(t); let (family, bold, italic) = text_object_style(t);
let m = anchor.matrix().map_err(err)?; let m = anchor.matrix().map_err(err)?;
let mc = mat_mul(mat_of(&m), entry.acc);
let tok = t.font().token(); let tok = t.font().token();
let unscaled = t.unscaled_font_size().value; 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 color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
let embedded = t.font().is_embedded().unwrap_or(false); let embedded = t.font().is_embedded().unwrap_or(false);
let data = t.font().data().ok(); 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 = // Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance =
@@ -756,7 +933,7 @@ pub fn apply_edits(
centered, centered,
first_baseline, first_baseline,
line_advance, 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), tf: unscaled.max(1.0),
scaled, scaled,
line_styles, 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}")))?; .map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual); page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
let obj_count = page.objects().len(); let valid = |i: u16| (i as usize) < entries.len();
let valid = |i: u16| (i as usize) < obj_count; 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 { for op in ops {
if let EditOp::Transform { index, dx, dy, sx, sy } = op { if let EditOp::Transform { index, dx, dy, sx, sy } = op {
if !valid(*index) { if !valid(*index) || is_nested(*index) {
continue; 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 { if (*sx - 1.0).abs() > f32::EPSILON || (*sy - 1.0).abs() > f32::EPSILON {
let b = obj.bounds().map_err(err)?; let b = obj.bounds().map_err(err)?;
let (x0, y0) = (b.left().value, b.bottom().value); 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)) { if !matches!(set_text_modes.get(&opi), Some(SetTextMode::InPlace)) {
continue; 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() { 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, None => continue,
}; };
if *text != old_text { if *text != old_text {
@@ -873,20 +1055,25 @@ pub fn apply_edits(
if !valid(idx) || captured.contains_key(&idx) { if !valid(idx) || captured.contains_key(&idx) {
continue; 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 let rect = obj
.bounds() .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 }); .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.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)), t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)),
) )
} else { } 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( captured.insert(
idx, idx,
@@ -917,7 +1104,32 @@ pub fn apply_edits(
to_remove.retain(|i| valid(*i)); to_remove.retain(|i| valid(*i));
to_remove.sort_unstable(); to_remove.sort_unstable();
to_remove.dedup(); 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)?; 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 // 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 // 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_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"); 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:?}");
}
+73 -3
View File
@@ -1,7 +1,77 @@
# 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 + 2 + 3 # 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 + 2 + 3 + 4
> Trạng thái sau Iteration 3: **sửa CẢ ĐOẠN với reflow "như Word" + GIỮ FONT**. > Trạng thái sau Iteration 4: sửa được cả text **NẰM TRONG Form XObject**
> 52/52 test engine xanh ngoài qpdf (17 test edit + 6 unit fontmatch + 29 test cũ). > (file Canva/Illustrator/InDesign gói cả trang vào form).
## Iteration 4 — Sửa text trong Form XObject (file Canva...)
User mở CV xuất từ Canva: xem được nhưng **không sửa được gì** — trang chỉ có
10 path + 1 Form XObject phủ toàn trang, TOÀN BỘ chữ nằm trong form (16 font
Type0/CID nhúng), trong khi `list_objects` chỉ duyệt object cấp trang.
### Engine (`edit.rs`)
- **Danh sách PHẲNG đệ quy** (`collect_flat`): duyệt object trang theo thứ tự
vẽ, đi sâu vào Form XObject qua `FPDFFormObj_CountObjects/GetObject`
(pdfium-render 0.8.37: `PdfPageXObjectFormObject::len()/get()` public; match
thẳng variant `PdfPageObject::XObjectForm` để giữ lifetime trang — accessor
`as_x_object_form_object()` bị rút ngắn lifetime theo `&self`). Mỗi mục có
`path` ([i] cấp trang, [i,j,…] con trong form) + `acc` = tích ma trận form
tổ tiên. Chặn depth 3 / 4000 mục. **`index` của 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 100%, UI không đổi cách gửi op).
- **Toạ độ/cỡ quy về trang**: rect con = AABB của 4 góc qua `acc`; cỡ hiển
thị nhân `mat_vscale(acc)` (độ dài ảnh vector đơn vị Y).
- **ObjectInfo thêm `nested`** (con trong form — sửa text OK, kéo/resize chưa)
**+ `expanded`** (form đã liệt kê con — UI bỏ khung form để khỏi che con).
- **Áp op cho con trong form**: SetText in-place resolve theo path (đổi cỡ:
so cỡ theo KHÔNG GIAN TRANG — target × vscale); SetText-substitute và
ReflowText tạo object mới ở CẤP TRANG với matrix/toạ độ đã compose;
**"xoá" con trong form = `set_text("")`** (glyph biến mất) vì
`FPDFFormObj_RemoveObject` chưa mở ở wrapper cho pdfium_7543 — làm TRƯỚC
khi xoá object cấp trang để path còn đúng. Transform (kéo/resize) con
trong form: bỏ qua, UI chặn kéo.
- **Điểm sống còn đã xác minh trước khi code**: PDFium (bản mới, bblanchon
latest) REGENERATE content stream của form khi con bị sửa —
`CPDF_PageContentGenerator::ProcessForm` → `HasDirtyStreams` → chạy
generator đệ quy cho form. Không có cái này thì sửa xong lưu ra vẫn thấy
chữ cũ.
- Vá kèm: đọc source crate pdfium-render tải từ crates.io để chốt API vì máy
dev không compile được Rust (xem mục CI bên dưới).
### UI (`main.js`)
- Bỏ khung overlay cho form `expanded` (khung phủ kín trang chặn hết click)
và cho ảnh/form `nested`; run text trong form tự chảy qua clusterTextLines
như run thường (rect đã quy về trang).
- Chặn kéo di chuyển dòng chứa run `nested` (engine chưa hỗ trợ Transform
trong form) — vẫn chọn/sửa text/xoá bình thường.
### Test (4 mới — `edit_roundtrip.rs`, fixture tự dựng bằng lopdf)
- Fixture: PDF 1 trang, chữ "Hello inside form" Helvetica 24pt nằm TRONG
Form XObject (trang chỉ có 1 object form) — đúng cấu trúc Canva.
- `form_xobject_children_are_listed` — thấy text nested, form expanded, rect
quy về trang đúng chỗ (72, ~700), cỡ ~24.
- `form_xobject_settext_inplace_roundtrip` — **canary quan trọng nhất**: sửa
in-place con trong form → lưu → text mới có mặt, text cũ biến mất (chứng
minh PDFium regenerate stream form).
- `form_xobject_reflow_roundtrip` — reflow tiếng Việt trong form: run cũ làm
rỗng, dòng mới ở cấp trang quanh khối cũ.
- `form_xobject_delete_clears_text` — xoá = text biến mất khỏi extract.
### CI chạy test engine
Máy dev không có Rust nên **CI là nơi duy nhất chạy test**: workflow thêm
bước `cargo test -p ff-engine` TRƯỚC khi build release (fail test = fail
build, không publish bản hỏng); cache thêm `target/` gốc workspace.
### Giới hạn ghi nhận (iteration 4 v1)
- Chưa di chuyển/resize object trong form (Transform bị bỏ qua, UI chặn kéo).
- Chưa sửa/thay ẢNH trong form (khung overlay ẩn).
- "Xoá" text trong form để lại text object rỗng trong form (vô hại, không
render); khi wrapper mở `FPDFFormObj_RemoveObject` cho pdfium_7543 sẽ xoá
thật.
- Form lồng sâu >3 cấp hoặc >4000 object con: phần vượt không liệt kê.
> Các iteration trước: 52/52 test engine xanh ngoài qpdf (17 test edit + 6
> unit fontmatch + 29 test cũ) — nay thêm 4 test form + 2 unit sfnt style.
## Iteration 3 — Reflow đoạn văn "như Word" (mới nhất) ## Iteration 3 — Reflow đoạn văn "như Word" (mới nhất)