Sửa 3 lỗi edit inline vòng 2 (file thật Word-export): hết □ thay dấu cách, dòng không vỡ khi thêm ký tự, ô sửa WYSIWYG đúng cỡ từng dòng

Chẩn đoán □: PDFium FPDFText_LoadFont nạp LẠI font subset rồi SetText map
U+0020 ra .notdef (hộp vuông) dù cmap font CÓ glyph space (đã probe cả 2
subtable) — lỗi PDFium với font nạp động, không phải do font.

Engine (edit.rs):
- Tầng 0 mới của thang font reflow: DÙNG LẠI chính font object gốc trong
  PDF (PdfFontToken của run neo, không nạp lại) — glyph + charcode map gốc
  xử lý dấu cách chuẩn, file không phình; chỉ chọn sau khi PROBE ghi/đọc-lại
  toàn bộ text mới thành công (tạo object nháp → so text → gỡ + mem::forget)
- Ghi từng dòng xong ĐỌC LẠI NGAY tự kiểm; PDFium vẫn vứt ký tự → gỡ và ghi
  lại TỪNG TỪ tự đặt vị trí bằng advance hmtx (không bao giờ ghi glyph dấu
  cách) — lưới an toàn cuối cho font lạ
- Dòng cứng (\n từ ô sửa) NỞ tới +35% bề rộng khối (chặn mép trang) trước
  khi bẻ lại — hộp text tự nở như Foxit; khối căn giữa nở đều quanh tâm
- line_styles theo baseline gốc: dòng cứng thứ i giữ ĐÚNG CỠ dòng gốc thứ i
  (tiêu đề 22.5/20pt không bị ép cả khối về 1 cỡ)

UI (main.js + styles.css):
- Ô sửa cả đoạn đổi textarea → contenteditable plaintext-only: MỖI DÒNG GỐC
  = 1 div đúng font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ, khối căn giữa hiện căn
  giữa (nở đều 2 phía quanh tâm), khung bù lệch dọc line-box → chữ trong ô
  TRÙNG vị trí chữ gốc khi mở; con trỏ đặt đúng điểm đúp (caretRangeFromPoint);
  Enter xuống dòng, Ctrl+Enter/blur áp dụng, Esc huỷ, dán luôn chữ thuần

