From 68883b9e75f7aa76dc7b14a3fe3b9513b70638be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Nam?= Date: Sat, 8 Aug 2026 00:50:22 +0700 Subject: [PATCH] =?UTF-8?q?G=E1=BB=91c=20r=E1=BB=85=20l=E1=BB=97i=20font?= =?UTF-8?q?=20=C3=B4=20s=E1=BB=ADa:=20field=20IPC=20l=E1=BB=87ch=20t=C3=AA?= =?UTF-8?q?n=20camelCase/snake=5Fcase=20+=20k=C3=A9o-th=E1=BA=A3=20m?= =?UTF-8?q?=E1=BB=9F=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug trên app thật (WebView2 remote debugging + CDP) với file thật tìm ra nguyên nhân gốc chuỗi lỗi font: DTO Tauri serialize camelCase (fontBold, fontFamily, fontSize) nhưng code edit-mode đọc snake_case (font_bold...) → mọi thuộc tính font trong UI luôn undefined (không đậm, sai family, cỡ mặc định 12). Đổi hết sang camelCase nhất quán với app. - Engine: fontmatch::style_from_font_bytes parse sfnt thủ công — OS/2 usWeightClass/fsSelection, fallback head.macStyle (LibreOffice subset vứt OS/2 nhưng head luôn còn; kiểm bằng qpdf trích FontFile2 file thật). 2 unit test sfnt tự dựng. - Kéo-thả .pdf từ Explorer vào cửa sổ → mở file (tauri://drag-drop). - Ẩn khung dòng overlay khi ô sửa mở (nền trong suốt lộ khung gây nhiễu). - Ignore /*.pdf ở root (file test thật của user không được commit). Kiểm chứng E2E không cần rebuild: inject hàm mới vào app build-12 đang chạy qua CDP, mở ô sửa trên file thật + chụp màn hình — tiêu đề 2 dòng bold đúng cỡ/vị trí nền trong suốt; khối bullet 8 dòng sạch PUA, bullet gốc nguyên trên trang. Test Node trên dữ liệu run thật từ engine: 6/6 PASS. Chi tiết: docs/12-phase4-summary.md (Vá vòng 5). Co-Authored-By: Claude Fable 5 --- .gitignore | 4 +- app/src/main.js | 67 ++++++++++++++++-------- crates/ff-engine/src/edit.rs | 17 +++--- crates/ff-engine/src/fontmatch.rs | 86 +++++++++++++++++++++++++++++++ docs/12-phase4-summary.md | 33 ++++++++++++ 5 files changed, 173 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 65d598c..2c6102b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,7 @@ Cargo.lock.bak *.tmp.png # File PDF thật của user (hồ sơ/kinh doanh cá nhân) — chỉ dùng test local, -# không đẩy lên repo công khai. +# không đẩy lên repo công khai. PDF thả ở root cũng là file test của user +# (corpus/ đã track sẵn nên không ảnh hưởng). /pdf-file-to-test/ +/*.pdf diff --git a/app/src/main.js b/app/src/main.js index 662db69..3b8d563 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -2279,18 +2279,18 @@ function selectEditObject(index, runIndices) { $("edBold").disabled = !isText; $("edItalic").disabled = !isText; if (isText) { - $("edFontSize").value = Math.round(o.font_size || 12); + $("edFontSize").value = Math.round(o.fontSize || 12); if (o.color) { state.editColor = o.color.slice(0, 3); $("edSw").style.background = rgbCss(state.editColor); } // Ô font: mặc định "(giữ nguyên: )" — chỉ đổi khi người dùng chọn khác. - $("edFontFamily").options[0].textContent = o.font_family - ? `(giữ nguyên: ${o.font_family})` + $("edFontFamily").options[0].textContent = o.fontFamily + ? `(giữ nguyên: ${o.fontFamily})` : "(giữ nguyên)"; $("edFontFamily").value = ""; - $("edBold").classList.toggle("on", !!o.font_bold); - $("edItalic").classList.toggle("on", !!o.font_italic); - const emb = o.font_embedded == null ? "" : o.font_embedded ? " · font nhúng" : " · font hệ thống"; + $("edBold").classList.toggle("on", !!o.fontBold); + $("edItalic").classList.toggle("on", !!o.fontItalic); + const emb = o.fontEmbedded == null ? "" : o.fontEmbedded ? " · font nhúng" : " · font hệ thống"; $("editHint").textContent = - `${o.font_family || o.font_name || "?"} · ${Math.round(o.font_size || 0)}pt${emb}`; + `${o.fontFamily || o.fontName || "?"} · ${Math.round(o.fontSize || 0)}pt${emb}`; } else { $("edFontFamily").options[0].textContent = "(giữ nguyên)"; $("edBold").classList.remove("on"); @@ -2387,7 +2387,7 @@ function composeLineText(runs) { const t = runs[i].text || ""; if (i > 0) { const gap = runs[i].rect.left - runs[i - 1].rect.right; - const em = (runs[i].font_size || 12) * 0.28; + const em = (runs[i].fontSize || 12) * 0.28; if (gap > em && !s.endsWith(" ") && !t.startsWith(" ")) s += " "; } s += t; @@ -2428,7 +2428,7 @@ function clusterTextLines(objs) { band.runs.sort((a, b) => a.rect.left - b.rect.left); let cur = null; for (const r of band.runs) { - const fs = r.font_size || 12; + const fs = r.fontSize || 12; const gapMax = Math.max(fs * 1.7, 10); if (cur && r.rect.left - cur.rect.right <= gapMax) { cur.runs.push(r); @@ -2475,13 +2475,13 @@ function isMarkerRun(run, lineRuns) { 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; + if (/symbol|wingding|dingbat|marlett|webding/i.test(run.fontFamily || "")) 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 gap > (run.fontSize || 12) * 0.4; } } return false; @@ -2497,7 +2497,7 @@ function stripLineMarkers(line) { 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, + fs: real.reduce((m, r) => Math.max(m, r.fontSize || 12), 0) || line.fs, rect: { left: Math.min(...real.map((r) => r.rect.left)), right: Math.max(...real.map((r) => r.rect.right)), @@ -2512,9 +2512,9 @@ 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, + fam: (rep.fontFamily || "").toLowerCase(), + bold: !!rep.fontBold, + italic: !!rep.fontItalic, color: (rep.color || [0, 0, 0]).slice(0, 3).join(","), }; } @@ -2527,7 +2527,7 @@ function lineFormat(l) { function paragraphLines(o) { 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 } } ]; + if (!rawMine) return [ { runs: [o], fs: o.fontSize || 12, rect: { ...o.rect } } ]; const pairs = raw .map((l) => ({ raw: l, s: stripLineMarkers(l) })) @@ -2653,7 +2653,7 @@ function startBlockTextEdit(o, lines, ev) { const totalHeightPx = perLineAdvances.reduce((a, b) => a + b, 0); // 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 (line-height − cỡ chữ)/2 cho trùng. - const fs0 = (lines[0].fs || o.font_size || 12) * s; + const fs0 = (lines[0].fs || o.fontSize || 12) * s; const topPx = Math.max(0, (p.heightPt - union.top) * s - Math.max(0, (perLineAdvances[0] - fs0) / 2)); Object.assign(ce.style, { @@ -2675,9 +2675,9 @@ function startBlockTextEdit(o, lines, ev) { const divFs = Math.max(6, (l.fs || 12) * s); div.style.fontSize = divFs + "px"; div.style.lineHeight = perLineAdvances[i] + "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"; + div.style.fontFamily = cssFontStack(rep.fontFamily || o.fontFamily); + div.style.fontWeight = (rep.fontBold != null ? rep.fontBold : o.fontBold) ? "bold" : "normal"; + div.style.fontStyle = (rep.fontItalic != null ? rep.fontItalic : o.fontItalic) ? "italic" : "normal"; if (rep.color || o.color) div.style.color = rgbCss(rep.color || o.color); // Giữ thụt lề từng dòng so với mép khối (dòng đầu đoạn thụt vào…). if (!centered) { @@ -2686,6 +2686,12 @@ function startBlockTextEdit(o, lines, ev) { } ce.appendChild(div); } + // Ẩn khung dòng overlay của các run đang sửa — nền ô trong suốt nên khung + // này lộ qua gây nhiễu; khôi phục khi đóng ô. + ov.querySelectorAll(".edit-box.kind-text").forEach((b) => { + const rIdxs = JSON.parse(b.dataset.runs || "[]"); + if (rIdxs.some((i) => allRuns.some((r) => r.index === i))) b.style.display = "none"; + }); ov.appendChild(ce); // WebView không hỗ trợ plaintext-only (hiếm) → contenteditable thường. if (!ce.isContentEditable) ce.contentEditable = "true"; @@ -2755,6 +2761,7 @@ function startBlockTextEdit(o, lines, ev) { done = true; const text = readText(); ce.remove(); + ov.querySelectorAll(".edit-box").forEach((b) => { b.style.display = ""; }); 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; @@ -3596,6 +3603,22 @@ async function verifySignaturesAction(pathOverride) { // ---------- Sự kiện ---------- $("openBtn").addEventListener("click", openFile); + +// Kéo-thả file PDF từ File Explorer vào cửa sổ → mở luôn (chuẩn desktop app). +// Tauri v2 bắn sự kiện drag-drop toàn cửa sổ với danh sách đường dẫn thật. +if (window.__TAURI__.event) { + window.__TAURI__.event.listen("tauri://drag-drop", (e) => { + const paths = (e.payload && e.payload.paths) || []; + const pdf = paths.find((p) => /\.pdf$/i.test(p)); + if (!pdf) { + if (paths.length) $("status").textContent = "Chỉ mở được file .pdf"; + return; + } + if (state.editMode) exitEditMode(); + if (state.organizeMode) exitOrganizeMode(); + loadDocument(pdf); + }); +} $("zoomIn").addEventListener("click", () => setZoom(state.zoom * 1.25)); $("zoomOut").addEventListener("click", () => setZoom(state.zoom / 1.25)); $("zoomFit").addEventListener("click", fitWidth); @@ -3761,11 +3784,11 @@ $("edFontFamily").addEventListener("change", () => { }); $("edBold").addEventListener("click", () => { const o = state.editObjects.find((x) => x.index === state.editSel); - if (o) applyTextPropToSelected({ bold: !o.font_bold }); + if (o) applyTextPropToSelected({ bold: !o.fontBold }); }); $("edItalic").addEventListener("click", () => { const o = state.editObjects.find((x) => x.index === state.editSel); - if (o) applyTextPropToSelected({ italic: !o.font_italic }); + if (o) applyTextPropToSelected({ italic: !o.fontItalic }); }); $("edColorBtn").addEventListener("click", () => { openColorPopover($("edColorBtn"), state.editColor, (rgb) => { diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index 4709e36..53d2609 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -169,19 +169,14 @@ fn text_object_style(t: &PdfPageTextObject) -> (String, bool, bool) { 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. + // Fallback: nhiều file không khai weight trong descriptor và tên không + // chứa "Bold"/"Italic" → đọc thẳng từ bytes font nhúng (OS/2 usWeightClass + // / fsSelection, kể cả khi subset đã vứt OS/2 thì còn head.macStyle). 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; - } - } + let (data_bold, data_italic) = fontmatch::style_from_font_bytes(&bytes); + bold = bold || data_bold; + italic = italic || data_italic; } } (family, bold, italic) diff --git a/crates/ff-engine/src/fontmatch.rs b/crates/ff-engine/src/fontmatch.rs index b80cb35..a7e6aa1 100644 --- a/crates/ff-engine/src/fontmatch.rs +++ b/crates/ff-engine/src/fontmatch.rs @@ -51,6 +51,60 @@ pub(crate) fn clean_font_name(raw: &str) -> (String, bool, bool) { (family.trim().to_string(), bold, italic) } +/// Đọc đậm/nghiêng TRỰC TIẾP từ bytes font sfnt/TTC, không qua ttf-parser: +/// ưu tiên OS/2 (usWeightClass ≥600, fsSelection bit BOLD/ITALIC), fallback +/// `head.macStyle` (bit0 bold, bit1 italic). Lý do: LibreOffice subset font +/// khi xuất PDF thường VỨT bảng OS/2 (ttf-parser weight() trả 400 mặc định) +/// nhưng `head` là bảng bắt buộc luôn còn — macStyle vẫn khai đúng kiểu chữ. +pub(crate) fn style_from_font_bytes(bytes: &[u8]) -> (bool, bool) { + fn u16_at(b: &[u8], o: usize) -> Option { + Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?])) + } + fn u32_at(b: &[u8], o: usize) -> Option { + Some(u32::from_be_bytes([*b.get(o)?, *b.get(o + 1)?, *b.get(o + 2)?, *b.get(o + 3)?])) + } + // TTC: nhảy tới face đầu tiên; sfnt thường: bảng directory ở offset 0. + let base = if bytes.get(0..4) == Some(b"ttcf") { + match u32_at(bytes, 12) { + Some(v) => v as usize, + None => return (false, false), + } + } else { + 0 + }; + let num = match u16_at(bytes, base + 4) { + Some(n) => n as usize, + None => return (false, false), + }; + let (mut bold, mut italic) = (false, false); + for i in 0..num.min(64) { + let e = base + 12 + i * 16; + let tag = match bytes.get(e..e + 4) { + Some(t) => t, + None => break, + }; + let off = match u32_at(bytes, e + 8) { + Some(v) => v as usize, + None => break, + }; + if tag == b"OS/2" { + if let Some(w) = u16_at(bytes, off + 4) { + bold |= (600..=1000).contains(&w); + } + if let Some(fs) = u16_at(bytes, off + 62) { + bold |= fs & 0x20 != 0; + italic |= fs & 0x01 != 0; + } + } else if tag == b"head" { + if let Some(mac) = u16_at(bytes, off + 44) { + bold |= mac & 0x01 != 0; + italic |= mac & 0x02 != 0; + } + } + } + (bold, italic) +} + /// Font bytes (TTF/OTF/TTC) có glyph cho MỌI ký tự của `text` không? /// Parse hỏng (Type1 thuần...) → false (caller sẽ thử đường khác). pub(crate) fn coverage_ok(font_bytes: &[u8], text: &str) -> bool { @@ -279,6 +333,38 @@ mod tests { assert_eq!(clean_font_name("CalibriItalic"), ("Calibri".into(), false, true)); } + // sfnt tối thiểu: header + 1 entry bảng + payload bảng tại offset 28. + fn sfnt_one_table(tag: &[u8; 4], table: &[u8]) -> Vec { + let mut f = vec![0u8; 28 + table.len()]; + f[0..4].copy_from_slice(&0x00010000u32.to_be_bytes()); + f[4..6].copy_from_slice(&1u16.to_be_bytes()); + f[12..16].copy_from_slice(tag); + f[20..24].copy_from_slice(&28u32.to_be_bytes()); + f[24..28].copy_from_slice(&(table.len() as u32).to_be_bytes()); + f[28..].copy_from_slice(table); + f + } + + #[test] + fn style_from_bytes_macstyle_when_os2_stripped() { + // Như font LibreOffice subset: KHÔNG có OS/2, chỉ còn head. + let mut head = [0u8; 54]; + head[44..46].copy_from_slice(&0x0003u16.to_be_bytes()); // Bold|Italic + assert_eq!(style_from_font_bytes(&sfnt_one_table(b"head", &head)), (true, true)); + head[44..46].copy_from_slice(&0x0000u16.to_be_bytes()); + assert_eq!(style_from_font_bytes(&sfnt_one_table(b"head", &head)), (false, false)); + } + + #[test] + fn style_from_bytes_os2_weight() { + let mut os2 = [0u8; 96]; + os2[4..6].copy_from_slice(&700u16.to_be_bytes()); // usWeightClass + assert_eq!(style_from_font_bytes(&sfnt_one_table(b"OS/2", &os2)), (true, false)); + os2[4..6].copy_from_slice(&400u16.to_be_bytes()); + os2[62..64].copy_from_slice(&0x0001u16.to_be_bytes()); // fsSelection ITALIC + assert_eq!(style_from_font_bytes(&sfnt_one_table(b"OS/2", &os2)), (false, true)); + } + #[test] fn keys_match_soft() { assert!(keys_match("timesnewroman", "timesnewromanps")); diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 690562b..56d4c9e 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -149,6 +149,39 @@ công. Sửa tương ứng: đú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. +### Vá vòng 5 — TÌM RA GỐC RỄ: field IPC lệch tên; kiểm chứng E2E trên app thật +User test build 12 với file thật (LibreOffice export, NotoSans): vẫn mất in +đậm. Lần này debug TRÊN CHÍNH APP đang chạy (WebView2 remote debugging qua +`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port` + CDP) và +phát hiện **nguyên nhân gốc của cả chuỗi lỗi font**: DTO Tauri serialize +`rename_all = "camelCase"` (`fontBold`, `fontFamily`, `fontSize`…) nhưng toàn +bộ code edit-mode trong main.js đọc snake_case (`font_bold`…) → **mọi thuộc +tính font trong UI luôn undefined từ đầu**: không đậm, sai family, cỡ mặc +định 12 (giải thích luôn ảnh lỗi "chữ nhỏ đều nhau" ban đầu — vòng 3 đã chữa +TRIỆU CHỨNG cỡ chữ bằng đo mực, vòng này chữa đúng BỆNH). Fix: đổi hết sang +camelCase (nhất quán với phần còn lại của app: `widthPt`, `pageIndex`…). +- **Engine đọc kiểu chữ từ bytes font — vá tiếp cho LibreOffice**: subset của + LibreOffice VỨT bảng OS/2 (kiểm bằng qpdf trích FontFile2: chỉ còn cmap/ + glyf/head/hhea/…) nên ttf-parser `weight()` trả 400 mặc định. Thêm + `fontmatch::style_from_font_bytes` parse sfnt thủ công: OS/2 usWeightClass/ + fsSelection nếu còn, fallback **`head.macStyle`** (bảng bắt buộc, bit0=bold + bit1=italic — file thật: 0x1 cho NotoSans-Bold, 0x0 cho Regular). 2 unit + test sfnt tự dựng. (File này BaseFont có "-Bold" nên tên đã bắt được; vá để + chống file tên không nói gì.) +- **Kéo-thả mở file**: nghe `tauri://drag-drop` (Tauri v2, dragDropEnabled mặc + định) → thả .pdf từ Explorer vào cửa sổ là mở (thoát edit/organize mode + trước); thả file khác báo "Chỉ mở được file .pdf". +- **Ẩn khung dòng overlay khi ô sửa đang mở** (nền trong suốt làm khung chấm + lộ qua gây nhiễu) — khôi phục khi đóng. +- **Kiểm chứng E2E trên app thật + file thật** (không cần rebuild): inject + hàm đã sửa vào app build-12 đang chạy qua CDP `Runtime.evaluate` (main.js + là classic script nên function declaration đè được binding global), gọi + `edit_list_objects` lấy run thật, điều khiển mở ô sửa và chụp màn hình: + tiêu đề 2 dòng bold 25.48px (=18.5pt đúng engine) 1 khối nền trong suốt; + khối bullet 8 dòng không PUA, bullet gốc còn nguyên trên trang. Test Node + (hàm thật + dữ liệu run thật từ engine): tiêu đề gom [27,28], khối bullet + loại sạch OpenSymbol, nhãn đậm màu tách riêng — tất cả PASS. + ### 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).