Form XObject vòng 4 — kiến trúc cuối: MỞ GÓI form ra cấp trang trước khi sửa
Build Windows portable / build (push) Canceled after 0s

Kết luận sau 3 vòng CI + đọc nguyên văn source PDFium: PDFium hiện KHÔNG ghi
lại được stream của Form XObject — CPDF_PageContentManager chỉ biết key
/Contents (trang), form là stream tự thân nên nội dung regenerate rơi vào
key /Contents vô nghĩa trên dict form, stream thật giữ nguyên. Mọi cách sửa
"trong form" đều không thể lưu.

- Engine mới: flatten_form_xobjects — rút từng con khỏi form
  (FPDFFormObj_RemoveObject), nhân ma trận form vào con, chèn lại vào TRANG,
  xoá form rỗng; lặp <=4 vòng cho form lồng form. Hiển thị y hệt, mọi object
  thành cấp trang, sửa bằng đường page-level đã test kỹ.
- Tauri: command edit_flatten_to_temp; UI loadEditPage thấy object nested →
  tự mở gói một lần rồi nạp lại (bản mở gói không tính là "có thay đổi").
- apply_edits: gỡ toàn bộ đường sửa-trong-form (NestedRecreate, rút con,
  đánh thức trang — đã chứng minh không thể lưu); op nhắm vào object trong
  form khi chưa flatten → lỗi rõ "trang chưa được mở gói" (có test).
- Test viết lại theo flow flatten: 6 test (liệt kê nested, flatten giữ vị
  trí/nội dung, settext/reflow/delete sau flatten, từ chối op nested).
