Gốc rễ lỗi font ô sửa: field IPC lệch tên camelCase/snake_case + kéo-thả mở file
Build Windows portable / build (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 00:50:22 +07:00
co-authored by Claude Fable 5
parent de1ba2cc63
commit 68883b9e75
5 changed files with 173 additions and 34 deletions
+45 -22
View File
@@ -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: <family gốc>)" — 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) => {