Kiểm chứng file thật: "HỢP"→"HỢPP" giữ đúng 2 dòng căn giữa cỡ 22.5/20,
font BAAAAA+TimesNewRomanPS-BoldMT gốc, dấu cách nguyên vẹn, render khớp
bản gốc. Test: +2 hồi quy (giữ cỡ từng dòng; dòng nở không bẻ) → edit 21/21,
các suite annot/organize/redact/form xanh. Docs 12 (mục vá vòng 2), 13
(E39–E42).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
2026-07-16 16:15:04 +00:00
co-authored by Claude Fable 5
parent c8f22b091f
commit 58cba1f415
6 changed files with 500 additions and 96 deletions
+119 -39
View File
@@ -2200,7 +2200,7 @@ function buildEditOverlay() {
e.stopPropagation(); e.stopPropagation();
selectEditObject(rep.index, line.runs.map((r) => r.index)); selectEditObject(rep.index, line.runs.map((r) => r.index));
}); });
box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(rep); }); box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(rep, e); });
ov.appendChild(box); ov.appendChild(box);
}); });
@@ -2460,20 +2460,24 @@ function paragraphLines(o) {
// Double-click vào chữ: LUÔN mở ô sửa CẢ ĐOẠN (kể cả đoạn 1 dòng) — một // Double-click vào chữ: LUÔN mở ô sửa CẢ ĐOẠN (kể cả đoạn 1 dòng) — một
// đường code duy nhất, giữ nguyên cấu trúc xuống dòng, commit qua reflow. // đường code duy nhất, giữ nguyên cấu trúc xuống dòng, commit qua reflow.
function startTextEdit(o) { // `ev` (nếu có) = sự kiện chuột để đặt con trỏ đúng chỗ vừa đúp.
startBlockTextEdit(o, paragraphLines(o)); function startTextEdit(o, ev) {
startBlockTextEdit(o, paragraphLines(o), ev);
} }
// Sửa cả đoạn "như Word" (Foxit Edit Text): textarea phủ kín khối (che chữ // Sửa cả đoạn WYSIWYG (chuẩn Foxit Edit Text): contenteditable phủ kín khối,
// cũ bên dưới), MỖI DÒNG GỐC = 1 dòng textarea (giữ xuống dòng — sửa được cả // MỖI DÒNG GỐC = 1 div giữ ĐÚNG font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ (tiêu đề
// vị trí ngắt dòng); click trong ô = di chuyển con trỏ; commit → engine // 22.5/20pt hiện đúng 2 cỡ), khối căn giữa hiện căn giữa — chữ trong ô nằm
// reflow: xoá SẠCH mọi run của khối (kể cả run sót — engine tự nở theo bbox), // TRÙNG vị trí chữ gốc, không xê dịch. Enter = xuống dòng; click trong ô =
// giữ font + giữ căn giữa nếu khối gốc căn giữa. // di chuyển con trỏ; commit → engine reflow (xoá sạch run cũ, giữ font gốc,
function startBlockTextEdit(o, lines) { // giữ cỡ từng dòng, giữ căn giữa).
function startBlockTextEdit(o, lines, ev) {
const ov = $("editOverlay"); const ov = $("editOverlay");
const existing = ov.querySelector(".edit-inline"); const existing = ov.querySelector(".edit-inline");
if (existing) existing.remove(); if (existing) existing.remove();
const s = state.editScale;
const p = state.pages[state.editPage];
const allRuns = lines.flatMap((l) => l.runs); const allRuns = lines.flatMap((l) => l.runs);
const union = { const union = {
left: Math.min(...lines.map((l) => l.rect.left)), left: Math.min(...lines.map((l) => l.rect.left)),
@@ -2481,42 +2485,111 @@ function startBlockTextEdit(o, lines) {
bottom: Math.min(...lines.map((l) => l.rect.bottom)), bottom: Math.min(...lines.map((l) => l.rect.bottom)),
top: Math.max(...lines.map((l) => l.rect.top)), top: Math.max(...lines.map((l) => l.rect.top)),
}; };
// Giữ đúng cấu trúc dòng gốc: mỗi dòng thị giác = 1 dòng trong ô sửa. const bw = union.right - union.left;
const original = lines.map((l) => composeLineText(l.runs)).join("\n"); const bc = (union.left + union.right) / 2;
// Căn giữa? — cùng tiêu chí với engine: tâm mọi dòng ≈ tâm khối và có ít
// nhất 1 dòng thụt vào so với lề trái.
const tol = Math.max(2, bw * 0.02);
const centered =
lines.every((l) => Math.abs((l.rect.left + l.rect.right) / 2 - bc) <= tol) &&
lines.some((l) => l.rect.left - union.left > tol);
const ta = document.createElement("textarea");
ta.className = "edit-inline edit-inline-block";
ta.value = original;
Object.assign(ta.style, editBoxStyle(union));
const advPt = const advPt =
lines.length > 1 lines.length > 1
? Math.abs(lines[0].rect.bottom - lines[1].rect.bottom) ? Math.abs(lines[0].rect.bottom - lines[1].rect.bottom)
: (o.font_size || 12) * 1.25; : (o.font_size || 12) * 1.25;
const advPx = Math.max(8, advPt * state.editScale); const advPx = Math.max(8, advPt * s);
// Cao đủ số dòng + 1 dòng trống để gõ thêm; rộng thêm chút để gõ dài hơn.
ta.style.height = (lines.length + 1) * advPx + 6 + "px"; const original = lines.map((l) => composeLineText(l.runs)).join("\n");
const p = state.pages[state.editPage];
const maxW = (p.widthPt - union.left) * state.editScale - 4; const ce = document.createElement("div");
ta.style.width = Math.min(maxW, parseFloat(ta.style.width) + 60) + "px"; ce.className = "edit-inline edit-inline-wys";
ta.style.lineHeight = advPx + "px"; ce.contentEditable = "plaintext-only";
ta.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px"; ce.spellcheck = false;
ta.style.fontFamily = cssFontStack(o.font_family);
ta.style.fontWeight = o.font_bold ? "bold" : "normal"; // Khung: khối căn giữa nở đều 2 phía (tâm giữ nguyên → chữ không xê dịch);
ta.style.fontStyle = o.font_italic ? "italic" : "normal"; // khối thường giữ mép trái, nở sang phải — khớp giới hạn nở 35% của engine.
if (o.color) ta.style.color = rgbCss(o.color); let leftPt;
ov.appendChild(ta); let widthPt;
ta.focus(); if (centered) {
ta.setSelectionRange(0, 0); const grow = Math.min(bw * 0.175, bc - 8 - bw / 2, p.widthPt - 8 - bc - bw / 2);
ta.scrollIntoView({ block: "center", behavior: "instant" }); const g = Math.max(0, grow);
leftPt = union.left - g;
widthPt = bw + 2 * g;
} else {
leftPt = union.left;
widthPt = Math.min(bw * 1.35, p.widthPt - union.left - 8);
}
// Bù lệch dọc: line-box của trình duyệt căn giữa glyph trong line-height,
// còn bbox PDF ôm sát glyph → đẩy khung lên (advPx − cỡ chữ)/2 cho trùng.
const fs0 = (lines[0].fs || o.font_size || 12) * s;
const topPx = Math.max(0, (p.heightPt - union.top) * s - Math.max(0, (advPx - fs0) / 2));
Object.assign(ce.style, {
left: leftPt * s + "px",
top: topPx + "px",
width: widthPt * s + "px",
minHeight: lines.length * advPx + 4 + "px",
textAlign: centered ? "center" : "left",
});
// MỖI DÒNG 1 div với đúng cỡ/màu/kiểu của dòng đó (WYSIWYG khi gõ).
for (const l of lines) {
const rep = l.runs.find((r) => (r.text || "").trim()) || l.runs[0] || o;
const div = document.createElement("div");
div.className = "edit-line";
const t = composeLineText(l.runs);
if (t) div.textContent = t;
else div.innerHTML = "<br>";
div.style.fontSize = Math.max(6, (l.fs || 12) * s) + "px";
div.style.lineHeight = advPx + "px";
div.style.fontFamily = cssFontStack(rep.font_family || o.font_family);
div.style.fontWeight = (rep.font_bold != null ? rep.font_bold : o.font_bold) ? "bold" : "normal";
div.style.fontStyle = (rep.font_italic != null ? rep.font_italic : o.font_italic) ? "italic" : "normal";
if (rep.color || o.color) div.style.color = rgbCss(rep.color || o.color);
ce.appendChild(div);
}
ov.appendChild(ce);
// WebView không hỗ trợ plaintext-only (hiếm) → contenteditable thường.
if (!ce.isContentEditable) ce.contentEditable = "true";
ce.focus();
// Đặt con trỏ đúng chỗ vừa đúp (như Foxit); không có toạ độ → đầu đoạn.
let placed = false;
if (ev && document.caretRangeFromPoint) {
const r = document.caretRangeFromPoint(ev.clientX, ev.clientY);
if (r && ce.contains(r.startContainer)) {
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(r);
placed = true;
}
}
if (!placed) {
const sel = window.getSelection();
const r = document.createRange();
r.setStart(ce, 0);
r.collapse(true);
sel.removeAllRanges();
sel.addRange(r);
}
ce.scrollIntoView({ block: "center", behavior: "instant" });
$("editHint").textContent = $("editHint").textContent =
"Sửa cả đoạn — Enter: xuống dòng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ"; "Sửa cả đoạn — Enter: xuống dòng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ";
// Đọc nội dung theo DÒNG từ contenteditable (div/br → \n).
const readText = () => {
let t = ce.innerText != null ? ce.innerText : ce.textContent;
t = t.replace(/\u00a0/g, " ").replace(/\n+$/, "");
return t;
};
let done = false; let done = false;
const commit = (save) => { const commit = (save) => {
if (done) return; if (done) return;
done = true; done = true;
const text = ta.value; const text = readText();
ta.remove(); ce.remove();
if (save && text.trim() && text !== original) { if (save && text.trim() && text !== original) {
stageEditOp({ stageEditOp({
op: "reflowText", op: "reflowText",
@@ -2527,16 +2600,23 @@ function startBlockTextEdit(o, lines) {
$("editHint").textContent = ""; $("editHint").textContent = "";
} }
}; };
ta.addEventListener("keydown", (e) => { ce.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.preventDefault(); commit(false); } if (e.key === "Escape") { e.preventDefault(); commit(false); }
else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); } else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); }
e.stopPropagation(); e.stopPropagation();
}); });
// Dán luôn ở dạng chữ thuần (khi rơi về contenteditable thường).
ce.addEventListener("paste", (e) => {
if (ce.contentEditable === "plaintext-only") return;
e.preventDefault();
const t = (e.clipboardData || window.clipboardData).getData("text/plain");
document.execCommand("insertText", false, t);
});
// Click/chọn chữ TRONG ô sửa không được lan xuống overlay (gây thoát edit). // Click/chọn chữ TRONG ô sửa không được lan xuống overlay (gây thoát edit).
ta.addEventListener("mousedown", (e) => e.stopPropagation()); ce.addEventListener("mousedown", (e) => e.stopPropagation());
ta.addEventListener("click", (e) => e.stopPropagation()); ce.addEventListener("click", (e) => e.stopPropagation());
ta.addEventListener("dblclick", (e) => e.stopPropagation()); ce.addEventListener("dblclick", (e) => e.stopPropagation());
ta.addEventListener("blur", () => commit(true)); ce.addEventListener("blur", () => commit(true));
} }
// Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung // Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung
@@ -3453,7 +3533,7 @@ $("editOverlay").addEventListener("dblclick", (e) => {
const x = (e.clientX - r.left) / state.editScale; const x = (e.clientX - r.left) / state.editScale;
const y = p.heightPt - (e.clientY - r.top) / state.editScale; const y = p.heightPt - (e.clientY - r.top) / state.editScale;
const hit = pickTextAt(state.editObjects, x, y); const hit = pickTextAt(state.editObjects, x, y);
if (hit) startTextEdit(hit); if (hit) startTextEdit(hit, e);
}); });
// Phím tắt chuẩn của trình xem PDF (Foxit/Adobe): điều hướng trang, zoom, // Phím tắt chuẩn của trình xem PDF (Foxit/Adobe): điều hướng trang, zoom,
+14
View File
@@ -445,6 +445,20 @@ body.tool-active .annotlayer * { pointer-events: none; }
overflow: hidden; overflow: hidden;
} }
/* Ô sửa WYSIWYG cả đoạn: mỗi dòng gốc = 1 div giữ đúng font/cỡ/màu dòng đó,
chữ nằm trùng vị trí chữ gốc (che kín bên dưới), con trỏ đặt theo điểm đúp */
.edit-inline-wys {
white-space: pre-wrap;
word-break: break-word;
overflow: visible;
padding: 0;
border-width: 1px;
cursor: text;
}
.edit-inline-wys .edit-line {
min-height: 1em;
}
/* 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;
+205 -36
View File
@@ -265,12 +265,32 @@ fn builtin_for(family_key: &str, bold: bool, italic: bool) -> Option<PdfFontBuil
}) })
} }
/// Font để VẼ các dòng reflow mới.
enum ReflowFont {
/// Dùng lại CHÍNH font object gốc của PDF (không nạp lại) — giữ nguyên
/// glyph + mapping charcode gốc. Đường ưu tiên với font nhúng: PDFium
/// `FPDFText_LoadFont` nạp lại font subset (Word xuất) hay map dấu cách
/// ra .notdef (□), còn font object gốc thì không. Chỉ chọn sau khi probe
/// round-trip toàn bộ text thành công.
Original(PdfFontToken),
/// Font nạp mới (nhúng lại bytes / base-14 / hệ thống) theo key trong
/// bảng `tokens`.
Loaded(FontKey),
}
/// Cỡ chữ 1 dòng gốc (theo baseline, trên→dưới): Tf unscaled + cỡ hiển thị.
#[derive(Clone, Copy)]
struct LineStyle {
tf: f32,
scaled: f32,
}
/// Kế hoạch reflow 1 đoạn, dựng ở pha (A) từ hình học các run gốc. /// Kế hoạch reflow 1 đoạn, dựng ở pha (A) từ hình học các run gốc.
struct ReflowPlan { struct ReflowPlan {
/// Run text hợp lệ sẽ bị thay (đã sort, dedup). /// Run text hợp lệ sẽ bị thay (đã sort, dedup).
indices: Vec<u16>, indices: Vec<u16>,
/// Key font đã nạp ở pha (B) để tạo các dòng mới. /// Font vẽ các dòng mới.
font_key: FontKey, draw_font: ReflowFont,
/// Bytes để ĐO bề rộng (hmtx) — có thể khác font vẽ (vd base-14 đo bằng /// 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ự. /// font metric-compatible). None = xấp xỉ 0.5em/ký tự.
measure_bytes: Option<Vec<u8>>, measure_bytes: Option<Vec<u8>>,
@@ -284,12 +304,40 @@ struct ReflowPlan {
line_advance: 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). /// 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), linear: (f32, f32, f32, f32),
/// Cỡ Tf cho object mới (unscaled) + cỡ hiển thị (để đo). /// Cỡ Tf cho object mới (unscaled) + cỡ hiển thị (để đo) của run neo —
/// fallback khi dòng không có style riêng.
tf: f32, tf: f32,
scaled: f32, scaled: f32,
/// Cỡ chữ TỪNG DÒNG GỐC (trên→dưới) — dòng cứng thứ i của text mới giữ
/// đúng cỡ dòng gốc thứ i (tiêu đề 22.5/20pt không bị ép về 1 cỡ).
line_styles: Vec<LineStyle>,
color: PdfColor, color: PdfColor,
} }
/// Probe: font `token` có ghi được `text` tròn trịa qua PDFium không?
/// Tạo text object nháp → đọc lại → xoá ngay (mem::forget vì object đã
/// thuộc trang). Bắt được các case PDFium âm thầm vứt ký tự (dấu cách với
/// font subset nạp lại, chữ ngoài ToUnicode...).
fn probe_font_roundtrip(page: &mut PdfPage<'_>, token: PdfFontToken, text: &str) -> bool {
let probe: String = text.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
let ok = match page.objects_mut().create_text_object(
PdfPoints::ZERO,
PdfPoints::ZERO,
&probe,
token,
PdfPoints::new(10.0),
) {
Ok(obj) => obj.as_text_object().map(|t| t.text() == probe).unwrap_or(false),
Err(_) => return false,
};
let idx = page.objects().len().saturating_sub(1);
match page.objects_mut().remove_object_at_index(idx as usize) {
Ok(removed) => std::mem::forget(removed),
Err(_) => return false,
}
ok
}
/// 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.
@@ -357,10 +405,12 @@ pub fn apply_edits(
let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new(); let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new();
let mut font_needed: HashMap<FontKey, FontLoad> = HashMap::new(); let mut font_needed: HashMap<FontKey, FontLoad> = HashMap::new();
{ {
let page = document let mut page = document
.pages() .pages()
.get(page_index) .get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?; .map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
// 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 obj_count = page.objects().len();
let valid = |i: u16| (i as usize) < obj_count; let valid = |i: u16| (i as usize) < obj_count;
@@ -448,12 +498,16 @@ pub fn apply_edits(
f: f32, f: f32,
left: f32, left: f32,
right: f32, right: f32,
tf: f32,
scaled: f32,
} }
let geo_of = |i: u16| -> Result<Option<RunGeo>, EngineError> { let geo_of = |page: &PdfPage<'_>, i: u16| -> Result<Option<RunGeo>, EngineError> {
let obj = page.objects().get(i as usize).map_err(err)?; let obj = page.objects().get(i as usize).map_err(err)?;
if obj.as_text_object().is_none() { let Some(t) = obj.as_text_object() else {
return Ok(None); return Ok(None);
} };
let (tf, scaled) =
(t.unscaled_font_size().value, t.scaled_font_size().value);
let m = obj.matrix().map_err(err)?; let m = obj.matrix().map_err(err)?;
let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect {
left: m.e(), left: m.e(),
@@ -461,11 +515,11 @@ pub fn apply_edits(
right: m.e(), right: m.e(),
top: m.f(), top: m.f(),
}); });
Ok(Some(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right })) Ok(Some(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right, tf, scaled }))
}; };
let mut geos: Vec<RunGeo> = Vec::new(); let mut geos: Vec<RunGeo> = Vec::new();
for &i in &idxs { for &i in &idxs {
if let Some(g) = geo_of(i)? { if let Some(g) = geo_of(&page, i)? {
geos.push(g); geos.push(g);
} }
} }
@@ -520,7 +574,7 @@ pub fn apply_edits(
&& cy <= bb.top + pad && cy <= bb.top + pad
{ {
idxs.push(i); idxs.push(i);
if let Some(g) = geo_of(i)? { if let Some(g) = geo_of(&page, i)? {
geos.push(g); geos.push(g);
} }
} }
@@ -537,13 +591,21 @@ pub fn apply_edits(
}) })
.map(|g| g.idx) .map(|g| g.idx)
.unwrap_or(idxs[0]); .unwrap_or(idxs[0]);
// Chụp mọi thuộc tính của run neo trong 1 block để nhả mượn
// 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)?; let anchor = page.objects().get(anchor_idx as usize).map_err(err)?;
let t = anchor.as_text_object().expect("anchor là text"); let t = anchor.as_text_object().expect("anchor là text");
let (family, bold, italic) = text_object_style(t); let (family, bold, italic) = text_object_style(t);
let m = anchor.matrix().map_err(err)?; let m = anchor.matrix().map_err(err)?;
let tok = t.font().token();
let unscaled = t.unscaled_font_size().value; let unscaled = t.unscaled_font_size().value;
let scaled = t.scaled_font_size().value.max(1.0); let scaled = t.scaled_font_size().value.max(1.0);
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)); let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
let embedded = t.font().is_embedded().unwrap_or(false);
let data = t.font().data().ok();
(family, bold, italic, m, unscaled, scaled, color, embedded, tok, data)
};
// Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance = // Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance =
// median hiệu 2 cụm kề nhau; 1 dòng → 1.25 × cỡ hiển thị. // median hiệu 2 cụm kề nhau; 1 dòng → 1.25 × cỡ hiển thị.
@@ -595,41 +657,70 @@ pub fn apply_edits(
all_centered && any_indented all_centered && any_indented
}; };
// Cỡ chữ TỪNG DÒNG gốc (theo baseline, trên→dưới): dòng cứng
// thứ i của text mới giữ đúng cỡ dòng gốc thứ i (tiêu đề
// 22.5/20pt không bị ép hết về cỡ run neo).
let line_styles: Vec<LineStyle> = baselines
.iter()
.map(|&bl| {
geos.iter()
.filter(|g| (g.f - bl).abs() <= 1.0 && g.scaled > 0.5)
.max_by(|a, b| {
a.scaled
.partial_cmp(&b.scaled)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|g| LineStyle { tf: g.tf.max(1.0), scaled: g.scaled.max(1.0) })
.unwrap_or(LineStyle { tf: unscaled.max(1.0), scaled })
})
.collect();
// Thang font cho các dòng mới (giữ font như SetText): // 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 đó // (0) font NHÚNG + probe ghi/đọc lại tròn trịa → dùng LẠI chính
// (same glyphs — trông y hệt); // font object gốc (glyph + charcode y hệt, file không phình;
// (2) family thuộc nhóm base-14 + text ASCII → font chuẩn PDF, // tránh lỗi PDFium nạp lại subset làm dấu cách thành □);
// BaseFont giữ tên chuẩn, không phình file; // (1) font NHÚNG parse được + phủ đủ glyph → nhúng lại bytes đó;
// (2) family thuộc nhóm base-14 + text ASCII → font chuẩn PDF;
// (3) font hệ thống CÙNG HỌ; (4) fallback mặc định. // (3) font hệ thống CÙNG HỌ; (4) fallback mặc định.
let fam_key = fontmatch::normalize_key(&family); let fam_key = fontmatch::normalize_key(&family);
let ascii_only = let ascii_only =
text.chars().all(|c| c.is_control() || (' '..='~').contains(&c)); text.chars().all(|c| c.is_control() || (' '..='~').contains(&c));
let embedded_bytes = if t.font().is_embedded().unwrap_or(false) { let original_ok =
t.font().data().ok().filter(|b| fontmatch::coverage_ok(b, text)) font_embedded && probe_font_roundtrip(&mut page, orig_token, text);
let embedded_bytes = if font_embedded {
font_data.clone().filter(|b| fontmatch::coverage_ok(b, text))
} else { } else {
None None
}; };
let (font_key, load, measure_bytes) = if let Some(bytes) = embedded_bytes { let (draw_font, measure_bytes) = if original_ok {
// Đo vẫn bằng bytes font nhúng (hmtx) nếu parse được.
let measure =
font_data.filter(|b| ttf_parser::Face::parse(b, 0).is_ok());
(ReflowFont::Original(orig_token), measure)
} else if let Some(bytes) = embedded_bytes {
let key = (format!("emb:{fam_key}:{anchor_idx}"), bold, italic); let key = (format!("emb:{fam_key}:{anchor_idx}"), bold, italic);
(key, FontLoad::Bytes(bytes.clone()), Some(bytes)) font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes.clone()));
(ReflowFont::Loaded(key), Some(bytes))
} else if let (Some(builtin), true) = } else if let (Some(builtin), true) =
(builtin_for(&fam_key, bold, italic), ascii_only) (builtin_for(&fam_key, bold, italic), ascii_only)
{ {
// Đo bằng font hệ thống metric-compatible (Liberation/Arial…). // Đo bằng font hệ thống metric-compatible (Liberation/Arial…).
let measure = fontmatch::find_family_font_bytes(&family, bold, italic) let measure = fontmatch::find_family_font_bytes(&family, bold, italic)
.or_else(|| find_font_bytes(bold, italic)); .or_else(|| find_font_bytes(bold, italic));
((format!("b14:{fam_key}"), bold, italic), FontLoad::Builtin(builtin), measure) let key = (format!("b14:{fam_key}"), bold, italic);
font_needed.entry(key.clone()).or_insert(FontLoad::Builtin(builtin));
(ReflowFont::Loaded(key), measure)
} else { } else {
let (key, bytes) = resolve_substitute_font(&family, bold, italic, text)?; let (key, bytes) = resolve_substitute_font(&family, bold, italic, text)?;
(key, FontLoad::Bytes(bytes.clone()), Some(bytes)) font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes.clone()));
(ReflowFont::Loaded(key), Some(bytes))
}; };
font_needed.entry(font_key.clone()).or_insert(load);
reflow_plans.insert( reflow_plans.insert(
opi, opi,
ReflowPlan { ReflowPlan {
indices: idxs, indices: idxs,
font_key, draw_font,
measure_bytes, measure_bytes,
left, left,
width, width,
@@ -639,6 +730,7 @@ pub fn apply_edits(
linear: (m.a(), m.b(), m.c(), m.d()), linear: (m.a(), m.b(), m.c(), m.d()),
tf: unscaled.max(1.0), tf: unscaled.max(1.0),
scaled, scaled,
line_styles,
color, color,
}, },
); );
@@ -908,34 +1000,77 @@ pub fn apply_edits(
} }
EditOp::ReflowText { text, .. } => { EditOp::ReflowText { text, .. } => {
let Some(plan) = reflow_plans.get(&opi) else { continue }; let Some(plan) = reflow_plans.get(&opi) else { continue };
let token = tokens.get(&plan.font_key).copied().ok_or_else(|| { let token = match &plan.draw_font {
ReflowFont::Original(tok) => *tok,
ReflowFont::Loaded(key) => tokens.get(key).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho ReflowText".into()) 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. // Đo bề rộng ký tự theo hmtx của font (tại cỡ hiển thị của
// TỪNG dòng); không parse được → xấp xỉ 0.5em.
let face = plan let face = plan
.measure_bytes .measure_bytes
.as_deref() .as_deref()
.and_then(|b| ttf_parser::Face::parse(b, 0).ok()); .and_then(|b| ttf_parser::Face::parse(b, 0).ok());
let scaled = plan.scaled; let measure_at = |c: char, size: f32| match &face {
let measure = move |c: char| match &face { Some(f) => fontmatch::char_advance(f, c, size),
Some(f) => fontmatch::char_advance(f, c, scaled), None => size * 0.5,
None => scaled * 0.5,
}; };
let lines = fontmatch::wrap_lines(text, plan.width, &measure);
// Bề rộng tối đa 1 dòng có thể NỞ thêm (không bẻ lại) khi
// người dùng chỉ gõ thêm vài ký tự: +35% bề rộng khối, chặn
// bởi mép trang — hành vi hộp text tự nở của Foxit. Vượt quá
// mới bẻ dòng theo đúng bề rộng khối.
let page_w = page.width().value;
let avail = if plan.centered {
let c = plan.left + plan.width / 2.0;
2.0 * (c.min(page_w - c) - 8.0).max(0.0)
} else {
(page_w - plan.left - 8.0).max(0.0)
};
let grow_limit = (plan.width * 1.35).min(avail.max(plan.width * 1.02));
// Dòng cứng thứ i (theo \n từ ô sửa) giữ cỡ chữ dòng gốc thứ i.
let mut out_lines: Vec<(String, LineStyle)> = Vec::new();
let anchor_style = LineStyle { tf: plan.tf, scaled: plan.scaled };
for (li, hard) in text.split('\n').enumerate() {
let style = plan
.line_styles
.get(li)
.or(plan.line_styles.last())
.copied()
.unwrap_or(anchor_style);
let w: f32 = hard.chars().map(|c| measure_at(c, style.scaled)).sum();
if hard.trim().is_empty() || w <= grow_limit {
out_lines.push((hard.trim_end().to_string(), style));
} else {
let m = |c: char| measure_at(c, style.scaled);
for piece in fontmatch::wrap_lines(hard, plan.width, &m) {
out_lines.push((piece, style));
}
}
}
let (a, b, c2, d) = plan.linear; let (a, b, c2, d) = plan.linear;
for (i, line) in lines.iter().enumerate() { for (i, (line, style)) in out_lines.iter().enumerate() {
if line.is_empty() { if line.is_empty() {
continue; // đoạn trống vẫn chiếm 1 nhịp baseline 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 y = plan.first_baseline - (i as f32) * plan.line_advance;
// Khối căn giữa → từng dòng mới cũng căn giữa theo tâm khối. // Khối căn giữa → từng dòng mới cũng căn giữa theo tâm khối.
let line_w: f32 =
line.chars().map(|c| measure_at(c, style.scaled)).sum();
let x = if plan.centered { let x = if plan.centered {
let w: f32 = line.chars().map(&measure).sum(); let x = plan.left + plan.width / 2.0 - line_w / 2.0;
(plan.left + plan.width / 2.0 - w / 2.0).max(plan.left) x.max(8.0) // căn giữa nở đều 2 phía, không tràn mép trang
} else { } else {
plan.left plan.left
}; };
// Ghi cả dòng; đọc lại NGAY để chắc PDFium không âm thầm
// vứt ký tự (dấu cách với font subset...). Hỏng → gỡ và
// ghi lại TỪNG TỪ, tự đặt vị trí bằng advance đo được —
// không bao giờ ghi glyph dấu cách nữa.
let wrote_ok = {
let mut obj = page let mut obj = page
.objects_mut() .objects_mut()
.create_text_object( .create_text_object(
@@ -943,14 +1078,48 @@ pub fn apply_edits(
PdfPoints::ZERO, PdfPoints::ZERO,
line.clone(), line.clone(),
token, token,
PdfPoints::new(plan.tf), PdfPoints::new(style.tf),
) )
.map_err(err)?; .map_err(err)?;
// Giữ scale/nghiêng của run neo; đặt gốc dòng tại (x, y). let ok = obj
.as_text_object()
.map(|t| t.text() == *line)
.unwrap_or(false);
if ok {
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y)) obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
.map_err(err)?; .map_err(err)?;
obj.set_fill_color(plan.color).map_err(err)?; obj.set_fill_color(plan.color).map_err(err)?;
} }
ok
};
if !wrote_ok {
let idx = page.objects().len().saturating_sub(1);
let removed = page
.objects_mut()
.remove_object_at_index(idx as usize)
.map_err(err)?;
std::mem::forget(removed);
let space_w = measure_at(' ', style.scaled);
let mut cx = x;
for word in line.split_whitespace() {
let mut obj = page
.objects_mut()
.create_text_object(
PdfPoints::ZERO,
PdfPoints::ZERO,
word,
token,
PdfPoints::new(style.tf),
)
.map_err(err)?;
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
.map_err(err)?;
obj.set_fill_color(plan.color).map_err(err)?;
cx += word.chars().map(|c| measure_at(c, style.scaled)).sum::<f32>()
+ space_w;
}
}
}
} }
_ => {} _ => {}
} }
+107
View File
@@ -872,6 +872,113 @@ fn reflow_preserves_centered_alignment() {
assert_eq!(checked, 2, "phải kiểm được 2 dòng mới"); assert_eq!(checked, 2, "phải kiểm được 2 dòng mới");
} }
/// Mỗi DÒNG CỨNG (theo `\n` từ ô sửa) giữ đúng CỠ CHỮ của dòng gốc cùng thứ
/// tự — tiêu đề 22/16pt không bị ép cả khối về 1 cỡ.
#[test]
fn reflow_keeps_per_line_font_sizes() {
let pdf = pdfium();
let fx = tmp("ff_reflow_sz_fx.pdf");
let out = tmp("ff_reflow_sz_out.pdf");
let mk = |y: f32, s: &str, size: f32| EditOp::AddText {
x: 80.0,
y,
text: s.into(),
font_size: size,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
};
ff_engine::apply_edits(
&pdf,
&sample(),
0,
&[mk(500.0, "TIÊU ĐỀ CHÍNH CỠ LỚN", 22.0), mk(474.0, "phụ đề bên dưới cỡ nhỏ", 16.0)],
&fx,
None,
)
.expect("fixture 2 cỡ chữ");
let idxs: Vec<u16> = ff_engine::list_objects(&pdf, &fx, 0, None)
.expect("list fx")
.into_iter()
.filter(|o| o.text.as_deref().map(|t| t.contains("CỠ LỚN") || t.contains("cỡ nhỏ")).unwrap_or(false))
.map(|o| o.index)
.collect();
assert_eq!(idxs.len(), 2);
ff_engine::apply_edits(
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: idxs, text: "TIÊU ĐỀ MỚI\nphụ đề mới".into() }],
&out,
None,
)
.expect("reflow 2 cỡ");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let size_of = |needle: &str| {
after
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
.unwrap_or_else(|| panic!("thiếu dòng {needle:?}"))
.font_size
.expect("font_size")
};
let s1 = size_of("TIÊU ĐỀ MỚI");
let s2 = size_of("phụ đề mới");
assert!((s1 - 22.0).abs() < 0.6, "dòng 1 phải giữ ~22pt, được {s1}");
assert!((s2 - 16.0).abs() < 0.6, "dòng 2 phải giữ ~16pt, được {s2}");
}
/// Dòng cứng chỉ DÀI THÊM CHÚT (≤35% bề rộng khối) thì NỞ ra chứ không bị bẻ
/// lại thành 2 dòng (hộp text tự nở như Foxit) — sửa "HỢP"→"HỢPP" phải giữ
/// nguyên số dòng. Dấu cách cũng phải sống sót qua đường ghi.
#[test]
fn reflow_hard_line_grows_without_rewrap() {
let pdf = pdfium();
let fx = tmp("ff_reflow_grow_fx.pdf");
let out = tmp("ff_reflow_grow_out.pdf");
ff_engine::apply_edits(
&pdf,
&sample(),
0,
&[EditOp::AddText {
x: 90.0,
y: 520.0,
text: "Dòng tiêu đề dài vừa khít khung gốc".into(),
font_size: 18.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
}],
&fx,
None,
)
.expect("fixture 1 dòng");
let idx = find_text_index(&pdf, &fx, "vừa khít");
let new_text = "Dòng tiêu đề dài vừa khít khung gốc nở";
ff_engine::apply_edits(
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: vec![idx], text: new_text.into() }],
&out,
None,
)
.expect("reflow nở dòng");
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("tiêu đề") || t.contains("nở thêm")).unwrap_or(false))
.collect();
assert_eq!(new_runs.len(), 1, "dòng chỉ dài thêm ~20% phải GIỮ 1 dòng: {new_runs:?}");
assert_eq!(new_runs[0].text.as_deref(), Some(new_text), "nội dung (gồm dấu cách) phải tròn trịa");
}
#[test] #[test]
fn add_image_adds_image_object() { fn add_image_adds_image_object() {
let pdf = pdfium(); let pdf = pdfium();
+31 -1
View File
@@ -60,10 +60,40 @@ per-char xen run rỗng, cỡ 22.5pt + 20pt, bbox chữ có dấu tụt thấp):
chữ cũ; **phát hiện khối căn giữa** (tâm các dòng trùng tâm khối) → dòng mới chữ cũ; **phát hiện khối căn giữa** (tâm các dòng trùng tâm khối) → dòng mới
giữ căn giữa. 2 test hồi quy mới (nở indices, giữ căn giữa) — 19 test edit. giữ căn giữa. 2 test hồi quy mới (nở indices, giữ căn giữa) — 19 test edit.
### Vá vòng 2 theo phản hồi file thật (□ thay dấu cách, vỡ dòng, ô sửa lệch)
Ba lỗi user báo sau khi thử bản build 6 trên chính file Word-export:
- **Dấu cách thành ô vuông □ sau khi lưu**: PDFium `FPDFText_LoadFont` nạp
LẠI font subset (Word xuất) rồi `SetText` sẽ map U+0020 ra `.notdef` (glyph
hộp) dù cmap của font CÓ space — lỗi nằm ở PDFium, không phải font. Fix:
**tầng 0 mới của thang font reflow — dùng LẠI chính font object gốc trong
PDF** (lấy `PdfFontToken` từ run neo, không nạp lại): glyph + charcode map
gốc xử lý dấu cách chuẩn, file không phình thêm bản font nữa. Chỉ chọn sau
khi **probe ghi/đọc-lại toàn bộ text mới** thành công (tạo object nháp →
so text → gỡ). Khi ghi từng dòng cũng **đọc lại ngay để tự kiểm** — nếu
PDFium vẫn âm thầm vứt ký tự thì gỡ và **ghi lại theo TỪNG TỪ tự đặt vị trí
bằng advance hmtx** (không bao giờ ghi glyph dấu cách) — lưới an toàn cuối.
- **Thêm 1 ký tự làm vỡ dòng** ("HỢP"→"HỢPP" đẩy "LOG" rơi xuống dòng mới):
dòng cứng (`\n` từ ô sửa) giờ được **NỞ tới +35% bề rộng khối** (chặn mép
trang) trước khi phải bẻ lại — hành vi hộp text tự nở của Foxit; khối căn
giữa nở đều 2 phía quanh tâm.
- **Mỗi dòng giữ đúng CỠ CHỮ GỐC của dòng đó** (tiêu đề 22.5/20pt không còn
bị ép cả khối về cỡ run neo): kế hoạch reflow ghi `line_styles` theo từng
baseline gốc, dòng cứng thứ i ăn cỡ dòng gốc thứ i.
- **Ô sửa WYSIWYG**: thay textarea bằng `contenteditable` — mỗi dòng gốc là
1 div với đúng **font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ**, khối căn giữa hiện
căn giữa, khung đặt trùng bbox khối (bù lệch dọc line-box) → chữ trong ô
nằm NGUYÊN vị trí chữ gốc khi bắt đầu sửa; con trỏ đặt đúng chỗ vừa đúp
(`caretRangeFromPoint`).
- Kiểm chứng trên file thật: sửa "HỢP"→"HỢPP" giữ đúng 2 dòng căn giữa cỡ
22.5/20pt, font `BAAAAA+TimesNewRomanPS-BoldMT` gốc, dấu cách nguyên vẹn,
render so khớp bản gốc. 2 test hồi quy mới (`reflow_keeps_per_line_font_sizes`,
`reflow_hard_line_grows_without_rewrap`) — 21 test edit.
### Giới hạn ghi nhận (v1, sẽ nâng ở vòng sau) ### 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 - Đ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). 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. - Khối lẫn NHIỀU font/cỡ trong 1 ĐOẠN: cỡ giữ theo TỪNG DÒNG; font/màu theo
run neo (dòng lẫn nhiều font sẽ thống nhất font).
> Iteration 1: 43/43 test, đã chạy app thật + chụp ảnh xác minh. > Iteration 1: 43/43 test, đã chạy app thật + chụp ảnh xác minh.
+4
View File
@@ -49,6 +49,10 @@ cargo run
| E36 | Trong ô sửa: click chuột chỗ khác của đoạn để đổi vị trí con trỏ | Con trỏ di chuyển bình thường, **KHÔNG thoát edit** | | | E36 | Trong ô sửa: click chuột chỗ khác của đoạn để đổi vị trí con trỏ | Con trỏ di chuyển bình thường, **KHÔNG thoát edit** | |
| E37 | Sửa vài chữ rồi Ctrl+Enter | Chữ mới thay SẠCH chữ cũ (không chồng chữ), tiêu đề căn giữa **vẫn căn giữa** | | | E37 | Sửa vài chữ rồi Ctrl+Enter | Chữ mới thay SẠCH chữ cũ (không chồng chữ), tiêu đề căn giữa **vẫn căn giữa** | |
| E38 | Khi ô sửa đang mở | Ô che kín chữ cũ bên dưới (không thấy 2 lớp chữ) | | | E38 | Khi ô sửa đang mở | Ô che kín chữ cũ bên dưới (không thấy 2 lớp chữ) | |
| E39 | Mở ô sửa trên tiêu đề 2 dòng 2 cỡ chữ (22.5/20pt) | Chữ trong ô hiện **đúng cỡ TỪNG DÒNG + căn giữa + đậm/màu như bản gốc**, vị trí chữ TRÙNG chữ gốc (không xê dịch khi bắt đầu sửa) | |
| E40 | Thêm 1–2 ký tự vào dòng dài nhất (vd "HỢP"→"HỢPP") rồi áp dụng | Dòng **NỞ ra, KHÔNG bị bẻ** thành dòng mới; số dòng giữ nguyên | |
| E41 | Sau khi áp dụng, nhìn khoảng trắng giữa các từ | Dấu cách bình thường — **KHÔNG có ô vuông □**; mở lại ở Foxit/Adobe font vẫn là font gốc của file | |
| E42 | Sửa tiêu đề rồi đo bằng mắt cỡ 2 dòng | Dòng 1 vẫn to hơn dòng 2 đúng như gốc (cỡ chữ giữ THEO DÒNG) | |
## Bổ sung — Đúp chuột vào chữ từ viewer thường (chuẩn Foxit Editor) ## Bổ sung — Đúp chuột vào chữ từ viewer thường (chuẩn Foxit Editor)
| # | Thao tác | Kỳ vọng | KQ | | # | Thao tác | Kỳ vọng | KQ |