Form XObject vòng 2: rút object khỏi form thật sự — sửa trong form mới được LƯU
Build Windows portable / build (push) Canceled after 0s

CI vòng 1 bắt đúng lỗi thiết kế: FPDFText_SetText trên con trong form KHÔNG
đánh dấu dirty_streams của form (chỉ RemovePageObject/InsertPageObjectAtIndex
mới làm — xem cpdf_pageobjectholder.cpp) nên mọi sửa in-place trong form
không bao giờ được ghi ra file. Test canary fail chính xác ở điểm này.

- Đổi feature pdfium-render pdfium_latest(7543) -> pdfium_7350 (cả ff-engine
  lẫn app): mở remove_object cho Form XObject qua blanket impl
  PdfPageObjectsCommon (wrapper 0.8.37 sót gate 7543 dù binding có đủ);
  API 7350 là subset của DLL bblanchon mới, đã quét 0 hàm 7543-only.
- Mọi sửa/xoá con trong form = RÚT THẬT khỏi form (FPDFFormObj_RemoveObject,
  thứ tự path sâu trước + cùng cha con lớn trước) -> form dirty -> ProcessForm
  regenerate stream khi lưu. SetText con trong form = SetTextMode mới
  NestedRecreate: rút khỏi form + tạo lại ở cấp trang giữ font gốc qua token
  (probe như tầng 0 reflow; thiếu glyph -> nhúng lại bytes/cùng họ), matrix
  đã compose.
- CI test thêm --test-threads=1: PDFium serialize qua 1 mutex toàn cục,
  1 test panic khi giữ lock poison mutex làm 17 test song song chết oan.
