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:
2026-07-16 10:49:59 +00:00
co-authored by Claude Fable 5
parent 77ed917913
commit c8f22b091f
5 changed files with 470 additions and 218 deletions
+201 -199
View File
@@ -33,7 +33,8 @@ const state = {
editUndo: [], // stack đường dẫn editBase trước mỗi op editUndo: [], // stack đường dẫn editBase trước mỗi op
editRedo: [], editRedo: [],
editObjects: [], // ObjectInfo của editPage (đọc lại từ editBase sau mỗi op) 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 editScale: 1, // px/pt của ảnh stage
editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh) editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
@@ -2181,28 +2182,45 @@ function buildEditOverlay() {
const ov = $("editOverlay"); const ov = $("editOverlay");
ov.innerHTML = ""; ov.innerHTML = "";
ov.classList.toggle("armed", !!state.editArm); 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ị. // TEXT: 1 khung mỗi DÒNG thị giác (như Foxit) — không vẽ ô chi chít từng
state.editObjects.forEach((o) => { // 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.
if (o.kind === "path" || o.kind === "shading") return; // path/shading: chưa cho chọn ở v1 const lines = clusterTextLines(state.editObjects);
lines.forEach((line) => {
const box = document.createElement("div"); 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); box.dataset.index = String(o.index);
Object.assign(box.style, editBoxStyle(o.rect)); Object.assign(box.style, editBoxStyle(o.rect));
box.title = o.kind === "text" ? (o.text || "") : o.kind; box.title = o.kind;
box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, o)); box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, o, [o.index]));
box.addEventListener("click", (e) => { 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(); 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); ov.appendChild(box);
}); });
refreshEditSelection(); 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 để // 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; const isSel = Number(box.dataset.index) === state.editSel;
box.classList.toggle("selected", isSel); box.classList.toggle("selected", isSel);
const existing = box.querySelector(".ed-handle"); const existing = box.querySelector(".ed-handle");
if (isSel && !existing) { const o = state.editObjects.find((x) => x.index === Number(box.dataset.index));
const o = state.editObjects.find((x) => x.index === state.editSel); const resizable = o && (o.kind === "image" || o.kind === "form");
if (isSel && resizable && !existing) {
const h = document.createElement("div"); const h = document.createElement("div");
h.className = "ed-handle"; h.className = "ed-handle";
h.addEventListener("mousedown", (e) => onEditResizeMouseDown(e, o)); h.addEventListener("mousedown", (e) => onEditResizeMouseDown(e, o));
box.appendChild(h); box.appendChild(h);
} else if (!isSel && existing) { } else if ((!isSel || !resizable) && existing) {
existing.remove(); 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.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 o = state.editObjects.find((x) => x.index === state.editSel);
const isText = o && o.kind === "text"; const isText = o && o.kind === "text";
const isImage = o && o.kind === "image"; const isImage = o && o.kind === "image";
@@ -2315,36 +2337,6 @@ function cssFontStack(family) {
return `"${family}", ${generic}`; 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õ. // 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) { function composeLineText(runs) {
let s = ""; let s = "";
@@ -2360,86 +2352,137 @@ function composeLineText(runs) {
return s; return s;
} }
// Gom ĐOẠN VĂN nhiều dòng quanh run `o` (iteration 3): các dòng baseline cách // Gom TOÀN BỘ text run của trang thành các DÒNG thị giác. PDF từ Word hay cắt
// đều (±25%), cỡ chữ tương đồng, giao nhau theo chiều ngang. Trả về mảng dòng // 1 dòng thành run theo TỪNG TỪ/KÝ TỰ xen run rỗng, bbox chữ có dấu (Ạ, Ợ)
// trên→dưới, mỗi dòng = mảng run trái→phải. // tụt thấp hơn hẳn — nên cluster theo GIAO DỌC (≥50% chiều cao nhỏ hơn) thay
function paragraphLines(o) { // vì so baseline, rồi tách cột theo khoảng hở ngang lớn. Trả mảng dòng
const fs = o.font_size || 12; // {runs (trái→phải, gồm cả run rỗng trong tầm), rect (union), fs (cỡ lớn nhất)}.
const boundsOf = (runs) => ({ function clusterTextLines(objs) {
left: Math.min(...runs.map((r) => r.rect.left)), const texts = objs.filter((o) => o.kind === "text");
right: Math.max(...runs.map((r) => r.rect.right)), const real = texts.filter((o) => o.rect.top - o.rect.bottom > 0.5 && o.rect.right - o.rect.left > 0.1);
bottom: runs[0].rect.bottom, const degenerate = texts.filter((o) => !real.includes(o)); // run rỗng/điểm
// (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);
}); });
const overlaps = (a, b) => if (band) {
Math.min(a.right, b.right) - Math.max(a.left, b.left) > band.runs.push(r);
0.3 * Math.min(a.right - a.left, b.right - b.left); 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] });
}
}
const base = sameLineRuns(o); // (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 used = new Set(base.map((r) => r.index)); const lines = [];
const lines = [base]; for (const band of bands) {
const cands = state.editObjects.filter( band.runs.sort((a, b) => a.rect.left - b.rect.left);
(x) => let cur = null;
x.kind === "text" && for (const r of band.runs) {
!used.has(x.index) && const fs = r.font_size || 12;
(x.font_size || 12) <= fs * 1.6 && const gapMax = Math.max(fs * 1.7, 10);
(x.font_size || 12) >= fs / 1.6 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
); );
let advance = null; // khoảng baseline đã chốt của đoạn if (line) line.runs.push(d);
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;
} }
} for (const l of lines) l.runs.sort((a, b) => a.rect.left - b.rect.left);
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;
}
};
extend(-1);
extend(+1);
return lines; return lines;
} }
// Double-click: đoạn ≥2 dòng → sửa CẢ ĐOẠN với reflow; 1 dòng → sửa dòng. // Gom ĐOẠN VĂN nhiều dòng quanh run `o`: các dòng kề nhau theo chiều dọc
function startTextEdit(o) { // (khoảng hở ≤ 1.3× chiều cao dòng), giao ngang ≥30%, cỡ chữ tương đồng
const lines = paragraphLines(o); // (≤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.
if (lines.length >= 2) startBlockTextEdit(o, lines); function paragraphLines(o) {
else startInlineTextEdit(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 // Double-click vào chữ: LUÔN mở ô sửa CẢ ĐOẠN (kể cả đoạn 1 dòng) — một
// dòng; commit → engine reflow (bẻ dòng lại theo bề rộng khối, giữ font). // đườ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) { function startBlockTextEdit(o, lines) {
const ov = $("editOverlay"); const ov = $("editOverlay");
const existing = ov.querySelector(".edit-inline"); const existing = ov.querySelector(".edit-inline");
if (existing) existing.remove(); if (existing) existing.remove();
const allRuns = lines.flat(); const allRuns = lines.flatMap((l) => l.runs);
const union = { const union = {
left: Math.min(...allRuns.map((r) => r.rect.left)), left: Math.min(...lines.map((l) => l.rect.left)),
right: Math.max(...allRuns.map((r) => r.rect.right)), right: Math.max(...lines.map((l) => l.rect.right)),
bottom: Math.min(...allRuns.map((r) => r.rect.bottom)), bottom: Math.min(...lines.map((l) => l.rect.bottom)),
top: Math.max(...allRuns.map((r) => r.rect.top)), 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. // 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(composeLineText).join(" "); const original = lines.map((l) => composeLineText(l.runs)).join("\n");
const ta = document.createElement("textarea"); const ta = document.createElement("textarea");
ta.className = "edit-inline edit-inline-block"; ta.className = "edit-inline edit-inline-block";
@@ -2447,11 +2490,14 @@ function startBlockTextEdit(o, lines) {
Object.assign(ta.style, editBoxStyle(union)); Object.assign(ta.style, editBoxStyle(union));
const advPt = const advPt =
lines.length > 1 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; : (o.font_size || 12) * 1.25;
const advPx = Math.max(8, advPt * state.editScale); const advPx = Math.max(8, advPt * state.editScale);
// Chừa 2 dòng trống để gõ thêm. // 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 = parseFloat(ta.style.height) + 2 * advPx + "px"; 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.lineHeight = advPx + "px";
ta.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px"; ta.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px";
ta.style.fontFamily = cssFontStack(o.font_family); ta.style.fontFamily = cssFontStack(o.font_family);
@@ -2461,8 +2507,9 @@ function startBlockTextEdit(o, lines) {
ov.appendChild(ta); ov.appendChild(ta);
ta.focus(); ta.focus();
ta.setSelectionRange(0, 0); ta.setSelectionRange(0, 0);
ta.scrollIntoView({ block: "center", behavior: "instant" });
$("editHint").textContent = $("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; let done = false;
const commit = (save) => { const commit = (save) => {
@@ -2485,102 +2532,44 @@ function startBlockTextEdit(o, lines) {
else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); } else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); }
e.stopPropagation(); 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)); 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, // Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung
// Enter/blur commit — engine GIỮ FONT GỐC (chỉ thay khi thiếu glyph). // từng run), `part` chỉ chứa field muốn đổi — field khác null = GIỮ NGUYÊN.
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).
function applyTextPropToSelected(part) { function applyTextPropToSelected(part) {
const o = state.editObjects.find((x) => x.index === state.editSel); const runs = state.editSelRuns
if (!o || o.kind !== "text") return; .map((i) => state.editObjects.find((x) => x.index === i))
stageEditOp({ .filter((x) => x && x.kind === "text");
if (!runs.length) return;
stageEditOps(
runs.map((r) => ({
op: "setText", op: "setText",
index: o.index, index: r.index,
text: o.text || "", text: r.text || "",
fontSize: null, fontSize: null,
color: null, color: null,
fontFamily: null, fontFamily: null,
bold: null, bold: null,
italic: null, italic: null,
...part, ...part,
}); }))
);
} }
function deleteSelectedEditObject() { function deleteSelectedEditObject() {
if (state.editSel == null) return; if (!state.editSelRuns.length) return;
stageEditOp({ op: "delete", index: state.editSel }); stageEditOps(state.editSelRuns.map((i) => ({ op: "delete", index: i })));
} }
// Click lên trang khi đang "armed" để đặt chữ/ảnh mới. // Click lên trang khi đang "armed" để đặt chữ/ảnh mới.
function onEditStageClick(e) { 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; } if (!state.editArm) { selectEditObject(-1); state.editSel = null; buildEditOverlay(); return; }
const img = $("editImg"); const img = $("editImg");
const r = img.getBoundingClientRect(); const r = img.getBoundingClientRect();
@@ -2645,12 +2634,13 @@ function promptAddText(pdfX, pdfY) {
inp.addEventListener("blur", () => commit(true)); inp.addEventListener("blur", () => commit(true));
} }
// Kéo để di chuyển object đang chọn — box đi theo con trỏ NGAY (live feedback // Kéo để di chuyển khung đang chọn (dòng text = kéo CẢ DÒNG) — box đi theo
// như Foxit), thả chuột mới commit vào engine. // con trỏ NGAY (live feedback như Foxit), thả chuột mới commit vào engine.
function onEditBoxMouseDown(e, o) { function onEditBoxMouseDown(e, o, runs) {
if (state.editArm) return; if (state.editArm) return;
if (e.target.classList.contains("ed-handle")) 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 box = e.currentTarget;
const startX = e.clientX, startY = e.clientY; const startX = e.clientX, startY = e.clientY;
const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top); const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top);
@@ -2673,7 +2663,7 @@ function onEditBoxMouseDown(e, o) {
if (!moved) return; if (!moved) return;
const dx = (ev.clientX - startX) / state.editScale; const dx = (ev.clientX - startX) / state.editScale;
const dy = -(ev.clientY - startY) / state.editScale; // CSS y xuống = PDF y giảm 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("mousemove", onMove);
window.addEventListener("mouseup", onUp); window.addEventListener("mouseup", onUp);
@@ -3453,6 +3443,18 @@ $("edColorBtn").addEventListener("click", () => {
}); });
$("edSave").addEventListener("click", saveEdits); $("edSave").addEventListener("click", saveEdits);
$("editOverlay").addEventListener("click", onEditStageClick); $("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, // 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. // tìm kiếm, xoá/bỏ chọn annotation.
+105 -10
View File
@@ -277,6 +277,8 @@ struct ReflowPlan {
/// Khối: lề trái + bề rộng bẻ dòng (điểm PDF). /// Khối: lề trái + bề rộng bẻ dòng (điểm PDF).
left: f32, left: f32,
width: 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. /// Baseline dòng đầu (y của gốc text) + khoảng cách baseline giữa các dòng.
first_baseline: f32, first_baseline: f32,
line_advance: f32, line_advance: f32,
@@ -447,11 +449,11 @@ pub fn apply_edits(
left: f32, left: f32,
right: f32, right: f32,
} }
let mut geos: Vec<RunGeo> = Vec::new(); let geo_of = |i: u16| -> Result<Option<RunGeo>, EngineError> {
let mut old_text_all = String::new();
for &i in &idxs {
let obj = page.objects().get(i as usize).map_err(err)?; 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 m = obj.matrix().map_err(err)?;
let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect {
left: m.e(), left: m.e(),
@@ -459,14 +461,72 @@ pub fn apply_edits(
right: m.e(), right: m.e(),
top: m.f(), top: m.f(),
}); });
old_text_all.push_str(&t.text()); Ok(Some(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right }))
old_text_all.push(' '); };
geos.push(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() { if geos.is_empty() {
continue; 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. // Run neo: baseline cao nhất, trái nhất.
let anchor_idx = geos let anchor_idx = geos
.iter() .iter()
@@ -508,6 +568,33 @@ pub fn apply_edits(
let right = geos.iter().map(|g| g.right).fold(f32::NEG_INFINITY, f32::max); let right = geos.iter().map(|g| g.right).fold(f32::NEG_INFINITY, f32::max);
let width = (right - left).max(20.0); 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): // 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 đó // (1) font NHÚNG parse được + phủ đủ glyph → nhúng lại chính bytes đó
// (same glyphs — trông y hệt); // (same glyphs — trông y hệt);
@@ -546,6 +633,7 @@ pub fn apply_edits(
measure_bytes, measure_bytes,
left, left,
width, width,
centered,
first_baseline, first_baseline,
line_advance, line_advance,
linear: (m.a(), m.b(), m.c(), m.d()), 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 continue; // đoạn trống vẫn chiếm 1 nhịp baseline
} }
let y = plan.first_baseline - (i as f32) * plan.line_advance; 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 let mut obj = page
.objects_mut() .objects_mut()
.create_text_object( .create_text_object(
@@ -851,8 +946,8 @@ pub fn apply_edits(
PdfPoints::new(plan.tf), PdfPoints::new(plan.tf),
) )
.map_err(err)?; .map_err(err)?;
// Giữ scale/nghiêng của run neo; đặt gốc dòng tại (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, plan.left, y)) obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
.map_err(err)?; .map_err(err)?;
obj.set_fill_color(plan.color).map_err(err)?; obj.set_fill_color(plan.color).map_err(err)?;
} }
+132
View File
@@ -740,6 +740,138 @@ the declared standard Helvetica base font untouched.";
assert!(norm.contains("standard Helvetica base font untouched"), "round-trip: {norm:?}"); 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] #[test]
fn add_image_adds_image_object() { fn add_image_adds_image_object() {
let pdf = pdfium(); let pdf = pdfium();
+14
View File
@@ -46,6 +46,20 @@
dài → nhiều dòng, **BaseFont vẫn "Helvetica" chuẩn** (builtin, không nhúng). 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. - 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) ### 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).
+9
View File
@@ -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) | | | 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% | | | 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) ## Bổ sung — Đúp chuột vào chữ từ viewer thường (chuẩn Foxit Editor)
| # | Thao tác | Kỳ vọng | KQ | | # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----| |---|----------|---------|----|