Docs 12-phase4-summary.md cập nhật kết luận + giới hạn (mất clip/ExtGState
mức form khi flatten; cấu trúc file đổi khi lưu).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 02:26:30 +07:00
co-authored by Claude Fable 5
parent 1b3cf15664
commit d4b09e0ed8
6 changed files with 306 additions and 224 deletions
+23
View File
@@ -1302,6 +1302,28 @@ fn pick_save_pem(app: tauri::AppHandle) -> Option<String> {
.map(|fp| fp.to_string()) .map(|fp| fp.to_string())
} }
/// "Mở gói" mọi Form XObject của trang ra cấp trang (file Canva/Illustrator
/// gói cả trang vào form — PDFium không ghi lại được stream form nên phải
/// flatten trước khi sửa). Trả None nếu trang không có form (không tạo file).
#[tauri::command]
fn edit_flatten_to_temp(input: String, page: u16, password: Option<String>) -> Result<Option<String>, String> {
let pdfium = pdfium()?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let out = std::env::temp_dir().join(format!("ff_edit_flat_{}_{}.pdf", std::process::id(), nanos));
let n = ff_engine::flatten_form_xobjects(
&pdfium,
std::path::Path::new(&input),
page,
&out,
password.as_deref(),
)
.map_err(|e| e.to_string())?;
Ok(if n > 0 { Some(out.to_string_lossy().into_owned()) } else { None })
}
/// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên /// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên
/// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng. /// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng.
#[tauri::command] #[tauri::command]
@@ -1362,6 +1384,7 @@ fn main() {
edit_apply_to_temp, edit_apply_to_temp,
edit_preview, edit_preview,
edit_cleanup, edit_cleanup,
edit_flatten_to_temp,
redact_apply, redact_apply,
security_encrypt, security_encrypt,
security_decrypt, security_decrypt,
+23 -1
View File
@@ -39,6 +39,7 @@ const state = {
editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh) editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
editPendingPoint: null, // điểm PDF chờ mở ô sửa (đúp chuột từ viewer thường) editPendingPoint: null, // điểm PDF chờ mở ô sửa (đúp chuột từ viewer thường)
editFlatBase: null, // bản "mở gói" Form XObject — không tính là thay đổi
editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text
editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn) editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn)
secMode: false, // thanh Bảo mật (Phase 5) đang mở secMode: false, // thanh Bảo mật (Phase 5) đang mở
@@ -2054,6 +2055,7 @@ function enterEditMode(pageIndex) {
state.editSel = null; state.editSel = null;
state.editArm = null; state.editArm = null;
state.editPendingImage = null; state.editPendingImage = null;
state.editFlatBase = null;
$("annobar").classList.add("hidden"); $("annobar").classList.add("hidden");
$("editBar").classList.remove("hidden"); $("editBar").classList.remove("hidden");
$("viewport").classList.add("hidden"); $("viewport").classList.add("hidden");
@@ -2125,6 +2127,25 @@ async function loadEditPage() {
invoke("render_page", { path: state.editBase, page: state.editPage, width: EDIT_STAGE_W }), invoke("render_page", { path: state.editBase, page: state.editPage, width: EDIT_STAGE_W }),
invoke("edit_list_objects", { path: state.editBase, page: state.editPage, password: null }), invoke("edit_list_objects", { path: state.editBase, page: state.editPage, password: null }),
]); ]);
// Trang có object nằm TRONG Form XObject (file Canva/Illustrator gói cả
// trang vào form): PDFium không ghi lại được stream form → "mở gói" form
// ra cấp trang MỘT LẦN (hiển thị y hệt) rồi nạp lại — từ đó sửa như
// trang thường. Bản mở gói không tính là "có thay đổi" cho tới khi sửa.
if (objs.some((o) => o.nested)) {
try {
const flat = await invoke("edit_flatten_to_temp", {
input: state.editBase, page: state.editPage, password: null,
});
if (flat) {
state.editTemps.push(flat);
state.editBase = flat;
state.editFlatBase = flat;
return loadEditPage();
}
} catch (e) {
$("editHint").textContent = "Không mở gói được form: " + e;
}
}
$("editImg").src = url; $("editImg").src = url;
state.editObjects = objs; state.editObjects = objs;
buildEditOverlay(); buildEditOverlay();
@@ -2132,7 +2153,8 @@ async function loadEditPage() {
} catch (e) { } catch (e) {
$("editHint").textContent = "Lỗi nạp trang sửa: " + e; $("editHint").textContent = "Lỗi nạp trang sửa: " + e;
} }
$("edSave").disabled = state.editBase === state.path; // chưa có thay đổi nào // Chưa có thay đổi nào (bản mở gói form không tính).
$("edSave").disabled = state.editBase === state.path || state.editBase === state.editFlatBase;
updateEditUndoButtons(); updateEditUndoButtons();
// Đúp chuột từ viewer thường (kiểu Foxit): vào thẳng ô sửa tại điểm đã đúp. // Đúp chuột từ viewer thường (kiểu Foxit): vào thẳng ô sửa tại điểm đã đúp.
+136 -148
View File
@@ -298,27 +298,94 @@ 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 /// "MỞ GÓI" mọi Form XObject cấp trang thành object cấp trang: rút từng con
/// DUY NHẤT làm thay đổi trong form được lưu ra file: chỉ RemovePageObject /// khỏi form (`FPDFFormObj_RemoveObject` — cần feature pdfium_7350), nhân ma
/// mới đánh dấu `dirty_streams_` của form để PDFium ghi lại stream khi /// trận form vào con, chèn lại vào TRANG, xoá form rỗng. Hiển thị y hệt,
/// GenerateContent (SetText trên con KHÔNG làm form dirty — xem /// nhưng mọi object thành cấp trang — điều kiện để SỬA được.
/// cpdf_pageobjectholder.cpp). Cần feature pdfium_7350 của pdfium-render. ///
fn remove_form_child(page: &PdfPage<'_>, child_path: &[u16]) -> Result<(), EngineError> { /// Vì sao bắt buộc: PDFium hiện KHÔNG ghi lại được stream của form —
let err = |e: PdfiumError| EngineError::Pdfium(format!("rút object khỏi form: {e}")); /// `CPDF_PageContentManager` chỉ biết key /Contents (trang), còn form là
debug_assert!(child_path.len() >= 2); /// stream tự thân → mọi "regenerate" nội dung form rơi vào key /Contents vô
let parent_path = &child_path[..child_path.len() - 1]; /// nghĩa trên dict form, stream thật giữ nguyên. Đã kiểm chứng bằng test
let child_idx = *child_path.last().unwrap() as usize; /// round-trip 3 vòng CI + đọc source cpdf_pagecontentmanager.cpp.
let mut parent = object_at_path(page, parent_path)?; ///
/// Form lồng form: lặp tối đa 4 vòng (con là form sẽ thành cấp trang rồi
/// được mở tiếp). Trả về số form đã mở; 0 = trang không có form (KHÔNG ghi
/// `output`).
pub fn flatten_form_xobjects(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
output: &Path,
password: Option<&str>,
) -> Result<usize, EngineError> {
let mut document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let err = |e: PdfiumError| EngineError::Pdfium(format!("mở gói form: {e}"));
let mut flattened = 0usize;
{
let mut page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
for _round in 0..4 {
let n = page.objects().len();
let mut form_idxs: Vec<usize> = Vec::new();
for i in 0..n {
if let Ok(o) = page.objects().get(i) {
if matches!(o, PdfPageObject::XObjectForm(_)) {
form_idxs.push(i);
}
}
}
if form_idxs.is_empty() {
break;
}
// Duyệt NGƯỢC: xoá form không làm lệch index các form đứng trước.
for &fi in form_idxs.iter().rev() {
let fm = {
let o = page.objects().get(fi).map_err(err)?;
o.matrix().map_err(err)?
};
// Rút từng con (luôn con 0) → nhân ma trận form → chèn vào trang
// (chèn ở CUỐI nên không lệch index form đang xử lý).
loop {
let mut child = {
let mut parent = page.objects().get(fi).map_err(err)?;
let form = match &mut parent { let form = match &mut parent {
PdfPageObject::XObjectForm(f) => f, PdfPageObject::XObjectForm(f) => f,
_ => return Err(EngineError::Pdfium("path cha không phải form".into())), _ => break,
}; };
let child = form.get(child_idx).map_err(err)?; if form.is_empty() {
let removed = form.remove_object(child).map_err(err)?; break;
// 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). let c = form.get(0).map_err(err)?;
form.remove_object(c).map_err(err)?
};
child.apply_matrix(fm).map_err(err)?;
page.objects_mut().add_object(child).map_err(err)?;
}
let removed =
page.objects_mut().remove_object_at_index(fi).map_err(err)?;
// Bẫy Drop quen thuộc: object đã tách khỏi trang → forget.
std::mem::forget(removed); std::mem::forget(removed);
Ok(()) flattened += 1;
}
}
if flattened > 0 {
page.regenerate_content().map_err(err)?;
}
}
if flattened > 0 {
document
.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
}
Ok(flattened)
} }
/// 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
@@ -564,10 +631,15 @@ 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. /// Op sửa nhắm vào object TRONG Form XObject → lỗi rõ ràng: PDFium không ghi
NestedRecreate(ReflowFont), /// lại được stream form, mọi sửa trong form là vô nghĩa. UI phải "mở gói"
/// trang bằng `flatten_form_xobjects` trước khi sửa (loadEditPage tự làm).
fn nested_target_err() -> EngineError {
EngineError::Pdfium(
"object nằm trong Form XObject — trang chưa được mở gói (flatten) trước khi sửa".into(),
)
} }
/// 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ế.
@@ -647,57 +719,26 @@ pub fn apply_edits(
if !valid(*index) { if !valid(*index) {
continue; continue;
} }
let nested_obj = entries[*index as usize].path.len() > 1; if entries[*index as usize].path.len() > 1 {
// Chụp mọi thứ cần thiết trong 1 block để NHẢ mượn trang return Err(nested_target_err());
// 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)?; let obj = object_at_path(&page, &entries[*index as usize].path)?;
match obj.as_text_object() { let t = match obj.as_text_object() {
Some(t) => { Some(t) => 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 None => continue, // không phải text → bỏ qua op
}
}; };
let target_bold = bold.unwrap_or(info.bold); let (cur_family, cur_bold, cur_italic) = text_object_style(t);
let target_italic = italic.unwrap_or(info.italic); let target_bold = bold.unwrap_or(cur_bold);
let style_change = target_bold != info.bold || target_italic != info.italic; let target_italic = italic.unwrap_or(cur_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(&info.family) && fontmatch::normalize_key(f) != fontmatch::normalize_key(&cur_family)
}); });
// Font gốc có giữ được không (không xét in-place hay recreate)? let mode = if !style_change && !family_change {
let old_chars: HashSet<char> = info.old_text.chars().collect(); let old_text = t.text();
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));
@@ -705,56 +746,40 @@ pub fn apply_edits(
// 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 non_embedded = !t.font().is_embedded().unwrap_or(true);
let ascii_only = text let ascii_only = text
.chars() .chars()
.all(|c| c.is_control() || (' '..='~').contains(&c)); .all(|c| c.is_control() || (' '..='~').contains(&c));
let keep_font_ok = !style_change if *text == old_text || new_covered_by_old {
&& !family_change SetTextMode::InPlace
&& (*text == info.old_text } else if non_embedded && ascii_only {
|| new_covered_by_old SetTextMode::InPlace
|| (info.non_embedded && ascii_only) } else if t
|| info.data_covers); .font()
.data()
let mode = if keep_font_ok && !nested_obj { .ok()
.map_or(false, |bytes| fontmatch::coverage_ok(&bytes, text))
{
SetTextMode::InPlace 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 { } else {
let (key, bytes) = resolve_substitute_font( let (key, bytes) = resolve_substitute_font(
&info.family, &cur_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::NestedRecreate(ReflowFont::Loaded(key)) SetTextMode::Substitute(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(info.family); .unwrap_or(cur_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));
if nested_obj {
SetTextMode::NestedRecreate(ReflowFont::Loaded(key))
} else {
SetTextMode::Substitute(key) SetTextMode::Substitute(key)
}
}; };
set_text_modes.insert(opi, mode); set_text_modes.insert(opi, mode);
} }
@@ -769,6 +794,9 @@ pub fn apply_edits(
let mut idxs: Vec<u16> = indices.iter().copied().filter(|i| valid(*i)).collect(); let mut idxs: Vec<u16> = indices.iter().copied().filter(|i| valid(*i)).collect();
idxs.sort_unstable(); idxs.sort_unstable();
idxs.dedup(); idxs.dedup();
if idxs.iter().any(|&i| entries[i as usize].path.len() > 1) {
return Err(nested_target_err());
}
// Hình học từng run: gốc text (e,f của matrix) + bounds. // Hình học từng run: gốc text (e,f của matrix) + bounds.
struct RunGeo { struct RunGeo {
idx: u16, idx: u16,
@@ -816,10 +844,11 @@ 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). // Rect trang của 1 entry text (None nếu không phải text
// hoặc nằm trong form — sau flatten không còn, guard).
let text_rect = |i: u16| -> Option<Rect> { let text_rect = |i: u16| -> Option<Rect> {
let entry = &entries[i as usize]; let entry = &entries[i as usize];
if entry.kind != ObjectKind::Text { if entry.kind != ObjectKind::Text || entry.path.len() > 1 {
return None; return None;
} }
let obj = object_at_path(&page, &entry.path).ok()?; let obj = object_at_path(&page, &entry.path).ok()?;
@@ -1122,10 +1151,7 @@ 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!( if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) =>
set_text_modes.get(&opi),
Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_))
) =>
{ {
*index *index
} }
@@ -1166,10 +1192,7 @@ 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!( if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) =>
set_text_modes.get(&opi),
Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_))
) =>
{ {
to_remove.push(*index) to_remove.push(*index)
} }
@@ -1187,39 +1210,14 @@ 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: RÚT THẬT khỏi form (FPDFFormObj_RemoveObject) — cách // Op xoá nhắm vào object TRONG form: không xử lý được (PDFium không
// DUY NHẤT đánh dấu dirty_streams để stream form được ghi lại khi lưu // ghi lại stream form) — trang phải được flatten trước, lỗi rõ ràng.
// (SetText/Transform trên con không làm form dirty). Thứ tự: path SÂU if to_remove.iter().any(|&i| is_nested(i)) {
// trước, cùng cha thì con GIẢM DẦN — rút 1 con làm các em sau nó trong return Err(nested_target_err());
// 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)));
let mut touched_roots: Vec<u16> = nested_paths.iter().map(|p| p[0]).collect();
for path in nested_paths {
remove_form_child(&page, path)?;
}
// ĐÁNH THỨC trang: GenerateContent chỉ ghi lại stream trang khi có
// object CẤP TRANG dirty; rút con mới làm FORM dirty thôi — op thuần
// trong form (Delete...) không đụng cấp trang → trang không regenerate
// → ProcessForm (nơi ghi lại form) không bao giờ chạy. Nhân identity
// vào form tổ tiên cấp trang: Transform luôn SetDirty, giá trị không
// đổi → trang được ghi lại → form được ghi lại theo.
touched_roots.sort_unstable();
touched_roots.dedup();
for r in touched_roots {
let mut root = object_at_path(&page, &[r])?;
root.apply_matrix(PdfMatrix::IDENTITY).map_err(err)?;
} }
// 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
.iter() .iter()
.filter(|&&i| !is_nested(i))
.map(|&i| entries[i as usize].path[0]) .map(|&i| entries[i as usize].path[0])
.collect(); .collect();
page_idxs.sort_unstable(); page_idxs.sort_unstable();
@@ -1237,27 +1235,17 @@ 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, .. } => {
// Substitute (đổi font) hoặc NestedRecreate (con trong form, let key = match set_text_modes.get(&opi) {
// giữ font gốc qua token) — đều tạo lại object ở cấp trang Some(SetTextMode::Substitute(key)) => key,
// 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 {
+1 -1
View File
@@ -22,7 +22,7 @@ pub mod watermark;
pub use annot::{ pub use annot::{
apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec, apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec,
}; };
pub use edit::{apply_edits, list_objects, EditOp, ObjectInfo, ObjectKind}; pub use edit::{apply_edits, flatten_form_xobjects, list_objects, EditOp, ObjectInfo, ObjectKind};
pub use meta::{outline, page_dims, strip_metadata, OutlineItem, PageDim}; pub use meta::{outline, page_dims, strip_metadata, OutlineItem, PageDim};
pub use organize::{ pub use organize::{
build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages, build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages,
+70 -21
View File
@@ -1086,18 +1086,47 @@ fn form_xobject_children_are_listed() {
assert!((text.font_size.unwrap_or(0.0) - 24.0).abs() < 2.0, "cỡ hiển thị ~24: {:?}", text.font_size); 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 // PDFium KHÔNG ghi lại được stream của Form XObject (CPDF_PageContentManager
// regenerate content stream của form (ProcessForm → HasDirtyStreams). // chỉ biết /Contents của trang) → muốn sửa phải MỞ GÓI form ra cấp trang.
#[test] #[test]
fn form_xobject_settext_inplace_roundtrip() { fn flatten_form_xobjects_unwraps_page() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_flatten.pdf");
let out = tmp("ff_edit_formx_flatten_out.pdf");
let n = ff_engine::flatten_form_xobjects(&pdf, &input, 0, &out, None).expect("flatten");
assert_eq!(n, 1, "phải mở đúng 1 form");
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
assert!(
objs.iter().all(|o| o.kind != ObjectKind::XObjectForm),
"không còn form nào: {objs:?}"
);
let t = objs
.iter()
.find(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|s| s.contains("Hello inside form")).unwrap_or(false))
.unwrap_or_else(|| panic!("text phải thành cấp trang: {objs:?}"));
assert!(!t.nested, "text sau flatten phải hết nested: {t:?}");
// Vị trí giữ nguyên (baseline 700, lề 72) và render vẫn ra chữ.
assert!(t.rect.left > 60.0 && t.rect.left < 84.0, "left ~72: {:?}", t.rect);
assert!(t.rect.bottom > 680.0 && t.rect.top < 740.0, "bbox quanh y=700: {:?}", t.rect);
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Hello inside form"), "nội dung không đổi: {text:?}");
}
// Sau flatten, sửa text (giữ font base-14) round-trip như trang thường.
#[test]
fn form_xobject_settext_after_flatten_roundtrip() {
let pdf = pdfium(); let pdf = pdfium();
let input = form_fixture("ff_edit_formx_settext.pdf"); let input = form_fixture("ff_edit_formx_settext.pdf");
let flat = tmp("ff_edit_formx_settext_flat.pdf");
let out = tmp("ff_edit_formx_settext_out.pdf"); let out = tmp("ff_edit_formx_settext_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form"); ff_engine::flatten_form_xobjects(&pdf, &input, 0, &flat, None).expect("flatten");
let idx = find_text_index(&pdf, &flat, "Hello inside form");
ff_engine::apply_edits( ff_engine::apply_edits(
&pdf, &pdf,
&input, &flat,
0, 0,
&[EditOp::SetText { &[EditOp::SetText {
index: idx, index: idx,
@@ -1111,25 +1140,26 @@ fn form_xobject_settext_inplace_roundtrip() {
&out, &out,
None, None,
) )
.expect("apply_edits SetText trong form"); .expect("apply_edits SetText sau flatten");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); 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("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:?}"); assert!(!text.contains("Hello inside form"), "text cũ 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 // Reflow tiếng Việt sau flatten: bẻ dòng quanh vị trí khối cũ.
// form), dòng mới vẽ ở cấp trang với toạ độ đã compose ma trận form.
#[test] #[test]
fn form_xobject_reflow_roundtrip() { fn form_xobject_reflow_after_flatten_roundtrip() {
let pdf = pdfium(); let pdf = pdfium();
let input = form_fixture("ff_edit_formx_reflow.pdf"); let input = form_fixture("ff_edit_formx_reflow.pdf");
let flat = tmp("ff_edit_formx_reflow_flat.pdf");
let out = tmp("ff_edit_formx_reflow_out.pdf"); let out = tmp("ff_edit_formx_reflow_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form"); ff_engine::flatten_form_xobjects(&pdf, &input, 0, &flat, None).expect("flatten");
let idx = find_text_index(&pdf, &flat, "Hello inside form");
ff_engine::apply_edits( ff_engine::apply_edits(
&pdf, &pdf,
&input, &flat,
0, 0,
&[EditOp::ReflowText { &[EditOp::ReflowText {
indices: vec![idx], indices: vec![idx],
@@ -1138,12 +1168,11 @@ fn form_xobject_reflow_roundtrip() {
&out, &out,
None, None,
) )
.expect("apply_edits ReflowText trong form"); .expect("apply_edits ReflowText sau flatten");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); 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("Đ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:?}"); 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 objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let new_runs: Vec<_> = objs let new_runs: Vec<_> = objs
.iter() .iter()
@@ -1151,21 +1180,41 @@ fn form_xobject_reflow_roundtrip() {
.collect(); .collect();
assert!(!new_runs.is_empty(), "phải có run mới: {objs:?}"); assert!(!new_runs.is_empty(), "phải có run mới: {objs:?}");
for r in &new_runs { for r in &new_runs {
assert!(r.rect.left > 50.0 && r.rect.top < 740.0 && r.rect.bottom > 500.0, assert!(
"dòng mới phải quanh khối cũ: {:?}", r.rect); 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
);
} }
} }
// Xoá sau flatten: text biến mất thật khỏi file lưu ra.
#[test] #[test]
fn form_xobject_delete_clears_text() { fn form_xobject_delete_after_flatten_clears_text() {
let pdf = pdfium(); let pdf = pdfium();
let input = form_fixture("ff_edit_formx_delete.pdf"); let input = form_fixture("ff_edit_formx_delete.pdf");
let flat = tmp("ff_edit_formx_delete_flat.pdf");
let out = tmp("ff_edit_formx_delete_out.pdf"); let out = tmp("ff_edit_formx_delete_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form"); ff_engine::flatten_form_xobjects(&pdf, &input, 0, &flat, None).expect("flatten");
let idx = find_text_index(&pdf, &flat, "Hello inside form");
ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: idx }], &out, None) ff_engine::apply_edits(&pdf, &flat, 0, &[EditOp::Delete { index: idx }], &out, None)
.expect("apply_edits Delete trong form"); .expect("apply_edits Delete sau flatten");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); 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:?}"); assert!(!text.contains("Hello inside form"), "text phải biến mất sau xoá: {text:?}");
}
// Op nhắm thẳng vào object trong form (chưa flatten) phải bị TỪ CHỐI rõ ràng
// (không âm thầm ghi ra bản không đổi).
#[test]
fn nested_target_without_flatten_is_rejected() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_reject.pdf");
let out = tmp("ff_edit_formx_reject_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form");
let err = ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: idx }], &out, None)
.expect_err("op vào object trong form phải bị từ chối");
assert!(format!("{err}").contains("mở gói"), "thông điệp phải nhắc flatten: {err}");
} }
+35 -35
View File
@@ -23,28 +23,28 @@ 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 — bài học đắt giá từ CI vòng 1**: `dirty_streams_` - **KẾT LUẬN QUAN TRỌNG NHẤT (3 vòng CI + đọc source PDFium): PDFium hiện
của form CHỈ được đánh dấu bởi `RemovePageObject`/`InsertPageObjectAtIndex` KHÔNG ghi lại được stream của Form XObject.** Chuỗi chứng cứ:
trên CHÍNH form (xem cpdf_pageobjectholder.cpp) — **`FPDFText_SetText` trên 1. `FPDFText_SetText` trên con không làm form dirty (`dirty_streams_` chỉ
con KHÔNG làm form dirty → sửa in-place trong form không bao giờ được ghi được đánh dấu bởi Remove/Insert trên chính form — cpdf_pageobjectholder.cpp);
ra file** (test canary bắt được đúng lỗi này: extract sau lưu vẫn thấy text 2. rút con bằng `FPDFFormObj_RemoveObject` + làm trang dirty → `ProcessForm`
cũ). Cách làm đúng: mọi sửa/xoá con trong form = **RÚT THẬT khỏi form qua CÓ chạy generator cho form, NHƯNG `CPDF_PageContentManager` (nơi ghi kết
`FPDFFormObj_RemoveObject`** (đánh dấu dirty → `ProcessForm` regenerate quả) chỉ biết key `/Contents` — form là stream TỰ THÂN, không có
stream form khi lưu) **+ với SetText thì tạo lại ở CẤP TRANG** giữ font gốc /Contents → content mới rơi vào key /Contents vô nghĩa trên dict form,
qua token (probe như tầng 0 reflow; thiếu glyph → nhúng lại bytes/cùng họ) stream thật giữ nguyên (cpdf_pagecontentmanager.cpp, constructor +
với matrix đã compose (`SetTextMode::NestedRecreate`). Thứ tự rút: path sâu AddStream). Test round-trip bắt đúng triệu chứng: extract sau lưu vẫn
trước, cùng cha con lớn trước (rút làm tụt index các em). Transform thấy text cũ.
(kéo/resize) con trong form: bỏ qua, UI chặn kéo. - **Giải pháp cuối: MỞ GÓI (flatten) form ra cấp trang** —
- **Đổi feature pdfium-render `pdfium_latest`(7543) → `pdfium_7350`** (cả `flatten_form_xobjects`: rút từng con khỏi form (`FPDFFormObj_RemoveObject`
ff-engine lẫn app): wrapper 0.8.37 gate `remove_object` của form chỉ mở cho — cần đổi feature pdfium-render `pdfium_latest`→`pdfium_7350` vì wrapper
7350/future dù binding có đủ cho 7543 (sót gate upstream); API 7350 là 0.8.37 sót gate; đã quét 0 hàm 7543-only bị mất), nhân ma trận form vào
subset của DLL bblanchon mới → không mất gì (đã quét: 0 hàm 7543-only). con, chèn lại vào TRANG, xoá form rỗng; lặp ≤4 vòng cho form lồng form.
- **Bài học CI vòng 2 — "đánh thức" trang**: `GenerateContent` chỉ ghi lại Hiển thị y hệt (đã kiểm vị trí/nội dung), mọi object thành cấp trang → sửa
stream trang khi có object CẤP TRANG dirty; rút con chỉ làm FORM dirty → bằng đường page-level đã được test kỹ từ iteration 1-3. UI: `loadEditPage`
op thuần trong form (Delete) không kích hoạt gì → `ProcessForm` không chạy. thấy object `nested` → gọi `edit_flatten_to_temp` một lần rồi nạp lại
Fix: sau khi rút con, nhân identity matrix vào form tổ tiên cấp trang (bản mở gói không tính là "có thay đổi" cho tới khi sửa thật).
(Transform luôn SetDirty, giá trị không đổi) → trang regenerate → form - Op nhắm vào object trong form khi CHƯA flatten → engine trả lỗi rõ ràng
regenerate theo. ("trang chưa được mở gói") thay vì âm thầm ghi bản không đổi — có test.
- CI test chạy `--test-threads=1`: PDFium serialize qua 1 mutex toàn cục — - CI test chạy `--test-threads=1`: PDFium serialize qua 1 mutex toàn cục —
1 test panic (kể cả panic Ở ASSERT khi instance Pdfium còn sống — unwind 1 test panic (kể cả panic Ở ASSERT khi instance Pdfium còn sống — unwind
drop guard giữa panic) sẽ poison mutex, mọi test SAU đó trong cùng process drop guard giữa panic) sẽ poison mutex, mọi test SAU đó trong cùng process
@@ -60,17 +60,17 @@ Type0/CID nhúng), trong khi `list_objects` chỉ duyệt object cấp trang.
- Chặn kéo di chuyển dòng chứa run `nested` (engine chưa hỗ trợ Transform - 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. 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) ### Test (6 — `edit_roundtrip.rs`, fixture tự dựng bằng lopdf)
- Fixture: PDF 1 trang, chữ "Hello inside form" Helvetica 24pt nằm TRONG - 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 (trang chỉ có 1 object form) — đúng cấu trúc Canva.
- `form_xobject_children_are_listed` — thấy text nested, form expanded, rect - `form_xobject_children_are_listed` — thấy text nested, form expanded, rect
quy về trang đúng chỗ (72, ~700), cỡ ~24. quy về trang đúng chỗ (72, ~700), cỡ ~24.
- `form_xobject_settext_inplace_roundtrip` — **canary quan trọng nhất**: sửa - `flatten_form_xobjects_unwraps_page` — mở gói: hết form, text thành cấp
in-place con trong form → lưu → text mới có mặt, text cũ biến mất (chứng trang, vị trí giữ nguyên, nội dung không đổi.
minh PDFium regenerate stream form). - `form_xobject_settext_after_flatten_roundtrip` / `..._reflow_...` /
- `form_xobject_reflow_roundtrip` — reflow tiếng Việt trong form: run cũ làm `..._delete_...` — sửa/reflow tiếng Việt/xoá SAU flatten round-trip chuẩn.
rỗng, dòng mới ở cấp trang quanh khối cũ. - `nested_target_without_flatten_is_rejected` — op vào object trong form khi
- `form_xobject_delete_clears_text` — xoá = text biến mất khỏi extract. chưa flatten bị từ chối với thông điệp rõ.
### CI chạy test engine ### 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 Máy dev không có Rust nên **CI là nơi duy nhất chạy test**: workflow thêm
@@ -78,12 +78,12 @@ 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. 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) ### 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). - Flatten làm MẤT clip path/ExtGState/transparency group đặt Ở MỨC FORM
- Chưa sửa/thay ẢNH trong form (khung overlay ẩn). (file Canva wrapper toàn trang thường không có — hiển thị y hệt; file có
- "Xoá" text trong form để lại text object rỗng trong form (vô hại, không clip ở form sẽ khác biệt, sẽ xử lý khi gặp thật).
render); khi wrapper mở `FPDFFormObj_RemoveObject` cho pdfium_7543 sẽ xoá - Cấu trúc file sau khi SỬA thay đổi (form được mở ra trang) — nội dung và
thật. hiển thị giữ nguyên; chỉ xảy ra khi người dùng thật sự lưu thay đổi.
- Form lồng sâu >3 cấp hoặc >4000 object con: phần vượt không liệt kê. - Form lồng sâu >4 vòng mở hoặc >4000 object: phần vượt giữ nguyên trong form.
> Các iteration trước: 52/52 test engine xanh ngoài qpdf (17 test edit + 6 > 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. > unit fontmatch + 29 test cũ) — nay thêm 4 test form + 2 unit sfnt style.