diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index e2d37f3..2f42be1 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -29,6 +29,7 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git + target app/src-tauri/target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} @@ -40,6 +41,11 @@ jobs: shell: pwsh 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 working-directory: app/src-tauri run: cargo build --release diff --git a/README.md b/README.md index eaebd33..a70efce 100644 --- a/README.md +++ b/README.md @@ -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 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] **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) - **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) diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index bb50f66..ca42c23 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -663,6 +663,10 @@ struct ObjectInfoDto { index: u16, kind: String, 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, font_name: Option, font_family: Option, @@ -685,6 +689,8 @@ fn edit_list_objects(path: String, page: u16, password: Option) -> Resul index: o.index, 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 }, + nested: o.nested, + expanded: o.expanded, text: o.text, font_name: o.font_name, font_family: o.font_family, diff --git a/app/src/main.js b/app/src/main.js index 3b8d563..9b358dc 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -2226,6 +2226,10 @@ function buildEditOverlay() { // Ảnh/form: khung theo từng object như cũ (có resize). state.editObjects.forEach((o) => { 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"); box.className = "edit-box kind-" + o.kind; box.dataset.index = String(o.index); @@ -2979,8 +2983,12 @@ function promptAddText(pdfX, pdfY) { function onEditBoxMouseDown(e, o, runs) { if (state.editArm) 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); + // 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 startX = e.clientX, startY = e.clientY; const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top); diff --git a/crates/ff-engine/Cargo.toml b/crates/ff-engine/Cargo.toml index cd98046..5cdc00a 100644 --- a/crates/ff-engine/Cargo.toml +++ b/crates/ff-engine/Cargo.toml @@ -25,3 +25,7 @@ der = "0.7" spki = "0.7" signature = "2" const-oid = { version = "0.9", features = ["db"] } + +[dev-dependencies] +# Dựng PDF fixture (Form XObject...) trong integration test. +lopdf = "0.36" diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index 53d2609..d09a873 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -72,11 +72,19 @@ impl ObjectKind { /// Thông tin 1 page object trả về cho UI (để vẽ overlay & sửa). #[derive(Clone, Debug)] 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 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, + /// 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. pub text: Option, /// 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, + /// 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 { + fn walk_form( + form: &PdfPageXObjectFormObject<'_>, + base: &[u16], + acc: Mat6, + depth: usize, + out: &mut Vec, + ) { + 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, 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 /// 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 @@ -197,21 +349,27 @@ pub fn list_objects( .get(page_index) .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> = 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(); // 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. let mut style_cache: HashMap = HashMap::new(); - for (i, object) in page.objects().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 }); - + for (flat, entry) in entries.iter().enumerate() { let mut info = ObjectInfo { - index: i as u16, - kind, - rect, + index: flat as u16, + kind: entry.kind, + 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, font_name: None, font_family: None, @@ -221,6 +379,14 @@ pub fn list_objects( font_size: 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() { let raw_name = t.font().name(); // 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_italic = Some(italic); 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 .fill_color() .ok() @@ -433,6 +600,10 @@ pub fn apply_edits( let mut add_text_keys: HashMap = HashMap::new(); let mut reflow_plans: HashMap = HashMap::new(); let mut font_needed: HashMap = 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; { let mut page = document .pages() @@ -440,8 +611,8 @@ pub fn apply_edits( .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. page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual); - let obj_count = page.objects().len(); - let valid = |i: u16| (i as usize) < obj_count; + entries = collect_flat(&page); + let valid = |i: u16| (i as usize) < entries.len(); for (opi, op) in ops.iter().enumerate() { match op { @@ -449,7 +620,7 @@ pub fn apply_edits( if !valid(*index) { 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() { Some(t) => t, None => continue, // không phải text → bỏ qua op @@ -530,21 +701,27 @@ pub fn apply_edits( tf: 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, 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 { return Ok(None); }; + let vs = mat_vscale(entry.acc); 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 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(), bottom: m.f(), right: m.e(), 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 = Vec::new(); 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 → // đư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 { + 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 { left: f32::INFINITY, bottom: f32::INFINITY, @@ -569,12 +755,7 @@ pub fn apply_edits( top: f32::NEG_INFINITY, }; for &i in &idxs { - let obj = page.objects().get(i as usize).map_err(err)?; - if obj.as_text_object().is_none() { - continue; - } - if let Ok(q) = obj.bounds() { - let r = quad_to_rect(&q); + if let Some(r) = text_rect(i) { bb.left = bb.left.min(r.left); bb.bottom = bb.bottom.min(r.bottom); bb.right = bb.right.max(r.right); @@ -582,21 +763,12 @@ pub fn apply_edits( } } let pad = 2.0; - for i in 0..obj_count as u16 { + for i in 0..entries.len() as u16 { if idxs.contains(&i) { continue; } - let obj = page.objects().get(i as usize).map_err(err)?; - if obj.as_text_object().is_none() { - 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, - }; + let Some(r) = text_rect(i) else { continue }; + let (cx, cy) = ((r.left + r.right) / 2.0, (r.bottom + r.top) / 2.0); if cx >= bb.left - pad && cx <= bb.right + pad && cy >= bb.bottom - pad @@ -621,19 +793,24 @@ pub fn apply_edits( .map(|g| g.idx) .unwrap_or(idxs[0]); // 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). - let (family, bold, italic, m, unscaled, scaled, color, font_embedded, orig_token, font_data) = { - let anchor = page.objects().get(anchor_idx as usize).map_err(err)?; + // trang (probe font phía dưới cần &mut page). Ma trận/cỡ + // COMPOSE với ma trận form (run neo có thể nằm trong form; + // 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 (family, bold, italic) = text_object_style(t); let m = anchor.matrix().map_err(err)?; + let mc = mat_mul(mat_of(&m), entry.acc); let tok = t.font().token(); 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 embedded = t.font().is_embedded().unwrap_or(false); 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 = @@ -756,7 +933,7 @@ pub fn apply_edits( centered, first_baseline, 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), scaled, 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}")))?; page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual); - let obj_count = page.objects().len(); - let valid = |i: u16| (i as usize) < obj_count; + let valid = |i: u16| (i as usize) < entries.len(); + 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 { if let EditOp::Transform { index, dx, dy, sx, sy } = op { - if !valid(*index) { + if !valid(*index) || is_nested(*index) { 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 { let b = obj.bounds().map_err(err)?; 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)) { 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() { - Some(t) => (t.text(), t.scaled_font_size().value), + Some(t) => (t.text(), t.scaled_font_size().value * mat_vscale(entry.acc)), None => continue, }; if *text != old_text { @@ -873,20 +1055,25 @@ pub fn apply_edits( if !valid(idx) || captured.contains_key(&idx) { 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 .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 }); - 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.scaled_font_size().value, + t.scaled_font_size().value * vs, t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)), ) } 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( idx, @@ -917,7 +1104,32 @@ pub fn apply_edits( to_remove.retain(|i| valid(*i)); to_remove.sort_unstable(); 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 = 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)?; // 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 diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index 58605f9..bc39a67 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -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!(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:?}"); +} diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 56d4c9e..4c5efad 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -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**. -> 52/52 test engine xanh ngoài qpdf (17 test edit + 6 unit fontmatch + 29 test cũ). +> Trạng thái sau Iteration 4: sửa được cả text **NẰM TRONG Form XObject** +> (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)