Khối sửa chuẩn Foxit: tách đoạn theo format, nền trong suốt, bullet, giữ in đậm
Build Windows portable / build (push) Canceled after 0s

4 lỗi user báo khi test build 11 trên file thật:
- Gom đoạn quá tham (tiêu đề + code + thân bài + nhãn đỏ 1 khối): đối chiếu
  Foxit (mỗi đoạn = 1 text block riêng) — paragraphLines chỉ gom dòng kề
  CÙNG format (family + đậm/nghiêng + màu, cỡ chênh <=1.25x).
- Bullet hiện ô vuông trong ô sửa: bullet là run font Symbol mã PUA, DOM
  không render được — isMarkerRun/stripLineMarkers loại marker khỏi ô
  sửa/commit, bullet giữ nguyên trên trang (chuẩn Foxit); đúp trúng bullet
  vẫn mở khối chữ.
- Ô sửa nền trắng: render ngầm bản trang đã ẩn run đang sửa rồi chuyển nền
  trong suốt — thấy nền gốc (dải màu/bullet) dưới chữ đang gõ; huỷ trả ảnh
  gốc; commit vẫn tính trên editBase.
- Mất in đậm: engine thêm fallback đọc OS/2 usWeightClass + cờ italic từ
  bytes font nhúng (file Word xuất không khai /FontWeight, tên không có
  Bold); list_objects cache kiểu chữ theo tên font.