Docs cập nhật theo (12-phase4-summary.md Iteration 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 02:00:10 +07:00
co-authored by Claude Fable 5
parent e66252681c
commit fd731255ac
6 changed files with 183 additions and 77 deletions
+3 -1
View File
@@ -43,8 +43,10 @@ jobs:
# Máy dev không có Rust — CI là nơi duy nhất chạy test engine. # 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. # Fail test = fail build, không publish bản hỏng.
# --test-threads=1: PDFium serialize qua 1 mutex toàn cục; 1 test panic
# khi đang giữ lock sẽ poison mutex làm mọi test song song chết oan.
- name: Run engine tests - name: Run engine tests
run: cargo test -p ff-engine run: cargo test -p ff-engine -- --test-threads=1
- name: Build release - name: Build release
working-directory: app/src-tauri working-directory: app/src-tauri
+1 -1
View File
@@ -42,7 +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) - [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; sửa/xoá = rút object khỏi form — cách duy nhất làm form được ghi lại khi lưu; 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)
+2 -1
View File
@@ -18,7 +18,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.22"
image = "0.25" image = "0.25"
pdfium-render = "0.8" # Đồng bộ feature với ff-engine (xem chú thích ở crates/ff-engine/Cargo.toml).
pdfium-render = { version = "0.8", default-features = false, features = ["pdfium_7350", "thread_safe", "image"] }
lopdf = "0.36" lopdf = "0.36"
ff-engine = { path = "../../crates/ff-engine" } ff-engine = { path = "../../crates/ff-engine" }
+5 -1
View File
@@ -10,7 +10,11 @@ ff-cos = { path = "../ff-cos" }
ff-pdmodel = { path = "../ff-pdmodel" } ff-pdmodel = { path = "../ff-pdmodel" }
anyhow.workspace = true anyhow.workspace = true
thiserror.workspace = true thiserror.workspace = true
pdfium-render = "0.8" # default-features tắt để chọn pdfium_7350 thay vì pdfium_latest(7543):
# wrapper 0.8.37 chỉ mở FPDFFormObj_RemoveObject (rút object khỏi Form
# XObject — cần cho sửa file Canva) với gate 7350/future dù binding có cả
# 7543; DLL bblanchon mới hơn là superset của API 7350 nên tương thích.
pdfium-render = { version = "0.8", default-features = false, features = ["pdfium_7350", "thread_safe", "image"] }
image = "0.25" image = "0.25"
lopdf = "0.36" lopdf = "0.36"
ttf-parser = "0.25" ttf-parser = "0.25"
+153 -61
View File
@@ -298,6 +298,29 @@ fn object_at_path<'a>(page: &PdfPage<'a>, path: &[u16]) -> Result<PdfPageObject<
Ok(obj) Ok(obj)
} }
/// RÚT object con ra khỏi form chứa nó (path đầy đủ của CON). Đây là cách
/// DUY NHẤT làm thay đổi trong form được lưu ra file: chỉ RemovePageObject
/// mới đánh dấu `dirty_streams_` của form để PDFium ghi lại stream khi
/// GenerateContent (SetText trên con KHÔNG làm form dirty — xem
/// cpdf_pageobjectholder.cpp). Cần feature pdfium_7350 của pdfium-render.
fn remove_form_child(page: &PdfPage<'_>, child_path: &[u16]) -> Result<(), EngineError> {
let err = |e: PdfiumError| EngineError::Pdfium(format!("rút object khỏi form: {e}"));
debug_assert!(child_path.len() >= 2);
let parent_path = &child_path[..child_path.len() - 1];
let child_idx = *child_path.last().unwrap() as usize;
let mut parent = object_at_path(page, parent_path)?;
let form = match &mut parent {
PdfPageObject::XObjectForm(f) => f,
_ => return Err(EngineError::Pdfium("path cha không phải form".into())),
};
let child = form.get(child_idx).map_err(err)?;
let removed = form.remove_object(child).map_err(err)?;
// Cùng bẫy Drop như remove ở cấp trang: object đã tách, forget để khỏi
// destroy nhầm (rò rỉ nhỏ, giải phóng khi đóng document).
std::mem::forget(removed);
Ok(())
}
/// 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
@@ -541,6 +564,10 @@ enum SetTextMode {
/// Phải thay font (glyph thiếu hoặc người dùng đổi family/kiểu) — xoá và /// Phải thay font (glyph thiếu hoặc người dùng đổi family/kiểu) — xoá và
/// tạo lại object với font `key`. /// tạo lại object với font `key`.
Substitute(FontKey), Substitute(FontKey),
/// Con TRONG Form XObject: sửa in-place không được LƯU (SetText trên con
/// không làm form dirty) → rút khỏi form + tạo lại ở CẤP TRANG, ưu tiên
/// giữ chính font object gốc (token) như tầng 0 của reflow.
NestedRecreate(ReflowFont),
} }
/// Dữ liệu chụp lại từ 1 object trước khi xoá để tạo lại bản thay thế. /// Dữ liệu chụp lại từ 1 object trước khi xoá để tạo lại bản thay thế.
@@ -620,64 +647,114 @@ pub fn apply_edits(
if !valid(*index) { if !valid(*index) {
continue; continue;
} }
let obj = object_at_path(&page, &entries[*index as usize].path)?; let nested_obj = entries[*index as usize].path.len() > 1;
let t = match obj.as_text_object() { // Chụp mọi thứ cần thiết trong 1 block để NHẢ mượn trang
Some(t) => t, // trước khi probe font (probe cần &mut page).
None => continue, // không phải text → bỏ qua op struct SetTextInfo {
family: String,
bold: bool,
italic: bool,
old_text: String,
non_embedded: bool,
data_covers: bool,
token: PdfFontToken,
embedded_cover_bytes: Option<Vec<u8>>,
}
let info = {
let obj = object_at_path(&page, &entries[*index as usize].path)?;
match obj.as_text_object() {
Some(t) => {
let (family, bold, italic) = text_object_style(t);
let embedded = t.font().is_embedded().unwrap_or(false);
let data = t.font().data().ok();
let data_covers = data
.as_deref()
.map_or(false, |b| fontmatch::coverage_ok(b, text));
SetTextInfo {
family,
bold,
italic,
old_text: t.text(),
non_embedded: !embedded,
data_covers,
token: t.font().token(),
embedded_cover_bytes: if embedded && data_covers {
data
} else {
None
},
}
}
None => continue, // không phải text → bỏ qua op
}
}; };
let (cur_family, cur_bold, cur_italic) = text_object_style(t); let target_bold = bold.unwrap_or(info.bold);
let target_bold = bold.unwrap_or(cur_bold); let target_italic = italic.unwrap_or(info.italic);
let target_italic = italic.unwrap_or(cur_italic); let style_change = target_bold != info.bold || target_italic != info.italic;
let style_change = target_bold != cur_bold || target_italic != cur_italic;
let family_change = font_family.as_deref().map_or(false, |f| { let family_change = font_family.as_deref().map_or(false, |f| {
!f.trim().is_empty() !f.trim().is_empty()
&& fontmatch::normalize_key(f) != fontmatch::normalize_key(&cur_family) && fontmatch::normalize_key(f) != fontmatch::normalize_key(&info.family)
}); });
let mode = if !style_change && !family_change { // Font gốc có giữ được không (không xét in-place hay recreate)?
let old_text = t.text(); let old_chars: HashSet<char> = info.old_text.chars().collect();
let old_chars: HashSet<char> = old_text.chars().collect(); let new_covered_by_old = text
let new_covered_by_old = text .chars()
.chars() .all(|c| c.is_control() || old_chars.contains(&c));
.all(|c| c.is_control() || old_chars.contains(&c)); // Font KHÔNG nhúng (base-14 Helvetica/Times...): viewer nào
// Font KHÔNG nhúng (base-14 Helvetica/Times...): viewer nào // cũng tự thay bằng font hệ thống đủ Latin → re-encode text
// cũng tự thay bằng font hệ thống đủ Latin → re-encode text // ASCII tại chỗ chắc chắn an toàn, BaseFont khai báo giữ
// ASCII tại chỗ chắc chắn an toàn, BaseFont khai báo giữ // nguyên trong file (đúng hành vi Foxit với base-14).
// nguyên trong file (đúng hành vi Foxit với base-14). let ascii_only = text
let non_embedded = !t.font().is_embedded().unwrap_or(true); .chars()
let ascii_only = text .all(|c| c.is_control() || (' '..='~').contains(&c));
.chars() let keep_font_ok = !style_change
.all(|c| c.is_control() || (' '..='~').contains(&c)); && !family_change
if *text == old_text || new_covered_by_old { && (*text == info.old_text
SetTextMode::InPlace || new_covered_by_old
} else if non_embedded && ascii_only { || (info.non_embedded && ascii_only)
SetTextMode::InPlace || info.data_covers);
} else if t
.font() let mode = if keep_font_ok && !nested_obj {
.data() SetTextMode::InPlace
.ok() } else if keep_font_ok && nested_obj {
.map_or(false, |bytes| fontmatch::coverage_ok(&bytes, text)) // Giữ font gốc nhưng phải TẠO LẠI ở cấp trang: dùng lại
{ // chính font object (token) nếu probe ghi/đọc tròn trịa;
SetTextMode::InPlace // hỏng thì nhúng lại bytes font; cuối cùng mới thay họ.
if probe_font_roundtrip(&mut page, info.token, text) {
SetTextMode::NestedRecreate(ReflowFont::Original(info.token))
} else if let Some(bytes) = info.embedded_cover_bytes {
let key = (
format!("emb:{}:{}", fontmatch::normalize_key(&info.family), index),
info.bold,
info.italic,
);
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
SetTextMode::NestedRecreate(ReflowFont::Loaded(key))
} else { } else {
let (key, bytes) = resolve_substitute_font( let (key, bytes) = resolve_substitute_font(
&cur_family, &info.family,
target_bold, target_bold,
target_italic, target_italic,
text, text,
)?; )?;
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
SetTextMode::Substitute(key) SetTextMode::NestedRecreate(ReflowFont::Loaded(key))
} }
} else { } else {
// Thiếu glyph hoặc chủ động đổi family/kiểu → font thay thế.
let family = font_family let family = font_family
.clone() .clone()
.filter(|f| !f.trim().is_empty()) .filter(|f| !f.trim().is_empty())
.unwrap_or(cur_family); .unwrap_or(info.family);
let (key, bytes) = let (key, bytes) =
resolve_substitute_font(&family, target_bold, target_italic, text)?; resolve_substitute_font(&family, target_bold, target_italic, text)?;
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
SetTextMode::Substitute(key) if nested_obj {
SetTextMode::NestedRecreate(ReflowFont::Loaded(key))
} else {
SetTextMode::Substitute(key)
}
}; };
set_text_modes.insert(opi, mode); set_text_modes.insert(opi, mode);
} }
@@ -1045,7 +1122,10 @@ pub fn apply_edits(
for (opi, op) in ops.iter().enumerate() { for (opi, op) in ops.iter().enumerate() {
let idx = match op { let idx = match op {
EditOp::SetText { index, .. } EditOp::SetText { index, .. }
if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) => if matches!(
set_text_modes.get(&opi),
Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_))
) =>
{ {
*index *index
} }
@@ -1086,7 +1166,10 @@ pub fn apply_edits(
for (opi, op) in ops.iter().enumerate() { for (opi, op) in ops.iter().enumerate() {
match op { match op {
EditOp::SetText { index, .. } EditOp::SetText { index, .. }
if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) => if matches!(
set_text_modes.get(&opi),
Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_))
) =>
{ {
to_remove.push(*index) to_remove.push(*index)
} }
@@ -1104,22 +1187,21 @@ 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();
// Con TRONG form: chưa rút object ra được (FPDFFormObj_RemoveObject // Con TRONG form: RÚT THẬT khỏi form (FPDFFormObj_RemoveObject) — cách
// chưa mở ở wrapper) → "xoá" = set text RỖNG — glyph biến mất, PDFium // DUY NHẤT đánh dấu dirty_streams để stream form được ghi lại khi lưu
// regenerate stream form khi lưu (ProcessForm → HasDirtyStreams). // (SetText/Transform trên con không làm form dirty). Thứ tự: path SÂU
// Làm TRƯỚC khi xoá object cấp trang để path (nấc đầu = index trang) // trước, cùng cha thì con GIẢM DẦN — rút 1 con làm các em sau nó trong
// còn đúng. Con không phải text (ảnh/path trong form): v1 bỏ qua. // cùng form tụt index, và rút ở nấc nông làm lệch path xuyên qua form.
for &i in to_remove.iter().filter(|&&i| is_nested(i)) { // Làm TRƯỚC khi xoá cấp trang để nấc đầu path (index trang) còn đúng.
let entry = &entries[i as usize]; // Con không phải text (ảnh/path trong form): v1 bỏ qua.
if entry.kind != ObjectKind::Text { let mut nested_paths: Vec<&Vec<u16>> = to_remove
continue; .iter()
} .filter(|&&i| is_nested(i) && entries[i as usize].kind == ObjectKind::Text)
let mut obj = object_at_path(&page, &entry.path)?; .map(|&i| &entries[i as usize].path)
if let Some(t) = obj.as_text_object_mut() { .collect();
if t.set_text("").is_err() { nested_paths.sort_by(|a, b| b.len().cmp(&a.len()).then(b.cmp(a)));
let _ = t.set_text(" "); for path in nested_paths {
} remove_form_child(&page, path)?;
}
} }
// Cấp trang: xoá thật theo index trang GIẢM DẦN. // Cấp trang: xoá thật theo index trang GIẢM DẦN.
let mut page_idxs: Vec<u16> = to_remove let mut page_idxs: Vec<u16> = to_remove
@@ -1142,17 +1224,27 @@ pub fn apply_edits(
for (opi, op) in ops.iter().enumerate() { for (opi, op) in ops.iter().enumerate() {
match op { match op {
EditOp::SetText { index, text, font_size, color, .. } => { EditOp::SetText { index, text, font_size, color, .. } => {
let key = match set_text_modes.get(&opi) { // Substitute (đổi font) hoặc NestedRecreate (con trong form,
Some(SetTextMode::Substitute(key)) => key, // giữ font gốc qua token) — đều tạo lại object ở cấp trang
// với matrix ĐÃ COMPOSE từ pha chụp.
let token = match set_text_modes.get(&opi) {
Some(SetTextMode::Substitute(key)) => {
tokens.get(key).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho SetText".into())
})?
}
Some(SetTextMode::NestedRecreate(ReflowFont::Original(tok))) => *tok,
Some(SetTextMode::NestedRecreate(ReflowFont::Loaded(key))) => {
tokens.get(key).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho SetText".into())
})?
}
_ => continue, _ => continue,
}; };
let cap = match captured.get(index) { let cap = match captured.get(index) {
Some(c) => c, Some(c) => c,
None => continue, None => continue,
}; };
let token = tokens.get(key).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho SetText".into())
})?;
// Quy đổi cỡ hiển thị mong muốn → cỡ unscaled trước khi áp // Quy đổi cỡ hiển thị mong muốn → cỡ unscaled trước khi áp
// matrix gốc (matrix có thể chứa scale — tránh phóng đại kép). // matrix gốc (matrix có thể chứa scale — tránh phóng đại kép).
let tf = match font_size { let tf = match font_size {
+19 -12
View File
@@ -23,18 +23,25 @@ Type0/CID nhúng), trong khi `list_objects` chỉ duyệt object cấp trang.
thị nhân `mat_vscale(acc)` (độ dài ảnh vector đơn vị Y). 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) - **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). **+ `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ỡ: - **Áp op cho con trong form — bài học đắt giá từ CI vòng 1**: `dirty_streams_`
so cỡ theo KHÔNG GIAN TRANG — target × vscale); SetText-substitute và của form CHỈ được đánh dấu bởi `RemovePageObject`/`InsertPageObjectAtIndex`
ReflowText tạo object mới ở CẤP TRANG với matrix/toạ độ đã compose; trên CHÍNH form (xem cpdf_pageobjectholder.cpp) — **`FPDFText_SetText` trên
**"xoá" con trong form = `set_text("")`** (glyph biến mất) vì con KHÔNG làm form dirty → sửa in-place trong form không bao giờ được ghi
`FPDFFormObj_RemoveObject` chưa mở ở wrapper cho pdfium_7543 — làm TRƯỚC ra file** (test canary bắt được đúng lỗi này: extract sau lưu vẫn thấy text
khi xoá object cấp trang để path còn đúng. Transform (kéo/resize) con cũ). Cách làm đúng: mọi sửa/xoá con trong form = **RÚT THẬT khỏi form qua
trong form: bỏ qua, UI chặn kéo. `FPDFFormObj_RemoveObject`** (đánh dấu dirty → `ProcessForm` regenerate
- **Điểm sống còn đã xác minh trước khi code**: PDFium (bản mới, bblanchon stream form khi lưu) **+ với SetText thì tạo lại ở CẤP TRANG** giữ font gốc
latest) REGENERATE content stream của form khi con bị sửa — qua token (probe như tầng 0 reflow; thiếu glyph → nhúng lại bytes/cùng họ)
`CPDF_PageContentGenerator::ProcessForm` → `HasDirtyStreams` → chạy với matrix đã compose (`SetTextMode::NestedRecreate`). Thứ tự rút: path sâu
generator đệ quy cho form. Không có cái này thì sửa xong lưu ra vẫn thấy trước, cùng cha con lớn trước (rút làm tụt index các em). Transform
chữ cũ. (kéo/resize) con trong form: bỏ qua, UI chặn kéo.
- **Đổi feature pdfium-render `pdfium_latest`(7543) → `pdfium_7350`** (cả
ff-engine lẫn app): wrapper 0.8.37 gate `remove_object` của form chỉ mở cho
7350/future dù binding có đủ cho 7543 (sót gate upstream); API 7350 là
subset của DLL bblanchon mới → không mất gì (đã quét: 0 hàm 7543-only).
- CI test chạy `--test-threads=1`: PDFium serialize qua 1 mutex toàn cục —
1 test panic khi giữ lock sẽ poison mutex, 17 test song song chết oan
(đã dính ở CI vòng 1).
- Vá kèm: đọc source crate pdfium-render tải từ crates.io để chốt API vì máy - 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). dev không compile được Rust (xem mục CI bên dưới).