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
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:
+121
-10
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user