From fed43b2fb7ced6762f8968d65cb9f150c9c92628 Mon Sep 17 00:00:00 2001 From: trandinhnamuet Date: Wed, 15 Jul 2026 01:34:34 +0000 Subject: [PATCH] =?UTF-8?q?Phase=204=20Iteration=203:=20reflow=20=C4=91o?= =?UTF-8?q?=E1=BA=A1n=20v=C4=83n=20'nh=C6=B0=20Word'=20(EditOp::ReflowText?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine: - EditOp::ReflowText { indices, text }: engine tự suy hình học từ các run (lề trái/bề rộng từ bounds, baseline từ matrix.f gom cụm ±1pt, khoảng cách dòng = median hiệu baseline); bẻ dòng greedy đo bằng hmtx của font thật (fontmatch::wrap_lines + char_advance, ttf-parser); \n = ngắt dòng cứng, từ quá dài cắt theo ký tự, dòng rỗng giữ nhịp baseline - Giữ font theo thang 4 mức nhất quán với iteration 2: (1) font nhúng parse được + phủ đủ glyph → nhúng lại chính bytes đó; (2) family base-14 + ASCII → font chuẩn PDF qua FPDFText_LoadStandardFont (BaseFont giữ tên chuẩn, không nhúng, file không phình; đo bằng font metric-compatible); (3) font hệ thống cùng họ; (4) fallback. FontLoad enum Bytes/Builtin cho pha nạp font - Dòng mới giữ phần tuyến tính matrix của run neo (scale/nghiêng), Tf = unscaled run neo → cỡ hiển thị không đổi UI + Tauri: - paragraphLines: gom đoạn nhiều dòng (baseline cách đều ±25%, cỡ chữ tương đồng, giao ngang ≥30%); double-click đoạn ≥2 dòng → textarea sửa CẢ ĐOẠN WYSIWYG (đúng font/cỡ/màu/kiểu + line-height đúng nhịp baseline) - Enter = xuống dòng cứng · Ctrl+Enter/bấm ra ngoài = áp dụng · Esc = huỷ; 1 commit = 1 op reflowText = 1 nấc undo - DTO reflowText (indices) trong Tauri Test: 3 integration mới (wrap giữ font nhúng + tiếng Việt + baseline đều; hard break; base-14 giữ BaseFont Helvetica chuẩn) + 3 unit wrap_lines. Toàn bộ suite engine ngoài qpdf: 52/52 xanh. Docs: 12 (Iteration 3 + giới hạn v1), 13 (E26–E30), 14 (mục 3.1 → đã giải, cập nhật thứ tự việc tiếp theo), README. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk --- README.md | 7 +- app/src-tauri/src/main.rs | 4 + app/src/main.js | 130 +++++++++++- app/src/styles.css | 8 + crates/ff-engine/src/edit.rs | 255 ++++++++++++++++++++++- crates/ff-engine/src/fontmatch.rs | 79 +++++++ crates/ff-engine/tests/edit_roundtrip.rs | 165 +++++++++++++++ docs/12-phase4-summary.md | 57 ++++- docs/13-phase4-user-tests.md | 9 + docs/14-foxit-gap-analysis.md | 40 ++-- 10 files changed, 713 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 40f2891..308a232 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,13 @@ - [x] UI: chế độ "Tổ chức trang" (lưới + kéo-thả + multi-select), undo/redo toàn cục (chú thích + tổ chức trang chung 1 stack), dialog cho mọi thao tác (có xem trước thật cho Watermark/Header-Footer) - [ ] Follow-up: preview thật cho Insert/Extract/Replace/Crop, tách theo outline, watermark ảnh — xem [docs/10-phase3-summary.md](docs/10-phase3-summary.md) - **Checklist test cho bạn**: [docs/11-phase3-user-tests.md](docs/11-phase3-user-tests.md) -- [x] **Phase 4 — Chỉnh sửa nội dung (Edit)** ⭐ tính năng lõi/moat ✅ Iteration 1 + 2 hoàn tất (50/50 test xanh ngoài qpdf) +- [x] **Phase 4 — Chỉnh sửa nội dung (Edit)** ⭐ tính năng lõi/moat ✅ Iteration 1 + 2 + 3 hoàn tất (52/52 test xanh ngoài qpdf) - [x] Engine `edit.rs`: liệt kê object + sửa text run (tiếng Việt), xoá, di chuyển/resize, thêm chữ/ảnh, thay ảnh — round-trip test - [x] UI chế độ "Sửa nội dung": overlay đối tượng, sửa text tại chỗ WYSIWYG, thêm chữ/ảnh, xoá, cỡ chữ/màu, undo/redo, lưu - [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 - - [ ] Follow-up: reflow đoạn nhiều dòng (kế hoạch trong [docs/14-foxit-gap-analysis.md](docs/14-foxit-gap-analysis.md)), 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–E25) + - [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 + - [ ] 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) - [ ] **Phase 5 — Bảo mật & Chữ ký** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md) Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md). diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 67e7423..6b9a6a1 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -732,6 +732,9 @@ struct EditOpDto { height_pt: f32, #[serde(default)] image_path: String, + /// reflowText: index các run của khối đoạn văn. + #[serde(default)] + indices: Vec, } fn one() -> f32 { @@ -762,6 +765,7 @@ fn edit_op_from_dto(d: EditOpDto) -> Result { bold: d.bold.unwrap_or(false), italic: d.italic.unwrap_or(false), }, + "reflowText" => ff_engine::EditOp::ReflowText { indices: d.indices, text: d.text }, "addImage" => ff_engine::EditOp::AddImage { x: d.x, y: d.y, diff --git a/app/src/main.js b/app/src/main.js index 90f69c8..586d769 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -2091,7 +2091,7 @@ function buildEditOverlay() { selectEditObject(o.index); }); if (o.kind === "text") { - box.addEventListener("dblclick", (e) => { e.stopPropagation(); startInlineTextEdit(o); }); + box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(o); }); } ov.appendChild(box); }); @@ -2254,6 +2254,134 @@ function composeLineText(runs) { return s; } +// Gom ĐOẠN VĂN nhiều dòng quanh run `o` (iteration 3): các dòng baseline cách +// đều (±25%), cỡ chữ tương đồng, giao nhau theo chiều ngang. Trả về mảng dòng +// trên→dưới, mỗi dòng = mảng run trái→phải. +function paragraphLines(o) { + const fs = o.font_size || 12; + const boundsOf = (runs) => ({ + left: Math.min(...runs.map((r) => r.rect.left)), + right: Math.max(...runs.map((r) => r.rect.right)), + bottom: runs[0].rect.bottom, + }); + const overlaps = (a, b) => + Math.min(a.right, b.right) - Math.max(a.left, b.left) > + 0.3 * Math.min(a.right - a.left, b.right - b.left); + + const base = sameLineRuns(o); + const used = new Set(base.map((r) => r.index)); + const lines = [base]; + const cands = state.editObjects.filter( + (x) => + x.kind === "text" && + !used.has(x.index) && + (x.font_size || 12) <= fs * 1.6 && + (x.font_size || 12) >= fs / 1.6 + ); + let advance = null; // khoảng baseline đã chốt của đoạn + + const extend = (dir) => { + // dir = -1: mở rộng XUỐNG (bottom giảm); +1: mở rộng LÊN. + for (;;) { + const edge = dir < 0 ? lines[lines.length - 1] : lines[0]; + const eb = boundsOf(edge); + let best = null; + let bestGap = Infinity; + for (const r of cands) { + if (used.has(r.index)) continue; + const gap = dir < 0 ? eb.bottom - r.rect.bottom : r.rect.bottom - eb.bottom; + if (gap < fs * 0.6 || gap > fs * 2.6) continue; // quá sát/quá xa = không cùng đoạn + if (advance && (gap < advance * 0.75 || gap > advance * 1.25)) continue; + const line = sameLineRuns(r); + if (!overlaps(eb, boundsOf(line))) continue; + if (gap < bestGap) { + bestGap = gap; + best = line; + } + } + if (!best) break; + best.forEach((r) => used.add(r.index)); + if (dir < 0) lines.push(best); + else lines.unshift(best); + if (advance == null) advance = bestGap; + } + }; + extend(-1); + extend(+1); + return lines; +} + +// Double-click: đoạn ≥2 dòng → sửa CẢ ĐOẠN với reflow; 1 dòng → sửa dòng. +function startTextEdit(o) { + const lines = paragraphLines(o); + if (lines.length >= 2) startBlockTextEdit(o, lines); + else startInlineTextEdit(o); +} + +// Sửa cả đoạn "như Word" (Foxit Edit Text): textarea phủ khối, gõ tự chảy +// dòng; commit → engine reflow (bẻ dòng lại theo bề rộng khối, giữ font). +function startBlockTextEdit(o, lines) { + const ov = $("editOverlay"); + const existing = ov.querySelector(".edit-inline"); + if (existing) existing.remove(); + + const allRuns = lines.flat(); + const union = { + left: Math.min(...allRuns.map((r) => r.rect.left)), + right: Math.max(...allRuns.map((r) => r.rect.right)), + bottom: Math.min(...allRuns.map((r) => r.rect.bottom)), + top: Math.max(...allRuns.map((r) => r.rect.top)), + }; + // Đoạn = 1 dòng chảy; Enter của người dùng = ngắt cứng. + const original = lines.map(composeLineText).join(" "); + + const ta = document.createElement("textarea"); + ta.className = "edit-inline edit-inline-block"; + ta.value = original; + Object.assign(ta.style, editBoxStyle(union)); + const advPt = + lines.length > 1 + ? lines[0][0].rect.bottom - lines[1][0].rect.bottom + : (o.font_size || 12) * 1.25; + const advPx = Math.max(8, advPt * state.editScale); + // Chừa 2 dòng trống để gõ thêm. + ta.style.height = parseFloat(ta.style.height) + 2 * advPx + "px"; + ta.style.lineHeight = advPx + "px"; + ta.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px"; + ta.style.fontFamily = cssFontStack(o.font_family); + ta.style.fontWeight = o.font_bold ? "bold" : "normal"; + ta.style.fontStyle = o.font_italic ? "italic" : "normal"; + if (o.color) ta.style.color = rgbCss(o.color); + ov.appendChild(ta); + ta.focus(); + ta.setSelectionRange(0, 0); + $("editHint").textContent = + "Sửa cả đoạn — Enter: xuống dòng cứng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ"; + + let done = false; + const commit = (save) => { + if (done) return; + done = true; + const text = ta.value; + ta.remove(); + if (save && text.trim() && text !== original) { + stageEditOp({ + op: "reflowText", + indices: allRuns.map((r) => r.index), + text, + }); + } else { + $("editHint").textContent = ""; + } + }; + ta.addEventListener("keydown", (e) => { + if (e.key === "Escape") { e.preventDefault(); commit(false); } + else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); } + e.stopPropagation(); + }); + ta.addEventListener("blur", () => commit(true)); +} + // Sửa text tại chỗ kiểu Foxit: gộp cả dòng, ô sửa hiển thị đúng font/cỡ/màu, // Enter/blur commit — engine GIỮ FONT GỐC (chỉ thay khi thiếu glyph). function startInlineTextEdit(o) { diff --git a/app/src/styles.css b/app/src/styles.css index 0f745bd..6d0bf0c 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -413,6 +413,14 @@ body.tool-active .annotlayer * { pointer-events: none; } box-sizing: content-box; } +/* Textarea sửa cả đoạn (reflow) — đè white-space:pre của .edit-inline */ +.edit-inline-block { + white-space: pre-wrap; + word-break: break-word; + resize: none; + overflow: hidden; +} + /* Nút Đậm/Nghiêng trong thanh Sửa nội dung */ .ed-style { min-width: 28px; diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index c517ebc..14171fb 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -130,6 +130,11 @@ pub enum EditOp { }, /// Thêm ảnh mới từ file, khung width_pt × height_pt tại (x,y). AddImage { x: f32, y: f32, width_pt: f32, height_pt: f32, image_path: String }, + /// Sửa CẢ ĐOẠN nhiều dòng với reflow "như Word" (iteration 3): mọi run + /// trong `indices` bị thay bằng `text` mới, tự bẻ dòng theo bề rộng khối + /// (đo bằng hmtx của font), giữ baseline spacing + font/cỡ/màu của run + /// neo (run có baseline cao nhất). `\n` trong `text` = ngắt dòng cứng. + ReflowText { indices: Vec, text: String }, } fn quad_to_rect(q: &PdfQuadPoints) -> Rect { @@ -220,9 +225,69 @@ pub fn list_objects( } /// Key nhận diện 1 font thay thế cần nạp: (family chuẩn hoá — rỗng = font mặc -/// định, đậm, nghiêng). +/// định, đậm, nghiêng). Builtin base-14 dùng prefix "b14:" trong family. type FontKey = (String, bool, bool); +/// Nguồn font cần nạp vào document ở pha (B). +enum FontLoad { + /// Nhúng TTF/OTF từ bytes (font hệ thống hoặc copy font nhúng gốc). + Bytes(Vec), + /// Font chuẩn base-14 của PDF — KHÔNG nhúng, BaseFont giữ tên chuẩn. + Builtin(PdfFontBuiltin), +} + +/// Map family (đã chuẩn hoá) + kiểu chữ → font chuẩn base-14 tương ứng, nếu +/// family là (hoặc metric-compatible với) một trong Helvetica/Times/Courier. +fn builtin_for(family_key: &str, bold: bool, italic: bool) -> Option { + use PdfFontBuiltin::*; + let group = if matches!(family_key, "helvetica" | "helveticaneue" | "arial") { + 0 + } else if matches!(family_key, "times" | "timesroman" | "timesnewroman") { + 1 + } else if matches!(family_key, "courier" | "couriernew") { + 2 + } else { + return None; + }; + Some(match (group, bold, italic) { + (0, false, false) => Helvetica, + (0, true, false) => HelveticaBold, + (0, false, true) => HelveticaOblique, + (0, true, true) => HelveticaBoldOblique, + (1, false, false) => TimesRoman, + (1, true, false) => TimesBold, + (1, false, true) => TimesItalic, + (1, true, true) => TimesBoldItalic, + (_, false, false) => Courier, + (_, true, false) => CourierBold, + (_, false, true) => CourierOblique, + (_, true, true) => CourierBoldOblique, + }) +} + +/// Kế hoạch reflow 1 đoạn, dựng ở pha (A) từ hình học các run gốc. +struct ReflowPlan { + /// Run text hợp lệ sẽ bị thay (đã sort, dedup). + indices: Vec, + /// Key font đã nạp ở pha (B) để tạo các dòng mới. + font_key: FontKey, + /// Bytes để ĐO bề rộng (hmtx) — có thể khác font vẽ (vd base-14 đo bằng + /// font metric-compatible). None = xấp xỉ 0.5em/ký tự. + measure_bytes: Option>, + /// Khối: lề trái + bề rộng bẻ dòng (điểm PDF). + left: f32, + width: f32, + /// Baseline dòng đầu (y của gốc text) + khoảng cách baseline giữa các dòng. + first_baseline: f32, + line_advance: f32, + /// Phần tuyến tính matrix của run neo (giữ scale/nghiêng khi tạo dòng mới). + linear: (f32, f32, f32, f32), + /// Cỡ Tf cho object mới (unscaled) + cỡ hiển thị (để đo). + tf: f32, + scaled: f32, + color: PdfColor, +} + /// Cách xử lý 1 op SetText, quyết định ở pha lập kế hoạch. enum SetTextMode { /// Giữ nguyên font gốc — sửa text/cỡ/màu ngay trên object. @@ -287,7 +352,8 @@ pub fn apply_edits( // ---- (A) Lập kế hoạch font (mượn trang lần 1, chỉ đọc) ---- let mut set_text_modes: HashMap = HashMap::new(); let mut add_text_keys: HashMap = HashMap::new(); - let mut font_bytes_needed: HashMap> = HashMap::new(); + let mut reflow_plans: HashMap = HashMap::new(); + let mut font_needed: HashMap = HashMap::new(); { let page = document .pages() @@ -348,7 +414,7 @@ pub fn apply_edits( target_italic, text, )?; - font_bytes_needed.entry(key.clone()).or_insert(bytes); + font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); SetTextMode::Substitute(key) } } else { @@ -358,7 +424,7 @@ pub fn apply_edits( .unwrap_or(cur_family); let (key, bytes) = resolve_substitute_font(&family, target_bold, target_italic, text)?; - font_bytes_needed.entry(key.clone()).or_insert(bytes); + font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); SetTextMode::Substitute(key) }; set_text_modes.insert(opi, mode); @@ -366,21 +432,144 @@ pub fn apply_edits( EditOp::AddText { text, font_family, bold, italic, .. } => { let family = font_family.clone().unwrap_or_default(); let (key, bytes) = resolve_substitute_font(&family, *bold, *italic, text)?; - font_bytes_needed.entry(key.clone()).or_insert(bytes); + font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); add_text_keys.insert(opi, key); } + EditOp::ReflowText { indices, text } => { + // Run text hợp lệ của khối (sort + dedup). + let mut idxs: Vec = indices.iter().copied().filter(|i| valid(*i)).collect(); + idxs.sort_unstable(); + idxs.dedup(); + // Hình học từng run: gốc text (e,f của matrix) + bounds. + struct RunGeo { + idx: u16, + f: f32, + left: f32, + right: f32, + } + let mut geos: Vec = Vec::new(); + let mut old_text_all = String::new(); + for &i in &idxs { + let obj = page.objects().get(i as usize).map_err(err)?; + let Some(t) = obj.as_text_object() else { continue }; + let m = obj.matrix().map_err(err)?; + let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { + left: m.e(), + bottom: m.f(), + right: m.e(), + top: m.f(), + }); + old_text_all.push_str(&t.text()); + old_text_all.push(' '); + geos.push(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right }); + } + if geos.is_empty() { + continue; + } + idxs = geos.iter().map(|g| g.idx).collect(); + // Run neo: baseline cao nhất, trái nhất. + let anchor_idx = geos + .iter() + .max_by(|a, b| { + a.f.partial_cmp(&b.f) + .unwrap_or(std::cmp::Ordering::Equal) + .then(b.left.partial_cmp(&a.left).unwrap_or(std::cmp::Ordering::Equal)) + }) + .map(|g| g.idx) + .unwrap_or(idxs[0]); + let anchor = page.objects().get(anchor_idx as usize).map_err(err)?; + 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 unscaled = t.unscaled_font_size().value; + let scaled = t.scaled_font_size().value.max(1.0); + let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)); + + // Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance = + // median hiệu 2 cụm kề nhau; 1 dòng → 1.25 × cỡ hiển thị. + let mut baselines: Vec = Vec::new(); + let mut fs: Vec = geos.iter().map(|g| g.f).collect(); + fs.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + for f in fs { + if baselines.last().map_or(true, |&p| (p - f).abs() > 1.0) { + baselines.push(f); + } + } + let line_advance = if baselines.len() >= 2 { + let mut diffs: Vec = + baselines.windows(2).map(|w| (w[0] - w[1]).abs()).collect(); + diffs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + diffs[diffs.len() / 2].max(1.0) + } else { + scaled * 1.25 + }; + let first_baseline = baselines[0]; + let left = geos.iter().map(|g| g.left).fold(f32::INFINITY, f32::min); + let right = geos.iter().map(|g| g.right).fold(f32::NEG_INFINITY, f32::max); + let width = (right - left).max(20.0); + + // Thang font cho các dòng mới (giữ font như SetText): + // (1) font NHÚNG parse được + phủ đủ glyph → nhúng lại chính bytes đó + // (same glyphs — trông y hệt); + // (2) family thuộc nhóm base-14 + text ASCII → font chuẩn PDF, + // BaseFont giữ tên chuẩn, không phình file; + // (3) font hệ thống CÙNG HỌ; (4) fallback mặc định. + let fam_key = fontmatch::normalize_key(&family); + let ascii_only = + text.chars().all(|c| c.is_control() || (' '..='~').contains(&c)); + let embedded_bytes = if t.font().is_embedded().unwrap_or(false) { + t.font().data().ok().filter(|b| fontmatch::coverage_ok(b, text)) + } else { + None + }; + let (font_key, load, measure_bytes) = if let Some(bytes) = embedded_bytes { + let key = (format!("emb:{fam_key}:{anchor_idx}"), bold, italic); + (key, FontLoad::Bytes(bytes.clone()), Some(bytes)) + } else if let (Some(builtin), true) = + (builtin_for(&fam_key, bold, italic), ascii_only) + { + // Đo bằng font hệ thống metric-compatible (Liberation/Arial…). + let measure = fontmatch::find_family_font_bytes(&family, bold, italic) + .or_else(|| find_font_bytes(bold, italic)); + ((format!("b14:{fam_key}"), bold, italic), FontLoad::Builtin(builtin), measure) + } else { + let (key, bytes) = resolve_substitute_font(&family, bold, italic, text)?; + (key, FontLoad::Bytes(bytes.clone()), Some(bytes)) + }; + font_needed.entry(font_key.clone()).or_insert(load); + + reflow_plans.insert( + opi, + ReflowPlan { + indices: idxs, + font_key, + measure_bytes, + left, + width, + first_baseline, + line_advance, + linear: (m.a(), m.b(), m.c(), m.d()), + tf: unscaled.max(1.0), + scaled, + color, + }, + ); + } _ => {} } } } - // ---- (B) Nạp các font thay thế cần dùng (cần fonts_mut → ngoài mượn trang) ---- + // ---- (B) Nạp các font cần dùng (cần fonts_mut → ngoài mượn trang) ---- let mut tokens: HashMap = HashMap::new(); - for (key, bytes) in &font_bytes_needed { - let token = document - .fonts_mut() - .load_true_type_from_bytes(bytes, true) - .map_err(|e| EngineError::Pdfium(format!("nạp font: {e}")))?; + for (key, load) in &font_needed { + let token = match load { + FontLoad::Bytes(bytes) => document + .fonts_mut() + .load_true_type_from_bytes(bytes, true) + .map_err(|e| EngineError::Pdfium(format!("nạp font: {e}")))?, + FontLoad::Builtin(builtin) => document.fonts_mut().new_built_in(*builtin), + }; tokens.insert(key.clone(), token); } @@ -508,6 +697,11 @@ pub fn apply_edits( EditOp::ReplaceImage { index, .. } | EditOp::Delete { index } => { to_remove.push(*index) } + EditOp::ReflowText { .. } => { + if let Some(plan) = reflow_plans.get(&opi) { + to_remove.extend_from_slice(&plan.indices); + } + } _ => {} } } @@ -624,6 +818,45 @@ pub fn apply_edits( ) .map_err(err)?; } + EditOp::ReflowText { text, .. } => { + let Some(plan) = reflow_plans.get(&opi) else { continue }; + let token = tokens.get(&plan.font_key).copied().ok_or_else(|| { + EngineError::Pdfium("thiếu token font cho ReflowText".into()) + })?; + // Đo bề rộng ký tự theo hmtx của font (tại cỡ hiển thị); + // không parse được → xấp xỉ 0.5em. + let face = plan + .measure_bytes + .as_deref() + .and_then(|b| ttf_parser::Face::parse(b, 0).ok()); + let scaled = plan.scaled; + let measure = move |c: char| match &face { + Some(f) => fontmatch::char_advance(f, c, scaled), + None => scaled * 0.5, + }; + let lines = fontmatch::wrap_lines(text, plan.width, &measure); + let (a, b, c2, d) = plan.linear; + for (i, line) in lines.iter().enumerate() { + if line.is_empty() { + continue; // đoạn trống vẫn chiếm 1 nhịp baseline + } + let y = plan.first_baseline - (i as f32) * plan.line_advance; + let mut obj = page + .objects_mut() + .create_text_object( + PdfPoints::ZERO, + PdfPoints::ZERO, + line.clone(), + token, + PdfPoints::new(plan.tf), + ) + .map_err(err)?; + // Giữ scale/nghiêng của run neo; đặt gốc dòng tại (left, y). + obj.apply_matrix(PdfMatrix::new(a, b, c2, d, plan.left, y)) + .map_err(err)?; + obj.set_fill_color(plan.color).map_err(err)?; + } + } _ => {} } } diff --git a/crates/ff-engine/src/fontmatch.rs b/crates/ff-engine/src/fontmatch.rs index ca650fd..b80cb35 100644 --- a/crates/ff-engine/src/fontmatch.rs +++ b/crates/ff-engine/src/fontmatch.rs @@ -196,6 +196,61 @@ pub(crate) fn find_family_font_bytes(family: &str, bold: bool, italic: bool) -> } } +/// Bẻ dòng kiểu Word cho reflow đoạn (Phase 4 iteration 3): greedy theo TỪ, +/// tôn trọng `\n` là ngắt dòng cứng; từ đơn dài hơn bề rộng thì cắt theo ký +/// tự. `measure(c)` trả bề rộng 1 ký tự theo pt (đã nhân cỡ chữ). Dòng rỗng +/// (từ `\n\n`) được GIỮ trong kết quả để caller giữ nhịp baseline. +pub(crate) fn wrap_lines(text: &str, max_width: f32, measure: &dyn Fn(char) -> f32) -> Vec { + let width_of = |s: &str| s.chars().map(measure).sum::(); + // Nới 2% để tránh bẻ sớm vì sai số đo (không kerning). + let limit = max_width.max(1.0) * 1.02; + let mut out = Vec::new(); + for para in text.split('\n') { + let words: Vec<&str> = para.split_whitespace().collect(); + if words.is_empty() { + out.push(String::new()); + continue; + } + let mut line = String::new(); + for word in words { + let candidate_extra = if line.is_empty() { width_of(word) } else { measure(' ') + width_of(word) }; + if !line.is_empty() && width_of(&line) + candidate_extra > limit { + out.push(std::mem::take(&mut line)); + } + if line.is_empty() && width_of(word) > limit { + // Từ đơn quá dài: cắt theo ký tự. + let mut piece = String::new(); + for ch in word.chars() { + if !piece.is_empty() && width_of(&piece) + measure(ch) > limit { + out.push(std::mem::take(&mut piece)); + } + piece.push(ch); + } + line = piece; + } else { + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + } + if !line.is_empty() { + out.push(line); + } + } + out +} + +/// Bề rộng 1 ký tự (pt) theo hmtx của font tại cỡ `size_pt`. Glyph thiếu hoặc +/// không đo được → xấp xỉ 0.5em (đủ tốt cho bẻ dòng, không dùng để vẽ). +pub(crate) fn char_advance(face: &ttf_parser::Face<'_>, ch: char, size_pt: f32) -> f32 { + let upem = face.units_per_em() as f32; + face.glyph_index(ch) + .and_then(|g| face.glyph_hor_advance(g)) + .map(|adv| adv as f32 / upem * size_pt) + .unwrap_or(size_pt * 0.5) +} + /// So khớp mềm 2 key đã chuẩn hoá: bằng nhau, hoặc chứa nhau (đủ dài để tránh nhiễu). fn keys_match(a: &str, b: &str) -> bool { if a == b { @@ -230,4 +285,28 @@ mod tests { assert!(!keys_match("arial", "georgia")); assert!(keys_match("arial", "arial")); } + + #[test] + fn wrap_greedy_by_words() { + // Mỗi ký tự rộng 10pt (kể cả space) → bề rộng 35pt chứa tối đa 3 ký tự/dòng… + // dùng câu chữ 2-2-3 để kiểm greedy: "ab cd efg", limit 5 ký tự (50pt). + let m = |_c: char| 10.0; + let lines = wrap_lines("ab cd efg", 50.0, &m); + assert_eq!(lines, vec!["ab cd".to_string(), "efg".to_string()]); + } + + #[test] + fn wrap_respects_hard_breaks_and_empty_lines() { + let m = |_c: char| 10.0; + let lines = wrap_lines("mot\n\nhai ba", 100.0, &m); + assert_eq!(lines, vec!["mot".to_string(), String::new(), "hai ba".to_string()]); + } + + #[test] + fn wrap_splits_overlong_word() { + let m = |_c: char| 10.0; + let lines = wrap_lines("abcdefgh", 30.0, &m); + // limit 30*1.02 → 3 ký tự/dòng + assert_eq!(lines, vec!["abc".to_string(), "def".to_string(), "gh".to_string()]); + } } diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index d50a0fa..9b978bd 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -575,6 +575,171 @@ fn line_merge_batch_set_text_plus_delete() { assert!(!text.contains("nửa sau"), "run bị gộp phải biến mất: {text:?}"); } +/// Dựng "đoạn văn" 3 dòng bằng font nhúng (AddText) tại x=60, baseline cách 15pt. +fn make_paragraph_fixture(pdf: &pdfium_render::prelude::Pdfium, out: &std::path::Path) { + let mk = |x: f32, y: f32, s: &str| EditOp::AddText { + x, + y, + text: s.into(), + font_size: 12.0, + color: [0, 0, 0, 255], + font_family: None, + bold: false, + italic: false, + }; + ff_engine::apply_edits( + pdf, + &sample(), + 0, + &[ + mk(60.0, 500.0, "Dòng một của đoạn văn mẫu"), + mk(60.0, 485.0, "dòng hai nối tiếp nội dung"), + mk(60.0, 470.0, "dòng ba kết thúc đoạn."), + ], + out, + None, + ) + .expect("dựng đoạn 3 dòng"); +} + +/// Index (và ObjectInfo) các run thuộc đoạn fixture (baseline 470–500). +fn paragraph_runs(pdf: &pdfium_render::prelude::Pdfium, path: &std::path::Path) -> Vec { + ff_engine::list_objects(pdf, path, 0, None) + .expect("list") + .into_iter() + .filter(|o| { + o.kind == ObjectKind::Text && o.rect.bottom > 455.0 && o.rect.bottom < 505.0 && o.rect.left > 50.0 + }) + .collect() +} + +/// REFLOW (iteration 3): text dài hơn hẳn → tự bẻ thành NHIỀU dòng hơn, mọi +/// dòng nằm trong bề rộng khối, GIỮ NGUYÊN font nhúng + baseline spacing 15pt. +#[test] +fn reflow_wraps_and_keeps_embedded_font() { + let pdf = pdfium(); + let fx = tmp("ff_reflow_fx.pdf"); + let out = tmp("ff_reflow_out.pdf"); + make_paragraph_fixture(&pdf, &fx); + let runs = paragraph_runs(&pdf, &fx); + assert_eq!(runs.len(), 3, "fixture phải có 3 dòng"); + let font_before = runs[0].font_name.clone().expect("font fixture"); + let left = runs.iter().map(|r| r.rect.left).fold(f32::INFINITY, f32::min); + let right = runs.iter().map(|r| r.rect.right).fold(f32::NEG_INFINITY, f32::max); + let width = right - left; + + let long_text = "Nội dung hoàn toàn mới và dài hơn hẳn bản gốc, đủ nhiều từ tiếng Việt \ +để buộc thuật toán reflow phải bẻ lại thành nhiều dòng khác nhau trong đúng bề rộng \ +của khối đoạn văn ban đầu, giữ nguyên phông chữ nhúng và khoảng cách dòng."; + ff_engine::apply_edits( + &pdf, + &fx, + 0, + &[EditOp::ReflowText { indices: runs.iter().map(|r| r.index).collect(), text: long_text.into() }], + &out, + None, + ) + .expect("reflow"); + + // Run mới = mọi text object chứa mảnh của text dài (reflow kéo dài xuống dưới + // ngoài cửa sổ baseline của fixture nên không dùng paragraph_runs). + let new_runs: Vec<_> = ff_engine::list_objects(&pdf, &out, 0, None) + .expect("list out") + .into_iter() + .filter(|o| { + o.kind == ObjectKind::Text + && o.text.as_deref().map(|t| long_text.contains(t.trim()) && !t.trim().is_empty()).unwrap_or(false) + }) + .collect(); + assert!(new_runs.len() > 3, "text dài phải bẻ thành >3 dòng, được {}", new_runs.len()); + for r in &new_runs { + assert_eq!(r.font_name.as_deref(), Some(font_before.as_str()), "reflow phải giữ font nhúng"); + assert!(r.rect.left >= left - 2.0, "dòng không được tràn trái"); + assert!(r.rect.right <= left + width + width * 0.05 + 3.0, "dòng không được tràn phải: right={} limit={}", r.rect.right, left + width); + } + // Baseline cách đều 15pt. + let mut bottoms: Vec = new_runs.iter().map(|r| r.rect.bottom).collect(); + bottoms.sort_by(|a, b| b.partial_cmp(a).unwrap()); + for w in bottoms.windows(2) { + let d = w[0] - w[1]; + assert!((d - 15.0).abs() < 2.0, "khoảng baseline phải ~15pt, được {d}"); + } + let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); + let norm = text.split_whitespace().collect::>().join(" "); + assert!(norm.contains("thuật toán reflow"), "nội dung round-trip: {norm:?}"); + assert!(!norm.contains("Dòng một của đoạn"), "text cũ phải biến mất"); +} + +/// `\n` trong text reflow = ngắt dòng cứng (đoạn mới). +#[test] +fn reflow_hard_break_creates_new_line() { + let pdf = pdfium(); + let fx = tmp("ff_reflow_hb_fx.pdf"); + let out = tmp("ff_reflow_hb_out.pdf"); + make_paragraph_fixture(&pdf, &fx); + let runs = paragraph_runs(&pdf, &fx); + + ff_engine::apply_edits( + &pdf, + &fx, + 0, + &[EditOp::ReflowText { + indices: runs.iter().map(|r| r.index).collect(), + text: "Đoạn một ngắn.\nĐoạn hai riêng.".into(), + }], + &out, + None, + ) + .expect("reflow hard break"); + + let new_runs: Vec<_> = ff_engine::list_objects(&pdf, &out, 0, None) + .expect("list out") + .into_iter() + .filter(|o| o.text.as_deref().map(|t| t.contains("Đoạn")).unwrap_or(false)) + .collect(); + assert_eq!(new_runs.len(), 2, "2 đoạn = 2 dòng riêng"); + let d = (new_runs[0].rect.bottom - new_runs[1].rect.bottom).abs(); + assert!((d - 15.0).abs() < 2.0, "2 dòng cách đúng nhịp baseline: {d}"); +} + +/// Reflow trên font base-14 (Helvetica, không nhúng) + text ASCII: các dòng +/// mới dùng font CHUẨN PDF — BaseFont vẫn là Helvetica, file không phình. +#[test] +fn reflow_base14_ascii_keeps_standard_font() { + let pdf = pdfium(); + let input = sample(); + let out = tmp("ff_reflow_b14.pdf"); + let idx = find_text_index(&pdf, &input, "Page one"); + + let long_ascii = "This replacement paragraph is intentionally much longer than the \ +original single line so the reflow engine must wrap it into several lines while keeping \ +the declared standard Helvetica base font untouched."; + ff_engine::apply_edits( + &pdf, + &input, + 0, + &[EditOp::ReflowText { indices: vec![idx], text: long_ascii.into() }], + &out, + None, + ) + .expect("reflow base14"); + + let new_runs: Vec<_> = ff_engine::list_objects(&pdf, &out, 0, None) + .expect("list out") + .into_iter() + .filter(|o| o.text.as_deref().map(|t| long_ascii.contains(t.trim()) && !t.trim().is_empty()).unwrap_or(false)) + .collect(); + assert!(new_runs.len() >= 2, "text dài phải bẻ ≥2 dòng, được {}", new_runs.len()); + for r in &new_runs { + let f = r.font_name.clone().unwrap_or_default(); + assert!(f.contains("Helvetica"), "BaseFont phải giữ Helvetica chuẩn, được {f:?}"); + } + // Extract chèn ngắt dòng tại điểm wrap → chuẩn hoá khoảng trắng trước khi so. + let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); + let norm = text.split_whitespace().collect::>().join(" "); + assert!(norm.contains("standard Helvetica base font untouched"), "round-trip: {norm:?}"); +} + #[test] fn add_image_adds_image_object() { let pdf = pdfium(); diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 2bb215f..86ff81c 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -1,8 +1,57 @@ -# 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 + 2 +# 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 + 2 + 3 -> Trạng thái sau Iteration 2: **sửa text GIỮ NGUYÊN FONT (chuẩn Foxit)**. -> 50/50 test engine xanh ngoài qpdf (14 test edit + 3 unit fontmatch + 33 test cũ). -> Iteration 1: 43/43, đã chạy app thật + chụp ảnh xác minh. +> 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ũ). + +## Iteration 3 — Reflow đoạn văn "như Word" (mới nhất) + +Đóng gap trải nghiệm cuối cùng của tính năng moat (mục 3.1 của +`docs/14-foxit-gap-analysis.md`): double-click vào đoạn nhiều dòng → sửa cả +đoạn trong 1 ô, chữ tự bẻ dòng lại theo bề rộng khối khi commit. + +### Engine — `EditOp::ReflowText { indices, text }` +- Engine TỰ suy hình học từ các run: lề trái/bề rộng khối từ bounds, baseline + từ `matrix.f` (gom cụm ±1pt), khoảng cách dòng = median hiệu baseline + (1 dòng → 1.25× cỡ hiển thị) — UI chỉ cần gửi indices + text mới. +- **Bẻ dòng đo bằng hmtx thật** (`fontmatch::wrap_lines` + `char_advance` qua + ttf-parser): greedy theo từ, `\n` = ngắt cứng, từ quá dài cắt theo ký tự, + dòng rỗng giữ nhịp baseline. Không kerning (chấp nhận v1, nới 2%). +- **Giữ font theo thang 4 mức** (nhất quán triết lý iteration 2): + 1. Font NHÚNG parse được + phủ đủ glyph → nhúng lại chính bytes đó (glyph y hệt); + 2. Family nhóm base-14 (Helvetica/Times/Courier + Arial alias) + text ASCII → + **font chuẩn PDF qua `FPDFText_LoadStandardFont`** — BaseFont giữ tên chuẩn, + KHÔNG nhúng, file không phình; đo width bằng font metric-compatible + (Liberation/Arial); + 3. Font hệ thống cùng họ (coverage-checked); 4. fallback mặc định. +- Dòng mới giữ phần tuyến tính matrix của run neo (scale/nghiêng), gốc tại + (lề trái, baseline thứ i); Tf = unscaled của run neo → cỡ hiển thị không đổi. +- Đoạn dài ra thì các dòng mới nối xuống dưới theo đúng nhịp baseline (hành vi + tương tự Foxit khi khối text nở ra). + +### UI +- Double-click: **đoạn ≥2 dòng → textarea sửa cả đoạn** (`paragraphLines` gom + dòng baseline cách đều ±25%, cỡ chữ tương đồng, giao ngang ≥30%); 1 dòng → + ô sửa dòng như iteration 2. +- Textarea WYSIWYG: đúng font/cỡ/màu/kiểu + line-height đúng nhịp baseline; + nội dung khởi tạo = các dòng nối bằng khoảng trắng (đoạn chảy tự nhiên); + **Enter = ngắt cứng, Ctrl+Enter hoặc bấm ra ngoài = áp dụng, Esc = huỷ**. +- 1 commit = 1 op `reflowText` = 1 nấc undo. + +### Test mới (3 integration + 3 unit) +- `reflow_wraps_and_keeps_embedded_font` — đoạn 3 dòng font nhúng + text Việt + dài gấp mấy lần → bẻ >3 dòng, mọi dòng trong bề rộng khối, **font nhúng giữ + nguyên**, baseline đều 15pt±2, text cũ biến mất. +- `reflow_hard_break_creates_new_line` — `\n` ra đúng 2 dòng cách 1 nhịp. +- `reflow_base14_ascii_keeps_standard_font` — Helvetica không nhúng + ASCII + dài → nhiều dòng, **BaseFont vẫn "Helvetica" chuẩn** (builtin, không nhúng). +- Unit `wrap_lines`: greedy theo từ / ngắt cứng + dòng rỗng / cắt từ quá dài. + +### Giới hạn ghi nhận (v1, sẽ nâng ở vòng sau) +- Đoạn justify (giãn đều 2 lề) reflow về căn trái; chưa kerning; khối text + XOAY chưa reflow theo hướng xoay (dòng mới đặt theo trục ngang). +- Khối lẫn NHIỀU font/cỡ trong 1 đoạn: dòng mới thống nhất theo run neo. + +> Iteration 1: 43/43 test, đã chạy app thật + chụp ảnh xác minh. ## Iteration 2 — Giữ font gốc + trải nghiệm Foxit (mới) diff --git a/docs/13-phase4-user-tests.md b/docs/13-phase4-user-tests.md index 692caa5..0e90158 100644 --- a/docs/13-phase4-user-tests.md +++ b/docs/13-phase4-user-tests.md @@ -41,6 +41,15 @@ cargo run | E24 | Sửa text tiếng Việt trên file có font nhúng (file Word xuất PDF) | Đúng dấu **và giữ nguyên font nhúng** (mở lại ở Foxit xem tên font trong Edit) | | | E25 | Sửa nhiều lần rồi thoát chế độ sửa (không lưu) | Không còn file `ff_edit_*.pdf` rác trong thư mục %TEMP% | | +## Bổ sung Iteration 3 — Sửa cả đoạn với reflow "như Word" +| # | Thao tác | Kỳ vọng | KQ | +|---|----------|---------|----| +| E26 | Bấm đúp vào 1 đoạn văn NHIỀU dòng | Textarea phủ CẢ ĐOẠN, nội dung ghép đủ các dòng, đúng font/cỡ/màu | | +| E27 | Thêm 1 câu dài vào giữa đoạn → Ctrl+Enter (hoặc bấm ra ngoài) | Chữ tự bẻ dòng lại trong đúng bề rộng khối, không tràn phải, khoảng cách dòng giữ nguyên, **font giữ nguyên** | | +| E28 | Trong textarea bấm Enter giữa chừng rồi áp dụng | Vị trí Enter thành xuống dòng cứng (đoạn tách dòng tại đó) | | +| E29 | Xoá bớt chữ cho đoạn ngắn lại → áp dụng | Đoạn co lại còn ít dòng hơn, không sót dòng cũ | | +| E30 | Hoàn tác (Ctrl+Z) sau 1 lần reflow | Cả đoạn quay về nguyên trạng trong 1 bước | | + ## Mẫu phản hồi ``` [Mã] (vd E16) — Phần mềm mở: — Hiện tượng: — diff --git a/docs/14-foxit-gap-analysis.md b/docs/14-foxit-gap-analysis.md index fbdff20..a8779a8 100644 --- a/docs/14-foxit-gap-analysis.md +++ b/docs/14-foxit-gap-analysis.md @@ -51,21 +51,17 @@ tác tầng PDF object trực tiếp. Xếp theo mức độ ảnh hưởng trải nghiệm: -### 3.1 Reflow đoạn văn nhiều dòng ("sửa như Word") — GAP LỚN NHẤT CÒN LẠI -- **Hiện tại:** sửa được cả dòng (Iteration 2), chưa tự bẻ dòng lại cả đoạn. -- **Foxit:** click vào đoạn → gõ → chữ tự chảy xuống dòng trong khối. -- **Giải pháp (Iteration 3, khả thi với stack hiện tại):** - 1. Gom block: các dòng có baseline cách đều (±20%), cùng lề trái/phải giao nhau. - 2. Đo width chính xác tầng engine bằng `hmtx` của đúng font bytes - (`ttf-parser` đã có trong cây phụ thuộc; với font không nhúng thì đo bằng - font hệ thống đã match — chấp nhận sai số như chính Foxit). - 3. UI: textarea phủ block; engine nhận `ReflowBlock { runs, new_text, box }` - → tự bẻ dòng theo width, sinh dãy SetText/AddText/Delete với baseline - spacing gốc. - 4. Nghiệm thu so với Foxit: gõ giữa đoạn 5 dòng → không tràn box, giãn dòng - giữ nguyên, font giữ nguyên. -- **Rủi ro:** đoạn justify (giãn từ) và kerning → v1 chấp nhận căn trái/đều - theo heuristic, như mức "Edit Text" thường của Foxit. +### 3.1 Reflow đoạn văn nhiều dòng ("sửa như Word") — ✅ ĐÃ GIẢI (Iteration 3) +- **Đã làm đúng kế hoạch:** UI gom block theo baseline cách đều + giao ngang; + engine `EditOp::ReflowText` tự suy hình học từ matrix/bounds các run, đo + width bằng `hmtx` (ttf-parser), bẻ dòng greedy (`\n` = ngắt cứng), tạo lại + các dòng đúng baseline spacing; font giữ theo thang 4 mức (bytes nhúng → + font chuẩn base-14 qua `FPDFText_LoadStandardFont` → cùng họ → fallback). + 3 test integration + 3 unit — xem `docs/12` mục Iteration 3. +- **Còn lại của mục này (v2):** đoạn justify reflow về căn trái; chưa kerning; + khối text xoay chưa reflow theo hướng xoay; đoạn lẫn nhiều font thống nhất + theo run neo. Đây là các case Foxit xử lý tốt hơn — nâng khi gặp tài liệu + thực tế, không chặn trải nghiệm chính. ### 3.2 Hiệu năng phiên sửa trên file rất lớn - **Hiện tại:** mô hình "materialize tức thì" — mỗi thao tác lưu toàn bộ tài @@ -133,16 +129,16 @@ Foxit hơn" — chi phí khổng lồ, không giải quyết gap nào ở mục ## 5. Tiến độ so với đích + thứ tự việc đề xuất Đã xong (lõi, có test): Viewer ✅ · Annotate ✅ · Organize + lưu vững ✅ · -**Edit text/object giữ font ✅ (iteration 2)**. +**Edit text/object giữ font ✅ (iteration 2) · Reflow đoạn "như Word" ✅ +(iteration 3)**. Thứ tự đề xuất tiếp theo, bám giá trị người dùng: -1. **Iteration 3 của Edit: reflow đoạn** (mục 3.1) — hoàn tất lời hứa "sửa như - Word", gap trải nghiệm cuối cùng của tính năng moat. -2. **Phase 5 Bảo mật + "Lưu tối ưu"** (mục 3.3 gộp vào đây): mã hoá UI (nợ +1. **Phase 5 Bảo mật + "Lưu tối ưu"** (mục 3.3 gộp vào đây): mã hoá UI (nợ Phase 3), redaction thật, subset font, dọn rác, chữ ký số. -3. Phase 6 Form → Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap). -4. Song song, rải theo từng phase: mở rộng font matching (3.4) và nấc 1 của - hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế. +2. Phase 6 Form → Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap). +3. Song song, rải theo từng phase: mở rộng font matching (3.4), nấc 1 của + hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế, và v2 của reflow + (justify/xoay — mục 3.1). ## 6. Checklist FINAL TARGET & RULE cho chính tài liệu này - [x] Đối chiếu từng tính năng với hành vi Foxit làm chuẩn nghiệm thu.