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
|
||||
|
||||
# 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
|
||||
|
||||
+45
-22
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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?
|
||||
/// 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<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]
|
||||
fn keys_match_soft() {
|
||||
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.
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user