From a72bfae1768bd31eb586c5ed42feaf81e3265856 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 17:45:33 +0700 Subject: [PATCH] =?UTF-8?q?=C3=94=20s=E1=BB=ADa=20inline=20tr=C3=B9ng=20kh?= =?UTF-8?q?=C3=ADt=20ch=E1=BB=AF=20g=E1=BB=91c=20(=C4=91=C3=BAng=20font/c?= =?UTF-8?q?=E1=BB=A1/v=E1=BB=8B=20tr=C3=AD=20=E2=80=94=20WYSIWYG=20nh?= =?UTF-8?q?=C6=B0=20Foxit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ba lỗi làm chữ trong ô sửa khác hẳn chữ gốc: - Hồi quy: line-height từng dòng tính bằng khoảng hở giữa 2 bbox (~0/âm, kẹp 8px) thay vì khoảng cách baseline → các dòng dồn cục lên đầu ô. Fix: advance = hiệu mép dưới 2 bbox liền kề. - Sai font: family PostScript CamelCase (TimesNewRoman, SegoeUI) không khớp tên font Windows → CSS rơi về serif/sans mặc định. Fix: cssFontStack tách CamelCase + bảng alias (Helvetica→Arial...). - Sai cỡ với file engine báo cỡ lệch: thêm fitEditLinesToPdf — vòng ĐO-HIỆU CHỈNH sau khi mount: đo chiều cao mực thật của đúng chuỗi đó (canvas actualBoundingBox ở cỡ tham chiếu 100px, né hinting) so với bbox dòng PDF rồi tự sửa cỡ (dead-band 12% giữ ổn định file chuẩn); đo vị trí render thật (Range + font/ink ascent) rồi bù margin/indent từng dòng cho trùng mép mực — khối căn giữa cũng bù ngang qua text-indent. Kèm: giữ thụt lề từng dòng; đúp từ viewer thường đặt con trỏ đúng điểm đúp (chờ img.decode trước khi đo). Kiểm chứng bằng harness canvas + Edge headless: 3 kịch bản (cỡ báo sai 1/2, cỡ đúng, căn giữa) đều trùng khít ≤1.3px. Chi tiết: docs/12-phase4-summary.md (Vá vòng 3). Co-Authored-By: Claude Fable 5 --- app/src/main.js | 162 ++++++++++++++++++++++++++++++++------ docs/12-phase4-summary.md | 29 +++++++ 2 files changed, 169 insertions(+), 22 deletions(-) diff --git a/app/src/main.js b/app/src/main.js index 65f6db7..10ad442 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -2142,7 +2142,15 @@ async function loadEditPage() { const o = pickTextAt(state.editObjects, pending.x, pending.y); if (o) { selectEditObject(o.index); - startTextEdit(o); + // Chờ ảnh trang decode xong để mọi phép đo theo ảnh (ô sửa, con trỏ) đúng. + const img = $("editImg"); + if (img.decode) { try { await img.decode(); } catch (_) {} } + // Toạ độ chuột giả lập từ điểm PDF đã đúp → con trỏ đặt đúng chỗ đó. + const ir = img.getBoundingClientRect(); + startTextEdit(o, { + clientX: ir.left + pending.x * state.editScale, + clientY: ir.top + (p.heightPt - pending.y) * state.editScale, + }); } } } @@ -2333,15 +2341,39 @@ function editRedo() { } // CSS font-family xấp xỉ cho family PDF (để ô sửa hiển thị đúng dáng chữ). +// Tên family từ engine là dạng PostScript CamelCase KHÔNG dấu cách +// ("TimesNewRoman", "SegoeUI", "OpenSans") — không khớp tên font cài trên máy +// nên CSS rơi về serif/sans-serif mặc định (ô sửa sai font). Map alias phổ biến +// + tự tách CamelCase thành tên có dấu cách. +const FONT_CSS_ALIASES = { + helvetica: "Arial", + helveticaneue: "Arial", + arial: "Arial", + arialmt: "Arial", + times: "Times New Roman", + timesroman: "Times New Roman", + timesnewroman: "Times New Roman", + timesnewromanps: "Times New Roman", + courier: "Courier New", + couriernew: "Courier New", + couriernewps: "Courier New", + segoeui: "Segoe UI", +}; function cssFontStack(family) { if (!family) return "sans-serif"; - const k = family.toLowerCase(); + const k = family.toLowerCase().replace(/[^a-z0-9]/g, ""); const generic = /times|georgia|garamond|palatino|cambria|book|serif/.test(k) ? "serif" : /courier|consolas|mono/.test(k) ? "monospace" : "sans-serif"; - return `"${family}", ${generic}`; + const parts = [`"${family}"`]; + const spaced = family.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); + if (spaced !== family) parts.push(`"${spaced}"`); + const alias = FONT_CSS_ALIASES[k]; + if (alias && alias !== family && alias !== spaced) parts.push(`"${alias}"`); + parts.push(generic); + return parts.join(", "); } // Ghép nội dung 1 dòng từ các run: chèn khoảng trắng khi 2 run hở nhau rõ. @@ -2502,12 +2534,6 @@ function startBlockTextEdit(o, lines, ev) { lines.every((l) => Math.abs((l.rect.left + l.rect.right) / 2 - bc) <= tol) && lines.some((l) => l.rect.left - union.left > tol); - 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 * s); - const original = lines.map((l) => composeLineText(l.runs)).join("\n"); const ce = document.createElement("div"); @@ -2528,25 +2554,24 @@ function startBlockTextEdit(o, lines, ev) { 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)); - // Tính advance cho TỪNG DÒNG dựa trên vị trí gốc trong PDF để giữ layout. + // Advance TỪNG DÒNG = khoảng cách baseline dòng này → dòng dưới (xấp xỉ + // bằng hiệu mép DƯỚI 2 bbox — bbox PDF ôm sát glyph nên bottom bám baseline). + // KHÔNG dùng khoảng hở giữa 2 bbox (bottom trên − top dưới ≈ 0/âm) — chính + // là bug làm các dòng dồn cục 8px. const perLineAdvances = []; for (let i = 0; i < lines.length; i++) { - let linAdvPx; + let advPt; if (i < lines.length - 1) { - // Advance = khoảng cách giữa dòng này và dòng tiếp theo (giữ nguyên từ PDF). - const gap = lines[i].rect.bottom - lines[i + 1].rect.top; - linAdvPx = Math.max(8, gap * s); - } else { - // Dòng cuối: dùng cỡ chữ × 1.25. - linAdvPx = Math.max(8, (lines[i].fs || 12) * s * 1.25); + advPt = lines[i].rect.bottom - lines[i + 1].rect.bottom; } - perLineAdvances.push(linAdvPx); + if (!(advPt > 0)) advPt = (lines[i].fs || 12) * 1.25; + perLineAdvances.push(Math.max(8, advPt * s)); } 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 topPx = Math.max(0, (p.heightPt - union.top) * s - Math.max(0, (perLineAdvances[0] - fs0) / 2)); Object.assign(ce.style, { left: leftPt * s + "px", @@ -2571,11 +2596,19 @@ function startBlockTextEdit(o, lines, ev) { 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); + // 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) { + const indent = (l.rect.left - leftPt) * s; + if (indent > 0.5) div.style.textIndent = indent + "px"; + } ce.appendChild(div); } ov.appendChild(ce); // WebView không hỗ trợ plaintext-only (hiếm) → contenteditable thường. if (!ce.isContentEditable) ce.contentEditable = "true"; + // ĐO chữ vừa render rồi HIỆU CHỈNH cỡ/vị trí từng dòng cho TRÙNG bbox gốc — + // phải chạy TRƯỚC focus/đặt con trỏ để caretRangeFromPoint trúng layout cuối. + fitEditLinesToPdf(ce, lines, s, p); ce.focus(); // Đặt con trỏ đúng chỗ vừa đúp (như Foxit); không có toạ độ → đầu đoạn. @@ -2643,6 +2676,91 @@ function startBlockTextEdit(o, lines, ev) { ce.addEventListener("blur", () => commit(true)); } +// ĐO chữ đã render trong ô sửa rồi HIỆU CHỈNH cho trùng khít chữ gốc — vòng +// đo-bù nên đúng với MỌI loại file, không phụ thuộc engine trả cỡ chuẩn hay +// công thức line-height của trình duyệt: +// (1) CỠ CHỮ: chiều cao MỰC của đúng chuỗi đó (canvas actualBoundingBox) phải +// bằng chiều cao bbox dòng trong PDF (cùng là ink-bbox, gồm cả dấu tiếng +// Việt) — engine trả cỡ lệch (matrix scale lạ) hay font thay thế lệch +// metric đều tự sửa; lệch <12% coi như khác biệt metric, giữ nguyên. +// (2) VỊ TRÍ: quy cả 2 phía về MÉP MỰC TRÊN của dòng (DOM: đỉnh em-box của +// Range + (fontAscent − inkAscent); PDF: rect.top) rồi bù margin-top / +// text-indent từng dòng. Dòng đầu bù vào chính khung (giữ nền trắng che +// kín chữ cũ), các dòng sau bù margin (đo tuần tự nên tự cộng dồn đúng). +function fitEditLinesToPdf(ce, lines, s, page) { + const img = $("editImg"); + if (!img) return; + const canvas = (fitEditLinesToPdf._c = fitEditLinesToPdf._c || document.createElement("canvas")); + const ctx = canvas.getContext("2d"); + const divs = Array.from(ce.children); + const centered = ce.style.textAlign === "center"; + + const fontOf = (div, fsOverride) => { + const fs = parseFloat(div.style.fontSize) || 12; + const style = div.style.fontStyle === "italic" ? "italic " : ""; + const weight = div.style.fontWeight === "bold" ? "bold " : ""; + return { fs, css: `${style}${weight}${fsOverride || fs}px ${div.style.fontFamily || "sans-serif"}` }; + }; + + // (1) Cỡ chữ theo chiều cao mực thật. Đo ở cỡ THAM CHIẾU lớn (100px) rồi + // suy tuyến tính — đo trực tiếp ở cỡ nhỏ bị hinting làm tròn lệch tới ~5%. + const REF = 100; + for (let i = 0; i < divs.length && i < lines.length; i++) { + const text = (divs[i].textContent || "").trim(); + if (!text) continue; + const f = fontOf(divs[i], REF); + ctx.font = f.css; + const m = ctx.measureText(text); + const inkPerPx = ((m.actualBoundingBoxAscent || 0) + (m.actualBoundingBoxDescent || 0)) / REF; + const targetPx = (lines[i].rect.top - lines[i].rect.bottom) * s; + if (inkPerPx > 0.01 && targetPx > 1) { + const wantFs = targetPx / inkPerPx; + const ratio = wantFs / f.fs; + if (ratio < 0.88 || ratio > 1.12) { + divs[i].style.fontSize = f.fs * Math.max(0.3, Math.min(3, ratio)) + "px"; + } + } + } + + // (2) Vị trí từng dòng (đo lại sau khi đã chỉnh cỡ). + const imgR = img.getBoundingClientRect(); + for (let i = 0; i < divs.length && i < lines.length; i++) { + const r = firstTextRect(divs[i]); + if (!r) continue; + const text = (divs[i].textContent || "").trim(); + const f = fontOf(divs[i]); + ctx.font = f.css; + const m = ctx.measureText(text); + const fontAscent = m.fontBoundingBoxAscent || f.fs * 0.8; + const inkAscent = m.actualBoundingBoxAscent || fontAscent; + const curInkTop = r.top + (fontAscent - inkAscent); + const dy = imgR.top + (page.heightPt - lines[i].rect.top) * s - curInkTop; + if (Math.abs(dy) > 0.5) { + if (i === 0) ce.style.top = parseFloat(ce.style.top) + dy + "px"; + else divs[i].style.marginTop = (parseFloat(divs[i].style.marginTop) || 0) + dy + "px"; + } + // Ngang: khối căn trái bù qua ce.left (dòng đầu) / text-indent (dòng sau); + // khối căn giữa bù mọi dòng qua text-indent (indent cộng thêm vào offset + // căn giữa nên vẫn dịch được từng dòng mà không phá cơ chế center). + const dx = imgR.left + lines[i].rect.left * s - r.left; + if (Math.abs(dx) > 0.5) { + if (!centered && i === 0) ce.style.left = parseFloat(ce.style.left) + dx + "px"; + else divs[i].style.textIndent = (parseFloat(divs[i].style.textIndent) || 0) + dx + "px"; + } + } +} + +// Bbox render thật của text trong div — đo bằng Range (chỉ tính chữ, không +// tính line-box nên không dính line-height); null nếu dòng rỗng. +function firstTextRect(div) { + const tn = div.firstChild; + if (!tn || tn.nodeType !== Node.TEXT_NODE) return null; + const range = document.createRange(); + range.selectNodeContents(tn); + const rects = range.getClientRects(); + return rects.length ? rects[0] : null; +} + // Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung // từng run), `part` chỉ chứa field muốn đổi — field khác null = GIỮ NGUYÊN. function applyTextPropToSelected(part) { diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 91670da..5b724d0 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -89,6 +89,35 @@ Ba lỗi user báo sau khi thử bản build 6 trên chính file Word-export: 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. +### Vá vòng 3 — ô sửa trùng khít chữ gốc bằng ĐO–HIỆU CHỈNH (fix hồi quy vòng 2) +User báo (kèm ảnh): ô sửa hiện chữ SAI font + SAI cỡ + SAI vị trí. Ba nguyên nhân: +- **Hồi quy vòng 2**: `perLineAdvances` tính line-height mỗi dòng bằng KHOẢNG HỞ + giữa 2 bbox (`bottom trên − top dưới` ≈ 0/âm → kẹp 8px) thay vì khoảng cách + baseline → các dòng dồn cục. Fix: advance = hiệu mép DƯỚI 2 bbox liền kề + (bbox PDFium ôm sát glyph nên bottom bám baseline). +- **Sai font**: engine trả family PostScript CamelCase ("TimesNewRoman", + "SegoeUI") không khớp tên font cài trên Windows → CSS rơi về serif/sans mặc + định. Fix: `cssFontStack` tách CamelCase thành tên có dấu cách + bảng alias + (Helvetica→Arial, TimesNewRomanPS→Times New Roman…). +- **Sai cỡ với file lạ**: cỡ px suy mở từ `font_size` engine, không tự kiểm. + Fix: **`fitEditLinesToPdf` — vòng đo–hiệu chỉnh sau khi mount ô sửa**: + (1) CỠ: chiều cao MỰC của đúng chuỗi đó (canvas `measureText` actualBoundingBox, + đo ở cỡ tham chiếu 100px để né sai số hinting ~5% ở cỡ nhỏ) phải bằng chiều + cao bbox dòng PDF (cùng là ink-bbox, PDFium bounds = union glyph box) — engine + báo cỡ lệch bao nhiêu cũng tự sửa về đúng, lệch <12% coi là khác metric font + → giữ cỡ engine (ổn định file chuẩn); (2) VỊ TRÍ: quy 2 phía về MÉP MỰC TRÊN + (DOM: Range rect + (fontAscent − inkAscent) từ canvas; PDF: rect.top) rồi bù + `margin-top`/`text-indent` từng dòng (dòng đầu bù vào khung để nền trắng che + kín); khối căn giữa cũng bù ngang qua text-indent. Thêm: giữ thụt lề từng + dòng; đúp từ viewer thường nay đặt con trỏ đúng điểm đúp (toạ độ giả lập từ + điểm PDF, chờ `img.decode()` trước khi đo). +- **Kiểm chứng**: harness HTML mô phỏng trang bằng canvas (chữ gốc đỏ, bbox mực + thật làm ground truth) + Edge headless chụp 3 kịch bản: (A) engine báo cỡ sai + MỘT NỬA + family CamelCase → tự sửa về đúng, lệch ≤0.4px dọc / 0px ngang; + (B) engine báo đúng → dead-band giữ nguyên, trùng khít 100%; (C) khối căn + 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.) + ### 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).