Test Node trích hàm thật từ main.js: 11 case tách khối/bullet đều PASS.
Chi tiết: docs/12-phase4-summary.md (Vá vòng 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 18:18:30 +07:00
co-authored by Claude Fable 5
parent a72bfae176
commit de1ba2cc63
3 changed files with 189 additions and 13 deletions
+121 -10
View File
@@ -2204,7 +2204,11 @@ function buildEditOverlay() {
lines.forEach((line) => { lines.forEach((line) => {
const box = document.createElement("div"); const box = document.createElement("div");
box.className = "edit-box kind-text"; box.className = "edit-box kind-text";
const rep = line.runs.find((r) => (r.text || "").trim()) || line.runs[0]; // Đại diện dòng: ưu tiên run chữ thật (không phải bullet) để đúp mở đúng đoạn.
const rep =
line.runs.find((r) => (r.text || "").trim() && !isMarkerRun(r, line.runs)) ||
line.runs.find((r) => (r.text || "").trim()) ||
line.runs[0];
box.dataset.index = String(rep.index); box.dataset.index = String(rep.index);
box.dataset.runs = JSON.stringify(line.runs.map((r) => r.index)); box.dataset.runs = JSON.stringify(line.runs.map((r) => r.index));
Object.assign(box.style, editBoxStyle(line.rect)); Object.assign(box.style, editBoxStyle(line.rect));
@@ -2454,13 +2458,84 @@ function clusterTextLines(objs) {
return lines; return lines;
} }
// Gom ĐOẠN VĂN nhiều dòng quanh run `o`: các dòng kề nhau theo chiều dọc // Run "marker" đầu dòng (bullet/dấu gạch danh sách): Word/LibreOffice xuất
// (khoảng hở ≤ 1.3× chiều cao dòng), giao ngang ≥30%, cỡ chữ tương đồng // bullet bằng font Symbol/Wingdings với mã PUA (U+F0B7…) — DOM không render
// (≤1.8× — tiêu đề 2 dòng 22.5/20pt vẫn là 1 đoạn). Trả mảng dòng trên→dưới. // được (hiện ô vuông □) và Foxit cũng KHÔNG cho sửa bullet như chữ thường.
// Nhận diện để LOẠI khỏi ô sửa + commit (bullet giữ nguyên trên trang).
const BULLET_CHARS = new Set([..."•◦▪▫‣·∙●○■□◆♦►▶➢➤§–—"]);
function isMarkerRun(run, lineRuns) {
const t = (run.text || "").trim();
if (!t || [...t].length > 2) return false;
// Phải là run có chữ TRÁI NHẤT của dòng.
const firstReal = lineRuns.find((r) => (r.text || "").trim());
if (firstReal !== run) return false;
const chars = [...t];
const puaOrBullet = chars.every((ch) => {
const c = ch.codePointAt(0);
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;
// 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 false;
}
// Bỏ marker khỏi 1 dòng cluster: runs còn lại + rect/fs tính lại từ phần chữ
// thật (để ô sửa, thụt lề, so-format không dính bbox của bullet).
function stripLineMarkers(line) {
const marker = line.runs.find((r) => isMarkerRun(r, line.runs));
if (!marker) return line;
const runs = line.runs.filter((r) => r !== marker);
const real = runs.filter((r) => (r.text || "").trim() && r.rect.top - r.rect.bottom > 0.5);
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,
rect: {
left: Math.min(...real.map((r) => r.rect.left)),
right: Math.max(...real.map((r) => r.rect.right)),
top: Math.max(...real.map((r) => r.rect.top)),
bottom: Math.min(...real.map((r) => r.rect.bottom)),
},
};
}
// Format đại diện của 1 dòng (từ run có chữ đầu tiên) — để so "cùng đoạn".
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,
color: (rep.color || [0, 0, 0]).slice(0, 3).join(","),
};
}
// Gom ĐOẠN VĂN quanh run `o` — CHUẨN FOXIT: mỗi đoạn là 1 khối riêng, chỉ gom
// các dòng kề nhau CÙNG FORMAT (font + đậm/nghiêng + màu, cỡ chênh ≤1.25× —
// tiêu đề 22.5/20pt vẫn 1 đoạn nhưng tiêu đề ≠ thân bài ≠ dòng code), khoảng
// hở ≤1.3× chiều cao dòng, giao ngang ≥30%, nhịp dòng đều ±35%. Bullet đầu
// dòng bị loại khỏi khối (giữ nguyên trên trang). Trả mảng dòng trên→dưới.
function paragraphLines(o) { function paragraphLines(o) {
const all = clusterTextLines(state.editObjects); const raw = clusterTextLines(state.editObjects);
const mine = all.find((l) => l.runs.some((r) => r.index === o.index)); const rawMine = raw.find((l) => l.runs.some((r) => r.index === o.index));
if (!mine) return [ { runs: [o], fs: o.font_size || 12, rect: { ...o.rect } } ]; if (!rawMine) return [ { runs: [o], fs: o.font_size || 12, rect: { ...o.rect } } ];
const pairs = raw
.map((l) => ({ raw: l, s: stripLineMarkers(l) }))
.filter((p) => p.s.runs.some((r) => (r.text || "").trim()));
const minePair = pairs.find((p) => p.raw === rawMine);
if (!minePair) return [rawMine]; // dòng chỉ có mỗi bullet (hiếm) → sửa mình nó
const all = pairs.map((p) => p.s);
const mine = minePair.s;
const hOf = (l) => Math.max(1, l.rect.top - l.rect.bottom); const hOf = (l) => Math.max(1, l.rect.top - l.rect.bottom);
const overlaps = (a, b) => const overlaps = (a, b) =>
@@ -2468,7 +2543,15 @@ function paragraphLines(o) {
0.3 * Math.min(a.rect.right - a.rect.left, b.rect.right - b.rect.left); 0.3 * Math.min(a.rect.right - a.rect.left, b.rect.right - b.rect.left);
const sizeOk = (a, b) => { const sizeOk = (a, b) => {
const r = a.fs / b.fs; const r = a.fs / b.fs;
return r <= 1.8 && r >= 1 / 1.8; return r <= 1.25 && r >= 1 / 1.25;
};
const myFmt = lineFormat(mine);
const fmtOk = (l) => {
const f = lineFormat(l);
return (
f && myFmt && f.fam === myFmt.fam && f.bold === myFmt.bold &&
f.italic === myFmt.italic && f.color === myFmt.color
);
}; };
const sorted = all.slice().sort((a, b) => b.rect.bottom - a.rect.bottom); // trên→dưới const sorted = all.slice().sort((a, b) => b.rect.bottom - a.rect.bottom); // trên→dưới
@@ -2483,7 +2566,7 @@ function paragraphLines(o) {
const gap = step > 0 ? edge.rect.bottom - cand.rect.top : cand.rect.bottom - edge.rect.top; const gap = step > 0 ? edge.rect.bottom - cand.rect.top : cand.rect.bottom - edge.rect.top;
const maxGap = 1.3 * Math.max(hOf(edge), hOf(cand)); const maxGap = 1.3 * Math.max(hOf(edge), hOf(cand));
if (gap < -3 || gap > maxGap) break; if (gap < -3 || gap > maxGap) break;
if (!overlaps(edge, cand) || !sizeOk(edge, cand)) break; if (!overlaps(edge, cand) || !sizeOk(edge, cand) || !fmtOk(cand)) break;
const adv = Math.abs(edge.rect.bottom - cand.rect.bottom); const adv = Math.abs(edge.rect.bottom - cand.rect.bottom);
if (advance && (adv < advance * 0.65 || adv > advance * 1.35)) break; if (advance && (adv < advance * 0.65 || adv > advance * 1.35)) break;
if (advance == null) advance = adv; if (advance == null) advance = adv;
@@ -2611,6 +2694,31 @@ function startBlockTextEdit(o, lines, ev) {
fitEditLinesToPdf(ce, lines, s, p); fitEditLinesToPdf(ce, lines, s, p);
ce.focus(); ce.focus();
// NỀN TRONG SUỐT chuẩn Foxit: render nền trang ĐÃ ẨN các run đang sửa (áp
// Delete ra file tạm chỉ để lấy ảnh — commit vẫn tính trên editBase gốc).
// Trong lúc chờ render giữ nền trắng che chữ cũ, xong mới chuyển trong suốt
// → thấy nguyên dải màu nền/bullet/kẻ bảng dưới ô sửa.
const img = $("editImg");
const prevSrc = img.src;
let bgSwapped = false;
(async () => {
try {
const tmp = await invoke("edit_apply_to_temp", {
input: state.editBase,
page: state.editPage,
ops: allRuns.map((r) => ({ op: "delete", index: r.index })),
password: null,
});
state.editTemps.push(tmp);
const url = await invoke("render_page", { path: tmp, page: state.editPage, width: EDIT_STAGE_W });
if (ce.isConnected) {
img.src = url;
bgSwapped = true;
ce.style.background = "transparent";
}
} catch (_) { /* không render được thì giữ nền trắng như cũ */ }
})();
// Đặt con trỏ đúng chỗ vừa đúp (như Foxit); không có toạ độ → đầu đoạn. // Đặt con trỏ đúng chỗ vừa đúp (như Foxit); không có toạ độ → đầu đoạn.
let placed = false; let placed = false;
if (ev && document.caretRangeFromPoint) { if (ev && document.caretRangeFromPoint) {
@@ -2647,7 +2755,10 @@ function startBlockTextEdit(o, lines, ev) {
done = true; done = true;
const text = readText(); const text = readText();
ce.remove(); ce.remove();
if (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).
if (!changed && bgSwapped) img.src = prevSrc;
if (changed) {
stageEditOp({ stageEditOp({
op: "reflowText", op: "reflowText",
indices: allRuns.map((r) => r.index), indices: allRuns.map((r) => r.index),
+37 -3
View File
@@ -166,7 +166,25 @@ fn text_object_style(t: &PdfPageTextObject) -> (String, bool, bool) {
| Ok(PdfFontWeight::Weight900) | Ok(PdfFontWeight::Weight900)
) || matches!(t.font().weight(), Ok(PdfFontWeight::Custom(v)) if v >= 600); ) || matches!(t.font().weight(), Ok(PdfFontWeight::Custom(v)) if v >= 600);
let angle_italic = t.font().italic_angle().map(|a| a != 0).unwrap_or(false); let angle_italic = t.font().italic_angle().map(|a| a != 0).unwrap_or(false);
(family, weight_bold || name_bold, angle_italic || name_italic)
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.
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;
}
}
}
}
(family, bold, italic)
} }
/// Liệt kê các page object của 1 trang để UI vẽ overlay chỉnh sửa. /// Liệt kê các page object của 1 trang để UI vẽ overlay chỉnh sửa.
@@ -185,6 +203,9 @@ pub fn list_objects(
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?; .map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
let mut out = Vec::new(); let mut out = Vec::new();
// Cache kiểu chữ theo tên font: fallback đậm/nghiêng phải đọc bytes font
// nhúng — file Word xuất hay có 200+ run chung vài font, không đọc lại.
let mut style_cache: HashMap<String, (String, bool, bool)> = HashMap::new();
for (i, object) in page.objects().iter().enumerate() { for (i, object) in page.objects().iter().enumerate() {
let kind = ObjectKind::from_pdfium(object.object_type()); let kind = ObjectKind::from_pdfium(object.object_type());
let rect = object let rect = object
@@ -206,9 +227,22 @@ pub fn list_objects(
color: None, color: None,
}; };
if let Some(t) = object.as_text_object() { if let Some(t) = object.as_text_object() {
let (family, bold, italic) = text_object_style(t); let raw_name = t.font().name();
// Tên rỗng không cache được (nhiều font khác nhau cùng key "").
let (family, bold, italic) = if raw_name.trim().is_empty() {
text_object_style(t)
} else {
match style_cache.get(&raw_name) {
Some(s) => s.clone(),
None => {
let s = text_object_style(t);
style_cache.insert(raw_name.clone(), s.clone());
s
}
}
};
info.text = Some(t.text()); info.text = Some(t.text());
info.font_name = Some(t.font().name()); info.font_name = Some(raw_name);
info.font_family = Some(family); info.font_family = Some(family);
info.font_bold = Some(bold); info.font_bold = Some(bold);
info.font_italic = Some(italic); info.font_italic = Some(italic);
+31
View File
@@ -118,6 +118,37 @@ User báo (kèm ảnh): ô sửa hiện chữ SAI font + SAI cỡ + SAI vị tr
giữa Times → lệch ngang ≤1.3px. (Không unit-test tự động được vì cần layout 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.) engine thật; harness lưu ở scratchpad phiên làm việc.)
### Vá vòng 4 — khối sửa chuẩn Foxit: tách đoạn theo FORMAT, nền trong suốt, bullet, in đậm
User test build 11 trên file thật, báo 4 lỗi (kèm ảnh). Đối chiếu hành vi
Foxit (help.foxit.com — Edit Text): mỗi ĐOẠN là 1 text block riêng, click vào
đâu chỉ sửa block đó, reflow trong block; muốn sửa nhiều block phải Link thủ
công. Sửa tương ứng:
- **Gom đoạn quá tham** (tiêu đề + dòng code + thân bài + nhãn đỏ dính 1 khối):
`paragraphLines` giờ chỉ gom dòng kề nhau **CÙNG FORMAT** — cùng font family
+ đậm/nghiêng + màu (của run chữ đầu dòng), cỡ chênh ≤1.25× (tiêu đề 22.5/20
vẫn 1 đoạn, tiêu đề ≠ thân bài) — cộng điều kiện hình học cũ (hở ≤1.3× cao
dòng, giao ngang ≥30%, nhịp đều ±35%).
- **Bullet thành ô vuông □ trong ô sửa**: bullet Word/LibreOffice là run font
Symbol/Wingdings mã PUA (U+F0B7…) DOM không render được. Thêm `isMarkerRun`
(run chữ trái nhất của dòng, ≤2 ký tự, PUA/ký tự bullet/font symbol; "-","*",
"o" chỉ nhận khi có khoảng hở rõ với chữ sau) + `stripLineMarkers`: marker bị
LOẠI khỏi ô sửa/commit/rect khối (thụt lề tính từ CHỮ) — bullet giữ nguyên
trên trang như Foxit. Đúp trúng chính bullet vẫn mở khối chữ của dòng đó.
- **Ô sửa nền trắng che nền gốc**: giờ mở ô xong render NGẦM bản trang đã ẨN
các run đang sửa (`edit_apply_to_temp` với Delete — chỉ lấy ảnh, commit vẫn
tính trên editBase gốc) → đổi ảnh stage + nền ô chuyển TRONG SUỐT: thấy dải
màu nền/bullet/kẻ bảng dưới chữ đang gõ, đúng cảm giác Foxit. Chờ render thì
tạm nền trắng; huỷ/không đổi → trả ảnh gốc; file tạm vào editTemps để dọn.
- **Mất in đậm dù gốc đậm**: file Word/Chrome xuất subset không khai
/FontWeight và tên không chứa "Bold" → engine `text_object_style` thêm
fallback đọc **OS/2 usWeightClass ≥600 + cờ italic từ bytes font nhúng**
(ttf-parser); `list_objects` cache theo tên font (file 200+ run chung vài
font, không đọc lại bytes).
- **Kiểm chứng**: test Node trích HÀM THẬT từ main.js (11 case: tách khối theo
format/màu, tiêu đề 2 cỡ vẫn 1 đoạn, bullet loại khỏi khối + rect từ chữ,
đú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.
### 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).