Sửa edit inline với PDF Word-export: khung theo dòng, sửa cả đoạn giữ xuống dòng, hết chồng chữ
Chẩn đoán trên file thật (Word xuất): tiêu đề 2 dòng bị cắt thành 41 run per-word/per-char xen run rỗng kích thước 0, cỡ 22.5pt + 20pt trộn nhau, bbox chữ có dấu (Ạ, Ợ) tụt thấp hơn hẳn chữ thường. UI (main.js): - clusterTextLines: gom run thành DÒNG theo GIAO DỌC ≥50% chiều cao nhỏ (miễn nhiễm dấu tiếng Việt) + tách cột theo hở ngang; gắn run rỗng vào dòng chứa nó. Overlay vẽ 1 khung/DÒNG (file thật: 231 ô vụn → 18 khung); chọn/kéo/xoá/đổi thuộc tính tác động CẢ DÒNG (batch ops, editSelRuns) - paragraphLines mới trên nền cluster: dòng kề nhau (hở ≤1.3× chiều cao), giao ngang ≥30%, cỡ chữ ≤1.8× (tiêu đề 22.5/20pt vẫn 1 đoạn), khoá nhịp ±35% - Đúp LUÔN mở ô sửa CẢ ĐOẠN, mỗi dòng gốc = 1 dòng textarea (giữ và sửa được cả xuống dòng); đúp trúng KHE giữa các từ vẫn mở (hit-test theo điểm trên overlay); click/chọn chữ trong ô sửa di chuyển con trỏ, KHÔNG thoát edit (stopPropagation + guard .edit-inline ở onEditStageClick); ô che kín chữ cũ, line-height đúng nhịp dòng, scrollIntoView Engine (edit.rs ReflowText): - TỰ NỞ danh sách run: mọi text object có tâm trong union bbox (+2pt) của các run được đưa đều bị thay — không bao giờ sót run rỗng/lệch bbox → hết cảnh chữ mới chồng chữ cũ - Phát hiện khối CĂN GIỮA (tâm mọi dòng ≈ tâm khối, có dòng thụt trái) → dòng mới đặt x = tâm khối − w/2 (đo hmtx) — tiêu đề căn giữa giữ căn giữa Kiểm chứng với file thật (cục bộ): 39 run tiêu đề xoá sạch 0 mảnh sót, 2 dòng mới căn giữa lệch tâm trang <5pt, extract sạch chữ cũ; grouping đúng khi đúp mọi từ của cả 2 dòng lẫn header 8pt per-char. Test: 2 test hồi quy mới (nở indices bắt run giữa bị sót; giữ căn giữa đo bằng fixture tự căn) → edit 19/19, toàn bộ engine 75/75 ngoài qpdf-fixture. Docs: 12 (mục vá theo phản hồi), 13 (E34–E38). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
+210
-208
@@ -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.
|
||||
|
||||
+105
-10
@@ -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<RunGeo> = Vec::new();
|
||||
let mut old_text_all = String::new();
|
||||
for &i in &idxs {
|
||||
let geo_of = |i: u16| -> Result<Option<RunGeo>, 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<RunGeo> = 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)?;
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>().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<u16> = 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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|---|----------|---------|----|
|
||||
|
||||
Reference in New Issue
Block a user