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
+3 -1
View File
@@ -23,5 +23,7 @@ Cargo.lock.bak
*.tmp.png *.tmp.png
# File PDF thật của user (hồ sơ/kinh doanh cá nhân) — chỉ dùng test local, # 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-file-to-test/
/*.pdf
+45 -22
View File
@@ -2279,18 +2279,18 @@ function selectEditObject(index, runIndices) {
$("edBold").disabled = !isText; $("edBold").disabled = !isText;
$("edItalic").disabled = !isText; $("edItalic").disabled = !isText;
if (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); } 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. // Ô 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 $("edFontFamily").options[0].textContent = o.fontFamily
? `(giữ nguyên: ${o.font_family})` ? `(giữ nguyên: ${o.fontFamily})`
: "(giữ nguyên)"; : "(giữ nguyên)";
$("edFontFamily").value = ""; $("edFontFamily").value = "";
$("edBold").classList.toggle("on", !!o.font_bold); $("edBold").classList.toggle("on", !!o.fontBold);
$("edItalic").classList.toggle("on", !!o.font_italic); $("edItalic").classList.toggle("on", !!o.fontItalic);
const emb = o.font_embedded == null ? "" : o.font_embedded ? " · font nhúng" : " · font hệ thống"; const emb = o.fontEmbedded == null ? "" : o.fontEmbedded ? " · font nhúng" : " · font hệ thống";
$("editHint").textContent = $("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 { } else {
$("edFontFamily").options[0].textContent = "(giữ nguyên)"; $("edFontFamily").options[0].textContent = "(giữ nguyên)";
$("edBold").classList.remove("on"); $("edBold").classList.remove("on");
@@ -2387,7 +2387,7 @@ function composeLineText(runs) {
const t = runs[i].text || ""; const t = runs[i].text || "";
if (i > 0) { if (i > 0) {
const gap = runs[i].rect.left - runs[i - 1].rect.right; 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 += " "; if (gap > em && !s.endsWith(" ") && !t.startsWith(" ")) s += " ";
} }
s += t; s += t;
@@ -2428,7 +2428,7 @@ function clusterTextLines(objs) {
band.runs.sort((a, b) => a.rect.left - b.rect.left); band.runs.sort((a, b) => a.rect.left - b.rect.left);
let cur = null; let cur = null;
for (const r of band.runs) { 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); const gapMax = Math.max(fs * 1.7, 10);
if (cur && r.rect.left - cur.rect.right <= gapMax) { if (cur && r.rect.left - cur.rect.right <= gapMax) {
cur.runs.push(r); cur.runs.push(r);
@@ -2475,13 +2475,13 @@ function isMarkerRun(run, lineRuns) {
return (c >= 0xe000 && c <= 0xf8ff) || BULLET_CHARS.has(ch); return (c >= 0xe000 && c <= 0xf8ff) || BULLET_CHARS.has(ch);
}); });
if (puaOrBullet) return true; 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. // 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)) { if (chars.length === 1 && "-*o".includes(t)) {
const next = lineRuns.find((r) => r !== run && (r.text || "").trim()); const next = lineRuns.find((r) => r !== run && (r.text || "").trim());
if (next) { if (next) {
const gap = next.rect.left - run.rect.right; 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; return false;
@@ -2497,7 +2497,7 @@ function stripLineMarkers(line) {
if (!real.length) return { runs, fs: line.fs, rect: { ...line.rect } }; if (!real.length) return { runs, fs: line.fs, rect: { ...line.rect } };
return { return {
runs, 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: { rect: {
left: Math.min(...real.map((r) => r.rect.left)), left: Math.min(...real.map((r) => r.rect.left)),
right: Math.max(...real.map((r) => r.rect.right)), 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]; const rep = l.runs.find((r) => (r.text || "").trim()) || l.runs[0];
if (!rep) return null; if (!rep) return null;
return { return {
fam: (rep.font_family || "").toLowerCase(), fam: (rep.fontFamily || "").toLowerCase(),
bold: !!rep.font_bold, bold: !!rep.fontBold,
italic: !!rep.font_italic, italic: !!rep.fontItalic,
color: (rep.color || [0, 0, 0]).slice(0, 3).join(","), color: (rep.color || [0, 0, 0]).slice(0, 3).join(","),
}; };
} }
@@ -2527,7 +2527,7 @@ function lineFormat(l) {
function paragraphLines(o) { function paragraphLines(o) {
const raw = clusterTextLines(state.editObjects); const raw = clusterTextLines(state.editObjects);
const rawMine = raw.find((l) => l.runs.some((r) => r.index === o.index)); 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 const pairs = raw
.map((l) => ({ raw: l, s: stripLineMarkers(l) })) .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); 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, // 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. // 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)); const topPx = Math.max(0, (p.heightPt - union.top) * s - Math.max(0, (perLineAdvances[0] - fs0) / 2));
Object.assign(ce.style, { Object.assign(ce.style, {
@@ -2675,9 +2675,9 @@ function startBlockTextEdit(o, lines, ev) {
const divFs = Math.max(6, (l.fs || 12) * s); const divFs = Math.max(6, (l.fs || 12) * s);
div.style.fontSize = divFs + "px"; div.style.fontSize = divFs + "px";
div.style.lineHeight = perLineAdvances[i] + "px"; div.style.lineHeight = perLineAdvances[i] + "px";
div.style.fontFamily = cssFontStack(rep.font_family || o.font_family); div.style.fontFamily = cssFontStack(rep.fontFamily || o.fontFamily);
div.style.fontWeight = (rep.font_bold != null ? rep.font_bold : o.font_bold) ? "bold" : "normal"; div.style.fontWeight = (rep.fontBold != null ? rep.fontBold : o.fontBold) ? "bold" : "normal";
div.style.fontStyle = (rep.font_italic != null ? rep.font_italic : o.font_italic) ? "italic" : "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); 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…). // 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) { if (!centered) {
@@ -2686,6 +2686,12 @@ function startBlockTextEdit(o, lines, ev) {
} }
ce.appendChild(div); 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); ov.appendChild(ce);
// WebView không hỗ trợ plaintext-only (hiếm) → contenteditable thường. // WebView không hỗ trợ plaintext-only (hiếm) → contenteditable thường.
if (!ce.isContentEditable) ce.contentEditable = "true"; if (!ce.isContentEditable) ce.contentEditable = "true";
@@ -2755,6 +2761,7 @@ function startBlockTextEdit(o, lines, ev) {
done = true; done = true;
const text = readText(); const text = readText();
ce.remove(); ce.remove();
ov.querySelectorAll(".edit-box").forEach((b) => { b.style.display = ""; });
const changed = 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). // 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 && bgSwapped) img.src = prevSrc;
@@ -3596,6 +3603,22 @@ async function verifySignaturesAction(pathOverride) {
// ---------- Sự kiện ---------- // ---------- Sự kiện ----------
$("openBtn").addEventListener("click", openFile); $("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)); $("zoomIn").addEventListener("click", () => setZoom(state.zoom * 1.25));
$("zoomOut").addEventListener("click", () => setZoom(state.zoom / 1.25)); $("zoomOut").addEventListener("click", () => setZoom(state.zoom / 1.25));
$("zoomFit").addEventListener("click", fitWidth); $("zoomFit").addEventListener("click", fitWidth);
@@ -3761,11 +3784,11 @@ $("edFontFamily").addEventListener("change", () => {
}); });
$("edBold").addEventListener("click", () => { $("edBold").addEventListener("click", () => {
const o = state.editObjects.find((x) => x.index === state.editSel); 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", () => { $("edItalic").addEventListener("click", () => {
const o = state.editObjects.find((x) => x.index === state.editSel); 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", () => { $("edColorBtn").addEventListener("click", () => {
openColorPopover($("edColorBtn"), state.editColor, (rgb) => { openColorPopover($("edColorBtn"), state.editColor, (rgb) => {
+6 -11
View File
@@ -169,19 +169,14 @@ fn text_object_style(t: &PdfPageTextObject) -> (String, bool, bool) {
let mut bold = weight_bold || name_bold; let mut bold = weight_bold || name_bold;
let mut italic = angle_italic || name_italic; let mut italic = angle_italic || name_italic;
// Fallback: nhiều file (Word/Chrome xuất subset "ABCDEF+Xyz") không khai // Fallback: nhiều file không khai weight trong descriptor và tên không
// weight trong descriptor và tên không chứa "Bold"/"Italic" → đọc thẳng // chứa "Bold"/"Italic" → đọc thẳng từ bytes font nhúng (OS/2 usWeightClass
// OS/2 usWeightClass + cờ italic từ bytes font nhúng. // / 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 (!bold || !italic) && t.font().is_embedded().unwrap_or(false) {
if let Ok(bytes) = t.font().data() { if let Ok(bytes) = t.font().data() {
let n = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1).max(1); let (data_bold, data_italic) = fontmatch::style_from_font_bytes(&bytes);
for i in 0..n { bold = bold || data_bold;
if let Ok(face) = ttf_parser::Face::parse(&bytes, i) { italic = italic || data_italic;
bold = bold || face.weight().to_number() >= 600;
italic = italic || face.is_italic() || face.is_oblique();
break;
}
}
} }
} }
(family, bold, italic) (family, bold, italic)
+86
View File
@@ -51,6 +51,60 @@ pub(crate) fn clean_font_name(raw: &str) -> (String, bool, bool) {
(family.trim().to_string(), bold, italic) (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<u16> {
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]))
}
fn u32_at(b: &[u8], o: usize) -> Option<u32> {
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? /// 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). /// Parse hỏng (Type1 thuần...) → false (caller sẽ thử đường khác).
pub(crate) fn coverage_ok(font_bytes: &[u8], text: &str) -> bool { 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)); 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<u8> {
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] #[test]
fn keys_match_soft() { fn keys_match_soft() {
assert!(keys_match("timesnewroman", "timesnewromanps")); assert!(keys_match("timesnewroman", "timesnewromanps"));
+33
View File
@@ -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. đú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. 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) ### 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 - Đ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). XOAY chưa reflow theo hướng xoay (dòng mới đặt theo trục ngang).