Sửa 3 lỗi edit inline vòng 2 (file thật Word-export): hết □ thay dấu cách, dòng không vỡ khi thêm ký tự, ô sửa WYSIWYG đúng cỡ từng dòng
Chẩn đoán □: PDFium FPDFText_LoadFont nạp LẠI font subset rồi SetText map U+0020 ra .notdef (hộp vuông) dù cmap font CÓ glyph space (đã probe cả 2 subtable) — lỗi PDFium với font nạp động, không phải do font. Engine (edit.rs): - Tầng 0 mới của thang font reflow: DÙNG LẠI chính font object gốc trong PDF (PdfFontToken của run neo, không nạp lại) — glyph + charcode map gốc xử lý dấu cách chuẩn, file không phình; chỉ chọn sau khi PROBE ghi/đọc-lại toàn bộ text mới thành công (tạo object nháp → so text → gỡ + mem::forget) - Ghi từng dòng xong ĐỌC LẠI NGAY tự kiểm; PDFium vẫn vứt ký tự → gỡ và ghi lại TỪNG TỪ tự đặt vị trí bằng advance hmtx (không bao giờ ghi glyph dấu cách) — lưới an toàn cuối cho font lạ - Dòng cứng (\n từ ô sửa) NỞ tới +35% bề rộng khối (chặn mép trang) trước khi bẻ lại — hộp text tự nở như Foxit; khối căn giữa nở đều quanh tâm - line_styles theo baseline gốc: dòng cứng thứ i giữ ĐÚNG CỠ dòng gốc thứ i (tiêu đề 22.5/20pt không bị ép cả khối về 1 cỡ) UI (main.js + styles.css): - Ô sửa cả đoạn đổi textarea → contenteditable plaintext-only: MỖI DÒNG GỐC = 1 div đúng font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ, khối căn giữa hiện căn giữa (nở đều 2 phía quanh tâm), khung bù lệch dọc line-box → chữ trong ô TRÙNG vị trí chữ gốc khi mở; con trỏ đặt đúng điểm đúp (caretRangeFromPoint); Enter xuống dòng, Ctrl+Enter/blur áp dụng, Esc huỷ, dán luôn chữ thuần Kiểm chứng file thật: "HỢP"→"HỢPP" giữ đúng 2 dòng căn giữa cỡ 22.5/20, font BAAAAA+TimesNewRomanPS-BoldMT gốc, dấu cách nguyên vẹn, render khớp bản gốc. Test: +2 hồi quy (giữ cỡ từng dòng; dòng nở không bẻ) → edit 21/21, các suite annot/organize/redact/form xanh. Docs 12 (mục vá vòng 2), 13 (E39–E42). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
+119
-39
@@ -2200,7 +2200,7 @@ function buildEditOverlay() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
selectEditObject(rep.index, line.runs.map((r) => r.index));
|
selectEditObject(rep.index, line.runs.map((r) => r.index));
|
||||||
});
|
});
|
||||||
box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(rep); });
|
box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(rep, e); });
|
||||||
ov.appendChild(box);
|
ov.appendChild(box);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2460,20 +2460,24 @@ function paragraphLines(o) {
|
|||||||
|
|
||||||
// Double-click vào chữ: LUÔN mở ô sửa CẢ ĐOẠN (kể cả đoạn 1 dòng) — một
|
// 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.
|
// đường code duy nhất, giữ nguyên cấu trúc xuống dòng, commit qua reflow.
|
||||||
function startTextEdit(o) {
|
// `ev` (nếu có) = sự kiện chuột để đặt con trỏ đúng chỗ vừa đúp.
|
||||||
startBlockTextEdit(o, paragraphLines(o));
|
function startTextEdit(o, ev) {
|
||||||
|
startBlockTextEdit(o, paragraphLines(o), ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sửa cả đoạn "như Word" (Foxit Edit Text): textarea phủ kín khối (che chữ
|
// Sửa cả đoạn WYSIWYG (chuẩn Foxit Edit Text): contenteditable phủ kín khối,
|
||||||
// cũ bên dưới), MỖI DÒNG GỐC = 1 dòng textarea (giữ xuống dòng — sửa được cả
|
// MỖI DÒNG GỐC = 1 div giữ ĐÚNG font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ (tiêu đề
|
||||||
// vị trí ngắt dòng); click trong ô = di chuyển con trỏ; commit → engine
|
// 22.5/20pt hiện đúng 2 cỡ), khối căn giữa hiện căn giữa — chữ trong ô nằm
|
||||||
// reflow: xoá SẠCH mọi run của khối (kể cả run sót — engine tự nở theo bbox),
|
// TRÙNG vị trí chữ gốc, không xê dịch. Enter = xuống dòng; click trong ô =
|
||||||
// giữ font + giữ căn giữa nếu khối gốc căn giữa.
|
// di chuyển con trỏ; commit → engine reflow (xoá sạch run cũ, giữ font gốc,
|
||||||
function startBlockTextEdit(o, lines) {
|
// giữ cỡ từng dòng, giữ căn giữa).
|
||||||
|
function startBlockTextEdit(o, lines, ev) {
|
||||||
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 s = state.editScale;
|
||||||
|
const p = state.pages[state.editPage];
|
||||||
const allRuns = lines.flatMap((l) => l.runs);
|
const allRuns = lines.flatMap((l) => l.runs);
|
||||||
const union = {
|
const union = {
|
||||||
left: Math.min(...lines.map((l) => l.rect.left)),
|
left: Math.min(...lines.map((l) => l.rect.left)),
|
||||||
@@ -2481,42 +2485,111 @@ function startBlockTextEdit(o, lines) {
|
|||||||
bottom: Math.min(...lines.map((l) => l.rect.bottom)),
|
bottom: Math.min(...lines.map((l) => l.rect.bottom)),
|
||||||
top: Math.max(...lines.map((l) => l.rect.top)),
|
top: Math.max(...lines.map((l) => l.rect.top)),
|
||||||
};
|
};
|
||||||
// Giữ đúng cấu trúc dòng gốc: mỗi dòng thị giác = 1 dòng trong ô sửa.
|
const bw = union.right - union.left;
|
||||||
const original = lines.map((l) => composeLineText(l.runs)).join("\n");
|
const bc = (union.left + union.right) / 2;
|
||||||
|
|
||||||
|
// Căn giữa? — cùng tiêu chí với engine: tâm mọi dòng ≈ tâm khối và có ít
|
||||||
|
// nhất 1 dòng thụt vào so với lề trái.
|
||||||
|
const tol = Math.max(2, bw * 0.02);
|
||||||
|
const centered =
|
||||||
|
lines.every((l) => Math.abs((l.rect.left + l.rect.right) / 2 - bc) <= tol) &&
|
||||||
|
lines.some((l) => l.rect.left - union.left > tol);
|
||||||
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.className = "edit-inline edit-inline-block";
|
|
||||||
ta.value = original;
|
|
||||||
Object.assign(ta.style, editBoxStyle(union));
|
|
||||||
const advPt =
|
const advPt =
|
||||||
lines.length > 1
|
lines.length > 1
|
||||||
? Math.abs(lines[0].rect.bottom - lines[1].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 * s);
|
||||||
// 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 original = lines.map((l) => composeLineText(l.runs)).join("\n");
|
||||||
const p = state.pages[state.editPage];
|
|
||||||
const maxW = (p.widthPt - union.left) * state.editScale - 4;
|
const ce = document.createElement("div");
|
||||||
ta.style.width = Math.min(maxW, parseFloat(ta.style.width) + 60) + "px";
|
ce.className = "edit-inline edit-inline-wys";
|
||||||
ta.style.lineHeight = advPx + "px";
|
ce.contentEditable = "plaintext-only";
|
||||||
ta.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px";
|
ce.spellcheck = false;
|
||||||
ta.style.fontFamily = cssFontStack(o.font_family);
|
|
||||||
ta.style.fontWeight = o.font_bold ? "bold" : "normal";
|
// Khung: khối căn giữa nở đều 2 phía (tâm giữ nguyên → chữ không xê dịch);
|
||||||
ta.style.fontStyle = o.font_italic ? "italic" : "normal";
|
// khối thường giữ mép trái, nở sang phải — khớp giới hạn nở 35% của engine.
|
||||||
if (o.color) ta.style.color = rgbCss(o.color);
|
let leftPt;
|
||||||
ov.appendChild(ta);
|
let widthPt;
|
||||||
ta.focus();
|
if (centered) {
|
||||||
ta.setSelectionRange(0, 0);
|
const grow = Math.min(bw * 0.175, bc - 8 - bw / 2, p.widthPt - 8 - bc - bw / 2);
|
||||||
ta.scrollIntoView({ block: "center", behavior: "instant" });
|
const g = Math.max(0, grow);
|
||||||
|
leftPt = union.left - g;
|
||||||
|
widthPt = bw + 2 * g;
|
||||||
|
} else {
|
||||||
|
leftPt = union.left;
|
||||||
|
widthPt = Math.min(bw * 1.35, p.widthPt - union.left - 8);
|
||||||
|
}
|
||||||
|
// Bù lệch dọc: line-box của trình duyệt căn giữa glyph trong line-height,
|
||||||
|
// còn bbox PDF ôm sát glyph → đẩy khung lên (advPx − cỡ chữ)/2 cho trùng.
|
||||||
|
const fs0 = (lines[0].fs || o.font_size || 12) * s;
|
||||||
|
const topPx = Math.max(0, (p.heightPt - union.top) * s - Math.max(0, (advPx - fs0) / 2));
|
||||||
|
Object.assign(ce.style, {
|
||||||
|
left: leftPt * s + "px",
|
||||||
|
top: topPx + "px",
|
||||||
|
width: widthPt * s + "px",
|
||||||
|
minHeight: lines.length * advPx + 4 + "px",
|
||||||
|
textAlign: centered ? "center" : "left",
|
||||||
|
});
|
||||||
|
|
||||||
|
// MỖI DÒNG 1 div với đúng cỡ/màu/kiểu của dòng đó (WYSIWYG khi gõ).
|
||||||
|
for (const l of lines) {
|
||||||
|
const rep = l.runs.find((r) => (r.text || "").trim()) || l.runs[0] || o;
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "edit-line";
|
||||||
|
const t = composeLineText(l.runs);
|
||||||
|
if (t) div.textContent = t;
|
||||||
|
else div.innerHTML = "<br>";
|
||||||
|
div.style.fontSize = Math.max(6, (l.fs || 12) * s) + "px";
|
||||||
|
div.style.lineHeight = advPx + "px";
|
||||||
|
div.style.fontFamily = cssFontStack(rep.font_family || o.font_family);
|
||||||
|
div.style.fontWeight = (rep.font_bold != null ? rep.font_bold : o.font_bold) ? "bold" : "normal";
|
||||||
|
div.style.fontStyle = (rep.font_italic != null ? rep.font_italic : o.font_italic) ? "italic" : "normal";
|
||||||
|
if (rep.color || o.color) div.style.color = rgbCss(rep.color || o.color);
|
||||||
|
ce.appendChild(div);
|
||||||
|
}
|
||||||
|
ov.appendChild(ce);
|
||||||
|
// WebView không hỗ trợ plaintext-only (hiếm) → contenteditable thường.
|
||||||
|
if (!ce.isContentEditable) ce.contentEditable = "true";
|
||||||
|
ce.focus();
|
||||||
|
|
||||||
|
// Đặt con trỏ đúng chỗ vừa đúp (như Foxit); không có toạ độ → đầu đoạn.
|
||||||
|
let placed = false;
|
||||||
|
if (ev && document.caretRangeFromPoint) {
|
||||||
|
const r = document.caretRangeFromPoint(ev.clientX, ev.clientY);
|
||||||
|
if (r && ce.contains(r.startContainer)) {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(r);
|
||||||
|
placed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const r = document.createRange();
|
||||||
|
r.setStart(ce, 0);
|
||||||
|
r.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(r);
|
||||||
|
}
|
||||||
|
ce.scrollIntoView({ block: "center", behavior: "instant" });
|
||||||
$("editHint").textContent =
|
$("editHint").textContent =
|
||||||
"Sửa cả đoạn — Enter: xuống dò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ỷ";
|
||||||
|
|
||||||
|
// Đọc nội dung theo DÒNG từ contenteditable (div/br → \n).
|
||||||
|
const readText = () => {
|
||||||
|
let t = ce.innerText != null ? ce.innerText : ce.textContent;
|
||||||
|
t = t.replace(/\u00a0/g, " ").replace(/\n+$/, "");
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
let done = false;
|
let done = false;
|
||||||
const commit = (save) => {
|
const commit = (save) => {
|
||||||
if (done) return;
|
if (done) return;
|
||||||
done = true;
|
done = true;
|
||||||
const text = ta.value;
|
const text = readText();
|
||||||
ta.remove();
|
ce.remove();
|
||||||
if (save && text.trim() && text !== original) {
|
if (save && text.trim() && text !== original) {
|
||||||
stageEditOp({
|
stageEditOp({
|
||||||
op: "reflowText",
|
op: "reflowText",
|
||||||
@@ -2527,16 +2600,23 @@ function startBlockTextEdit(o, lines) {
|
|||||||
$("editHint").textContent = "";
|
$("editHint").textContent = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ta.addEventListener("keydown", (e) => {
|
ce.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape") { e.preventDefault(); commit(false); }
|
if (e.key === "Escape") { e.preventDefault(); commit(false); }
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
// Dán luôn ở dạng chữ thuần (khi rơi về contenteditable thường).
|
||||||
|
ce.addEventListener("paste", (e) => {
|
||||||
|
if (ce.contentEditable === "plaintext-only") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const t = (e.clipboardData || window.clipboardData).getData("text/plain");
|
||||||
|
document.execCommand("insertText", false, t);
|
||||||
|
});
|
||||||
// Click/chọn chữ TRONG ô sửa không được lan xuống overlay (gây thoát edit).
|
// 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());
|
ce.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||||
ta.addEventListener("click", (e) => e.stopPropagation());
|
ce.addEventListener("click", (e) => e.stopPropagation());
|
||||||
ta.addEventListener("dblclick", (e) => e.stopPropagation());
|
ce.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||||
ta.addEventListener("blur", () => commit(true));
|
ce.addEventListener("blur", () => commit(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung
|
// Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung
|
||||||
@@ -3453,7 +3533,7 @@ $("editOverlay").addEventListener("dblclick", (e) => {
|
|||||||
const x = (e.clientX - r.left) / state.editScale;
|
const x = (e.clientX - r.left) / state.editScale;
|
||||||
const y = p.heightPt - (e.clientY - r.top) / state.editScale;
|
const y = p.heightPt - (e.clientY - r.top) / state.editScale;
|
||||||
const hit = pickTextAt(state.editObjects, x, y);
|
const hit = pickTextAt(state.editObjects, x, y);
|
||||||
if (hit) startTextEdit(hit);
|
if (hit) startTextEdit(hit, e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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,
|
||||||
|
|||||||
@@ -445,6 +445,20 @@ body.tool-active .annotlayer * { pointer-events: none; }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ô sửa WYSIWYG cả đoạn: mỗi dòng gốc = 1 div giữ đúng font/cỡ/màu dòng đó,
|
||||||
|
chữ nằm trùng vị trí chữ gốc (che kín bên dưới), con trỏ đặt theo điểm đúp */
|
||||||
|
.edit-inline-wys {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
|
border-width: 1px;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.edit-inline-wys .edit-line {
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Nút Đậm/Nghiêng trong thanh Sửa nội dung */
|
/* Nút Đậm/Nghiêng trong thanh Sửa nội dung */
|
||||||
.ed-style {
|
.ed-style {
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
|
|||||||
+205
-36
@@ -265,12 +265,32 @@ fn builtin_for(family_key: &str, bold: bool, italic: bool) -> Option<PdfFontBuil
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Font để VẼ các dòng reflow mới.
|
||||||
|
enum ReflowFont {
|
||||||
|
/// Dùng lại CHÍNH font object gốc của PDF (không nạp lại) — giữ nguyên
|
||||||
|
/// glyph + mapping charcode gốc. Đường ưu tiên với font nhúng: PDFium
|
||||||
|
/// `FPDFText_LoadFont` nạp lại font subset (Word xuất) hay map dấu cách
|
||||||
|
/// ra .notdef (□), còn font object gốc thì không. Chỉ chọn sau khi probe
|
||||||
|
/// round-trip toàn bộ text thành công.
|
||||||
|
Original(PdfFontToken),
|
||||||
|
/// Font nạp mới (nhúng lại bytes / base-14 / hệ thống) theo key trong
|
||||||
|
/// bảng `tokens`.
|
||||||
|
Loaded(FontKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cỡ chữ 1 dòng gốc (theo baseline, trên→dưới): Tf unscaled + cỡ hiển thị.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct LineStyle {
|
||||||
|
tf: f32,
|
||||||
|
scaled: f32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Kế hoạch reflow 1 đoạn, dựng ở pha (A) từ hình học các run gốc.
|
/// Kế hoạch reflow 1 đoạn, dựng ở pha (A) từ hình học các run gốc.
|
||||||
struct ReflowPlan {
|
struct ReflowPlan {
|
||||||
/// Run text hợp lệ sẽ bị thay (đã sort, dedup).
|
/// Run text hợp lệ sẽ bị thay (đã sort, dedup).
|
||||||
indices: Vec<u16>,
|
indices: Vec<u16>,
|
||||||
/// Key font đã nạp ở pha (B) để tạo các dòng mới.
|
/// Font vẽ các dòng mới.
|
||||||
font_key: FontKey,
|
draw_font: ReflowFont,
|
||||||
/// Bytes để ĐO bề rộng (hmtx) — có thể khác font vẽ (vd base-14 đo bằng
|
/// Bytes để ĐO bề rộng (hmtx) — có thể khác font vẽ (vd base-14 đo bằng
|
||||||
/// font metric-compatible). None = xấp xỉ 0.5em/ký tự.
|
/// font metric-compatible). None = xấp xỉ 0.5em/ký tự.
|
||||||
measure_bytes: Option<Vec<u8>>,
|
measure_bytes: Option<Vec<u8>>,
|
||||||
@@ -284,12 +304,40 @@ struct ReflowPlan {
|
|||||||
line_advance: f32,
|
line_advance: f32,
|
||||||
/// Phần tuyến tính matrix của run neo (giữ scale/nghiêng khi tạo dòng mới).
|
/// Phần tuyến tính matrix của run neo (giữ scale/nghiêng khi tạo dòng mới).
|
||||||
linear: (f32, f32, f32, f32),
|
linear: (f32, f32, f32, f32),
|
||||||
/// Cỡ Tf cho object mới (unscaled) + cỡ hiển thị (để đo).
|
/// Cỡ Tf cho object mới (unscaled) + cỡ hiển thị (để đo) của run neo —
|
||||||
|
/// fallback khi dòng không có style riêng.
|
||||||
tf: f32,
|
tf: f32,
|
||||||
scaled: f32,
|
scaled: f32,
|
||||||
|
/// Cỡ chữ TỪNG DÒNG GỐC (trên→dưới) — dòng cứng thứ i của text mới giữ
|
||||||
|
/// đúng cỡ dòng gốc thứ i (tiêu đề 22.5/20pt không bị ép về 1 cỡ).
|
||||||
|
line_styles: Vec<LineStyle>,
|
||||||
color: PdfColor,
|
color: PdfColor,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probe: font `token` có ghi được `text` tròn trịa qua PDFium không?
|
||||||
|
/// Tạo text object nháp → đọc lại → xoá ngay (mem::forget vì object đã
|
||||||
|
/// thuộc trang). Bắt được các case PDFium âm thầm vứt ký tự (dấu cách với
|
||||||
|
/// font subset nạp lại, chữ ngoài ToUnicode...).
|
||||||
|
fn probe_font_roundtrip(page: &mut PdfPage<'_>, token: PdfFontToken, text: &str) -> bool {
|
||||||
|
let probe: String = text.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
|
||||||
|
let ok = match page.objects_mut().create_text_object(
|
||||||
|
PdfPoints::ZERO,
|
||||||
|
PdfPoints::ZERO,
|
||||||
|
&probe,
|
||||||
|
token,
|
||||||
|
PdfPoints::new(10.0),
|
||||||
|
) {
|
||||||
|
Ok(obj) => obj.as_text_object().map(|t| t.text() == probe).unwrap_or(false),
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let idx = page.objects().len().saturating_sub(1);
|
||||||
|
match page.objects_mut().remove_object_at_index(idx as usize) {
|
||||||
|
Ok(removed) => std::mem::forget(removed),
|
||||||
|
Err(_) => return false,
|
||||||
|
}
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
/// Cách xử lý 1 op SetText, quyết định ở pha lập kế hoạch.
|
/// Cách xử lý 1 op SetText, quyết định ở pha lập kế hoạch.
|
||||||
enum SetTextMode {
|
enum SetTextMode {
|
||||||
/// Giữ nguyên font gốc — sửa text/cỡ/màu ngay trên object.
|
/// Giữ nguyên font gốc — sửa text/cỡ/màu ngay trên object.
|
||||||
@@ -357,10 +405,12 @@ pub fn apply_edits(
|
|||||||
let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new();
|
let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new();
|
||||||
let mut font_needed: HashMap<FontKey, FontLoad> = HashMap::new();
|
let mut font_needed: HashMap<FontKey, FontLoad> = HashMap::new();
|
||||||
{
|
{
|
||||||
let page = document
|
let mut page = document
|
||||||
.pages()
|
.pages()
|
||||||
.get(page_index)
|
.get(page_index)
|
||||||
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
||||||
|
// Pha A chỉ đọc + probe font (tạo/xoá object nháp) — không đụng content.
|
||||||
|
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
|
||||||
let obj_count = page.objects().len();
|
let obj_count = page.objects().len();
|
||||||
let valid = |i: u16| (i as usize) < obj_count;
|
let valid = |i: u16| (i as usize) < obj_count;
|
||||||
|
|
||||||
@@ -448,12 +498,16 @@ pub fn apply_edits(
|
|||||||
f: f32,
|
f: f32,
|
||||||
left: f32,
|
left: f32,
|
||||||
right: f32,
|
right: f32,
|
||||||
|
tf: f32,
|
||||||
|
scaled: f32,
|
||||||
}
|
}
|
||||||
let geo_of = |i: u16| -> Result<Option<RunGeo>, EngineError> {
|
let geo_of = |page: &PdfPage<'_>, i: u16| -> Result<Option<RunGeo>, EngineError> {
|
||||||
let obj = page.objects().get(i as usize).map_err(err)?;
|
let obj = page.objects().get(i as usize).map_err(err)?;
|
||||||
if obj.as_text_object().is_none() {
|
let Some(t) = obj.as_text_object() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
};
|
||||||
|
let (tf, scaled) =
|
||||||
|
(t.unscaled_font_size().value, t.scaled_font_size().value);
|
||||||
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(),
|
||||||
@@ -461,11 +515,11 @@ pub fn apply_edits(
|
|||||||
right: m.e(),
|
right: m.e(),
|
||||||
top: m.f(),
|
top: m.f(),
|
||||||
});
|
});
|
||||||
Ok(Some(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, tf, scaled }))
|
||||||
};
|
};
|
||||||
let mut geos: Vec<RunGeo> = Vec::new();
|
let mut geos: Vec<RunGeo> = Vec::new();
|
||||||
for &i in &idxs {
|
for &i in &idxs {
|
||||||
if let Some(g) = geo_of(i)? {
|
if let Some(g) = geo_of(&page, i)? {
|
||||||
geos.push(g);
|
geos.push(g);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,7 +574,7 @@ pub fn apply_edits(
|
|||||||
&& cy <= bb.top + pad
|
&& cy <= bb.top + pad
|
||||||
{
|
{
|
||||||
idxs.push(i);
|
idxs.push(i);
|
||||||
if let Some(g) = geo_of(i)? {
|
if let Some(g) = geo_of(&page, i)? {
|
||||||
geos.push(g);
|
geos.push(g);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,13 +591,21 @@ pub fn apply_edits(
|
|||||||
})
|
})
|
||||||
.map(|g| g.idx)
|
.map(|g| g.idx)
|
||||||
.unwrap_or(idxs[0]);
|
.unwrap_or(idxs[0]);
|
||||||
|
// Chụp mọi thuộc tính của run neo trong 1 block để nhả mượn
|
||||||
|
// trang (probe font phía dưới cần &mut page).
|
||||||
|
let (family, bold, italic, m, unscaled, scaled, color, font_embedded, orig_token, font_data) = {
|
||||||
let anchor = page.objects().get(anchor_idx as usize).map_err(err)?;
|
let anchor = page.objects().get(anchor_idx as usize).map_err(err)?;
|
||||||
let t = anchor.as_text_object().expect("anchor là text");
|
let t = anchor.as_text_object().expect("anchor là text");
|
||||||
let (family, bold, italic) = text_object_style(t);
|
let (family, bold, italic) = text_object_style(t);
|
||||||
let m = anchor.matrix().map_err(err)?;
|
let m = anchor.matrix().map_err(err)?;
|
||||||
|
let tok = t.font().token();
|
||||||
let unscaled = t.unscaled_font_size().value;
|
let unscaled = t.unscaled_font_size().value;
|
||||||
let scaled = t.scaled_font_size().value.max(1.0);
|
let scaled = t.scaled_font_size().value.max(1.0);
|
||||||
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
|
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
|
||||||
|
let embedded = t.font().is_embedded().unwrap_or(false);
|
||||||
|
let data = t.font().data().ok();
|
||||||
|
(family, bold, italic, m, unscaled, scaled, color, embedded, tok, data)
|
||||||
|
};
|
||||||
|
|
||||||
// Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance =
|
// Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance =
|
||||||
// median hiệu 2 cụm kề nhau; 1 dòng → 1.25 × cỡ hiển thị.
|
// median hiệu 2 cụm kề nhau; 1 dòng → 1.25 × cỡ hiển thị.
|
||||||
@@ -595,41 +657,70 @@ pub fn apply_edits(
|
|||||||
all_centered && any_indented
|
all_centered && any_indented
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cỡ chữ TỪNG DÒNG gốc (theo baseline, trên→dưới): dòng cứng
|
||||||
|
// thứ i của text mới giữ đúng cỡ dòng gốc thứ i (tiêu đề
|
||||||
|
// 22.5/20pt không bị ép hết về cỡ run neo).
|
||||||
|
let line_styles: Vec<LineStyle> = baselines
|
||||||
|
.iter()
|
||||||
|
.map(|&bl| {
|
||||||
|
geos.iter()
|
||||||
|
.filter(|g| (g.f - bl).abs() <= 1.0 && g.scaled > 0.5)
|
||||||
|
.max_by(|a, b| {
|
||||||
|
a.scaled
|
||||||
|
.partial_cmp(&b.scaled)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.map(|g| LineStyle { tf: g.tf.max(1.0), scaled: g.scaled.max(1.0) })
|
||||||
|
.unwrap_or(LineStyle { tf: unscaled.max(1.0), scaled })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// 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 đó
|
// (0) font NHÚNG + probe ghi/đọc lại tròn trịa → dùng LẠI chính
|
||||||
// (same glyphs — trông y hệt);
|
// font object gốc (glyph + charcode y hệt, file không phình;
|
||||||
// (2) family thuộc nhóm base-14 + text ASCII → font chuẩn PDF,
|
// tránh lỗi PDFium nạp lại subset làm dấu cách thành □);
|
||||||
// BaseFont giữ tên chuẩn, không phình file;
|
// (1) font NHÚNG parse được + phủ đủ glyph → nhúng lại bytes đó;
|
||||||
|
// (2) family thuộc nhóm base-14 + text ASCII → font chuẩn PDF;
|
||||||
// (3) font hệ thống CÙNG HỌ; (4) fallback mặc định.
|
// (3) font hệ thống CÙNG HỌ; (4) fallback mặc định.
|
||||||
let fam_key = fontmatch::normalize_key(&family);
|
let fam_key = fontmatch::normalize_key(&family);
|
||||||
let ascii_only =
|
let ascii_only =
|
||||||
text.chars().all(|c| c.is_control() || (' '..='~').contains(&c));
|
text.chars().all(|c| c.is_control() || (' '..='~').contains(&c));
|
||||||
let embedded_bytes = if t.font().is_embedded().unwrap_or(false) {
|
let original_ok =
|
||||||
t.font().data().ok().filter(|b| fontmatch::coverage_ok(b, text))
|
font_embedded && probe_font_roundtrip(&mut page, orig_token, text);
|
||||||
|
let embedded_bytes = if font_embedded {
|
||||||
|
font_data.clone().filter(|b| fontmatch::coverage_ok(b, text))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let (font_key, load, measure_bytes) = if let Some(bytes) = embedded_bytes {
|
let (draw_font, measure_bytes) = if original_ok {
|
||||||
|
// Đo vẫn bằng bytes font nhúng (hmtx) nếu parse được.
|
||||||
|
let measure =
|
||||||
|
font_data.filter(|b| ttf_parser::Face::parse(b, 0).is_ok());
|
||||||
|
(ReflowFont::Original(orig_token), measure)
|
||||||
|
} else if let Some(bytes) = embedded_bytes {
|
||||||
let key = (format!("emb:{fam_key}:{anchor_idx}"), bold, italic);
|
let key = (format!("emb:{fam_key}:{anchor_idx}"), bold, italic);
|
||||||
(key, FontLoad::Bytes(bytes.clone()), Some(bytes))
|
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes.clone()));
|
||||||
|
(ReflowFont::Loaded(key), Some(bytes))
|
||||||
} else if let (Some(builtin), true) =
|
} else if let (Some(builtin), true) =
|
||||||
(builtin_for(&fam_key, bold, italic), ascii_only)
|
(builtin_for(&fam_key, bold, italic), ascii_only)
|
||||||
{
|
{
|
||||||
// Đo bằng font hệ thống metric-compatible (Liberation/Arial…).
|
// Đo bằng font hệ thống metric-compatible (Liberation/Arial…).
|
||||||
let measure = fontmatch::find_family_font_bytes(&family, bold, italic)
|
let measure = fontmatch::find_family_font_bytes(&family, bold, italic)
|
||||||
.or_else(|| find_font_bytes(bold, italic));
|
.or_else(|| find_font_bytes(bold, italic));
|
||||||
((format!("b14:{fam_key}"), bold, italic), FontLoad::Builtin(builtin), measure)
|
let key = (format!("b14:{fam_key}"), bold, italic);
|
||||||
|
font_needed.entry(key.clone()).or_insert(FontLoad::Builtin(builtin));
|
||||||
|
(ReflowFont::Loaded(key), measure)
|
||||||
} else {
|
} else {
|
||||||
let (key, bytes) = resolve_substitute_font(&family, bold, italic, text)?;
|
let (key, bytes) = resolve_substitute_font(&family, bold, italic, text)?;
|
||||||
(key, FontLoad::Bytes(bytes.clone()), Some(bytes))
|
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes.clone()));
|
||||||
|
(ReflowFont::Loaded(key), Some(bytes))
|
||||||
};
|
};
|
||||||
font_needed.entry(font_key.clone()).or_insert(load);
|
|
||||||
|
|
||||||
reflow_plans.insert(
|
reflow_plans.insert(
|
||||||
opi,
|
opi,
|
||||||
ReflowPlan {
|
ReflowPlan {
|
||||||
indices: idxs,
|
indices: idxs,
|
||||||
font_key,
|
draw_font,
|
||||||
measure_bytes,
|
measure_bytes,
|
||||||
left,
|
left,
|
||||||
width,
|
width,
|
||||||
@@ -639,6 +730,7 @@ pub fn apply_edits(
|
|||||||
linear: (m.a(), m.b(), m.c(), m.d()),
|
linear: (m.a(), m.b(), m.c(), m.d()),
|
||||||
tf: unscaled.max(1.0),
|
tf: unscaled.max(1.0),
|
||||||
scaled,
|
scaled,
|
||||||
|
line_styles,
|
||||||
color,
|
color,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -908,34 +1000,77 @@ pub fn apply_edits(
|
|||||||
}
|
}
|
||||||
EditOp::ReflowText { text, .. } => {
|
EditOp::ReflowText { text, .. } => {
|
||||||
let Some(plan) = reflow_plans.get(&opi) else { continue };
|
let Some(plan) = reflow_plans.get(&opi) else { continue };
|
||||||
let token = tokens.get(&plan.font_key).copied().ok_or_else(|| {
|
let token = match &plan.draw_font {
|
||||||
|
ReflowFont::Original(tok) => *tok,
|
||||||
|
ReflowFont::Loaded(key) => tokens.get(key).copied().ok_or_else(|| {
|
||||||
EngineError::Pdfium("thiếu token font cho ReflowText".into())
|
EngineError::Pdfium("thiếu token font cho ReflowText".into())
|
||||||
})?;
|
})?,
|
||||||
// Đo bề rộng ký tự theo hmtx của font (tại cỡ hiển thị);
|
};
|
||||||
// không parse được → xấp xỉ 0.5em.
|
// Đo bề rộng ký tự theo hmtx của font (tại cỡ hiển thị của
|
||||||
|
// TỪNG dòng); không parse được → xấp xỉ 0.5em.
|
||||||
let face = plan
|
let face = plan
|
||||||
.measure_bytes
|
.measure_bytes
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|b| ttf_parser::Face::parse(b, 0).ok());
|
.and_then(|b| ttf_parser::Face::parse(b, 0).ok());
|
||||||
let scaled = plan.scaled;
|
let measure_at = |c: char, size: f32| match &face {
|
||||||
let measure = move |c: char| match &face {
|
Some(f) => fontmatch::char_advance(f, c, size),
|
||||||
Some(f) => fontmatch::char_advance(f, c, scaled),
|
None => size * 0.5,
|
||||||
None => scaled * 0.5,
|
|
||||||
};
|
};
|
||||||
let lines = fontmatch::wrap_lines(text, plan.width, &measure);
|
|
||||||
|
// Bề rộng tối đa 1 dòng có thể NỞ thêm (không bẻ lại) khi
|
||||||
|
// người dùng chỉ gõ thêm vài ký tự: +35% bề rộng khối, chặn
|
||||||
|
// bởi mép trang — hành vi hộp text tự nở của Foxit. Vượt quá
|
||||||
|
// mới bẻ dòng theo đúng bề rộng khối.
|
||||||
|
let page_w = page.width().value;
|
||||||
|
let avail = if plan.centered {
|
||||||
|
let c = plan.left + plan.width / 2.0;
|
||||||
|
2.0 * (c.min(page_w - c) - 8.0).max(0.0)
|
||||||
|
} else {
|
||||||
|
(page_w - plan.left - 8.0).max(0.0)
|
||||||
|
};
|
||||||
|
let grow_limit = (plan.width * 1.35).min(avail.max(plan.width * 1.02));
|
||||||
|
|
||||||
|
// Dòng cứng thứ i (theo \n từ ô sửa) giữ cỡ chữ dòng gốc thứ i.
|
||||||
|
let mut out_lines: Vec<(String, LineStyle)> = Vec::new();
|
||||||
|
let anchor_style = LineStyle { tf: plan.tf, scaled: plan.scaled };
|
||||||
|
for (li, hard) in text.split('\n').enumerate() {
|
||||||
|
let style = plan
|
||||||
|
.line_styles
|
||||||
|
.get(li)
|
||||||
|
.or(plan.line_styles.last())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(anchor_style);
|
||||||
|
let w: f32 = hard.chars().map(|c| measure_at(c, style.scaled)).sum();
|
||||||
|
if hard.trim().is_empty() || w <= grow_limit {
|
||||||
|
out_lines.push((hard.trim_end().to_string(), style));
|
||||||
|
} else {
|
||||||
|
let m = |c: char| measure_at(c, style.scaled);
|
||||||
|
for piece in fontmatch::wrap_lines(hard, plan.width, &m) {
|
||||||
|
out_lines.push((piece, style));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (a, b, c2, d) = plan.linear;
|
let (a, b, c2, d) = plan.linear;
|
||||||
for (i, line) in lines.iter().enumerate() {
|
for (i, (line, style)) in out_lines.iter().enumerate() {
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
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.
|
// Khối căn giữa → từng dòng mới cũng căn giữa theo tâm khối.
|
||||||
|
let line_w: f32 =
|
||||||
|
line.chars().map(|c| measure_at(c, style.scaled)).sum();
|
||||||
let x = if plan.centered {
|
let x = if plan.centered {
|
||||||
let w: f32 = line.chars().map(&measure).sum();
|
let x = plan.left + plan.width / 2.0 - line_w / 2.0;
|
||||||
(plan.left + plan.width / 2.0 - w / 2.0).max(plan.left)
|
x.max(8.0) // căn giữa nở đều 2 phía, không tràn mép trang
|
||||||
} else {
|
} else {
|
||||||
plan.left
|
plan.left
|
||||||
};
|
};
|
||||||
|
// Ghi cả dòng; đọc lại NGAY để chắc PDFium không âm thầm
|
||||||
|
// vứt ký tự (dấu cách với font subset...). Hỏng → gỡ và
|
||||||
|
// ghi lại TỪNG TỪ, tự đặt vị trí bằng advance đo được —
|
||||||
|
// không bao giờ ghi glyph dấu cách nữa.
|
||||||
|
let wrote_ok = {
|
||||||
let mut obj = page
|
let mut obj = page
|
||||||
.objects_mut()
|
.objects_mut()
|
||||||
.create_text_object(
|
.create_text_object(
|
||||||
@@ -943,14 +1078,48 @@ pub fn apply_edits(
|
|||||||
PdfPoints::ZERO,
|
PdfPoints::ZERO,
|
||||||
line.clone(),
|
line.clone(),
|
||||||
token,
|
token,
|
||||||
PdfPoints::new(plan.tf),
|
PdfPoints::new(style.tf),
|
||||||
)
|
)
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
// Giữ scale/nghiêng của run neo; đặt gốc dòng tại (x, y).
|
let ok = obj
|
||||||
|
.as_text_object()
|
||||||
|
.map(|t| t.text() == *line)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ok {
|
||||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, 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)?;
|
||||||
}
|
}
|
||||||
|
ok
|
||||||
|
};
|
||||||
|
if !wrote_ok {
|
||||||
|
let idx = page.objects().len().saturating_sub(1);
|
||||||
|
let removed = page
|
||||||
|
.objects_mut()
|
||||||
|
.remove_object_at_index(idx as usize)
|
||||||
|
.map_err(err)?;
|
||||||
|
std::mem::forget(removed);
|
||||||
|
let space_w = measure_at(' ', style.scaled);
|
||||||
|
let mut cx = x;
|
||||||
|
for word in line.split_whitespace() {
|
||||||
|
let mut obj = page
|
||||||
|
.objects_mut()
|
||||||
|
.create_text_object(
|
||||||
|
PdfPoints::ZERO,
|
||||||
|
PdfPoints::ZERO,
|
||||||
|
word,
|
||||||
|
token,
|
||||||
|
PdfPoints::new(style.tf),
|
||||||
|
)
|
||||||
|
.map_err(err)?;
|
||||||
|
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
|
||||||
|
.map_err(err)?;
|
||||||
|
obj.set_fill_color(plan.color).map_err(err)?;
|
||||||
|
cx += word.chars().map(|c| measure_at(c, style.scaled)).sum::<f32>()
|
||||||
|
+ space_w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -872,6 +872,113 @@ fn reflow_preserves_centered_alignment() {
|
|||||||
assert_eq!(checked, 2, "phải kiểm được 2 dòng mới");
|
assert_eq!(checked, 2, "phải kiểm được 2 dòng mới");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mỗi DÒNG CỨNG (theo `\n` từ ô sửa) giữ đúng CỠ CHỮ của dòng gốc cùng thứ
|
||||||
|
/// tự — tiêu đề 22/16pt không bị ép cả khối về 1 cỡ.
|
||||||
|
#[test]
|
||||||
|
fn reflow_keeps_per_line_font_sizes() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let fx = tmp("ff_reflow_sz_fx.pdf");
|
||||||
|
let out = tmp("ff_reflow_sz_out.pdf");
|
||||||
|
let mk = |y: f32, s: &str, size: f32| EditOp::AddText {
|
||||||
|
x: 80.0,
|
||||||
|
y,
|
||||||
|
text: s.into(),
|
||||||
|
font_size: size,
|
||||||
|
color: [0, 0, 0, 255],
|
||||||
|
font_family: None,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
};
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&sample(),
|
||||||
|
0,
|
||||||
|
&[mk(500.0, "TIÊU ĐỀ CHÍNH CỠ LỚN", 22.0), mk(474.0, "phụ đề bên dưới cỡ nhỏ", 16.0)],
|
||||||
|
&fx,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("fixture 2 cỡ chữ");
|
||||||
|
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("CỠ LỚN") || t.contains("cỡ nhỏ")).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\nphụ đề mới".into() }],
|
||||||
|
&out,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("reflow 2 cỡ");
|
||||||
|
|
||||||
|
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
|
||||||
|
let size_of = |needle: &str| {
|
||||||
|
after
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
|
||||||
|
.unwrap_or_else(|| panic!("thiếu dòng {needle:?}"))
|
||||||
|
.font_size
|
||||||
|
.expect("font_size")
|
||||||
|
};
|
||||||
|
let s1 = size_of("TIÊU ĐỀ MỚI");
|
||||||
|
let s2 = size_of("phụ đề mới");
|
||||||
|
assert!((s1 - 22.0).abs() < 0.6, "dòng 1 phải giữ ~22pt, được {s1}");
|
||||||
|
assert!((s2 - 16.0).abs() < 0.6, "dòng 2 phải giữ ~16pt, được {s2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dòng cứng chỉ DÀI THÊM CHÚT (≤35% bề rộng khối) thì NỞ ra chứ không bị bẻ
|
||||||
|
/// lại thành 2 dòng (hộp text tự nở như Foxit) — sửa "HỢP"→"HỢPP" phải giữ
|
||||||
|
/// nguyên số dòng. Dấu cách cũng phải sống sót qua đường ghi.
|
||||||
|
#[test]
|
||||||
|
fn reflow_hard_line_grows_without_rewrap() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let fx = tmp("ff_reflow_grow_fx.pdf");
|
||||||
|
let out = tmp("ff_reflow_grow_out.pdf");
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&sample(),
|
||||||
|
0,
|
||||||
|
&[EditOp::AddText {
|
||||||
|
x: 90.0,
|
||||||
|
y: 520.0,
|
||||||
|
text: "Dòng tiêu đề dài vừa khít khung gốc".into(),
|
||||||
|
font_size: 18.0,
|
||||||
|
color: [0, 0, 0, 255],
|
||||||
|
font_family: None,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
}],
|
||||||
|
&fx,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("fixture 1 dòng");
|
||||||
|
let idx = find_text_index(&pdf, &fx, "vừa khít");
|
||||||
|
|
||||||
|
let new_text = "Dòng tiêu đề dài vừa khít khung gốc nở";
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&fx,
|
||||||
|
0,
|
||||||
|
&[EditOp::ReflowText { indices: vec![idx], text: new_text.into() }],
|
||||||
|
&out,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("reflow nở dòng");
|
||||||
|
|
||||||
|
let new_runs: Vec<_> = ff_engine::list_objects(&pdf, &out, 0, None)
|
||||||
|
.expect("list out")
|
||||||
|
.into_iter()
|
||||||
|
.filter(|o| o.text.as_deref().map(|t| t.contains("tiêu đề") || t.contains("nở thêm")).unwrap_or(false))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(new_runs.len(), 1, "dòng chỉ dài thêm ~20% phải GIỮ 1 dòng: {new_runs:?}");
|
||||||
|
assert_eq!(new_runs[0].text.as_deref(), Some(new_text), "nội dung (gồm dấu cách) phải tròn trịa");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_image_adds_image_object() {
|
fn add_image_adds_image_object() {
|
||||||
let pdf = pdfium();
|
let pdf = pdfium();
|
||||||
|
|||||||
@@ -60,10 +60,40 @@ per-char xen run rỗng, cỡ 22.5pt + 20pt, bbox chữ có dấu tụt thấp):
|
|||||||
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
|
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ữ căn giữa. 2 test hồi quy mới (nở indices, giữ căn giữa) — 19 test edit.
|
||||||
|
|
||||||
|
### Vá vòng 2 theo phản hồi file thật (□ thay dấu cách, vỡ dòng, ô sửa lệch)
|
||||||
|
Ba lỗi user báo sau khi thử bản build 6 trên chính file Word-export:
|
||||||
|
- **Dấu cách thành ô vuông □ sau khi lưu**: PDFium `FPDFText_LoadFont` nạp
|
||||||
|
LẠI font subset (Word xuất) rồi `SetText` sẽ map U+0020 ra `.notdef` (glyph
|
||||||
|
hộp) dù cmap của font CÓ space — lỗi nằm ở PDFium, không phải font. Fix:
|
||||||
|
**tầng 0 mới của thang font reflow — dùng LẠI chính font object gốc trong
|
||||||
|
PDF** (lấy `PdfFontToken` từ run neo, không nạp lại): glyph + charcode map
|
||||||
|
gốc xử lý dấu cách chuẩn, file không phình thêm bản font nữa. Chỉ chọn sau
|
||||||
|
khi **probe ghi/đọc-lại toàn bộ text mới** thành công (tạo object nháp →
|
||||||
|
so text → gỡ). Khi ghi từng dòng cũng **đọc lại ngay để tự kiểm** — nếu
|
||||||
|
PDFium vẫn âm thầm vứt ký tự thì gỡ và **ghi lại theo TỪNG TỪ tự đặt vị trí
|
||||||
|
bằng advance hmtx** (không bao giờ ghi glyph dấu cách) — lưới an toàn cuối.
|
||||||
|
- **Thêm 1 ký tự làm vỡ dòng** ("HỢP"→"HỢPP" đẩy "LOG" rơi xuống dòng mới):
|
||||||
|
dòng cứng (`\n` từ ô sửa) giờ được **NỞ tới +35% bề rộng khối** (chặn mép
|
||||||
|
trang) trước khi phải bẻ lại — hành vi hộp text tự nở của Foxit; khối căn
|
||||||
|
giữa nở đều 2 phía quanh tâm.
|
||||||
|
- **Mỗi dòng giữ đúng CỠ CHỮ GỐC của dòng đó** (tiêu đề 22.5/20pt không còn
|
||||||
|
bị ép cả khối về cỡ run neo): kế hoạch reflow ghi `line_styles` theo từng
|
||||||
|
baseline gốc, dòng cứng thứ i ăn cỡ dòng gốc thứ i.
|
||||||
|
- **Ô sửa WYSIWYG**: thay textarea bằng `contenteditable` — mỗi dòng gốc là
|
||||||
|
1 div với đúng **font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ**, khối căn giữa hiện
|
||||||
|
căn giữa, khung đặt trùng bbox khối (bù lệch dọc line-box) → chữ trong ô
|
||||||
|
nằm NGUYÊN vị trí chữ gốc khi bắt đầu sửa; con trỏ đặt đúng chỗ vừa đúp
|
||||||
|
(`caretRangeFromPoint`).
|
||||||
|
- Kiểm chứng trên file thật: sửa "HỢP"→"HỢPP" giữ đúng 2 dòng căn giữa cỡ
|
||||||
|
22.5/20pt, font `BAAAAA+TimesNewRomanPS-BoldMT` gốc, dấu cách nguyên vẹn,
|
||||||
|
render so khớp bản gốc. 2 test hồi quy mới (`reflow_keeps_per_line_font_sizes`,
|
||||||
|
`reflow_hard_line_grows_without_rewrap`) — 21 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).
|
||||||
- Khối lẫn NHIỀU font/cỡ trong 1 đoạn: dòng mới thống nhất theo run neo.
|
- Khối lẫn NHIỀU font/cỡ trong 1 ĐOẠN: cỡ giữ theo TỪNG DÒNG; font/màu theo
|
||||||
|
run neo (dòng lẫn nhiều font sẽ thống nhất font).
|
||||||
|
|
||||||
> Iteration 1: 43/43 test, đã chạy app thật + chụp ảnh xác minh.
|
> Iteration 1: 43/43 test, đã chạy app thật + chụp ảnh xác minh.
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ cargo run
|
|||||||
| 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** | |
|
| 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** | |
|
| 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ữ) | |
|
| E38 | Khi ô sửa đang mở | Ô che kín chữ cũ bên dưới (không thấy 2 lớp chữ) | |
|
||||||
|
| E39 | Mở ô sửa trên tiêu đề 2 dòng 2 cỡ chữ (22.5/20pt) | Chữ trong ô hiện **đúng cỡ TỪNG DÒNG + căn giữa + đậm/màu như bản gốc**, vị trí chữ TRÙNG chữ gốc (không xê dịch khi bắt đầu sửa) | |
|
||||||
|
| E40 | Thêm 1–2 ký tự vào dòng dài nhất (vd "HỢP"→"HỢPP") rồi áp dụng | Dòng **NỞ ra, KHÔNG bị bẻ** thành dòng mới; số dòng giữ nguyên | |
|
||||||
|
| E41 | Sau khi áp dụng, nhìn khoảng trắng giữa các từ | Dấu cách bình thường — **KHÔNG có ô vuông □**; mở lại ở Foxit/Adobe font vẫn là font gốc của file | |
|
||||||
|
| E42 | Sửa tiêu đề rồi đo bằng mắt cỡ 2 dòng | Dòng 1 vẫn to hơn dòng 2 đúng như gốc (cỡ chữ giữ THEO DÒNG) | |
|
||||||
|
|
||||||
## 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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user