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
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:
+3
-1
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user