From 58cba1f415d18e6a9a3e2a0c4333b812a1289328 Mon Sep 17 00:00:00 2001 From: trandinhnamuet Date: Thu, 16 Jul 2026 16:15:04 +0000 Subject: [PATCH] =?UTF-8?q?S=E1=BB=ADa=203=20l=E1=BB=97i=20edit=20inline?= =?UTF-8?q?=20v=C3=B2ng=202=20(file=20th=E1=BA=ADt=20Word-export):=20h?= =?UTF-8?q?=E1=BA=BFt=20=E2=96=A1=20thay=20d=E1=BA=A5u=20c=C3=A1ch,=20d?= =?UTF-8?q?=C3=B2ng=20kh=C3=B4ng=20v=E1=BB=A1=20khi=20th=C3=AAm=20k=C3=BD?= =?UTF-8?q?=20t=E1=BB=B1,=20=C3=B4=20s=E1=BB=ADa=20WYSIWYG=20=C4=91=C3=BAn?= =?UTF-8?q?g=20c=E1=BB=A1=20t=E1=BB=ABng=20d=C3=B2ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk --- app/src/main.js | 158 +++++++++---- app/src/styles.css | 14 ++ crates/ff-engine/src/edit.rs | 281 ++++++++++++++++++----- crates/ff-engine/tests/edit_roundtrip.rs | 107 +++++++++ docs/12-phase4-summary.md | 32 ++- docs/13-phase4-user-tests.md | 4 + 6 files changed, 500 insertions(+), 96 deletions(-) diff --git a/app/src/main.js b/app/src/main.js index 54a9df8..5f285ba 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -2200,7 +2200,7 @@ function buildEditOverlay() { e.stopPropagation(); 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); }); @@ -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 // đường code duy nhất, giữ nguyên cấu trúc xuống dòng, commit qua reflow. -function startTextEdit(o) { - startBlockTextEdit(o, paragraphLines(o)); +// `ev` (nếu có) = sự kiện chuột để đặt con trỏ đúng chỗ vừa đúp. +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ữ -// cũ bên dưới), MỖI DÒNG GỐC = 1 dòng textarea (giữ xuống dòng — sửa được cả -// vị trí ngắt dòng); click trong ô = di chuyển con trỏ; commit → engine -// reflow: xoá SẠCH mọi run của khối (kể cả run sót — engine tự nở theo bbox), -// giữ font + giữ căn giữa nếu khối gốc căn giữa. -function startBlockTextEdit(o, lines) { +// Sửa cả đoạn WYSIWYG (chuẩn Foxit Edit Text): contenteditable phủ kín khối, +// MỖI DÒNG GỐC = 1 div giữ ĐÚNG font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ (tiêu đề +// 22.5/20pt hiện đúng 2 cỡ), khối căn giữa hiện căn giữa — chữ trong ô nằm +// TRÙNG vị trí chữ gốc, không xê dịch. Enter = xuống dòng; click trong ô = +// di chuyển con trỏ; commit → engine reflow (xoá sạch run cũ, giữ font gốc, +// giữ cỡ từng dòng, giữ căn giữa). +function startBlockTextEdit(o, lines, ev) { const ov = $("editOverlay"); const existing = ov.querySelector(".edit-inline"); if (existing) existing.remove(); + const s = state.editScale; + const p = state.pages[state.editPage]; const allRuns = lines.flatMap((l) => l.runs); const union = { 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)), 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 original = lines.map((l) => composeLineText(l.runs)).join("\n"); + const bw = union.right - union.left; + 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 = lines.length > 1 ? Math.abs(lines[0].rect.bottom - lines[1].rect.bottom) : (o.font_size || 12) * 1.25; - const advPx = Math.max(8, advPt * state.editScale); - // 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 p = state.pages[state.editPage]; - const maxW = (p.widthPt - union.left) * state.editScale - 4; - ta.style.width = Math.min(maxW, parseFloat(ta.style.width) + 60) + "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); - ta.scrollIntoView({ block: "center", behavior: "instant" }); + const advPx = Math.max(8, advPt * s); + + const original = lines.map((l) => composeLineText(l.runs)).join("\n"); + + const ce = document.createElement("div"); + ce.className = "edit-inline edit-inline-wys"; + ce.contentEditable = "plaintext-only"; + ce.spellcheck = false; + + // Khung: khối căn giữa nở đều 2 phía (tâm giữ nguyên → chữ không xê dịch); + // khối thường giữ mép trái, nở sang phải — khớp giới hạn nở 35% của engine. + let leftPt; + let widthPt; + if (centered) { + const grow = Math.min(bw * 0.175, bc - 8 - bw / 2, p.widthPt - 8 - bc - bw / 2); + 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 = "
"; + 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 = "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; const commit = (save) => { if (done) return; done = true; - const text = ta.value; - ta.remove(); + const text = readText(); + ce.remove(); if (save && text.trim() && text !== original) { stageEditOp({ op: "reflowText", @@ -2527,16 +2600,23 @@ function startBlockTextEdit(o, lines) { $("editHint").textContent = ""; } }; - ta.addEventListener("keydown", (e) => { + ce.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); commit(false); } else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); } 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). - ta.addEventListener("mousedown", (e) => e.stopPropagation()); - ta.addEventListener("click", (e) => e.stopPropagation()); - ta.addEventListener("dblclick", (e) => e.stopPropagation()); - ta.addEventListener("blur", () => commit(true)); + ce.addEventListener("mousedown", (e) => e.stopPropagation()); + ce.addEventListener("click", (e) => e.stopPropagation()); + ce.addEventListener("dblclick", (e) => e.stopPropagation()); + 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 @@ -3453,7 +3533,7 @@ $("editOverlay").addEventListener("dblclick", (e) => { const x = (e.clientX - r.left) / state.editScale; const y = p.heightPt - (e.clientY - r.top) / state.editScale; 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, diff --git a/app/src/styles.css b/app/src/styles.css index eea8d90..904b042 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -445,6 +445,20 @@ body.tool-active .annotlayer * { pointer-events: none; } 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 */ .ed-style { min-width: 28px; diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index a043b2d..0ab65ef 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -265,12 +265,32 @@ fn builtin_for(family_key: &str, bold: bool, italic: bool) -> Option, - /// Key font đã nạp ở pha (B) để tạo các dòng mới. - font_key: FontKey, + /// Font vẽ các dòng mới. + draw_font: ReflowFont, /// 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>, @@ -284,12 +304,40 @@ struct ReflowPlan { 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). + /// 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, 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, 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. enum SetTextMode { /// 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 = HashMap::new(); let mut font_needed: HashMap = HashMap::new(); { - let page = document + let mut page = document .pages() .get(page_index) .map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?; + // Pha A chỉ đọc + probe font (tạo/xoá object nháp) — không đụng content. + page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual); let obj_count = page.objects().len(); let valid = |i: u16| (i as usize) < obj_count; @@ -448,12 +498,16 @@ pub fn apply_edits( f: f32, left: f32, right: f32, + tf: f32, + scaled: f32, } - let geo_of = |i: u16| -> Result, EngineError> { + let geo_of = |page: &PdfPage<'_>, i: u16| -> Result, EngineError> { 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); - } + }; + let (tf, scaled) = + (t.unscaled_font_size().value, t.scaled_font_size().value); let m = obj.matrix().map_err(err)?; let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { left: m.e(), @@ -461,11 +515,11 @@ pub fn apply_edits( right: m.e(), 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 = Vec::new(); for &i in &idxs { - if let Some(g) = geo_of(i)? { + if let Some(g) = geo_of(&page, i)? { geos.push(g); } } @@ -520,7 +574,7 @@ pub fn apply_edits( && cy <= bb.top + pad { idxs.push(i); - if let Some(g) = geo_of(i)? { + if let Some(g) = geo_of(&page, i)? { geos.push(g); } } @@ -537,13 +591,21 @@ pub fn apply_edits( }) .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)); + // 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 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 tok = t.font().token(); + 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)); + 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 = // 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 }; + // 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 = 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): - // (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; + // (0) font NHÚNG + probe ghi/đọc lại tròn trịa → dùng LẠI chính + // font object gốc (glyph + charcode y hệt, file không phình; + // tránh lỗi PDFium nạp lại subset làm dấu cách thành □); + // (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. 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)) + let original_ok = + 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 { 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); - (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) = (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) + let key = (format!("b14:{fam_key}"), bold, italic); + font_needed.entry(key.clone()).or_insert(FontLoad::Builtin(builtin)); + (ReflowFont::Loaded(key), measure) } else { 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( opi, ReflowPlan { indices: idxs, - font_key, + draw_font, measure_bytes, left, width, @@ -639,6 +730,7 @@ pub fn apply_edits( linear: (m.a(), m.b(), m.c(), m.d()), tf: unscaled.max(1.0), scaled, + line_styles, color, }, ); @@ -908,48 +1000,125 @@ pub fn apply_edits( } 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 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()) + })?, + }; + // Đ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 .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 measure_at = |c: char, size: f32| match &face { + Some(f) => fontmatch::char_advance(f, c, size), + None => size * 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; - for (i, line) in lines.iter().enumerate() { + for (i, (line, style)) in out_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; // 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 w: f32 = line.chars().map(&measure).sum(); - (plan.left + plan.width / 2.0 - w / 2.0).max(plan.left) + let x = plan.left + plan.width / 2.0 - line_w / 2.0; + x.max(8.0) // căn giữa nở đều 2 phía, không tràn mép trang } else { plan.left }; - 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 (x, y). - obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y)) - .map_err(err)?; - obj.set_fill_color(plan.color).map_err(err)?; + // 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 + .objects_mut() + .create_text_object( + PdfPoints::ZERO, + PdfPoints::ZERO, + line.clone(), + token, + PdfPoints::new(style.tf), + ) + .map_err(err)?; + 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)) + .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::() + + space_w; + } + } } } _ => {} diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index 51e75bd..58605f9 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -872,6 +872,113 @@ fn reflow_preserves_centered_alignment() { 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 = 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] fn add_image_adds_image_object() { let pdf = pdfium(); diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 5d34b16..91670da 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -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 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) - Đ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. +- 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. diff --git a/docs/13-phase4-user-tests.md b/docs/13-phase4-user-tests.md index b5e9696..f39b400 100644 --- a/docs/13-phase4-user-tests.md +++ b/docs/13-phase4-user-tests.md @@ -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** | | | 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ữ) | | +| 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) | # | Thao tác | Kỳ vọng | KQ |