Sửa được text nằm trong Form XObject (file Canva/Illustrator) + CI chạy test
Build Windows portable / build (push) Canceled after 0s
Build Windows portable / build (push) Canceled after 0s
File Canva gói toàn bộ nội dung trang vào 1 Form XObject nên chế độ sửa không thấy gì để sửa. Engine giờ liệt kê PHẲNG đệ quy vào form (FPDFFormObj_CountObjects/GetObject qua pdfium-render), mỗi mục có path [i,j,...] + tích ma trận form để quy toạ độ/cỡ chữ về trang; index của ObjectInfo/EditOp = vị trí trong danh sách phẳng (trang không form thì trùng index trang — tương thích ngược, UI không đổi cách gửi op). - SetText in-place/substitute + ReflowText + Delete hoạt động với run trong form; xoá trong form = set_text chuỗi rỗng (FPDFFormObj_RemoveObject chưa mở ở wrapper cho pdfium_7543), object mới vẽ ở cấp trang với matrix đã compose. - Đã xác minh trước: PDFium regenerate stream form khi con bị sửa (ProcessForm / HasDirtyStreams) — canary test round-trip kèm theo. - ObjectInfo thêm nested/expanded; UI bỏ khung form phủ trang + chặn kéo run trong form (Transform chưa hỗ trợ). - 4 test integration mới với fixture Form XObject tự dựng bằng lopdf (dev-dependency mới); CI thêm bước cargo test -p ff-engine trước build (máy dev không có Rust — CI là nơi duy nhất chạy test), cache thêm target/. Chi tiết: docs/12-phase4-summary.md (Iteration 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
|
target
|
||||||
app/src-tauri/target
|
app/src-tauri/target
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
@@ -40,6 +41,11 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: powershell -ExecutionPolicy Bypass -File scripts/fetch-qpdf.ps1
|
run: powershell -ExecutionPolicy Bypass -File scripts/fetch-qpdf.ps1
|
||||||
|
|
||||||
|
# Máy dev không có Rust — CI là nơi duy nhất chạy test engine.
|
||||||
|
# Fail test = fail build, không publish bản hỏng.
|
||||||
|
- name: Run engine tests
|
||||||
|
run: cargo test -p ff-engine
|
||||||
|
|
||||||
- name: Build release
|
- name: Build release
|
||||||
working-directory: app/src-tauri
|
working-directory: app/src-tauri
|
||||||
run: cargo build --release
|
run: cargo build --release
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
- [x] **Iteration 2 — GIỮ FONT khi sửa (chuẩn Foxit)**: sửa tại chỗ giữ nguyên font gốc/nhúng (kể cả tiếng Việt); thiếu glyph mới thay font CÙNG HỌ (`fontmatch.rs`); đổi cỡ/màu không đụng font (fix bug phóng đại kép); sửa CẢ DÒNG (gộp run); WYSIWYG khi gõ; B/I + đổi font family; kéo-thả live; dọn file tạm
|
- [x] **Iteration 2 — GIỮ FONT khi sửa (chuẩn Foxit)**: sửa tại chỗ giữ nguyên font gốc/nhúng (kể cả tiếng Việt); thiếu glyph mới thay font CÙNG HỌ (`fontmatch.rs`); đổi cỡ/màu không đụng font (fix bug phóng đại kép); sửa CẢ DÒNG (gộp run); WYSIWYG khi gõ; B/I + đổi font family; kéo-thả live; dọn file tạm
|
||||||
- [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng
|
- [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng
|
||||||
- [x] **Đúp chuột từ viewer thường (chuẩn Foxit)**: đang xem, đúp vào chữ → tự vào chế độ sửa đúng trang + mở ngay ô sửa tại đoạn đó; đúp vùng trống không làm gì
|
- [x] **Đúp chuột từ viewer thường (chuẩn Foxit)**: đang xem, đúp vào chữ → tự vào chế độ sửa đúng trang + mở ngay ô sửa tại đoạn đó; đúp vùng trống không làm gì
|
||||||
|
- [x] **Iteration 4 — Sửa text TRONG Form XObject**: file Canva/Illustrator gói cả trang vào form giờ sửa được (liệt kê đệ quy + quy toạ độ về trang; xoá trong form = set text rỗng; CI chạy `cargo test` trước khi build)
|
||||||
- [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md)
|
- [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md)
|
||||||
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30)
|
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30)
|
||||||
- [x] **Phase 5 — Bảo mật** ✅ Iteration 1 + 2 hoàn tất (62/62 test engine xanh)
|
- [x] **Phase 5 — Bảo mật** ✅ Iteration 1 + 2 hoàn tất (62/62 test engine xanh)
|
||||||
|
|||||||
@@ -663,6 +663,10 @@ struct ObjectInfoDto {
|
|||||||
index: u16,
|
index: u16,
|
||||||
kind: String,
|
kind: String,
|
||||||
rect: RectDto,
|
rect: RectDto,
|
||||||
|
/// Object nằm trong Form XObject (sửa text OK, kéo/resize chưa hỗ trợ).
|
||||||
|
nested: bool,
|
||||||
|
/// Form đã liệt kê con riêng — UI bỏ khung của form này.
|
||||||
|
expanded: bool,
|
||||||
text: Option<String>,
|
text: Option<String>,
|
||||||
font_name: Option<String>,
|
font_name: Option<String>,
|
||||||
font_family: Option<String>,
|
font_family: Option<String>,
|
||||||
@@ -685,6 +689,8 @@ fn edit_list_objects(path: String, page: u16, password: Option<String>) -> Resul
|
|||||||
index: o.index,
|
index: o.index,
|
||||||
kind: o.kind.as_str().to_string(),
|
kind: o.kind.as_str().to_string(),
|
||||||
rect: RectDto { left: o.rect.left, bottom: o.rect.bottom, right: o.rect.right, top: o.rect.top },
|
rect: RectDto { left: o.rect.left, bottom: o.rect.bottom, right: o.rect.right, top: o.rect.top },
|
||||||
|
nested: o.nested,
|
||||||
|
expanded: o.expanded,
|
||||||
text: o.text,
|
text: o.text,
|
||||||
font_name: o.font_name,
|
font_name: o.font_name,
|
||||||
font_family: o.font_family,
|
font_family: o.font_family,
|
||||||
|
|||||||
+9
-1
@@ -2226,6 +2226,10 @@ function buildEditOverlay() {
|
|||||||
// Ảnh/form: khung theo từng object như cũ (có resize).
|
// Ảnh/form: khung theo từng object như cũ (có resize).
|
||||||
state.editObjects.forEach((o) => {
|
state.editObjects.forEach((o) => {
|
||||||
if (o.kind !== "image" && o.kind !== "form") return;
|
if (o.kind !== "image" && o.kind !== "form") return;
|
||||||
|
// Form đã liệt kê con riêng (file Canva gói cả trang vào form): khung của
|
||||||
|
// form phủ kín trang sẽ che hết con → bỏ. Ảnh/form TRONG form: chưa sửa
|
||||||
|
// được (di chuyển/thay cần địa chỉ lồng) → cũng bỏ khung.
|
||||||
|
if (o.expanded || o.nested) return;
|
||||||
const box = document.createElement("div");
|
const box = document.createElement("div");
|
||||||
box.className = "edit-box kind-" + o.kind;
|
box.className = "edit-box kind-" + o.kind;
|
||||||
box.dataset.index = String(o.index);
|
box.dataset.index = String(o.index);
|
||||||
@@ -2979,8 +2983,12 @@ function promptAddText(pdfX, pdfY) {
|
|||||||
function onEditBoxMouseDown(e, o, runs) {
|
function onEditBoxMouseDown(e, o, runs) {
|
||||||
if (state.editArm) return;
|
if (state.editArm) return;
|
||||||
if (e.target.classList.contains("ed-handle")) return;
|
if (e.target.classList.contains("ed-handle")) return;
|
||||||
const runIndices = (runs && runs.length ? runs : [o]).map((r) => (typeof r === "number" ? r : r.index));
|
const runObjs = runs && runs.length ? runs : [o];
|
||||||
|
const runIndices = runObjs.map((r) => (typeof r === "number" ? r : r.index));
|
||||||
selectEditObject(o.index, runIndices);
|
selectEditObject(o.index, runIndices);
|
||||||
|
// Run TRONG Form XObject (file Canva...): sửa text được nhưng chưa hỗ trợ
|
||||||
|
// kéo di chuyển/resize — chọn thôi, không bắt đầu drag.
|
||||||
|
if (runObjs.some((r) => r && typeof r === "object" && r.nested)) return;
|
||||||
const box = e.currentTarget;
|
const box = e.currentTarget;
|
||||||
const startX = e.clientX, startY = e.clientY;
|
const startX = e.clientX, startY = e.clientY;
|
||||||
const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top);
|
const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top);
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ der = "0.7"
|
|||||||
spki = "0.7"
|
spki = "0.7"
|
||||||
signature = "2"
|
signature = "2"
|
||||||
const-oid = { version = "0.9", features = ["db"] }
|
const-oid = { version = "0.9", features = ["db"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# Dựng PDF fixture (Form XObject...) trong integration test.
|
||||||
|
lopdf = "0.36"
|
||||||
|
|||||||
+270
-58
@@ -72,11 +72,19 @@ impl ObjectKind {
|
|||||||
/// Thông tin 1 page object trả về cho UI (để vẽ overlay & sửa).
|
/// Thông tin 1 page object trả về cho UI (để vẽ overlay & sửa).
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ObjectInfo {
|
pub struct ObjectInfo {
|
||||||
/// Index trong danh sách object của trang (0-based, theo thứ tự vẽ/z-order).
|
/// Vị trí trong DANH SÁCH PHẲNG object của trang (0-based, thứ tự vẽ,
|
||||||
|
/// gồm cả object nằm trong Form XObject). Trang không có form → trùng
|
||||||
|
/// index trang như cũ. EditOp cũng đánh địa chỉ theo index này.
|
||||||
pub index: u16,
|
pub index: u16,
|
||||||
pub kind: ObjectKind,
|
pub kind: ObjectKind,
|
||||||
/// Khung bao (AABB) theo điểm PDF.
|
/// Khung bao (AABB) theo điểm PDF — object trong form đã quy về TRANG.
|
||||||
pub rect: Rect,
|
pub rect: Rect,
|
||||||
|
/// Object nằm BÊN TRONG một Form XObject (file Canva/Illustrator...).
|
||||||
|
/// Sửa text được (in-place / reflow); di chuyển/resize chưa hỗ trợ.
|
||||||
|
pub nested: bool,
|
||||||
|
/// Chỉ với kind = XObjectForm: các object con đã được liệt kê riêng
|
||||||
|
/// (UI bỏ khung của form để khỏi che con).
|
||||||
|
pub expanded: bool,
|
||||||
/// Chỉ với text: nội dung hiện tại.
|
/// Chỉ với text: nội dung hiện tại.
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
/// Chỉ với text: tên font gốc trong PDF (có thể kèm prefix subset).
|
/// Chỉ với text: tên font gốc trong PDF (có thể kèm prefix subset).
|
||||||
@@ -146,6 +154,150 @@ fn quad_to_rect(q: &PdfQuadPoints) -> Rect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Object trong Form XObject (Phase 4 iteration 4) ----
|
||||||
|
//
|
||||||
|
// File Canva/Illustrator/InDesign hay GÓI toàn bộ nội dung trang vào 1 Form
|
||||||
|
// XObject — trang chỉ có 1 object "form", chữ nằm bên trong. Để sửa được:
|
||||||
|
// duyệt PHẲNG đệ quy vào form (FPDFFormObj_CountObjects/GetObject qua
|
||||||
|
// pdfium-render), mỗi mục có "path" ([i] = cấp trang, [i,j,...] = con trong
|
||||||
|
// form) + tích ma trận form tổ tiên để quy toạ độ về trang. `index` trong
|
||||||
|
// ObjectInfo/EditOp = vị trí trong danh sách phẳng này — trang không có form
|
||||||
|
// thì trùng index trang như cũ (tương thích ngược).
|
||||||
|
|
||||||
|
/// Ma trận PDF (a,b,c,d,e,f) dạng giá trị thuần — quy ước hàng: p' = p·M.
|
||||||
|
type Mat6 = (f32, f32, f32, f32, f32, f32);
|
||||||
|
|
||||||
|
const MAT_ID: Mat6 = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
|
||||||
|
|
||||||
|
fn mat_of(m: &PdfMatrix) -> Mat6 {
|
||||||
|
(m.a(), m.b(), m.c(), m.d(), m.e(), m.f())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// K = M rồi N (p·K = (p·M)·N).
|
||||||
|
fn mat_mul(m: Mat6, n: Mat6) -> Mat6 {
|
||||||
|
(
|
||||||
|
m.0 * n.0 + m.1 * n.2,
|
||||||
|
m.0 * n.1 + m.1 * n.3,
|
||||||
|
m.2 * n.0 + m.3 * n.2,
|
||||||
|
m.2 * n.1 + m.3 * n.3,
|
||||||
|
m.4 * n.0 + m.5 * n.2 + n.4,
|
||||||
|
m.4 * n.1 + m.5 * n.3 + n.5,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat_apply(m: Mat6, x: f32, y: f32) -> (f32, f32) {
|
||||||
|
(m.0 * x + m.2 * y + m.4, m.1 * x + m.3 * y + m.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AABB của rect sau biến đổi (4 góc → bao).
|
||||||
|
fn mat_rect(m: Mat6, r: &Rect) -> Rect {
|
||||||
|
let pts = [
|
||||||
|
mat_apply(m, r.left, r.bottom),
|
||||||
|
mat_apply(m, r.right, r.bottom),
|
||||||
|
mat_apply(m, r.left, r.top),
|
||||||
|
mat_apply(m, r.right, r.top),
|
||||||
|
];
|
||||||
|
Rect {
|
||||||
|
left: pts.iter().map(|p| p.0).fold(f32::INFINITY, f32::min),
|
||||||
|
bottom: pts.iter().map(|p| p.1).fold(f32::INFINITY, f32::min),
|
||||||
|
right: pts.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max),
|
||||||
|
top: pts.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hệ số scale DỌC của ma trận (độ dài ảnh của vector đơn vị Y) — dùng quy
|
||||||
|
/// đổi cỡ chữ hiển thị của object trong form về không gian trang.
|
||||||
|
fn mat_vscale(m: Mat6) -> f32 {
|
||||||
|
(m.2 * m.2 + m.3 * m.3).sqrt().max(0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1 mục trong danh sách phẳng object của trang (gồm cả con trong form).
|
||||||
|
struct FlatEntry {
|
||||||
|
/// Địa chỉ: [i] = object cấp trang; [i, j, ...] = con lồng trong form.
|
||||||
|
path: Vec<u16>,
|
||||||
|
/// Tích ma trận các form tổ tiên (đưa toạ độ trong form về trang).
|
||||||
|
acc: Mat6,
|
||||||
|
kind: ObjectKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chặn duyệt form quá sâu/quá nhiều (file lỗi/đệ quy).
|
||||||
|
const FORM_WALK_MAX_DEPTH: usize = 3;
|
||||||
|
const FORM_WALK_MAX_ENTRIES: usize = 4000;
|
||||||
|
|
||||||
|
/// Danh sách phẳng object của trang theo thứ tự vẽ, đi sâu vào Form XObject.
|
||||||
|
/// Thứ tự này là HỢP ĐỒNG giữa `list_objects` và `apply_edits` — index trong
|
||||||
|
/// EditOp tra vào đúng danh sách này.
|
||||||
|
fn collect_flat(page: &PdfPage<'_>) -> Vec<FlatEntry> {
|
||||||
|
fn walk_form(
|
||||||
|
form: &PdfPageXObjectFormObject<'_>,
|
||||||
|
base: &[u16],
|
||||||
|
acc: Mat6,
|
||||||
|
depth: usize,
|
||||||
|
out: &mut Vec<FlatEntry>,
|
||||||
|
) {
|
||||||
|
for j in 0..form.len() {
|
||||||
|
if out.len() >= FORM_WALK_MAX_ENTRIES {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(child) = form.get(j) else { continue };
|
||||||
|
let mut path = base.to_vec();
|
||||||
|
path.push(j as u16);
|
||||||
|
out.push(FlatEntry {
|
||||||
|
path: path.clone(),
|
||||||
|
acc,
|
||||||
|
kind: ObjectKind::from_pdfium(child.object_type()),
|
||||||
|
});
|
||||||
|
if depth < FORM_WALK_MAX_DEPTH {
|
||||||
|
if let PdfPageObject::XObjectForm(f2) = &child {
|
||||||
|
let m = child.matrix().map(|m| mat_of(&m)).unwrap_or(MAT_ID);
|
||||||
|
walk_form(f2, &path, mat_mul(m, acc), depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let n = page.objects().len();
|
||||||
|
for i in 0..n {
|
||||||
|
if out.len() >= FORM_WALK_MAX_ENTRIES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Ok(obj) = page.objects().get(i) else { continue };
|
||||||
|
let path = vec![i as u16];
|
||||||
|
out.push(FlatEntry {
|
||||||
|
path: path.clone(),
|
||||||
|
acc: MAT_ID,
|
||||||
|
kind: ObjectKind::from_pdfium(obj.object_type()),
|
||||||
|
});
|
||||||
|
if let PdfPageObject::XObjectForm(f) = &obj {
|
||||||
|
let m = obj.matrix().map(|m| mat_of(&m)).unwrap_or(MAT_ID);
|
||||||
|
walk_form(f, &path, m, 1, &mut out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lấy object theo path (đi sâu vào form theo từng nấc).
|
||||||
|
fn object_at_path<'a>(page: &PdfPage<'a>, path: &[u16]) -> Result<PdfPageObject<'a>, EngineError> {
|
||||||
|
let err = |e: PdfiumError| EngineError::Pdfium(format!("lấy object theo path: {e}"));
|
||||||
|
let mut obj = page
|
||||||
|
.objects()
|
||||||
|
.get(path[0] as usize)
|
||||||
|
.map_err(err)?;
|
||||||
|
for &j in &path[1..] {
|
||||||
|
let child = match &obj {
|
||||||
|
PdfPageObject::XObjectForm(f) => f.get(j as usize).map_err(err)?,
|
||||||
|
_ => {
|
||||||
|
return Err(EngineError::Pdfium(
|
||||||
|
"path object lồng không trỏ vào form".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
obj = child;
|
||||||
|
}
|
||||||
|
Ok(obj)
|
||||||
|
}
|
||||||
|
|
||||||
/// Kiểu chữ (đậm/nghiêng) của 1 text object, tổng hợp từ weight, italic-angle
|
/// Kiểu chữ (đậm/nghiêng) của 1 text object, tổng hợp từ weight, italic-angle
|
||||||
/// và tên font. Family lấy từ `name()` (BaseFont khai báo trong PDF) — với
|
/// và tên font. Family lấy từ `name()` (BaseFont khai báo trong PDF) — với
|
||||||
/// font KHÔNG nhúng, `family()` trả tên font stub nội bộ của PDFium ("Chrom
|
/// font KHÔNG nhúng, `family()` trả tên font stub nội bộ của PDFium ("Chrom
|
||||||
@@ -197,21 +349,27 @@ pub fn list_objects(
|
|||||||
.get(page_index)
|
.get(page_index)
|
||||||
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
||||||
|
|
||||||
|
// Danh sách PHẲNG (đi sâu vào Form XObject) — index = vị trí trong list.
|
||||||
|
let entries = collect_flat(&page);
|
||||||
|
// Form nào có con được liệt kê → expanded (UI bỏ khung của form đó).
|
||||||
|
let mut parents_with_children: HashSet<Vec<u16>> = HashSet::new();
|
||||||
|
for e in &entries {
|
||||||
|
if e.path.len() > 1 {
|
||||||
|
parents_with_children.insert(e.path[..e.path.len() - 1].to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
// Cache kiểu chữ theo tên font: fallback đậm/nghiêng phải đọc bytes font
|
// Cache kiểu chữ theo tên font: fallback đậm/nghiêng phải đọc bytes font
|
||||||
// nhúng — file Word xuất hay có 200+ run chung vài font, không đọc lại.
|
// nhúng — file Word xuất hay có 200+ run chung vài font, không đọc lại.
|
||||||
let mut style_cache: HashMap<String, (String, bool, bool)> = HashMap::new();
|
let mut style_cache: HashMap<String, (String, bool, bool)> = HashMap::new();
|
||||||
for (i, object) in page.objects().iter().enumerate() {
|
for (flat, entry) in entries.iter().enumerate() {
|
||||||
let kind = ObjectKind::from_pdfium(object.object_type());
|
|
||||||
let rect = object
|
|
||||||
.bounds()
|
|
||||||
.map(|q| quad_to_rect(&q))
|
|
||||||
.unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
|
|
||||||
|
|
||||||
let mut info = ObjectInfo {
|
let mut info = ObjectInfo {
|
||||||
index: i as u16,
|
index: flat as u16,
|
||||||
kind,
|
kind: entry.kind,
|
||||||
rect,
|
rect: Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 },
|
||||||
|
nested: entry.path.len() > 1,
|
||||||
|
expanded: parents_with_children.contains(&entry.path),
|
||||||
text: None,
|
text: None,
|
||||||
font_name: None,
|
font_name: None,
|
||||||
font_family: None,
|
font_family: None,
|
||||||
@@ -221,6 +379,14 @@ pub fn list_objects(
|
|||||||
font_size: None,
|
font_size: None,
|
||||||
color: None,
|
color: None,
|
||||||
};
|
};
|
||||||
|
// Không resolve được vẫn giữ 1 chỗ trong list — index phải khớp vị trí.
|
||||||
|
let Ok(object) = object_at_path(&page, &entry.path) else {
|
||||||
|
out.push(info);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Ok(q) = object.bounds() {
|
||||||
|
info.rect = mat_rect(entry.acc, &quad_to_rect(&q));
|
||||||
|
}
|
||||||
if let Some(t) = object.as_text_object() {
|
if let Some(t) = object.as_text_object() {
|
||||||
let raw_name = t.font().name();
|
let raw_name = t.font().name();
|
||||||
// Tên rỗng không cache được (nhiều font khác nhau cùng key "").
|
// Tên rỗng không cache được (nhiều font khác nhau cùng key "").
|
||||||
@@ -242,7 +408,8 @@ pub fn list_objects(
|
|||||||
info.font_bold = Some(bold);
|
info.font_bold = Some(bold);
|
||||||
info.font_italic = Some(italic);
|
info.font_italic = Some(italic);
|
||||||
info.font_embedded = t.font().is_embedded().ok();
|
info.font_embedded = t.font().is_embedded().ok();
|
||||||
info.font_size = Some(t.scaled_font_size().value);
|
// Cỡ hiển thị = cỡ đã scale của run × scale dọc của form chứa nó.
|
||||||
|
info.font_size = Some(t.scaled_font_size().value * mat_vscale(entry.acc));
|
||||||
info.color = t
|
info.color = t
|
||||||
.fill_color()
|
.fill_color()
|
||||||
.ok()
|
.ok()
|
||||||
@@ -433,6 +600,10 @@ pub fn apply_edits(
|
|||||||
let mut add_text_keys: HashMap<usize, FontKey> = HashMap::new();
|
let mut add_text_keys: HashMap<usize, FontKey> = HashMap::new();
|
||||||
let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new();
|
let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new();
|
||||||
let mut font_needed: HashMap<FontKey, FontLoad> = HashMap::new();
|
let mut font_needed: HashMap<FontKey, FontLoad> = HashMap::new();
|
||||||
|
// Danh sách PHẲNG object (gồm cả con trong Form XObject) — index của mọi
|
||||||
|
// op tra vào đây; dùng chung cho cả pha (A) lẫn (C). Probe font ở pha A
|
||||||
|
// chỉ thêm+xoá object nháp ở CUỐI trang nên không làm lệch index.
|
||||||
|
let entries: Vec<FlatEntry>;
|
||||||
{
|
{
|
||||||
let mut page = document
|
let mut page = document
|
||||||
.pages()
|
.pages()
|
||||||
@@ -440,8 +611,8 @@ pub fn apply_edits(
|
|||||||
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
||||||
// Pha A chỉ đọc + probe font (tạo/xoá object nháp) — không đụng content.
|
// Pha A chỉ đọc + probe font (tạo/xoá object nháp) — không đụng content.
|
||||||
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
|
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
|
||||||
let obj_count = page.objects().len();
|
entries = collect_flat(&page);
|
||||||
let valid = |i: u16| (i as usize) < obj_count;
|
let valid = |i: u16| (i as usize) < entries.len();
|
||||||
|
|
||||||
for (opi, op) in ops.iter().enumerate() {
|
for (opi, op) in ops.iter().enumerate() {
|
||||||
match op {
|
match op {
|
||||||
@@ -449,7 +620,7 @@ pub fn apply_edits(
|
|||||||
if !valid(*index) {
|
if !valid(*index) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let obj = page.objects().get(*index as usize).map_err(err)?;
|
let obj = object_at_path(&page, &entries[*index as usize].path)?;
|
||||||
let t = match obj.as_text_object() {
|
let t = match obj.as_text_object() {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => continue, // không phải text → bỏ qua op
|
None => continue, // không phải text → bỏ qua op
|
||||||
@@ -530,21 +701,27 @@ pub fn apply_edits(
|
|||||||
tf: f32,
|
tf: f32,
|
||||||
scaled: f32,
|
scaled: f32,
|
||||||
}
|
}
|
||||||
|
// Mọi toạ độ/cỡ quy về KHÔNG GIAN TRANG (run trong form
|
||||||
|
// nhân thêm ma trận form) — layout khối tính đúng chỗ.
|
||||||
let geo_of = |page: &PdfPage<'_>, i: u16| -> Result<Option<RunGeo>, EngineError> {
|
let geo_of = |page: &PdfPage<'_>, i: u16| -> Result<Option<RunGeo>, EngineError> {
|
||||||
let obj = page.objects().get(i as usize).map_err(err)?;
|
let entry = &entries[i as usize];
|
||||||
|
let obj = object_at_path(page, &entry.path)?;
|
||||||
let Some(t) = obj.as_text_object() else {
|
let Some(t) = obj.as_text_object() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
let vs = mat_vscale(entry.acc);
|
||||||
let (tf, scaled) =
|
let (tf, scaled) =
|
||||||
(t.unscaled_font_size().value, t.scaled_font_size().value);
|
(t.unscaled_font_size().value, t.scaled_font_size().value * vs);
|
||||||
let m = obj.matrix().map_err(err)?;
|
let m = obj.matrix().map_err(err)?;
|
||||||
let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect {
|
let (_, fy) = mat_apply(entry.acc, m.e(), m.f());
|
||||||
|
let b_raw = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect {
|
||||||
left: m.e(),
|
left: m.e(),
|
||||||
bottom: m.f(),
|
bottom: m.f(),
|
||||||
right: m.e(),
|
right: m.e(),
|
||||||
top: m.f(),
|
top: m.f(),
|
||||||
});
|
});
|
||||||
Ok(Some(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right, tf, scaled }))
|
let b = mat_rect(entry.acc, &b_raw);
|
||||||
|
Ok(Some(RunGeo { idx: i, f: fy, left: b.left, right: b.right, tf, scaled }))
|
||||||
};
|
};
|
||||||
let mut geos: Vec<RunGeo> = Vec::new();
|
let mut geos: Vec<RunGeo> = Vec::new();
|
||||||
for &i in &idxs {
|
for &i in &idxs {
|
||||||
@@ -562,6 +739,15 @@ pub fn apply_edits(
|
|||||||
// object có TÂM nằm trong bbox khối (nới 2pt) đều thuộc khối →
|
// object có TÂM nằm trong bbox khối (nới 2pt) đều thuộc khối →
|
||||||
// đưa hết vào để xoá sạch, không còn chữ cũ đè dưới chữ mới.
|
// đưa hết vào để xoá sạch, không còn chữ cũ đè dưới chữ mới.
|
||||||
{
|
{
|
||||||
|
// Rect trang của 1 entry text (None nếu không phải text).
|
||||||
|
let text_rect = |i: u16| -> Option<Rect> {
|
||||||
|
let entry = &entries[i as usize];
|
||||||
|
if entry.kind != ObjectKind::Text {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let obj = object_at_path(&page, &entry.path).ok()?;
|
||||||
|
obj.bounds().ok().map(|q| mat_rect(entry.acc, &quad_to_rect(&q)))
|
||||||
|
};
|
||||||
let mut bb = Rect {
|
let mut bb = Rect {
|
||||||
left: f32::INFINITY,
|
left: f32::INFINITY,
|
||||||
bottom: f32::INFINITY,
|
bottom: f32::INFINITY,
|
||||||
@@ -569,12 +755,7 @@ pub fn apply_edits(
|
|||||||
top: f32::NEG_INFINITY,
|
top: f32::NEG_INFINITY,
|
||||||
};
|
};
|
||||||
for &i in &idxs {
|
for &i in &idxs {
|
||||||
let obj = page.objects().get(i as usize).map_err(err)?;
|
if let Some(r) = text_rect(i) {
|
||||||
if obj.as_text_object().is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Ok(q) = obj.bounds() {
|
|
||||||
let r = quad_to_rect(&q);
|
|
||||||
bb.left = bb.left.min(r.left);
|
bb.left = bb.left.min(r.left);
|
||||||
bb.bottom = bb.bottom.min(r.bottom);
|
bb.bottom = bb.bottom.min(r.bottom);
|
||||||
bb.right = bb.right.max(r.right);
|
bb.right = bb.right.max(r.right);
|
||||||
@@ -582,21 +763,12 @@ pub fn apply_edits(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let pad = 2.0;
|
let pad = 2.0;
|
||||||
for i in 0..obj_count as u16 {
|
for i in 0..entries.len() as u16 {
|
||||||
if idxs.contains(&i) {
|
if idxs.contains(&i) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let obj = page.objects().get(i as usize).map_err(err)?;
|
let Some(r) = text_rect(i) else { continue };
|
||||||
if obj.as_text_object().is_none() {
|
let (cx, cy) = ((r.left + r.right) / 2.0, (r.bottom + r.top) / 2.0);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (cx, cy) = match obj.bounds() {
|
|
||||||
Ok(q) => {
|
|
||||||
let r = quad_to_rect(&q);
|
|
||||||
((r.left + r.right) / 2.0, (r.bottom + r.top) / 2.0)
|
|
||||||
}
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
if cx >= bb.left - pad
|
if cx >= bb.left - pad
|
||||||
&& cx <= bb.right + pad
|
&& cx <= bb.right + pad
|
||||||
&& cy >= bb.bottom - pad
|
&& cy >= bb.bottom - pad
|
||||||
@@ -621,19 +793,24 @@ pub fn apply_edits(
|
|||||||
.map(|g| g.idx)
|
.map(|g| g.idx)
|
||||||
.unwrap_or(idxs[0]);
|
.unwrap_or(idxs[0]);
|
||||||
// Chụp mọi thuộc tính của run neo trong 1 block để nhả mượn
|
// Chụp mọi thuộc tính của run neo trong 1 block để nhả mượn
|
||||||
// trang (probe font phía dưới cần &mut page).
|
// trang (probe font phía dưới cần &mut page). Ma trận/cỡ
|
||||||
let (family, bold, italic, m, unscaled, scaled, color, font_embedded, orig_token, font_data) = {
|
// COMPOSE với ma trận form (run neo có thể nằm trong form;
|
||||||
let anchor = page.objects().get(anchor_idx as usize).map_err(err)?;
|
// dòng mới vẽ ở CẤP TRANG nên cần toạ độ trang).
|
||||||
|
let (family, bold, italic, mc, unscaled, scaled, color, font_embedded, orig_token, font_data) = {
|
||||||
|
let entry = &entries[anchor_idx as usize];
|
||||||
|
let anchor = object_at_path(&page, &entry.path)?;
|
||||||
let t = anchor.as_text_object().expect("anchor là text");
|
let t = anchor.as_text_object().expect("anchor là text");
|
||||||
let (family, bold, italic) = text_object_style(t);
|
let (family, bold, italic) = text_object_style(t);
|
||||||
let m = anchor.matrix().map_err(err)?;
|
let m = anchor.matrix().map_err(err)?;
|
||||||
|
let mc = mat_mul(mat_of(&m), entry.acc);
|
||||||
let tok = t.font().token();
|
let tok = t.font().token();
|
||||||
let unscaled = t.unscaled_font_size().value;
|
let unscaled = t.unscaled_font_size().value;
|
||||||
let scaled = t.scaled_font_size().value.max(1.0);
|
let scaled =
|
||||||
|
(t.scaled_font_size().value * mat_vscale(entry.acc)).max(1.0);
|
||||||
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
|
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
|
||||||
let embedded = t.font().is_embedded().unwrap_or(false);
|
let embedded = t.font().is_embedded().unwrap_or(false);
|
||||||
let data = t.font().data().ok();
|
let data = t.font().data().ok();
|
||||||
(family, bold, italic, m, unscaled, scaled, color, embedded, tok, data)
|
(family, bold, italic, mc, unscaled, scaled, color, embedded, tok, data)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance =
|
// Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance =
|
||||||
@@ -756,7 +933,7 @@ pub fn apply_edits(
|
|||||||
centered,
|
centered,
|
||||||
first_baseline,
|
first_baseline,
|
||||||
line_advance,
|
line_advance,
|
||||||
linear: (m.a(), m.b(), m.c(), m.d()),
|
linear: (mc.0, mc.1, mc.2, mc.3),
|
||||||
tf: unscaled.max(1.0),
|
tf: unscaled.max(1.0),
|
||||||
scaled,
|
scaled,
|
||||||
line_styles,
|
line_styles,
|
||||||
@@ -790,16 +967,18 @@ pub fn apply_edits(
|
|||||||
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
||||||
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
|
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
|
||||||
|
|
||||||
let obj_count = page.objects().len();
|
let valid = |i: u16| (i as usize) < entries.len();
|
||||||
let valid = |i: u16| (i as usize) < obj_count;
|
let is_nested = |i: u16| entries[i as usize].path.len() > 1;
|
||||||
|
|
||||||
// (1) Transform in-place (không đổi cấu trúc/index).
|
// (1) Transform in-place (không đổi cấu trúc/index). Object TRONG form
|
||||||
|
// chưa hỗ trợ di chuyển/resize (dx/dy tính theo trang còn matrix con
|
||||||
|
// theo không gian form) — bỏ qua, UI cũng đã chặn kéo.
|
||||||
for op in ops {
|
for op in ops {
|
||||||
if let EditOp::Transform { index, dx, dy, sx, sy } = op {
|
if let EditOp::Transform { index, dx, dy, sx, sy } = op {
|
||||||
if !valid(*index) {
|
if !valid(*index) || is_nested(*index) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut obj = page.objects().get(*index as usize).map_err(err)?;
|
let mut obj = object_at_path(&page, &entries[*index as usize].path)?;
|
||||||
if (*sx - 1.0).abs() > f32::EPSILON || (*sy - 1.0).abs() > f32::EPSILON {
|
if (*sx - 1.0).abs() > f32::EPSILON || (*sy - 1.0).abs() > f32::EPSILON {
|
||||||
let b = obj.bounds().map_err(err)?;
|
let b = obj.bounds().map_err(err)?;
|
||||||
let (x0, y0) = (b.left().value, b.bottom().value);
|
let (x0, y0) = (b.left().value, b.bottom().value);
|
||||||
@@ -823,9 +1002,12 @@ pub fn apply_edits(
|
|||||||
if !matches!(set_text_modes.get(&opi), Some(SetTextMode::InPlace)) {
|
if !matches!(set_text_modes.get(&opi), Some(SetTextMode::InPlace)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut obj = page.objects().get(*index as usize).map_err(err)?;
|
let entry = &entries[*index as usize];
|
||||||
|
let mut obj = object_at_path(&page, &entry.path)?;
|
||||||
|
// Cỡ hiển thị so theo KHÔNG GIAN TRANG (nhân scale form) — target
|
||||||
|
// từ UI cũng theo trang; hệ số k không đổi khi áp trong form.
|
||||||
let (old_text, scaled) = match obj.as_text_object() {
|
let (old_text, scaled) = match obj.as_text_object() {
|
||||||
Some(t) => (t.text(), t.scaled_font_size().value),
|
Some(t) => (t.text(), t.scaled_font_size().value * mat_vscale(entry.acc)),
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
if *text != old_text {
|
if *text != old_text {
|
||||||
@@ -873,20 +1055,25 @@ pub fn apply_edits(
|
|||||||
if !valid(idx) || captured.contains_key(&idx) {
|
if !valid(idx) || captured.contains_key(&idx) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let obj = page.objects().get(idx as usize).map_err(err)?;
|
let entry = &entries[idx as usize];
|
||||||
|
let obj = object_at_path(&page, &entry.path)?;
|
||||||
|
// Rect + matrix COMPOSE về trang: bản thay thế được tạo ở cấp
|
||||||
|
// trang nên phải mang sẵn toạ độ trang (kể cả khi bản gốc trong form).
|
||||||
let rect = obj
|
let rect = obj
|
||||||
.bounds()
|
.bounds()
|
||||||
.map(|q| quad_to_rect(&q))
|
.map(|q| mat_rect(entry.acc, &quad_to_rect(&q)))
|
||||||
.unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
|
.unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
|
||||||
let (matrix, unscaled, scaled, color) = if let Some(t) = obj.as_text_object() {
|
let mc = mat_mul(mat_of(&obj.matrix().map_err(err)?), entry.acc);
|
||||||
|
let matrix = PdfMatrix::new(mc.0, mc.1, mc.2, mc.3, mc.4, mc.5);
|
||||||
|
let vs = mat_vscale(entry.acc);
|
||||||
|
let (unscaled, scaled, color) = if let Some(t) = obj.as_text_object() {
|
||||||
(
|
(
|
||||||
obj.matrix().map_err(err)?,
|
|
||||||
t.unscaled_font_size().value,
|
t.unscaled_font_size().value,
|
||||||
t.scaled_font_size().value,
|
t.scaled_font_size().value * vs,
|
||||||
t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)),
|
t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(obj.matrix().map_err(err)?, 0.0, 0.0, PdfColor::new(0, 0, 0, 255))
|
(0.0, 0.0, PdfColor::new(0, 0, 0, 255))
|
||||||
};
|
};
|
||||||
captured.insert(
|
captured.insert(
|
||||||
idx,
|
idx,
|
||||||
@@ -917,7 +1104,32 @@ pub fn apply_edits(
|
|||||||
to_remove.retain(|i| valid(*i));
|
to_remove.retain(|i| valid(*i));
|
||||||
to_remove.sort_unstable();
|
to_remove.sort_unstable();
|
||||||
to_remove.dedup();
|
to_remove.dedup();
|
||||||
for idx in to_remove.into_iter().rev() {
|
// Con TRONG form: chưa rút object ra được (FPDFFormObj_RemoveObject
|
||||||
|
// chưa mở ở wrapper) → "xoá" = set text RỖNG — glyph biến mất, PDFium
|
||||||
|
// regenerate stream form khi lưu (ProcessForm → HasDirtyStreams).
|
||||||
|
// Làm TRƯỚC khi xoá object cấp trang để path (nấc đầu = index trang)
|
||||||
|
// còn đúng. Con không phải text (ảnh/path trong form): v1 bỏ qua.
|
||||||
|
for &i in to_remove.iter().filter(|&&i| is_nested(i)) {
|
||||||
|
let entry = &entries[i as usize];
|
||||||
|
if entry.kind != ObjectKind::Text {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut obj = object_at_path(&page, &entry.path)?;
|
||||||
|
if let Some(t) = obj.as_text_object_mut() {
|
||||||
|
if t.set_text("").is_err() {
|
||||||
|
let _ = t.set_text(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cấp trang: xoá thật theo index trang GIẢM DẦN.
|
||||||
|
let mut page_idxs: Vec<u16> = to_remove
|
||||||
|
.iter()
|
||||||
|
.filter(|&&i| !is_nested(i))
|
||||||
|
.map(|&i| entries[i as usize].path[0])
|
||||||
|
.collect();
|
||||||
|
page_idxs.sort_unstable();
|
||||||
|
page_idxs.dedup();
|
||||||
|
for idx in page_idxs.into_iter().rev() {
|
||||||
let removed = page.objects_mut().remove_object_at_index(idx as usize).map_err(err)?;
|
let removed = page.objects_mut().remove_object_at_index(idx as usize).map_err(err)?;
|
||||||
// BẪY PDFium: object vừa tách khỏi trang bị đánh dấu "unowned" → Drop
|
// BẪY PDFium: object vừa tách khỏi trang bị đánh dấu "unowned" → Drop
|
||||||
// gọi FPDFPageObj_Destroy, mà destroy object vốn thuộc document gây
|
// gọi FPDFPageObj_Destroy, mà destroy object vốn thuộc document gây
|
||||||
|
|||||||
@@ -1015,3 +1015,157 @@ fn add_image_adds_image_object() {
|
|||||||
assert_eq!(after.len(), before + 1, "thêm ảnh phải tăng 1 object");
|
assert_eq!(after.len(), before + 1, "thêm ảnh phải tăng 1 object");
|
||||||
assert!(after.iter().any(|o| o.kind == ObjectKind::Image), "phải có object kind Image");
|
assert!(after.iter().any(|o| o.kind == ObjectKind::Image), "phải có object kind Image");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- Phase 4 iteration 4: sửa text NẰM TRONG Form XObject (file Canva/
|
||||||
|
// Illustrator gói cả trang vào form — trước đây không thấy gì để sửa) ----
|
||||||
|
|
||||||
|
/// Dựng PDF 1 trang mà TOÀN BỘ chữ nằm trong 1 Form XObject (như Canva xuất):
|
||||||
|
/// trang chỉ có 1 object "form", bên trong có 1 text run Helvetica 24pt.
|
||||||
|
fn build_form_xobject_pdf(path: &std::path::Path) {
|
||||||
|
use lopdf::{dictionary, Document, Object, Stream};
|
||||||
|
let mut doc = Document::with_version("1.5");
|
||||||
|
let pages_id = doc.new_object_id();
|
||||||
|
let font_id = doc.add_object(dictionary! {
|
||||||
|
"Type" => "Font",
|
||||||
|
"Subtype" => "Type1",
|
||||||
|
"BaseFont" => "Helvetica",
|
||||||
|
});
|
||||||
|
let form_id = doc.add_object(Stream::new(
|
||||||
|
dictionary! {
|
||||||
|
"Type" => "XObject",
|
||||||
|
"Subtype" => "Form",
|
||||||
|
"BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||||
|
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font_id } },
|
||||||
|
},
|
||||||
|
b"BT /F1 24 Tf 72 700 Td (Hello inside form) Tj ET".to_vec(),
|
||||||
|
));
|
||||||
|
let content_id = doc.add_object(Stream::new(dictionary! {}, b"q /Fx1 Do Q".to_vec()));
|
||||||
|
let page_id = doc.add_object(dictionary! {
|
||||||
|
"Type" => "Page",
|
||||||
|
"Parent" => pages_id,
|
||||||
|
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||||
|
"Resources" => dictionary! { "XObject" => dictionary! { "Fx1" => form_id } },
|
||||||
|
"Contents" => content_id,
|
||||||
|
});
|
||||||
|
doc.objects.insert(
|
||||||
|
pages_id,
|
||||||
|
Object::Dictionary(dictionary! {
|
||||||
|
"Type" => "Pages",
|
||||||
|
"Kids" => vec![page_id.into()],
|
||||||
|
"Count" => 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
|
||||||
|
doc.trailer.set("Root", catalog_id);
|
||||||
|
doc.save(path).expect("lưu fixture form xobject");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form_fixture(name: &str) -> PathBuf {
|
||||||
|
let p = tmp(name);
|
||||||
|
build_form_xobject_pdf(&p);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn form_xobject_children_are_listed() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let input = form_fixture("ff_edit_formx_list.pdf");
|
||||||
|
let objs = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
|
||||||
|
// Form cấp trang phải được đánh dấu expanded, con text phải lộ ra.
|
||||||
|
let form = objs.iter().find(|o| o.kind == ObjectKind::XObjectForm).expect("phải thấy form");
|
||||||
|
assert!(form.expanded, "form có con phải expanded: {form:?}");
|
||||||
|
let text = objs
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains("Hello inside form")).unwrap_or(false))
|
||||||
|
.unwrap_or_else(|| panic!("phải thấy text trong form: {objs:?}"));
|
||||||
|
assert!(text.nested, "text trong form phải nested: {text:?}");
|
||||||
|
// Toạ độ đã quy về trang: baseline 700, cỡ 24 → bbox quanh (72, ~694..~724).
|
||||||
|
assert!(text.rect.left > 60.0 && text.rect.left < 84.0, "left ~72: {:?}", text.rect);
|
||||||
|
assert!(text.rect.bottom > 680.0 && text.rect.top < 740.0, "bbox quanh y=700: {:?}", text.rect);
|
||||||
|
assert!((text.font_size.unwrap_or(0.0) - 24.0).abs() < 2.0, "cỡ hiển thị ~24: {:?}", text.font_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canary quan trọng nhất: sửa IN-PLACE con trong form rồi lưu — PDFium phải
|
||||||
|
// regenerate content stream của form (ProcessForm → HasDirtyStreams).
|
||||||
|
#[test]
|
||||||
|
fn form_xobject_settext_inplace_roundtrip() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let input = form_fixture("ff_edit_formx_settext.pdf");
|
||||||
|
let out = tmp("ff_edit_formx_settext_out.pdf");
|
||||||
|
let idx = find_text_index(&pdf, &input, "Hello inside form");
|
||||||
|
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&input,
|
||||||
|
0,
|
||||||
|
&[EditOp::SetText {
|
||||||
|
index: idx,
|
||||||
|
text: "Edited in form!".into(),
|
||||||
|
font_size: None,
|
||||||
|
color: None,
|
||||||
|
font_family: None,
|
||||||
|
bold: None,
|
||||||
|
italic: None,
|
||||||
|
}],
|
||||||
|
&out,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("apply_edits SetText trong form");
|
||||||
|
|
||||||
|
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||||
|
assert!(text.contains("Edited in form!"), "text mới phải có mặt: {text:?}");
|
||||||
|
assert!(!text.contains("Hello inside form"), "text cũ trong form phải biến mất: {text:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflow đoạn trong form: run cũ bị làm rỗng (không rút được object khỏi
|
||||||
|
// form), dòng mới vẽ ở cấp trang với toạ độ đã compose ma trận form.
|
||||||
|
#[test]
|
||||||
|
fn form_xobject_reflow_roundtrip() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let input = form_fixture("ff_edit_formx_reflow.pdf");
|
||||||
|
let out = tmp("ff_edit_formx_reflow_out.pdf");
|
||||||
|
let idx = find_text_index(&pdf, &input, "Hello inside form");
|
||||||
|
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&input,
|
||||||
|
0,
|
||||||
|
&[EditOp::ReflowText {
|
||||||
|
indices: vec![idx],
|
||||||
|
text: "Đoạn văn tiếng Việt thay thế trong form, đủ dài để chắc chắn phải bẻ xuống dòng mới".into(),
|
||||||
|
}],
|
||||||
|
&out,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("apply_edits ReflowText trong form");
|
||||||
|
|
||||||
|
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||||
|
assert!(text.contains("Đoạn văn tiếng Việt thay thế"), "text mới phải có mặt: {text:?}");
|
||||||
|
assert!(!text.contains("Hello inside form"), "text cũ phải biến mất: {text:?}");
|
||||||
|
// Dòng mới phải nằm quanh vị trí khối cũ (không rơi về góc trang).
|
||||||
|
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
|
||||||
|
let new_runs: Vec<_> = objs
|
||||||
|
.iter()
|
||||||
|
.filter(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains("Đoạn")).unwrap_or(false))
|
||||||
|
.collect();
|
||||||
|
assert!(!new_runs.is_empty(), "phải có run mới: {objs:?}");
|
||||||
|
for r in &new_runs {
|
||||||
|
assert!(r.rect.left > 50.0 && r.rect.top < 740.0 && r.rect.bottom > 500.0,
|
||||||
|
"dòng mới phải quanh khối cũ: {:?}", r.rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn form_xobject_delete_clears_text() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let input = form_fixture("ff_edit_formx_delete.pdf");
|
||||||
|
let out = tmp("ff_edit_formx_delete_out.pdf");
|
||||||
|
let idx = find_text_index(&pdf, &input, "Hello inside form");
|
||||||
|
|
||||||
|
ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: idx }], &out, None)
|
||||||
|
.expect("apply_edits Delete trong form");
|
||||||
|
|
||||||
|
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||||
|
assert!(!text.contains("Hello inside form"), "text trong form phải biến mất sau xoá: {text:?}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,77 @@
|
|||||||
# 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 + 2 + 3
|
# 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 + 2 + 3 + 4
|
||||||
|
|
||||||
> Trạng thái sau Iteration 3: **sửa CẢ ĐOẠN với reflow "như Word" + GIỮ FONT**.
|
> Trạng thái sau Iteration 4: sửa được cả text **NẰM TRONG Form XObject**
|
||||||
> 52/52 test engine xanh ngoài qpdf (17 test edit + 6 unit fontmatch + 29 test cũ).
|
> (file Canva/Illustrator/InDesign gói cả trang vào form).
|
||||||
|
|
||||||
|
## Iteration 4 — Sửa text trong Form XObject (file Canva...)
|
||||||
|
|
||||||
|
User mở CV xuất từ Canva: xem được nhưng **không sửa được gì** — trang chỉ có
|
||||||
|
10 path + 1 Form XObject phủ toàn trang, TOÀN BỘ chữ nằm trong form (16 font
|
||||||
|
Type0/CID nhúng), trong khi `list_objects` chỉ duyệt object cấp trang.
|
||||||
|
|
||||||
|
### Engine (`edit.rs`)
|
||||||
|
- **Danh sách PHẲNG đệ quy** (`collect_flat`): duyệt object trang theo thứ tự
|
||||||
|
vẽ, đi sâu vào Form XObject qua `FPDFFormObj_CountObjects/GetObject`
|
||||||
|
(pdfium-render 0.8.37: `PdfPageXObjectFormObject::len()/get()` public; match
|
||||||
|
thẳng variant `PdfPageObject::XObjectForm` để giữ lifetime trang — accessor
|
||||||
|
`as_x_object_form_object()` bị rút ngắn lifetime theo `&self`). Mỗi mục có
|
||||||
|
`path` ([i] cấp trang, [i,j,…] con trong form) + `acc` = tích ma trận form
|
||||||
|
tổ tiên. Chặn depth 3 / 4000 mục. **`index` của ObjectInfo/EditOp = vị trí
|
||||||
|
trong danh sách phẳng này** — trang không có form thì trùng index trang như
|
||||||
|
cũ (tương thích ngược 100%, UI không đổi cách gửi op).
|
||||||
|
- **Toạ độ/cỡ quy về trang**: rect con = AABB của 4 góc qua `acc`; cỡ hiển
|
||||||
|
thị nhân `mat_vscale(acc)` (độ dài ảnh vector đơn vị Y).
|
||||||
|
- **ObjectInfo thêm `nested`** (con trong form — sửa text OK, kéo/resize chưa)
|
||||||
|
**+ `expanded`** (form đã liệt kê con — UI bỏ khung form để khỏi che con).
|
||||||
|
- **Áp op cho con trong form**: SetText in-place resolve theo path (đổi cỡ:
|
||||||
|
so cỡ theo KHÔNG GIAN TRANG — target × vscale); SetText-substitute và
|
||||||
|
ReflowText tạo object mới ở CẤP TRANG với matrix/toạ độ đã compose;
|
||||||
|
**"xoá" con trong form = `set_text("")`** (glyph biến mất) vì
|
||||||
|
`FPDFFormObj_RemoveObject` chưa mở ở wrapper cho pdfium_7543 — làm TRƯỚC
|
||||||
|
khi xoá object cấp trang để path còn đúng. Transform (kéo/resize) con
|
||||||
|
trong form: bỏ qua, UI chặn kéo.
|
||||||
|
- **Điểm sống còn đã xác minh trước khi code**: PDFium (bản mới, bblanchon
|
||||||
|
latest) REGENERATE content stream của form khi con bị sửa —
|
||||||
|
`CPDF_PageContentGenerator::ProcessForm` → `HasDirtyStreams` → chạy
|
||||||
|
generator đệ quy cho form. Không có cái này thì sửa xong lưu ra vẫn thấy
|
||||||
|
chữ cũ.
|
||||||
|
- Vá kèm: đọc source crate pdfium-render tải từ crates.io để chốt API vì máy
|
||||||
|
dev không compile được Rust (xem mục CI bên dưới).
|
||||||
|
|
||||||
|
### UI (`main.js`)
|
||||||
|
- Bỏ khung overlay cho form `expanded` (khung phủ kín trang chặn hết click)
|
||||||
|
và cho ảnh/form `nested`; run text trong form tự chảy qua clusterTextLines
|
||||||
|
như run thường (rect đã quy về trang).
|
||||||
|
- Chặn kéo di chuyển dòng chứa run `nested` (engine chưa hỗ trợ Transform
|
||||||
|
trong form) — vẫn chọn/sửa text/xoá bình thường.
|
||||||
|
|
||||||
|
### Test (4 mới — `edit_roundtrip.rs`, fixture tự dựng bằng lopdf)
|
||||||
|
- Fixture: PDF 1 trang, chữ "Hello inside form" Helvetica 24pt nằm TRONG
|
||||||
|
Form XObject (trang chỉ có 1 object form) — đúng cấu trúc Canva.
|
||||||
|
- `form_xobject_children_are_listed` — thấy text nested, form expanded, rect
|
||||||
|
quy về trang đúng chỗ (72, ~700), cỡ ~24.
|
||||||
|
- `form_xobject_settext_inplace_roundtrip` — **canary quan trọng nhất**: sửa
|
||||||
|
in-place con trong form → lưu → text mới có mặt, text cũ biến mất (chứng
|
||||||
|
minh PDFium regenerate stream form).
|
||||||
|
- `form_xobject_reflow_roundtrip` — reflow tiếng Việt trong form: run cũ làm
|
||||||
|
rỗng, dòng mới ở cấp trang quanh khối cũ.
|
||||||
|
- `form_xobject_delete_clears_text` — xoá = text biến mất khỏi extract.
|
||||||
|
|
||||||
|
### CI chạy test engine
|
||||||
|
Máy dev không có Rust nên **CI là nơi duy nhất chạy test**: workflow thêm
|
||||||
|
bước `cargo test -p ff-engine` TRƯỚC khi build release (fail test = fail
|
||||||
|
build, không publish bản hỏng); cache thêm `target/` gốc workspace.
|
||||||
|
|
||||||
|
### Giới hạn ghi nhận (iteration 4 v1)
|
||||||
|
- Chưa di chuyển/resize object trong form (Transform bị bỏ qua, UI chặn kéo).
|
||||||
|
- Chưa sửa/thay ẢNH trong form (khung overlay ẩn).
|
||||||
|
- "Xoá" text trong form để lại text object rỗng trong form (vô hại, không
|
||||||
|
render); khi wrapper mở `FPDFFormObj_RemoveObject` cho pdfium_7543 sẽ xoá
|
||||||
|
thật.
|
||||||
|
- Form lồng sâu >3 cấp hoặc >4000 object con: phần vượt không liệt kê.
|
||||||
|
|
||||||
|
> Các iteration trước: 52/52 test engine xanh ngoài qpdf (17 test edit + 6
|
||||||
|
> unit fontmatch + 29 test cũ) — nay thêm 4 test form + 2 unit sfnt style.
|
||||||
|
|
||||||
## Iteration 3 — Reflow đoạn văn "như Word" (mới nhất)
|
## Iteration 3 — Reflow đoạn văn "như Word" (mới nhất)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user