From de1ba2cc63c2b0c482100e441e1059d27b525038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Nam?= Date: Fri, 7 Aug 2026 18:18:30 +0700 Subject: [PATCH] =?UTF-8?q?Kh=E1=BB=91i=20s=E1=BB=ADa=20chu=E1=BA=A9n=20Fo?= =?UTF-8?q?xit:=20t=C3=A1ch=20=C4=91o=E1=BA=A1n=20theo=20format,=20n?= =?UTF-8?q?=E1=BB=81n=20trong=20su=E1=BB=91t,=20bullet,=20gi=E1=BB=AF=20in?= =?UTF-8?q?=20=C4=91=E1=BA=ADm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 lỗi user báo khi test build 11 trên file thật: - Gom đoạn quá tham (tiêu đề + code + thân bài + nhãn đỏ 1 khối): đối chiếu Foxit (mỗi đoạn = 1 text block riêng) — paragraphLines chỉ gom dòng kề CÙNG format (family + đậm/nghiêng + màu, cỡ chênh <=1.25x). - Bullet hiện ô vuông trong ô sửa: bullet là run font Symbol mã PUA, DOM không render được — isMarkerRun/stripLineMarkers loại marker khỏi ô sửa/commit, bullet giữ nguyên trên trang (chuẩn Foxit); đúp trúng bullet vẫn mở khối chữ. - Ô sửa nền trắng: render ngầm bản trang đã ẩn run đang sửa rồi chuyển nền trong suốt — thấy nền gốc (dải màu/bullet) dưới chữ đang gõ; huỷ trả ảnh gốc; commit vẫn tính trên editBase. - Mất in đậm: engine thêm fallback đọc OS/2 usWeightClass + cờ italic từ bytes font nhúng (file Word xuất không khai /FontWeight, tên không có Bold); list_objects cache kiểu chữ theo tên font. Test Node trích hàm thật từ main.js: 11 case tách khối/bullet đều PASS. Chi tiết: docs/12-phase4-summary.md (Vá vòng 4). Co-Authored-By: Claude Fable 5 --- app/src/main.js | 131 ++++++++++++++++++++++++++++++++--- crates/ff-engine/src/edit.rs | 40 ++++++++++- docs/12-phase4-summary.md | 31 +++++++++ 3 files changed, 189 insertions(+), 13 deletions(-) diff --git a/app/src/main.js b/app/src/main.js index 10ad442..662db69 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -2204,7 +2204,11 @@ function buildEditOverlay() { lines.forEach((line) => { const box = document.createElement("div"); box.className = "edit-box kind-text"; - const rep = line.runs.find((r) => (r.text || "").trim()) || line.runs[0]; + // Đại diện dòng: ưu tiên run chữ thật (không phải bullet) để đúp mở đúng đoạn. + const rep = + line.runs.find((r) => (r.text || "").trim() && !isMarkerRun(r, line.runs)) || + line.runs.find((r) => (r.text || "").trim()) || + line.runs[0]; box.dataset.index = String(rep.index); box.dataset.runs = JSON.stringify(line.runs.map((r) => r.index)); Object.assign(box.style, editBoxStyle(line.rect)); @@ -2454,13 +2458,84 @@ function clusterTextLines(objs) { return lines; } -// Gom ĐOẠN VĂN nhiều dòng quanh run `o`: các dòng kề nhau theo chiều dọc -// (khoảng hở ≤ 1.3× chiều cao dòng), giao ngang ≥30%, cỡ chữ tương đồng -// (≤1.8× — tiêu đề 2 dòng 22.5/20pt vẫn là 1 đoạn). Trả mảng dòng trên→dưới. +// Run "marker" đầu dòng (bullet/dấu gạch danh sách): Word/LibreOffice xuất +// bullet bằng font Symbol/Wingdings với mã PUA (U+F0B7…) — DOM không render +// được (hiện ô vuông □) và Foxit cũng KHÔNG cho sửa bullet như chữ thường. +// Nhận diện để LOẠI khỏi ô sửa + commit (bullet giữ nguyên trên trang). +const BULLET_CHARS = new Set([..."•◦▪▫‣·∙●○■□◆♦►▶➢➤§–—"]); +function isMarkerRun(run, lineRuns) { + const t = (run.text || "").trim(); + if (!t || [...t].length > 2) return false; + // Phải là run có chữ TRÁI NHẤT của dòng. + const firstReal = lineRuns.find((r) => (r.text || "").trim()); + if (firstReal !== run) return false; + const chars = [...t]; + const puaOrBullet = chars.every((ch) => { + const c = ch.codePointAt(0); + return (c >= 0xe000 && c <= 0xf8ff) || BULLET_CHARS.has(ch); + }); + if (puaOrBullet) return true; + if (/symbol|wingding|dingbat|marlett|webding/i.test(run.font_family || "")) return true; + // Bullet ASCII mơ hồ ("-", "*", "o"): chỉ nhận khi có KHOẢNG HỞ rõ với chữ sau. + if (chars.length === 1 && "-*o".includes(t)) { + const next = lineRuns.find((r) => r !== run && (r.text || "").trim()); + if (next) { + const gap = next.rect.left - run.rect.right; + return gap > (run.font_size || 12) * 0.4; + } + } + return false; +} + +// Bỏ marker khỏi 1 dòng cluster: runs còn lại + rect/fs tính lại từ phần chữ +// thật (để ô sửa, thụt lề, so-format không dính bbox của bullet). +function stripLineMarkers(line) { + const marker = line.runs.find((r) => isMarkerRun(r, line.runs)); + if (!marker) return line; + const runs = line.runs.filter((r) => r !== marker); + const real = runs.filter((r) => (r.text || "").trim() && r.rect.top - r.rect.bottom > 0.5); + if (!real.length) return { runs, fs: line.fs, rect: { ...line.rect } }; + return { + runs, + fs: real.reduce((m, r) => Math.max(m, r.font_size || 12), 0) || line.fs, + rect: { + left: Math.min(...real.map((r) => r.rect.left)), + right: Math.max(...real.map((r) => r.rect.right)), + top: Math.max(...real.map((r) => r.rect.top)), + bottom: Math.min(...real.map((r) => r.rect.bottom)), + }, + }; +} + +// Format đại diện của 1 dòng (từ run có chữ đầu tiên) — để so "cùng đoạn". +function lineFormat(l) { + const rep = l.runs.find((r) => (r.text || "").trim()) || l.runs[0]; + if (!rep) return null; + return { + fam: (rep.font_family || "").toLowerCase(), + bold: !!rep.font_bold, + italic: !!rep.font_italic, + color: (rep.color || [0, 0, 0]).slice(0, 3).join(","), + }; +} + +// Gom ĐOẠN VĂN quanh run `o` — CHUẨN FOXIT: mỗi đoạn là 1 khối riêng, chỉ gom +// các dòng kề nhau CÙNG FORMAT (font + đậm/nghiêng + màu, cỡ chênh ≤1.25× — +// tiêu đề 22.5/20pt vẫn 1 đoạn nhưng tiêu đề ≠ thân bài ≠ dòng code), khoảng +// hở ≤1.3× chiều cao dòng, giao ngang ≥30%, nhịp dòng đều ±35%. Bullet đầu +// dòng bị loại khỏi khối (giữ nguyên trên trang). Trả mảng dòng trên→dưới. function paragraphLines(o) { - const all = clusterTextLines(state.editObjects); - const mine = all.find((l) => l.runs.some((r) => r.index === o.index)); - if (!mine) return [ { runs: [o], fs: o.font_size || 12, rect: { ...o.rect } } ]; + const raw = clusterTextLines(state.editObjects); + const rawMine = raw.find((l) => l.runs.some((r) => r.index === o.index)); + if (!rawMine) return [ { runs: [o], fs: o.font_size || 12, rect: { ...o.rect } } ]; + + const pairs = raw + .map((l) => ({ raw: l, s: stripLineMarkers(l) })) + .filter((p) => p.s.runs.some((r) => (r.text || "").trim())); + const minePair = pairs.find((p) => p.raw === rawMine); + if (!minePair) return [rawMine]; // dòng chỉ có mỗi bullet (hiếm) → sửa mình nó + const all = pairs.map((p) => p.s); + const mine = minePair.s; const hOf = (l) => Math.max(1, l.rect.top - l.rect.bottom); const overlaps = (a, b) => @@ -2468,7 +2543,15 @@ function paragraphLines(o) { 0.3 * Math.min(a.rect.right - a.rect.left, b.rect.right - b.rect.left); const sizeOk = (a, b) => { const r = a.fs / b.fs; - return r <= 1.8 && r >= 1 / 1.8; + return r <= 1.25 && r >= 1 / 1.25; + }; + const myFmt = lineFormat(mine); + const fmtOk = (l) => { + const f = lineFormat(l); + return ( + f && myFmt && f.fam === myFmt.fam && f.bold === myFmt.bold && + f.italic === myFmt.italic && f.color === myFmt.color + ); }; const sorted = all.slice().sort((a, b) => b.rect.bottom - a.rect.bottom); // trên→dưới @@ -2483,7 +2566,7 @@ function paragraphLines(o) { const gap = step > 0 ? edge.rect.bottom - cand.rect.top : cand.rect.bottom - edge.rect.top; const maxGap = 1.3 * Math.max(hOf(edge), hOf(cand)); if (gap < -3 || gap > maxGap) break; - if (!overlaps(edge, cand) || !sizeOk(edge, cand)) break; + if (!overlaps(edge, cand) || !sizeOk(edge, cand) || !fmtOk(cand)) break; const adv = Math.abs(edge.rect.bottom - cand.rect.bottom); if (advance && (adv < advance * 0.65 || adv > advance * 1.35)) break; if (advance == null) advance = adv; @@ -2611,6 +2694,31 @@ function startBlockTextEdit(o, lines, ev) { fitEditLinesToPdf(ce, lines, s, p); ce.focus(); + // NỀN TRONG SUỐT chuẩn Foxit: render nền trang ĐÃ ẨN các run đang sửa (áp + // Delete ra file tạm chỉ để lấy ảnh — commit vẫn tính trên editBase gốc). + // Trong lúc chờ render giữ nền trắng che chữ cũ, xong mới chuyển trong suốt + // → thấy nguyên dải màu nền/bullet/kẻ bảng dưới ô sửa. + const img = $("editImg"); + const prevSrc = img.src; + let bgSwapped = false; + (async () => { + try { + const tmp = await invoke("edit_apply_to_temp", { + input: state.editBase, + page: state.editPage, + ops: allRuns.map((r) => ({ op: "delete", index: r.index })), + password: null, + }); + state.editTemps.push(tmp); + const url = await invoke("render_page", { path: tmp, page: state.editPage, width: EDIT_STAGE_W }); + if (ce.isConnected) { + img.src = url; + bgSwapped = true; + ce.style.background = "transparent"; + } + } catch (_) { /* không render được thì giữ nền trắng như cũ */ } + })(); + // Đặt con trỏ đúng chỗ vừa đúp (như Foxit); không có toạ độ → đầu đoạn. let placed = false; if (ev && document.caretRangeFromPoint) { @@ -2647,7 +2755,10 @@ function startBlockTextEdit(o, lines, ev) { done = true; const text = readText(); ce.remove(); - if (save && text.trim() && text !== original) { + const changed = save && text.trim() && text !== original; + // Huỷ/không đổi gì → trả lại ảnh trang gốc (đang hiện bản ẩn run sửa). + if (!changed && bgSwapped) img.src = prevSrc; + if (changed) { stageEditOp({ op: "reflowText", indices: allRuns.map((r) => r.index), diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index 0ab65ef..4709e36 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -166,7 +166,25 @@ fn text_object_style(t: &PdfPageTextObject) -> (String, bool, bool) { | Ok(PdfFontWeight::Weight900) ) || matches!(t.font().weight(), Ok(PdfFontWeight::Custom(v)) if v >= 600); let angle_italic = t.font().italic_angle().map(|a| a != 0).unwrap_or(false); - (family, weight_bold || name_bold, angle_italic || name_italic) + + let mut bold = weight_bold || name_bold; + let mut italic = angle_italic || name_italic; + // Fallback: nhiều file (Word/Chrome xuất subset "ABCDEF+Xyz") không khai + // weight trong descriptor và tên không chứa "Bold"/"Italic" → đọc thẳng + // OS/2 usWeightClass + cờ italic từ bytes font nhúng. + if (!bold || !italic) && t.font().is_embedded().unwrap_or(false) { + if let Ok(bytes) = t.font().data() { + let n = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1).max(1); + for i in 0..n { + if let Ok(face) = ttf_parser::Face::parse(&bytes, i) { + bold = bold || face.weight().to_number() >= 600; + italic = italic || face.is_italic() || face.is_oblique(); + break; + } + } + } + } + (family, bold, italic) } /// Liệt kê các page object của 1 trang để UI vẽ overlay chỉnh sửa. @@ -185,6 +203,9 @@ pub fn list_objects( .map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?; let mut out = Vec::new(); + // Cache kiểu chữ theo tên font: fallback đậm/nghiêng phải đọc bytes font + // nhúng — file Word xuất hay có 200+ run chung vài font, không đọc lại. + let mut style_cache: HashMap = HashMap::new(); for (i, object) in page.objects().iter().enumerate() { let kind = ObjectKind::from_pdfium(object.object_type()); let rect = object @@ -206,9 +227,22 @@ pub fn list_objects( color: None, }; if let Some(t) = object.as_text_object() { - let (family, bold, italic) = text_object_style(t); + let raw_name = t.font().name(); + // Tên rỗng không cache được (nhiều font khác nhau cùng key ""). + let (family, bold, italic) = if raw_name.trim().is_empty() { + text_object_style(t) + } else { + match style_cache.get(&raw_name) { + Some(s) => s.clone(), + None => { + let s = text_object_style(t); + style_cache.insert(raw_name.clone(), s.clone()); + s + } + } + }; info.text = Some(t.text()); - info.font_name = Some(t.font().name()); + info.font_name = Some(raw_name); info.font_family = Some(family); info.font_bold = Some(bold); info.font_italic = Some(italic); diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 5b724d0..690562b 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -118,6 +118,37 @@ User báo (kèm ảnh): ô sửa hiện chữ SAI font + SAI cỡ + SAI vị tr giữa Times → lệch ngang ≤1.3px. (Không unit-test tự động được vì cần layout engine thật; harness lưu ở scratchpad phiên làm việc.) +### Vá vòng 4 — khối sửa chuẩn Foxit: tách đoạn theo FORMAT, nền trong suốt, bullet, in đậm +User test build 11 trên file thật, báo 4 lỗi (kèm ảnh). Đối chiếu hành vi +Foxit (help.foxit.com — Edit Text): mỗi ĐOẠN là 1 text block riêng, click vào +đâu chỉ sửa block đó, reflow trong block; muốn sửa nhiều block phải Link thủ +công. Sửa tương ứng: +- **Gom đoạn quá tham** (tiêu đề + dòng code + thân bài + nhãn đỏ dính 1 khối): + `paragraphLines` giờ chỉ gom dòng kề nhau **CÙNG FORMAT** — cùng font family + + đậm/nghiêng + màu (của run chữ đầu dòng), cỡ chênh ≤1.25× (tiêu đề 22.5/20 + vẫn 1 đoạn, tiêu đề ≠ thân bài) — cộng điều kiện hình học cũ (hở ≤1.3× cao + dòng, giao ngang ≥30%, nhịp đều ±35%). +- **Bullet thành ô vuông □ trong ô sửa**: bullet Word/LibreOffice là run font + Symbol/Wingdings mã PUA (U+F0B7…) DOM không render được. Thêm `isMarkerRun` + (run chữ trái nhất của dòng, ≤2 ký tự, PUA/ký tự bullet/font symbol; "-","*", + "o" chỉ nhận khi có khoảng hở rõ với chữ sau) + `stripLineMarkers`: marker bị + LOẠI khỏi ô sửa/commit/rect khối (thụt lề tính từ CHỮ) — bullet giữ nguyên + trên trang như Foxit. Đúp trúng chính bullet vẫn mở khối chữ của dòng đó. +- **Ô sửa nền trắng che nền gốc**: giờ mở ô xong render NGẦM bản trang đã ẨN + các run đang sửa (`edit_apply_to_temp` với Delete — chỉ lấy ảnh, commit vẫn + tính trên editBase gốc) → đổi ảnh stage + nền ô chuyển TRONG SUỐT: thấy dải + màu nền/bullet/kẻ bảng dưới chữ đang gõ, đúng cảm giác Foxit. Chờ render thì + tạm nền trắng; huỷ/không đổi → trả ảnh gốc; file tạm vào editTemps để dọn. +- **Mất in đậm dù gốc đậm**: file Word/Chrome xuất subset không khai + /FontWeight và tên không chứa "Bold" → engine `text_object_style` thêm + fallback đọc **OS/2 usWeightClass ≥600 + cờ italic từ bytes font nhúng** + (ttf-parser); `list_objects` cache theo tên font (file 200+ run chung vài + font, không đọc lại bytes). +- **Kiểm chứng**: test Node trích HÀM THẬT từ main.js (11 case: tách khối theo + format/màu, tiêu đề 2 cỡ vẫn 1 đoạn, bullet loại khỏi khối + rect từ chữ, + đúp trúng marker, phân biệt "-" gạch đầu dòng vs "-5°C") — tất cả PASS. + Phần render nền ẩn cần app thật → kiểm trên build CI. + ### 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).