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
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:
@@ -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
@@ -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.
|
||||||
|
|||||||
+154
-166
@@ -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)?;
|
///
|
||||||
let form = match &mut parent {
|
/// Form lồng form: lặp tối đa 4 vòng (con là form sẽ thành cấp trang rồi
|
||||||
PdfPageObject::XObjectForm(f) => f,
|
/// được mở tiếp). Trả về số form đã mở; 0 = trang không có form (KHÔNG ghi
|
||||||
_ => return Err(EngineError::Pdfium("path cha không phải form".into())),
|
/// `output`).
|
||||||
};
|
pub fn flatten_form_xobjects(
|
||||||
let child = form.get(child_idx).map_err(err)?;
|
pdfium: &Pdfium,
|
||||||
let removed = form.remove_object(child).map_err(err)?;
|
input: &Path,
|
||||||
// Cùng bẫy Drop như remove ở cấp trang: object đã tách, forget để khỏi
|
page_index: u16,
|
||||||
// destroy nhầm (rò rỉ nhỏ, giải phóng khi đóng document).
|
output: &Path,
|
||||||
std::mem::forget(removed);
|
password: Option<&str>,
|
||||||
Ok(())
|
) -> 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 {
|
||||||
|
PdfPageObject::XObjectForm(f) => f,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
if form.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
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,114 +719,67 @@ 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)?;
|
let t = match obj.as_text_object() {
|
||||||
match obj.as_text_object() {
|
Some(t) => t,
|
||||||
Some(t) => {
|
None => continue, // không phải text → bỏ qua op
|
||||||
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 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 new_covered_by_old = text
|
let old_chars: HashSet<char> = old_text.chars().collect();
|
||||||
.chars()
|
let new_covered_by_old = text
|
||||||
.all(|c| c.is_control() || old_chars.contains(&c));
|
.chars()
|
||||||
// Font KHÔNG nhúng (base-14 Helvetica/Times...): viewer nào
|
.all(|c| c.is_control() || old_chars.contains(&c));
|
||||||
// cũng tự thay bằng font hệ thống đủ Latin → re-encode text
|
// Font KHÔNG nhúng (base-14 Helvetica/Times...): viewer nào
|
||||||
// ASCII tại chỗ chắc chắn an toàn, BaseFont khai báo giữ
|
// cũng tự thay bằng font hệ thống đủ Latin → re-encode text
|
||||||
// nguyên trong file (đúng hành vi Foxit với base-14).
|
// ASCII tại chỗ chắc chắn an toàn, BaseFont khai báo giữ
|
||||||
let ascii_only = text
|
// nguyên trong file (đúng hành vi Foxit với base-14).
|
||||||
.chars()
|
let non_embedded = !t.font().is_embedded().unwrap_or(true);
|
||||||
.all(|c| c.is_control() || (' '..='~').contains(&c));
|
let ascii_only = text
|
||||||
let keep_font_ok = !style_change
|
.chars()
|
||||||
&& !family_change
|
.all(|c| c.is_control() || (' '..='~').contains(&c));
|
||||||
&& (*text == info.old_text
|
if *text == old_text || new_covered_by_old {
|
||||||
|| new_covered_by_old
|
SetTextMode::InPlace
|
||||||
|| (info.non_embedded && ascii_only)
|
} else if non_embedded && ascii_only {
|
||||||
|| info.data_covers);
|
SetTextMode::InPlace
|
||||||
|
} else if t
|
||||||
let mode = if keep_font_ok && !nested_obj {
|
.font()
|
||||||
SetTextMode::InPlace
|
.data()
|
||||||
} else if keep_font_ok && nested_obj {
|
.ok()
|
||||||
// Giữ font gốc nhưng phải TẠO LẠI ở cấp trang: dùng lại
|
.map_or(false, |bytes| fontmatch::coverage_ok(&bytes, text))
|
||||||
// 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ọ.
|
SetTextMode::InPlace
|
||||||
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::Substitute(key)
|
||||||
SetTextMode::NestedRecreate(ReflowFont::Loaded(key))
|
|
||||||
} else {
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user