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
+153 -61
View File
@@ -298,6 +298,29 @@ fn object_at_path<'a>(page: &PdfPage<'a>, path: &[u16]) -> Result<PdfPageObject<
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
/// 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
@@ -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à
/// tạo lại object với font `key`.
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ế.
@@ -620,64 +647,114 @@ pub fn apply_edits(
if !valid(*index) {
continue;
}
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
let nested_obj = entries[*index as usize].path.len() > 1;
// Chụp mọi thứ cần thiết trong 1 block để NHẢ mượn trang
// trước khi probe font (probe cần &mut page).
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(cur_bold);
let target_italic = italic.unwrap_or(cur_italic);
let style_change = target_bold != cur_bold || target_italic != cur_italic;
let target_bold = bold.unwrap_or(info.bold);
let target_italic = italic.unwrap_or(info.italic);
let style_change = target_bold != info.bold || target_italic != info.italic;
let family_change = font_family.as_deref().map_or(false, |f| {
!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 {
let old_text = t.text();
let old_chars: HashSet<char> = old_text.chars().collect();
let new_covered_by_old = text
.chars()
.all(|c| c.is_control() || old_chars.contains(&c));
// 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
// 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).
let non_embedded = !t.font().is_embedded().unwrap_or(true);
let ascii_only = text
.chars()
.all(|c| c.is_control() || (' '..='~').contains(&c));
if *text == old_text || new_covered_by_old {
SetTextMode::InPlace
} else if non_embedded && ascii_only {
SetTextMode::InPlace
} else if t
.font()
.data()
.ok()
.map_or(false, |bytes| fontmatch::coverage_ok(&bytes, text))
{
SetTextMode::InPlace
// Font gốc có giữ được không (không xét in-place hay recreate)?
let old_chars: HashSet<char> = info.old_text.chars().collect();
let new_covered_by_old = text
.chars()
.all(|c| c.is_control() || old_chars.contains(&c));
// 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
// 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).
let ascii_only = text
.chars()
.all(|c| c.is_control() || (' '..='~').contains(&c));
let keep_font_ok = !style_change
&& !family_change
&& (*text == info.old_text
|| new_covered_by_old
|| (info.non_embedded && ascii_only)
|| info.data_covers);
let mode = if keep_font_ok && !nested_obj {
SetTextMode::InPlace
} else if keep_font_ok && nested_obj {
// 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;
// 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 {
let (key, bytes) = resolve_substitute_font(
&cur_family,
&info.family,
target_bold,
target_italic,
text,
)?;
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
SetTextMode::Substitute(key)
SetTextMode::NestedRecreate(ReflowFont::Loaded(key))
}
} else {
// Thiếu glyph hoặc chủ động đổi family/kiểu → font thay thế.
let family = font_family
.clone()
.filter(|f| !f.trim().is_empty())
.unwrap_or(cur_family);
.unwrap_or(info.family);
let (key, bytes) =
resolve_substitute_font(&family, target_bold, target_italic, text)?;
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);
}
@@ -1045,7 +1122,10 @@ pub fn apply_edits(
for (opi, op) in ops.iter().enumerate() {
let idx = match op {
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
}
@@ -1086,7 +1166,10 @@ pub fn apply_edits(
for (opi, op) in ops.iter().enumerate() {
match op {
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)
}
@@ -1104,22 +1187,21 @@ pub fn apply_edits(
to_remove.retain(|i| valid(*i));
to_remove.sort_unstable();
to_remove.dedup();
// 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(" ");
}
}
// Con TRONG form: RÚT THẬT khỏi form (FPDFFormObj_RemoveObject) — cách
// DUY NHẤT đánh dấu dirty_streams để stream form được ghi lại khi lưu
// (SetText/Transform trên con không làm form dirty). Thứ tự: path SÂU
// trước, cùng cha thì con GIẢM DẦN — rút 1 con làm các em sau nó trong
// cùng form tụt index, và rút ở nấc nông làm lệch path xuyên qua form.
// Làm TRƯỚC khi xoá cấp trang để nấc đầu path (index trang) còn đúng.
// Con không phải text (ảnh/path trong form): v1 bỏ qua.
let mut nested_paths: Vec<&Vec<u16>> = to_remove
.iter()
.filter(|&&i| is_nested(i) && entries[i as usize].kind == ObjectKind::Text)
.map(|&i| &entries[i as usize].path)
.collect();
nested_paths.sort_by(|a, b| b.len().cmp(&a.len()).then(b.cmp(a)));
for path in nested_paths {
remove_form_child(&page, path)?;
}
// Cấp trang: xoá thật theo index trang GIẢM DẦN.
let mut page_idxs: Vec<u16> = to_remove
@@ -1142,17 +1224,27 @@ pub fn apply_edits(
for (opi, op) in ops.iter().enumerate() {
match op {
EditOp::SetText { index, text, font_size, color, .. } => {
let key = match set_text_modes.get(&opi) {
Some(SetTextMode::Substitute(key)) => key,
// Substitute (đổi font) hoặc NestedRecreate (con trong form,
// 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,
};
let cap = match captured.get(index) {
Some(c) => c,
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
// matrix gốc (matrix có thể chứa scale — tránh phóng đại kép).
let tf = match font_size {