Phase 4 Iteration 3: reflow đoạn văn 'như Word' (EditOp::ReflowText)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
2026-07-15 01:34:34 +00:00
co-authored by Claude Fable 5
parent c2076c0da6
commit fed43b2fb7
10 changed files with 713 additions and 41 deletions
+4 -3
View File
@@ -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) - [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) - [ ] 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) - **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] 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] 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 - [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) - [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
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E25) - [ ] 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) - [ ] **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). Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md).
+4
View File
@@ -732,6 +732,9 @@ struct EditOpDto {
height_pt: f32, height_pt: f32,
#[serde(default)] #[serde(default)]
image_path: String, image_path: String,
/// reflowText: index các run của khối đoạn văn.
#[serde(default)]
indices: Vec<u16>,
} }
fn one() -> f32 { fn one() -> f32 {
@@ -762,6 +765,7 @@ fn edit_op_from_dto(d: EditOpDto) -> Result<ff_engine::EditOp, String> {
bold: d.bold.unwrap_or(false), bold: d.bold.unwrap_or(false),
italic: d.italic.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 { "addImage" => ff_engine::EditOp::AddImage {
x: d.x, x: d.x,
y: d.y, y: d.y,
+129 -1
View File
@@ -2091,7 +2091,7 @@ function buildEditOverlay() {
selectEditObject(o.index); selectEditObject(o.index);
}); });
if (o.kind === "text") { if (o.kind === "text") {
box.addEventListener("dblclick", (e) => { e.stopPropagation(); startInlineTextEdit(o); }); box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(o); });
} }
ov.appendChild(box); ov.appendChild(box);
}); });
@@ -2254,6 +2254,134 @@ function composeLineText(runs) {
return s; 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, // 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). // Enter/blur commit — engine GIỮ FONT GỐC (chỉ thay khi thiếu glyph).
function startInlineTextEdit(o) { function startInlineTextEdit(o) {
+8
View File
@@ -413,6 +413,14 @@ body.tool-active .annotlayer * { pointer-events: none; }
box-sizing: content-box; 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 */ /* Nút Đậm/Nghiêng trong thanh Sửa nội dung */
.ed-style { .ed-style {
min-width: 28px; min-width: 28px;
+242 -9
View File
@@ -130,6 +130,11 @@ pub enum EditOp {
}, },
/// Thêm ảnh mới từ file, khung width_pt × height_pt tại (x,y). /// 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 }, 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<u16>, text: String },
} }
fn quad_to_rect(q: &PdfQuadPoints) -> Rect { 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 /// 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); 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<u8>),
/// 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<PdfFontBuiltin> {
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<u16>,
/// 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<Vec<u8>>,
/// 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. /// Cách xử lý 1 op SetText, quyết định ở pha lập kế hoạch.
enum SetTextMode { enum SetTextMode {
/// Giữ nguyên font gốc — sửa text/cỡ/màu ngay trên object. /// 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) ---- // ---- (A) Lập kế hoạch font (mượn trang lần 1, chỉ đọc) ----
let mut set_text_modes: HashMap<usize, SetTextMode> = HashMap::new(); let mut set_text_modes: HashMap<usize, SetTextMode> = HashMap::new();
let mut add_text_keys: HashMap<usize, FontKey> = HashMap::new(); let mut add_text_keys: HashMap<usize, FontKey> = HashMap::new();
let mut font_bytes_needed: HashMap<FontKey, Vec<u8>> = HashMap::new(); let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new();
let mut font_needed: HashMap<FontKey, FontLoad> = HashMap::new();
{ {
let page = document let page = document
.pages() .pages()
@@ -348,7 +414,7 @@ pub fn apply_edits(
target_italic, target_italic,
text, text,
)?; )?;
font_bytes_needed.entry(key.clone()).or_insert(bytes); font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
SetTextMode::Substitute(key) SetTextMode::Substitute(key)
} }
} else { } else {
@@ -358,7 +424,7 @@ pub fn apply_edits(
.unwrap_or(cur_family); .unwrap_or(cur_family);
let (key, bytes) = let (key, bytes) =
resolve_substitute_font(&family, target_bold, target_italic, text)?; resolve_substitute_font(&family, target_bold, target_italic, text)?;
font_bytes_needed.entry(key.clone()).or_insert(bytes); font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
SetTextMode::Substitute(key) SetTextMode::Substitute(key)
}; };
set_text_modes.insert(opi, mode); set_text_modes.insert(opi, mode);
@@ -366,21 +432,144 @@ pub fn apply_edits(
EditOp::AddText { text, font_family, bold, italic, .. } => { EditOp::AddText { text, font_family, bold, italic, .. } => {
let family = font_family.clone().unwrap_or_default(); let family = font_family.clone().unwrap_or_default();
let (key, bytes) = resolve_substitute_font(&family, *bold, *italic, text)?; 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); 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<u16> = 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<RunGeo> = 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<f32> = Vec::new();
let mut fs: Vec<f32> = 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<f32> =
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<FontKey, PdfFontToken> = HashMap::new(); let mut tokens: HashMap<FontKey, PdfFontToken> = HashMap::new();
for (key, bytes) in &font_bytes_needed { for (key, load) in &font_needed {
let token = document let token = match load {
FontLoad::Bytes(bytes) => document
.fonts_mut() .fonts_mut()
.load_true_type_from_bytes(bytes, true) .load_true_type_from_bytes(bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font: {e}")))?; .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); tokens.insert(key.clone(), token);
} }
@@ -508,6 +697,11 @@ pub fn apply_edits(
EditOp::ReplaceImage { index, .. } | EditOp::Delete { index } => { EditOp::ReplaceImage { index, .. } | EditOp::Delete { index } => {
to_remove.push(*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)?; .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)?;
}
}
_ => {} _ => {}
} }
} }
+79
View File
@@ -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<String> {
let width_of = |s: &str| s.chars().map(measure).sum::<f32>();
// 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). /// 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 { fn keys_match(a: &str, b: &str) -> bool {
if a == b { if a == b {
@@ -230,4 +285,28 @@ mod tests {
assert!(!keys_match("arial", "georgia")); assert!(!keys_match("arial", "georgia"));
assert!(keys_match("arial", "arial")); 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()]);
}
} }
+165
View File
@@ -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:?}"); 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::ObjectInfo> {
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<f32> = 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::<Vec<_>>().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::<Vec<_>>().join(" ");
assert!(norm.contains("standard Helvetica base font untouched"), "round-trip: {norm:?}");
}
#[test] #[test]
fn add_image_adds_image_object() { fn add_image_adds_image_object() {
let pdf = pdfium(); let pdf = pdfium();
+53 -4
View File
@@ -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)**. > Trạng thái sau Iteration 3: **sửa CẢ ĐOẠN với reflow "như Word" + GIỮ FONT**.
> 50/50 test engine xanh ngoài qpdf (14 test edit + 3 unit fontmatch + 33 test cũ). > 52/52 test engine xanh ngoài qpdf (17 test edit + 6 unit fontmatch + 29 test cũ).
> Iteration 1: 43/43, đã chạy app thật + chụp ảnh xác minh.
## 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) ## Iteration 2 — Giữ font gốc + trải nghiệm Foxit (mới)
+9
View File
@@ -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) | | | 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% | | | 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ẫu phản hồi
``` ```
[Mã] (vd E16) — Phần mềm mở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> — [Mã] (vd E16) — Phần mềm mở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> —
+18 -22
View File
@@ -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: 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 ### 3.1 Reflow đoạn văn nhiều dòng ("sửa như Word") — ✅ ĐÃ GIẢI (Iteration 3)
- **Hiện tại:** sửa được cả dòng (Iteration 2), chưa tự bẻ dòng lại cả đoạn. - **Đã làm đúng kế hoạch:** UI gom block theo baseline cách đều + giao ngang;
- **Foxit:** click vào đoạn → gõ → chữ tự chảy xuống dòng trong khối. engine `EditOp::ReflowText` tự suy hình học từ matrix/bounds các run, đo
- **Giải pháp (Iteration 3, khả thi với stack hiện tại):** width bằng `hmtx` (ttf-parser), bẻ dòng greedy (`\n` = ngắt cứng), tạo lại
1. Gom block: các dòng có baseline cách đều (±20%), cùng lề trái/phải giao nhau. các dòng đúng baseline spacing; font giữ theo thang 4 mức (bytes nhúng →
2. Đo width chính xác tầng engine bằng `hmtx` của đúng font bytes font chuẩn base-14 qua `FPDFText_LoadStandardFont` → cùng họ → fallback).
(`ttf-parser` đã có trong cây phụ thuộc; với font không nhúng thì đo bằng 3 test integration + 3 unit — xem `docs/12` mục Iteration 3.
font hệ thống đã match — chấp nhận sai số như chính Foxit). - **Còn lại của mục này (v2):** đoạn justify reflow về căn trái; chưa kerning;
3. UI: textarea phủ block; engine nhận `ReflowBlock { runs, new_text, box }` khối text xoay chưa reflow theo hướng xoay; đoạn lẫn nhiều font thống nhất
→ tự bẻ dòng theo width, sinh dãy SetText/AddText/Delete với baseline theo run neo. Đây là các case Foxit xử lý tốt hơn — nâng khi gặp tài liệu
spacing gốc. thực tế, không chặn trải nghiệm chính.
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.2 Hiệu năng phiên sửa trên file rất lớn ### 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 - **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 ## 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 ✅ · Đã 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: 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ư 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ợ
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ợ
Phase 3), redaction thật, subset font, dọn rác, chữ ký số. 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). 2. 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 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ế. 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 ## 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. - [x] Đối chiếu từng tính năng với hành vi Foxit làm chuẩn nghiệm thu.