diff --git a/app/src/main.js b/app/src/main.js index 45e841f..54a9df8 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -33,7 +33,8 @@ const state = { editUndo: [], // stack đường dẫn editBase trước mỗi op editRedo: [], editObjects: [], // ObjectInfo của editPage (đọc lại từ editBase sau mỗi op) - editSel: null, // index object đang chọn + editSel: null, // index object đại diện đang chọn + editSelRuns: [], // mọi run thuộc khung đang chọn (dòng text = nhiều run) editScale: 1, // px/pt của ảnh stage editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh) @@ -2181,28 +2182,45 @@ function buildEditOverlay() { const ov = $("editOverlay"); ov.innerHTML = ""; ov.classList.toggle("armed", !!state.editArm); - const img = $("editImg"); - // Khớp kích thước overlay với ảnh thực tế hiển thị. - state.editObjects.forEach((o) => { - if (o.kind === "path" || o.kind === "shading") return; // path/shading: chưa cho chọn ở v1 + + // TEXT: 1 khung mỗi DÒNG thị giác (như Foxit) — không vẽ ô chi chít từng + // run khi PDF cắt dòng thành từng từ/ký tự. Chọn/kéo/xoá tác động CẢ DÒNG. + const lines = clusterTextLines(state.editObjects); + lines.forEach((line) => { const box = document.createElement("div"); - box.className = "edit-box kind-" + o.kind + (state.editSel === o.index ? " selected" : ""); + box.className = "edit-box kind-text"; + const rep = line.runs.find((r) => (r.text || "").trim()) || line.runs[0]; + box.dataset.index = String(rep.index); + box.dataset.runs = JSON.stringify(line.runs.map((r) => r.index)); + Object.assign(box.style, editBoxStyle(line.rect)); + box.title = composeLineText(line.runs); + box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, rep, line.runs)); + box.addEventListener("click", (e) => { + if (state.editArm) return; + e.stopPropagation(); + selectEditObject(rep.index, line.runs.map((r) => r.index)); + }); + box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(rep); }); + ov.appendChild(box); + }); + + // Ảnh/form: khung theo từng object như cũ (có resize). + state.editObjects.forEach((o) => { + if (o.kind !== "image" && o.kind !== "form") return; + const box = document.createElement("div"); + box.className = "edit-box kind-" + o.kind; box.dataset.index = String(o.index); Object.assign(box.style, editBoxStyle(o.rect)); - box.title = o.kind === "text" ? (o.text || "") : o.kind; - box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, o)); + box.title = o.kind; + box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, o, [o.index])); box.addEventListener("click", (e) => { - if (state.editArm) return; // đang chờ đặt chữ/ảnh → để click rơi xuống overlay + if (state.editArm) return; e.stopPropagation(); - selectEditObject(o.index); + selectEditObject(o.index, [o.index]); }); - if (o.kind === "text") { - box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(o); }); - } ov.appendChild(box); }); refreshEditSelection(); - void img; } // Cập nhật trạng thái chọn TẠI CHỖ (không dựng lại overlay) — quan trọng để @@ -2213,20 +2231,24 @@ function refreshEditSelection() { const isSel = Number(box.dataset.index) === state.editSel; box.classList.toggle("selected", isSel); const existing = box.querySelector(".ed-handle"); - if (isSel && !existing) { - const o = state.editObjects.find((x) => x.index === state.editSel); + const o = state.editObjects.find((x) => x.index === Number(box.dataset.index)); + const resizable = o && (o.kind === "image" || o.kind === "form"); + if (isSel && resizable && !existing) { const h = document.createElement("div"); h.className = "ed-handle"; h.addEventListener("mousedown", (e) => onEditResizeMouseDown(e, o)); box.appendChild(h); - } else if (!isSel && existing) { + } else if ((!isSel || !resizable) && existing) { existing.remove(); } }); } -function selectEditObject(index) { +// Chọn 1 khung. `runIndices`: mọi run thuộc khung (dòng text = nhiều run) — +// thao tác xoá/kéo/đổi thuộc tính áp cho TẤT CẢ. +function selectEditObject(index, runIndices) { state.editSel = index != null && index >= 0 ? index : null; + state.editSelRuns = state.editSel != null ? (runIndices || [state.editSel]) : []; const o = state.editObjects.find((x) => x.index === state.editSel); const isText = o && o.kind === "text"; const isImage = o && o.kind === "image"; @@ -2315,36 +2337,6 @@ function cssFontStack(family) { return `"${family}", ${generic}`; } -// Gom các text run CÙNG DÒNG với `o` (baseline xấp xỉ, liền kề theo chiều -// ngang) — PDF hay cắt 1 dòng nhìn thấy thành nhiều run; Foxit cho sửa cả -// dòng nên ta cũng vậy. Trả về mảng run đã xếp trái→phải (luôn chứa `o`). -function sameLineRuns(o) { - const fs = o.font_size || 12; - const tol = Math.max(1.5, fs * 0.25); - const cands = state.editObjects - .filter( - (x) => - x.kind === "text" && - Math.abs(x.rect.bottom - o.rect.bottom) <= tol && - (x.font_size || 12) <= fs * 2.2 && - (x.font_size || 12) >= fs / 2.2 - ) - .sort((a, b) => a.rect.left - b.rect.left); - const i0 = cands.findIndex((x) => x.index === o.index); - if (i0 < 0) return [o]; - const gapMax = Math.max(fs * 1.2, 6); // hở quá 1.2em coi như cột khác - const runs = [cands[i0]]; - for (let i = i0 - 1; i >= 0; i--) { - if (runs[0].rect.left - cands[i].rect.right <= gapMax) runs.unshift(cands[i]); - else break; - } - for (let i = i0 + 1; i < cands.length; i++) { - if (cands[i].rect.left - runs[runs.length - 1].rect.right <= gapMax) runs.push(cands[i]); - else break; - } - return runs; -} - // Ghép nội dung 1 dòng từ các run: chèn khoảng trắng khi 2 run hở nhau rõ. function composeLineText(runs) { let s = ""; @@ -2360,86 +2352,137 @@ function composeLineText(runs) { return s; } -// Gom ĐOẠN VĂN nhiều dòng quanh run `o` (iteration 3): các dòng baseline cách -// đều (±25%), cỡ chữ tương đồng, giao nhau theo chiều ngang. Trả về mảng dòng -// trên→dưới, mỗi dòng = mảng run trái→phải. -function paragraphLines(o) { - const fs = o.font_size || 12; - const boundsOf = (runs) => ({ - left: Math.min(...runs.map((r) => r.rect.left)), - right: Math.max(...runs.map((r) => r.rect.right)), - bottom: runs[0].rect.bottom, - }); - const overlaps = (a, b) => - Math.min(a.right, b.right) - Math.max(a.left, b.left) > - 0.3 * Math.min(a.right - a.left, b.right - b.left); +// Gom TOÀN BỘ text run của trang thành các DÒNG thị giác. PDF từ Word hay cắt +// 1 dòng thành run theo TỪNG TỪ/KÝ TỰ xen run rỗng, bbox chữ có dấu (Ạ, Ợ) +// tụt thấp hơn hẳn — nên cluster theo GIAO DỌC (≥50% chiều cao nhỏ hơn) thay +// vì so baseline, rồi tách cột theo khoảng hở ngang lớn. Trả mảng dòng +// {runs (trái→phải, gồm cả run rỗng trong tầm), rect (union), fs (cỡ lớn nhất)}. +function clusterTextLines(objs) { + const texts = objs.filter((o) => o.kind === "text"); + const real = texts.filter((o) => o.rect.top - o.rect.bottom > 0.5 && o.rect.right - o.rect.left > 0.1); + const degenerate = texts.filter((o) => !real.includes(o)); // run rỗng/điểm - const base = sameLineRuns(o); - const used = new Set(base.map((r) => r.index)); - const lines = [base]; - const cands = state.editObjects.filter( - (x) => - x.kind === "text" && - !used.has(x.index) && - (x.font_size || 12) <= fs * 1.6 && - (x.font_size || 12) >= fs / 1.6 - ); - let advance = null; // khoảng baseline đã chốt của đoạn - - const extend = (dir) => { - // dir = -1: mở rộng XUỐNG (bottom giảm); +1: mở rộng LÊN. - for (;;) { - const edge = dir < 0 ? lines[lines.length - 1] : lines[0]; - const eb = boundsOf(edge); - let best = null; - let bestGap = Infinity; - for (const r of cands) { - if (used.has(r.index)) continue; - const gap = dir < 0 ? eb.bottom - r.rect.bottom : r.rect.bottom - eb.bottom; - if (gap < fs * 0.6 || gap > fs * 2.6) continue; // quá sát/quá xa = không cùng đoạn - if (advance && (gap < advance * 0.75 || gap > advance * 1.25)) continue; - const line = sameLineRuns(r); - if (!overlaps(eb, boundsOf(line))) continue; - if (gap < bestGap) { - bestGap = gap; - best = line; - } - } - if (!best) break; - best.forEach((r) => used.add(r.index)); - if (dir < 0) lines.push(best); - else lines.unshift(best); - if (advance == null) advance = bestGap; + // (1) Cụm theo giao dọc. + const bands = []; + for (const r of real.slice().sort((a, b) => b.rect.bottom - a.rect.bottom)) { + const h = r.rect.top - r.rect.bottom; + const band = bands.find((bd) => { + const ov = Math.min(bd.top, r.rect.top) - Math.max(bd.bottom, r.rect.bottom); + return ov > 0.5 * Math.min(bd.top - bd.bottom, h); + }); + if (band) { + band.runs.push(r); + band.top = Math.max(band.top, r.rect.top); + band.bottom = Math.min(band.bottom, r.rect.bottom); + } else { + bands.push({ top: r.rect.top, bottom: r.rect.bottom, runs: [r] }); } - }; - extend(-1); - extend(+1); + } + + // (2) Trong mỗi cụm dọc: xếp trái→phải, tách dòng/cột khi hở quá lớn. + const lines = []; + for (const band of bands) { + 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 gapMax = Math.max(fs * 1.7, 10); + if (cur && r.rect.left - cur.rect.right <= gapMax) { + cur.runs.push(r); + cur.rect.right = Math.max(cur.rect.right, r.rect.right); + cur.rect.top = Math.max(cur.rect.top, r.rect.top); + cur.rect.bottom = Math.min(cur.rect.bottom, r.rect.bottom); + cur.fs = Math.max(cur.fs, fs); + } else { + cur = { runs: [r], fs, rect: { left: r.rect.left, right: r.rect.right, top: r.rect.top, bottom: r.rect.bottom } }; + lines.push(cur); + } + } + } + + // (3) Gắn run rỗng vào dòng chứa nó (để khi sửa xoá sạch, không sót). + for (const d of degenerate) { + const line = lines.find( + (l) => + d.rect.bottom >= l.rect.bottom - 3 && + d.rect.bottom <= l.rect.top + 3 && + d.rect.left >= l.rect.left - 6 && + d.rect.left <= l.rect.right + 6 + ); + if (line) line.runs.push(d); + } + for (const l of lines) l.runs.sort((a, b) => a.rect.left - b.rect.left); return lines; } -// Double-click: đoạn ≥2 dòng → sửa CẢ ĐOẠN với reflow; 1 dòng → sửa dòng. -function startTextEdit(o) { - const lines = paragraphLines(o); - if (lines.length >= 2) startBlockTextEdit(o, lines); - else startInlineTextEdit(o); +// Gom ĐOẠN VĂN nhiều dòng quanh run `o`: các dòng kề nhau theo chiều dọc +// (khoảng hở ≤ 1.3× chiều cao dòng), giao ngang ≥30%, cỡ chữ tương đồng +// (≤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. +function paragraphLines(o) { + const all = clusterTextLines(state.editObjects); + const mine = all.find((l) => l.runs.some((r) => r.index === o.index)); + if (!mine) return [ { runs: [o], fs: o.font_size || 12, rect: { ...o.rect } } ]; + + const hOf = (l) => Math.max(1, l.rect.top - l.rect.bottom); + const overlaps = (a, b) => + Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left) > + 0.3 * Math.min(a.rect.right - a.rect.left, b.rect.right - b.rect.left); + const sizeOk = (a, b) => { + const r = a.fs / b.fs; + return r <= 1.8 && r >= 1 / 1.8; + }; + + const sorted = all.slice().sort((a, b) => b.rect.bottom - a.rect.bottom); // trên→dưới + const iMine = sorted.indexOf(mine); + const block = [mine]; + let advance = null; + // Mở rộng XUỐNG rồi LÊN, khoá nhịp khoảng cách dòng ±35% sau lần đầu. + const tryDir = (step) => { + let edge = mine; + for (let i = iMine + step; i >= 0 && i < sorted.length; i += step) { + const cand = sorted[i]; + 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)); + if (gap < -3 || gap > maxGap) break; + if (!overlaps(edge, cand) || !sizeOk(edge, cand)) break; + const adv = Math.abs(edge.rect.bottom - cand.rect.bottom); + if (advance && (adv < advance * 0.65 || adv > advance * 1.35)) break; + if (advance == null) advance = adv; + if (step > 0) block.push(cand); + else block.unshift(cand); + edge = cand; + } + }; + tryDir(+1); + tryDir(-1); + return block.map((l) => l); // mỗi phần tử: {runs, fs, rect} } -// Sửa cả đoạn "như Word" (Foxit Edit Text): textarea phủ khối, gõ tự chảy -// dòng; commit → engine reflow (bẻ dòng lại theo bề rộng khối, giữ font). +// Double-click vào chữ: LUÔN mở ô sửa CẢ ĐOẠN (kể cả đoạn 1 dòng) — một +// đường code duy nhất, giữ nguyên cấu trúc xuống dòng, commit qua reflow. +function startTextEdit(o) { + startBlockTextEdit(o, paragraphLines(o)); +} + +// Sửa cả đoạn "như Word" (Foxit Edit Text): textarea phủ kín khối (che chữ +// cũ bên dưới), MỖI DÒNG GỐC = 1 dòng textarea (giữ xuống dòng — sửa được cả +// vị trí ngắt dòng); click trong ô = di chuyển con trỏ; commit → engine +// reflow: xoá SẠCH mọi run của khối (kể cả run sót — engine tự nở theo bbox), +// giữ font + giữ căn giữa nếu khối gốc căn giữa. function startBlockTextEdit(o, lines) { const ov = $("editOverlay"); const existing = ov.querySelector(".edit-inline"); if (existing) existing.remove(); - const allRuns = lines.flat(); + const allRuns = lines.flatMap((l) => l.runs); const union = { - left: Math.min(...allRuns.map((r) => r.rect.left)), - right: Math.max(...allRuns.map((r) => r.rect.right)), - bottom: Math.min(...allRuns.map((r) => r.rect.bottom)), - top: Math.max(...allRuns.map((r) => r.rect.top)), + left: Math.min(...lines.map((l) => l.rect.left)), + right: Math.max(...lines.map((l) => l.rect.right)), + bottom: Math.min(...lines.map((l) => l.rect.bottom)), + top: Math.max(...lines.map((l) => l.rect.top)), }; - // Đoạn = 1 dòng chảy; Enter của người dùng = ngắt cứng. - const original = lines.map(composeLineText).join(" "); + // Giữ đúng cấu trúc dòng gốc: mỗi dòng thị giác = 1 dòng trong ô sửa. + const original = lines.map((l) => composeLineText(l.runs)).join("\n"); const ta = document.createElement("textarea"); ta.className = "edit-inline edit-inline-block"; @@ -2447,11 +2490,14 @@ function startBlockTextEdit(o, lines) { Object.assign(ta.style, editBoxStyle(union)); const advPt = lines.length > 1 - ? lines[0][0].rect.bottom - lines[1][0].rect.bottom + ? Math.abs(lines[0].rect.bottom - lines[1].rect.bottom) : (o.font_size || 12) * 1.25; const advPx = Math.max(8, advPt * state.editScale); - // Chừa 2 dòng trống để gõ thêm. - ta.style.height = parseFloat(ta.style.height) + 2 * advPx + "px"; + // Cao đủ số dòng + 1 dòng trống để gõ thêm; rộng thêm chút để gõ dài hơn. + ta.style.height = (lines.length + 1) * advPx + 6 + "px"; + const p = state.pages[state.editPage]; + const maxW = (p.widthPt - union.left) * state.editScale - 4; + ta.style.width = Math.min(maxW, parseFloat(ta.style.width) + 60) + "px"; ta.style.lineHeight = advPx + "px"; ta.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px"; ta.style.fontFamily = cssFontStack(o.font_family); @@ -2461,8 +2507,9 @@ function startBlockTextEdit(o, lines) { ov.appendChild(ta); ta.focus(); ta.setSelectionRange(0, 0); + ta.scrollIntoView({ block: "center", behavior: "instant" }); $("editHint").textContent = - "Sửa cả đoạn — Enter: xuống dòng cứng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ"; + "Sửa cả đoạn — Enter: xuống dòng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ"; let done = false; const commit = (save) => { @@ -2485,102 +2532,44 @@ function startBlockTextEdit(o, lines) { else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); } e.stopPropagation(); }); + // Click/chọn chữ TRONG ô sửa không được lan xuống overlay (gây thoát edit). + ta.addEventListener("mousedown", (e) => e.stopPropagation()); + ta.addEventListener("click", (e) => e.stopPropagation()); + ta.addEventListener("dblclick", (e) => e.stopPropagation()); ta.addEventListener("blur", () => commit(true)); } -// Sửa text tại chỗ kiểu Foxit: gộp cả dòng, ô sửa hiển thị đúng font/cỡ/màu, -// Enter/blur commit — engine GIỮ FONT GỐC (chỉ thay khi thiếu glyph). -function startInlineTextEdit(o) { - const ov = $("editOverlay"); - const existing = ov.querySelector(".edit-inline"); - if (existing) existing.remove(); - - const runs = sameLineRuns(o); - const original = composeLineText(runs); - const union = { - left: Math.min(...runs.map((r) => r.rect.left)), - right: Math.max(...runs.map((r) => r.rect.right)), - bottom: Math.min(...runs.map((r) => r.rect.bottom)), - top: Math.max(...runs.map((r) => r.rect.top)), - }; - - const inp = document.createElement("input"); - inp.className = "edit-inline"; - inp.value = original; - const st = editBoxStyle(union); - Object.assign(inp.style, st); - // Chừa chỗ gõ thêm về bên phải (không tràn khung trang). - const p = state.pages[state.editPage]; - const maxW = (p.widthPt - union.left) * state.editScale - 4; - inp.style.width = Math.min(maxW, parseFloat(st.width) + 220) + "px"; - // WYSIWYG khi gõ: đúng font/cỡ/màu/kiểu của run đầu. - inp.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px"; - inp.style.fontFamily = cssFontStack(o.font_family); - inp.style.fontWeight = o.font_bold ? "bold" : "normal"; - inp.style.fontStyle = o.font_italic ? "italic" : "normal"; - if (o.color) inp.style.color = rgbCss(o.color); - ov.appendChild(inp); - inp.focus(); - inp.select(); - - let done = false; - const commit = (save) => { - if (done) return; - done = true; - const text = inp.value; - inp.remove(); - if (save && text !== original) { - // Run đầu nhận toàn bộ text mới (giữ font/cỡ/màu gốc — mọi field null), - // các run còn lại của dòng bị xoá. - const ops = [ - { - op: "setText", - index: runs[0].index, - text, - fontSize: null, - color: null, - fontFamily: null, - bold: null, - italic: null, - }, - ...runs.slice(1).map((r) => ({ op: "delete", index: r.index })), - ]; - stageEditOps(ops); - } - }; - inp.addEventListener("keydown", (e) => { - if (e.key === "Enter") { e.preventDefault(); commit(true); } - else if (e.key === "Escape") { e.preventDefault(); commit(false); } - e.stopPropagation(); - }); - inp.addEventListener("blur", () => commit(true)); -} - -// Đổi thuộc tính chữ cho run đang chọn. `part` chỉ chứa field muốn đổi — -// mọi field khác gửi null = GIỮ NGUYÊN (engine không đụng font khi không cần). +// Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung +// từng run), `part` chỉ chứa field muốn đổi — field khác null = GIỮ NGUYÊN. function applyTextPropToSelected(part) { - const o = state.editObjects.find((x) => x.index === state.editSel); - if (!o || o.kind !== "text") return; - stageEditOp({ - op: "setText", - index: o.index, - text: o.text || "", - fontSize: null, - color: null, - fontFamily: null, - bold: null, - italic: null, - ...part, - }); + const runs = state.editSelRuns + .map((i) => state.editObjects.find((x) => x.index === i)) + .filter((x) => x && x.kind === "text"); + if (!runs.length) return; + stageEditOps( + runs.map((r) => ({ + op: "setText", + index: r.index, + text: r.text || "", + fontSize: null, + color: null, + fontFamily: null, + bold: null, + italic: null, + ...part, + })) + ); } function deleteSelectedEditObject() { - if (state.editSel == null) return; - stageEditOp({ op: "delete", index: state.editSel }); + if (!state.editSelRuns.length) return; + stageEditOps(state.editSelRuns.map((i) => ({ op: "delete", index: i }))); } // Click lên trang khi đang "armed" để đặt chữ/ảnh mới. function onEditStageClick(e) { + // Click vào ô sửa đang mở (di chuyển con trỏ) — không được đụng overlay. + if (e.target.closest && e.target.closest(".edit-inline")) return; if (!state.editArm) { selectEditObject(-1); state.editSel = null; buildEditOverlay(); return; } const img = $("editImg"); const r = img.getBoundingClientRect(); @@ -2645,12 +2634,13 @@ function promptAddText(pdfX, pdfY) { inp.addEventListener("blur", () => commit(true)); } -// Kéo để di chuyển object đang chọn — box đi theo con trỏ NGAY (live feedback -// như Foxit), thả chuột mới commit vào engine. -function onEditBoxMouseDown(e, o) { +// Kéo để di chuyển khung đang chọn (dòng text = kéo CẢ DÒNG) — box đi theo +// con trỏ NGAY (live feedback như Foxit), thả chuột mới commit vào engine. +function onEditBoxMouseDown(e, o, runs) { if (state.editArm) return; if (e.target.classList.contains("ed-handle")) return; - selectEditObject(o.index); + const runIndices = (runs && runs.length ? runs : [o]).map((r) => (typeof r === "number" ? r : r.index)); + selectEditObject(o.index, runIndices); const box = e.currentTarget; const startX = e.clientX, startY = e.clientY; const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top); @@ -2673,7 +2663,7 @@ function onEditBoxMouseDown(e, o) { if (!moved) return; const dx = (ev.clientX - startX) / state.editScale; const dy = -(ev.clientY - startY) / state.editScale; // CSS y xuống = PDF y giảm - stageEditOp({ op: "transform", index: o.index, dx, dy, sx: 1, sy: 1 }); + stageEditOps(runIndices.map((i) => ({ op: "transform", index: i, dx, dy, sx: 1, sy: 1 }))); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); @@ -3453,6 +3443,18 @@ $("edColorBtn").addEventListener("click", () => { }); $("edSave").addEventListener("click", saveEdits); $("editOverlay").addEventListener("click", onEditStageClick); +// Đúp vào KHE giữa các khung (PDF cắt run rời rạc) vẫn mở ô sửa tại chữ gần đó. +$("editOverlay").addEventListener("dblclick", (e) => { + if (state.editArm) return; + if (e.target.closest && e.target.closest(".edit-inline")) return; + const img = $("editImg"); + const r = img.getBoundingClientRect(); + const p = state.pages[state.editPage]; + const x = (e.clientX - r.left) / state.editScale; + const y = p.heightPt - (e.clientY - r.top) / state.editScale; + const hit = pickTextAt(state.editObjects, x, y); + if (hit) startTextEdit(hit); +}); // Phím tắt chuẩn của trình xem PDF (Foxit/Adobe): điều hướng trang, zoom, // tìm kiếm, xoá/bỏ chọn annotation. diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index 14171fb..a043b2d 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -277,6 +277,8 @@ struct ReflowPlan { /// Khối: lề trái + bề rộng bẻ dòng (điểm PDF). left: f32, width: f32, + /// Khối gốc căn giữa → dòng mới đặt x = tâm khối − w/2. + centered: bool, /// Baseline dòng đầu (y của gốc text) + khoảng cách baseline giữa các dòng. first_baseline: f32, line_advance: f32, @@ -447,11 +449,11 @@ pub fn apply_edits( left: f32, right: f32, } - let mut geos: Vec = Vec::new(); - let mut old_text_all = String::new(); - for &i in &idxs { + let geo_of = |i: u16| -> Result, EngineError> { let obj = page.objects().get(i as usize).map_err(err)?; - let Some(t) = obj.as_text_object() else { continue }; + if obj.as_text_object().is_none() { + return Ok(None); + } let m = obj.matrix().map_err(err)?; let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { left: m.e(), @@ -459,14 +461,72 @@ pub fn apply_edits( right: m.e(), top: m.f(), }); - old_text_all.push_str(&t.text()); - old_text_all.push(' '); - geos.push(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right }); + Ok(Some(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right })) + }; + let mut geos: Vec = Vec::new(); + for &i in &idxs { + if let Some(g) = geo_of(i)? { + geos.push(g); + } } if geos.is_empty() { continue; } - idxs = geos.iter().map(|g| g.idx).collect(); + + // NỞ danh sách run theo bbox khối (an toàn — chống sót): PDF từ + // Word hay cắt 1 dòng thành run theo TỪNG TỪ/KÝ TỰ xen run rỗng, + // bbox chữ có dấu tụt thấp — UI có thể sót vài run. Mọi text + // object có TÂM nằm trong bbox khối (nới 2pt) đều thuộc khối → + // đưa hết vào để xoá sạch, không còn chữ cũ đè dưới chữ mới. + { + let mut bb = Rect { + left: f32::INFINITY, + bottom: f32::INFINITY, + right: f32::NEG_INFINITY, + top: f32::NEG_INFINITY, + }; + for &i in &idxs { + let obj = page.objects().get(i as usize).map_err(err)?; + if obj.as_text_object().is_none() { + continue; + } + if let Ok(q) = obj.bounds() { + let r = quad_to_rect(&q); + bb.left = bb.left.min(r.left); + bb.bottom = bb.bottom.min(r.bottom); + bb.right = bb.right.max(r.right); + bb.top = bb.top.max(r.top); + } + } + let pad = 2.0; + for i in 0..obj_count as u16 { + if idxs.contains(&i) { + continue; + } + let obj = page.objects().get(i as usize).map_err(err)?; + if obj.as_text_object().is_none() { + continue; + } + let (cx, cy) = match obj.bounds() { + Ok(q) => { + let r = quad_to_rect(&q); + ((r.left + r.right) / 2.0, (r.bottom + r.top) / 2.0) + } + Err(_) => continue, + }; + if cx >= bb.left - pad + && cx <= bb.right + pad + && cy >= bb.bottom - pad + && cy <= bb.top + pad + { + idxs.push(i); + if let Some(g) = geo_of(i)? { + geos.push(g); + } + } + } + idxs.sort_unstable(); + } // Run neo: baseline cao nhất, trái nhất. let anchor_idx = geos .iter() @@ -508,6 +568,33 @@ pub fn apply_edits( let right = geos.iter().map(|g| g.right).fold(f32::NEG_INFINITY, f32::max); let width = (right - left).max(20.0); + // Phát hiện CĂN GIỮA (tiêu đề...): mọi dòng gốc có tâm ≈ tâm + // khối và có ít nhất 1 dòng thụt vào so với lề trái → các dòng + // mới cũng đặt căn giữa (giữ bố cục như Foxit). + let block_center = (left + right) / 2.0; + let centered = { + let tol = (width * 0.02).max(2.0); + let mut any_indented = false; + let mut all_centered = true; + for &bl in &baselines { + let (mut l, mut r) = (f32::INFINITY, f32::NEG_INFINITY); + for g in geos.iter().filter(|g| (g.f - bl).abs() <= 1.0) { + l = l.min(g.left); + r = r.max(g.right); + } + if !l.is_finite() || r - l < 1.0 { + continue; + } + if ((l + r) / 2.0 - block_center).abs() > tol { + all_centered = false; + } + if l - left > tol { + any_indented = true; + } + } + all_centered && any_indented + }; + // Thang font cho các dòng mới (giữ font như SetText): // (1) font NHÚNG parse được + phủ đủ glyph → nhúng lại chính bytes đó // (same glyphs — trông y hệt); @@ -546,6 +633,7 @@ pub fn apply_edits( measure_bytes, left, width, + centered, first_baseline, line_advance, linear: (m.a(), m.b(), m.c(), m.d()), @@ -841,6 +929,13 @@ pub fn apply_edits( continue; // đoạn trống vẫn chiếm 1 nhịp baseline } let y = plan.first_baseline - (i as f32) * plan.line_advance; + // Khối căn giữa → từng dòng mới cũng căn giữa theo tâm khối. + let x = if plan.centered { + let w: f32 = line.chars().map(&measure).sum(); + (plan.left + plan.width / 2.0 - w / 2.0).max(plan.left) + } else { + plan.left + }; let mut obj = page .objects_mut() .create_text_object( @@ -851,8 +946,8 @@ pub fn apply_edits( PdfPoints::new(plan.tf), ) .map_err(err)?; - // Giữ scale/nghiêng của run neo; đặt gốc dòng tại (left, y). - obj.apply_matrix(PdfMatrix::new(a, b, c2, d, plan.left, y)) + // Giữ scale/nghiêng của run neo; đặt gốc dòng tại (x, y). + obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y)) .map_err(err)?; obj.set_fill_color(plan.color).map_err(err)?; } diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index 9b978bd..51e75bd 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -740,6 +740,138 @@ the declared standard Helvetica base font untouched."; assert!(norm.contains("standard Helvetica base font untouched"), "round-trip: {norm:?}"); } +/// Engine tự NỞ danh sách run theo bbox khối: dòng bị cắt làm 3 run, UI chỉ +/// gửi run ĐẦU + CUỐI (sót run giữa — như run rỗng/lệch bbox của Word) — +/// run giữa vẫn bị thay sạch, không còn chữ cũ đè dưới chữ mới. +#[test] +fn reflow_expands_indices_to_cover_block() { + let pdf = pdfium(); + let fx = tmp("ff_reflow_exp_fx.pdf"); + let out = tmp("ff_reflow_exp_out.pdf"); + let mk = |x: f32, s: &str| EditOp::AddText { + x, + y: 400.0, + text: s.into(), + font_size: 14.0, + color: [0, 0, 0, 255], + font_family: None, + bold: false, + italic: false, + }; + // 1 dòng thị giác bị cắt thành 3 run sát nhau (kiểu Word). + ff_engine::apply_edits( + &pdf, + &sample(), + 0, + &[mk(60.0, "Mảnh một"), mk(125.0, "mảnh hai"), mk(188.0, "mảnh ba.")], + &fx, + None, + ) + .expect("fixture 3 mảnh"); + let first = find_text_index(&pdf, &fx, "Mảnh một"); + let third = find_text_index(&pdf, &fx, "mảnh ba"); + + ff_engine::apply_edits( + &pdf, + &fx, + 0, + &[EditOp::ReflowText { indices: vec![first, third], text: "Dòng đã thay hoàn toàn".into() }], + &out, + None, + ) + .expect("reflow partial"); + + let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); + let norm = text.split_whitespace().collect::>().join(" "); + assert!(norm.contains("Dòng đã thay hoàn toàn"), "text mới: {norm:?}"); + assert!(!norm.contains("mảnh hai"), "run GIỮA bị sót phải được nở vào và xoá: {norm:?}"); + assert!(!norm.contains("Mảnh một"), "run đã đưa phải bị thay: {norm:?}"); +} + +/// Khối gốc CĂN GIỮA (dòng ngắn thụt vào, tâm trùng nhau) → các dòng mới cũng +/// đặt căn giữa theo tâm khối (không dồn hết về lề trái). +#[test] +fn reflow_preserves_centered_alignment() { + let pdf = pdfium(); + let probe = tmp("ff_center_probe.pdf"); + let fx = tmp("ff_center_fx.pdf"); + let out = tmp("ff_center_out.pdf"); + let mk = |x: f32, y: f32, s: &str| EditOp::AddText { + x, + y, + text: s.into(), + font_size: 16.0, + color: [0, 0, 0, 255], + font_family: None, + bold: false, + italic: false, + }; + // Đo bề rộng thật 2 dòng để dựng fixture căn giữa quanh tâm 300. + ff_engine::apply_edits( + &pdf, + &sample(), + 0, + &[mk(10.0, 500.0, "TIÊU ĐỀ DÀI Ở DÒNG TRÊN"), mk(10.0, 460.0, "DÒNG DƯỚI NGẮN")], + &probe, + None, + ) + .expect("probe"); + let objs = ff_engine::list_objects(&pdf, &probe, 0, None).expect("list probe"); + let w = |needle: &str| { + let o = objs + .iter() + .find(|o| o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false)) + .expect(needle); + o.rect.right - o.rect.left + }; + let (w1, w2) = (w("DÒNG TRÊN"), w("DÒNG DƯỚI")); + let cx = 300.0; + ff_engine::apply_edits( + &pdf, + &sample(), + 0, + &[ + mk(cx - w1 / 2.0, 500.0, "TIÊU ĐỀ DÀI Ở DÒNG TRÊN"), + mk(cx - w2 / 2.0, 480.0, "DÒNG DƯỚI NGẮN"), + ], + &fx, + None, + ) + .expect("fixture centered"); + let idxs: Vec = ff_engine::list_objects(&pdf, &fx, 0, None) + .expect("list fx") + .into_iter() + .filter(|o| o.text.as_deref().map(|t| t.contains("DÒNG")).unwrap_or(false)) + .map(|o| o.index) + .collect(); + assert_eq!(idxs.len(), 2); + + ff_engine::apply_edits( + &pdf, + &fx, + 0, + &[EditOp::ReflowText { + indices: idxs, + text: "TIÊU ĐỀ MỚI DÀI HƠN CHÚT\nNGẮN".into(), + }], + &out, + None, + ) + .expect("reflow centered"); + + let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out"); + let mut checked = 0; + for o in &after { + let Some(t) = &o.text else { continue }; + if t.contains("TIÊU ĐỀ MỚI") || t.trim() == "NGẮN" { + let c = (o.rect.left + o.rect.right) / 2.0; + assert!((c - cx).abs() < 6.0, "dòng mới phải căn giữa quanh {cx}: center={c} text={t:?}"); + checked += 1; + } + } + assert_eq!(checked, 2, "phải kiểm được 2 dòng mới"); +} + #[test] fn add_image_adds_image_object() { let pdf = pdfium(); diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 86ff81c..5d34b16 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -46,6 +46,20 @@ dài → nhiều dòng, **BaseFont vẫn "Helvetica" chuẩn** (builtin, không nhúng). - Unit `wrap_lines`: greedy theo từ / ngắt cứng + dòng rỗng / cắt từ quá dài. +### Vá theo phản hồi file thật (Word-export, run cắt theo từ/ký tự) +Kiểm với PDF thật xuất từ Word (tiêu đề 2 dòng bị cắt thành 41 run per-word/ +per-char xen run rỗng, cỡ 22.5pt + 20pt, bbox chữ có dấu tụt thấp): +- **UI cluster DÒNG theo GIAO DỌC** (≥50% chiều cao nhỏ) thay vì so baseline — + miễn nhiễm dấu tiếng Việt; overlay 1 khung/dòng (file thật: 231 ô → 18 khung); + chọn/kéo/xoá/đổi thuộc tính tác động CẢ DÒNG (batch ops). +- **Đúp mở CẢ ĐOẠN giữ xuống dòng** (`\n` giữa các dòng gốc — sửa được cả vị + trí ngắt dòng); đúp trúng KHE giữa các từ vẫn mở (hit-test theo điểm); + click trong ô sửa di chuyển con trỏ, không thoát edit; ô che kín chữ cũ. +- **Engine ReflowText tự NỞ danh sách run theo bbox khối** (tâm nằm trong + union + 2pt) — không bao giờ sót run rỗng/lệch bbox → hết cảnh chữ mới đè + chữ cũ; **phát hiện khối căn giữa** (tâm các dòng trùng tâm khối) → dòng mới + giữ căn giữa. 2 test hồi quy mới (nở indices, giữ căn giữa) — 19 test edit. + ### 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). diff --git a/docs/13-phase4-user-tests.md b/docs/13-phase4-user-tests.md index cad4340..b5e9696 100644 --- a/docs/13-phase4-user-tests.md +++ b/docs/13-phase4-user-tests.md @@ -41,6 +41,15 @@ cargo run | E24 | Sửa text tiếng Việt trên file có font nhúng (file Word xuất PDF) | Đúng dấu **và giữ nguyên font nhúng** (mở lại ở Foxit xem tên font trong Edit) | | | E25 | Sửa nhiều lần rồi thoát chế độ sửa (không lưu) | Không còn file `ff_edit_*.pdf` rác trong thư mục %TEMP% | | +## Bổ sung — Sửa đoạn hoàn chỉnh với PDF Word-export (khắc phục theo phản hồi thật) +| # | Thao tác | Kỳ vọng | KQ | +|---|----------|---------|----| +| E34 | Mở PDF xuất từ Word (tiêu đề bị cắt thành từng từ/ký tự), bật chế độ Sửa | Overlay hiện **1 khung mỗi DÒNG** (không phải hàng chục ô nhỏ từng từ) | | +| E35 | Đúp vào tiêu đề 2 dòng (kể cả đúp vào KHE giữa 2 từ) | Ô sửa mở cho **CẢ ĐOẠN 2 DÒNG, giữ nguyên xuống dòng** | | +| E36 | Trong ô sửa: click chuột chỗ khác của đoạn để đổi vị trí con trỏ | Con trỏ di chuyển bình thường, **KHÔNG thoát edit** | | +| E37 | Sửa vài chữ rồi Ctrl+Enter | Chữ mới thay SẠCH chữ cũ (không chồng chữ), tiêu đề căn giữa **vẫn căn giữa** | | +| E38 | Khi ô sửa đang mở | Ô che kín chữ cũ bên dưới (không thấy 2 lớp chữ) | | + ## Bổ sung — Đúp chuột vào chữ từ viewer thường (chuẩn Foxit Editor) | # | Thao tác | Kỳ vọng | KQ | |---|----------|---------|----|