Edit mode: zoom + chuyển trang + dòng lẫn nhiều style (thường/đậm/mono)
Build Windows portable / build (push) Canceled after 0s

3 vấn đề user báo trong chế độ Sửa nội dung:

1. ZOOM: stage sửa trước đây cứng 820px — nút +/-, dropdown %, Vừa rộng/
   Vừa trang và Ctrl+lăn chuột đều vô tác dụng. Giờ editZoom riêng (kế thừa
   zoom viewer khi vào): co giãn ảnh + overlay NGAY (CSS, mượt), render nét
   PDFium sau 220ms ngừng thao tác; mọi nút zoom/fit + Ctrl+wheel hoạt động;
   fix flex-center overflow (margin auto) để zoom to không cắt mép trái.

2. CHUYỂN TRANG: nút ▲▼/ô nhập trang trước đây chỉ điều khiển viewer đang
   ẩn. Giờ goToPage/gotoPrev/Next nhận biết edit mode → switchEditPage
   (đóng ô sửa đang mở, giữ nguyên undo/thay đổi các trang khác vì dùng
   chung editBase).

3. DÒNG LẪN STYLE (thường + đậm/mono): ô sửa trước áp 1 style/dòng nên
   "windowsInitialPassword" đậm hiện thành chữ thường, commit còn ép cả
   dòng về 1 font. Giờ:
   - UI: mỗi nhóm run liền kề cùng style = 1 SPAN riêng (đúng font/cỡ/màu
     từng phần khi gõ); commit đọc theo đoạn style (text node trần mượn
     style span kề); >=2 style gửi kèm richLines.
   - Engine: ReflowText thêm rich (Vec<Vec<RichSeg>>) — resolve thang
     giữ-font RIÊNG cho từng style-run (probe token gốc/nhúng lại bytes/
     builtin/cùng họ), token hoá theo từ giữ style + glue giữa đoạn dính
     liền, bẻ dòng chung cả khối, vẽ mỗi cụm cùng-style thành 1 object
     đúng font/cỡ/màu, tự kiểm + fallback từng từ như đường 1-style.
   - fitEditLinesToPdf: đo text node sâu trong span; hiệu chỉnh cỡ scale
     cả span. Tầng-0 token gốc tắt khi KHỐI có run nested (trước chỉ xét
     anchor — token vô hiệu sau khi mở lại document hậu phẫu thuật).
   - Test: fixture 1 dòng Helvetica + Courier-Bold → sửa phần thường,
     phần mono giữ nguyên BaseFont Courier-Bold, cùng baseline đúng thứ tự.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 09:03:32 +07:00
co-authored by Claude Fable 5
parent 0bb1a12ac1
commit f0b4de9af1
6 changed files with 673 additions and 32 deletions
+26 -1
View File
@@ -741,6 +741,18 @@ struct EditOpDto {
/// reflowText: index các run của khối đoạn văn.
#[serde(default)]
indices: Vec<u16>,
/// reflowText nhiều style: mỗi dòng cứng = danh sách đoạn (text + run
/// mang style). None/thiếu = khối 1 style.
#[serde(default)]
rich_lines: Option<Vec<Vec<RichSegDto>>>,
}
/// 1 đoạn cùng style trong reflow nhiều-style.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RichSegDto {
text: String,
style: u16,
}
fn one() -> f32 {
@@ -771,7 +783,20 @@ fn edit_op_from_dto(d: EditOpDto) -> Result<ff_engine::EditOp, String> {
bold: d.bold.unwrap_or(false),
italic: d.italic.unwrap_or(false),
},
"reflowText" => ff_engine::EditOp::ReflowText { indices: d.indices, text: d.text },
"reflowText" => ff_engine::EditOp::ReflowText {
indices: d.indices,
text: d.text,
rich: d.rich_lines.map(|lines| {
lines
.into_iter()
.map(|line| {
line.into_iter()
.map(|s| ff_engine::RichSeg { text: s.text, style: s.style })
.collect()
})
.collect()
}),
},
"addImage" => ff_engine::EditOp::AddImage {
x: d.x,
y: d.y,
+207 -17
View File
@@ -39,6 +39,7 @@ const state = {
editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
editPendingPoint: null, // điểm PDF chờ mở ô sửa (đúp chuột từ viewer thường)
editZoom: 1, // zoom stage sửa (1.0 = 100% như viewer)
editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text
editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn)
secMode: false, // thanh Bảo mật (Phase 5) đang mở
@@ -330,14 +331,15 @@ function updatePageTotal() {
function goToPageInput() {
const n = parseInt($("pageInput").value, 10);
if (!Number.isFinite(n)) { $("pageInput").value = state.current + 1; return; }
if (!Number.isFinite(n)) { $("pageInput").value = currentPageIndex() + 1; return; }
const idx = Math.max(0, Math.min(state.pages.length - 1, n - 1));
goToPage(idx);
$("pageInput").value = idx + 1;
}
function gotoPrevPage() { goToPage(Math.max(0, state.current - 1)); }
function gotoNextPage() { goToPage(Math.min(state.pages.length - 1, state.current + 1)); }
function currentPageIndex() { return state.editMode ? state.editPage : state.current; }
function gotoPrevPage() { goToPage(Math.max(0, currentPageIndex() - 1)); }
function gotoNextPage() { goToPage(Math.min(state.pages.length - 1, currentPageIndex() + 1)); }
// ---------- Thumbnails ----------
@@ -412,13 +414,63 @@ function buildOutline(items) {
// ---------- Điều hướng ----------
function goToPage(idx) {
// Chế độ Sửa nội dung: chuyển trang = nạp trang khác của editBase.
if (state.editMode) { switchEditPage(idx); return; }
const slot = state.slots[idx];
if (slot) slot.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Chuyển trang trong chế độ Sửa nội dung (undo/redo dùng chung editBase nên
// vẫn giữ nguyên các thay đổi đã áp ở trang khác).
function switchEditPage(idx) {
const i = Math.max(0, Math.min(state.pages.length - 1, idx));
if (i === state.editPage) return;
const ce = $("editOverlay").querySelector(".edit-inline");
if (ce) ce.remove(); // đóng ô sửa đang mở, không lưu
state.editPage = i;
state.editSel = null;
selectEditObject(-1);
$("pageInput").value = i + 1;
loadEditPage();
}
// ---------- Zoom ----------
// Bề rộng px của stage sửa theo mức zoom hiện tại (1.0 = 100% như viewer).
function editStageWidth(p) {
return Math.max(200, Math.round(p.widthPt * PT_PER_PX * state.editZoom));
}
// Zoom chế độ Sửa nội dung: co giãn ảnh + overlay NGAY (mượt), render nét
// bằng PDFium sau khi ngừng thao tác.
function setEditZoom(z) {
state.editZoom = Math.max(0.25, Math.min(4, z));
const p = state.pages[state.editPage];
if (!p) return;
const w = editStageWidth(p);
state.editScale = w / p.widthPt;
$("editImg").style.width = w + "px";
buildEditOverlay();
updateZoomLabel();
scheduleEditSharpRender();
}
let editSharpTimer = null;
function scheduleEditSharpRender() {
clearTimeout(editSharpTimer);
editSharpTimer = setTimeout(async () => {
try {
const p = state.pages[state.editPage];
const url = await invoke("render_page", {
path: state.editBase, page: state.editPage, width: editStageWidth(p),
});
$("editImg").src = url;
} catch (_) { /* giữ ảnh cũ (đã co giãn CSS) */ }
}, 220);
}
function setZoom(z) {
if (state.editMode) { setEditZoom(z); return; }
finishEditing();
closeNotePopup();
closeColorPopover();
@@ -468,7 +520,7 @@ function buildZoomSelect() {
}
function updateZoomLabel() {
const sel = $("zoomSelect");
const pct = Math.round(state.zoom * 100);
const pct = Math.round((state.editMode ? state.editZoom : state.zoom) * 100);
let custom = sel.querySelector('option[data-custom="1"]');
const matchesPreset = ZOOM_PRESETS.includes(pct);
if (matchesPreset) {
@@ -503,6 +555,11 @@ function zoomAtPoint(newZoom, clientX, clientY) {
function fitWidth() {
if (!state.pages.length) return;
if (state.editMode) {
const p = state.pages[state.editPage];
setEditZoom(($("editStage").clientWidth - 48) / (p.widthPt * PT_PER_PX));
return;
}
const avail = $("viewport").clientWidth - 48;
const p = state.pages[state.current] || state.pages[0];
setZoom(avail / (p.widthPt * PT_PER_PX));
@@ -510,6 +567,15 @@ function fitWidth() {
function fitPage() {
if (!state.pages.length) return;
if (state.editMode) {
const st = $("editStage");
const p = state.pages[state.editPage];
setEditZoom(Math.min(
(st.clientWidth - 48) / (p.widthPt * PT_PER_PX),
(st.clientHeight - 48) / (p.heightPt * PT_PER_PX)
));
return;
}
const vp = $("viewport");
const availW = vp.clientWidth - 48;
const availH = vp.clientHeight - 48;
@@ -2047,6 +2113,8 @@ function enterEditMode(pageIndex) {
if (state.organizeMode) exitOrganizeMode();
state.editMode = true;
state.editPage = Number.isInteger(pageIndex) ? pageIndex : state.current;
// Kế thừa mức zoom đang xem để không "giật" cỡ hiển thị khi vào chế độ sửa.
state.editZoom = Math.max(0.25, Math.min(4, state.zoom));
state.editBase = state.path;
state.editUndo = [];
state.editRedo = [];
@@ -2119,18 +2187,23 @@ async function onViewerDblClick(e) {
// Đọc lại object + render ảnh trang hiện tại từ editBase, dựng overlay box.
async function loadEditPage() {
const p = state.pages[state.editPage];
state.editScale = EDIT_STAGE_W / p.widthPt;
const stageW = editStageWidth(p);
state.editScale = stageW / p.widthPt;
try {
const [url, objs] = await Promise.all([
invoke("render_page", { path: state.editBase, page: state.editPage, width: EDIT_STAGE_W }),
invoke("render_page", { path: state.editBase, page: state.editPage, width: stageW }),
invoke("edit_list_objects", { path: state.editBase, page: state.editPage, password: null }),
]);
// Object nằm TRONG Form XObject (file Canva/Illustrator): sửa/xoá text
// được xử lý bằng PHẪU THUẬT stream ở engine (trong suốt với UI) — không
// cần mở gói trang.
$("editImg").src = url;
const img = $("editImg");
img.src = url;
img.style.width = stageW + "px";
state.editObjects = objs;
buildEditOverlay();
$("pageInput").value = state.editPage + 1;
updateZoomLabel();
$("editHint").textContent = `Trang ${state.editPage + 1} · ${objs.length} đối tượng`;
} catch (e) {
$("editHint").textContent = "Lỗi nạp trang sửa: " + e;
@@ -2514,6 +2587,46 @@ function stripLineMarkers(line) {
};
}
// Khoá style của 1 run (gom span trong ô sửa + so trùng style).
function styleKeyOf(r) {
return [
(r.fontFamily || "").toLowerCase(),
!!r.fontBold,
!!r.fontItalic,
Math.round(Math.abs(r.fontSize || 12) * 10),
(r.color || [0, 0, 0]).slice(0, 3).join(","),
].join("|");
}
// Nhóm các run LIỀN KỀ cùng style của 1 dòng thành đoạn văn bản (giữ logic
// chèn khoảng trắng theo khoảng hở như composeLineText) — để dòng lẫn chữ
// thường + đậm hiện đúng từng phần trong ô sửa.
function styleGroupsOfLine(runs) {
const groups = [];
let prev = null;
for (const r of runs) {
const t = r.text || "";
if (!t.trim()) continue;
let sep = "";
if (prev && groups.length) {
const gap = r.rect.left - prev.rect.right;
const em = Math.abs(r.fontSize || 12) * 0.28;
const prevText = groups[groups.length - 1].text;
if (gap > em && !prevText.endsWith(" ") && !t.startsWith(" ")) sep = " ";
}
const key = styleKeyOf(r);
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.text += sep + t;
} else {
if (last && sep) last.text += " ";
groups.push({ key, rep: r, text: t });
}
prev = r;
}
return groups;
}
// Format đại diện của 1 dòng (từ run có chữ đầu tiên) — để so "cùng đoạn".
function lineFormat(l) {
const rep = l.runs.find((r) => (r.text || "").trim()) || l.runs[0];
@@ -2676,9 +2789,6 @@ function startBlockTextEdit(o, lines, ev) {
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>";
const divFs = Math.max(6, (l.fs || 12) * s);
div.style.fontSize = divFs + "px";
div.style.lineHeight = perLineAdvances[i] + "px";
@@ -2686,6 +2796,24 @@ function startBlockTextEdit(o, lines, ev) {
div.style.fontWeight = (rep.fontBold != null ? rep.fontBold : o.fontBold) ? "bold" : "normal";
div.style.fontStyle = (rep.fontItalic != null ? rep.fontItalic : o.fontItalic) ? "italic" : "normal";
if (rep.color || o.color) div.style.color = rgbCss(rep.color || o.color);
// MỖI NHÓM STYLE = 1 span riêng: dòng lẫn chữ thường + đậm/mono hiện
// đúng từng phần; dataset.styleRun = run mang style (commit giữ style).
const groups = styleGroupsOfLine(l.runs);
if (!groups.length) {
div.innerHTML = "<br>";
} else {
for (const g of groups) {
const span = document.createElement("span");
span.textContent = g.text;
span.dataset.styleRun = String(g.rep.index);
span.style.fontFamily = cssFontStack(g.rep.fontFamily || o.fontFamily);
span.style.fontWeight = g.rep.fontBold ? "bold" : "normal";
span.style.fontStyle = g.rep.fontItalic ? "italic" : "normal";
span.style.fontSize = Math.max(6, Math.abs(g.rep.fontSize || l.fs || 12) * s) + "px";
if (g.rep.color) span.style.color = rgbCss(g.rep.color);
div.appendChild(span);
}
}
// Giữ thụt lề từng dòng so với mép khối (dòng đầu đoạn thụt vào…).
if (!centered) {
const indent = (l.rect.left - leftPt) * s;
@@ -2723,7 +2851,9 @@ function startBlockTextEdit(o, lines, ev) {
password: null,
});
state.editTemps.push(tmp);
const url = await invoke("render_page", { path: tmp, page: state.editPage, width: EDIT_STAGE_W });
const url = await invoke("render_page", {
path: tmp, page: state.editPage, width: editStageWidth(state.pages[state.editPage]),
});
if (ce.isConnected) {
img.src = url;
bgSwapped = true;
@@ -2762,11 +2892,44 @@ function startBlockTextEdit(o, lines, ev) {
return t;
};
// \u0110\u1ecdc theo \u0110O\u1ea0N STYLE: m\u1ed7i d\u00f2ng = [{text, style: index run mang style}].
// Text node tr\u1ea7n (g\u00f5 \u1edf m\u00e9p d\u00f2ng) m\u01b0\u1ee3n style c\u1ee7a span k\u1ec1 b\u00ean.
const readRich = () => {
const lines = [];
for (const div of ce.querySelectorAll(".edit-line")) {
const segs = [];
for (const node of div.childNodes) {
const txt = (node.textContent || "").replace(/\u00a0/g, " ");
if (!txt || node.nodeName === "BR") continue;
let si = null;
if (node.nodeType === 1 && node.dataset && node.dataset.styleRun) {
si = Number(node.dataset.styleRun);
} else {
const pick = (sib) =>
sib && sib.nodeType === 1 && sib.dataset && sib.dataset.styleRun
? Number(sib.dataset.styleRun)
: null;
const p = pick(node.previousSibling);
si = p != null ? p : pick(node.nextSibling);
}
if (segs.length && segs[segs.length - 1].style === si) segs[segs.length - 1].text += txt;
else segs.push({ text: txt, style: si });
}
const known = segs.find((g) => g.style != null);
for (const g of segs) {
if (g.style == null) g.style = known ? known.style : allRuns[0].index;
}
lines.push(segs);
}
return lines;
};
let done = false;
const commit = (save) => {
if (done) return;
done = true;
const text = readText();
const rich = readRich();
ce.remove();
ov.querySelectorAll(".edit-box").forEach((b) => { b.style.display = ""; });
const changed = save && text.trim() && text !== original;
@@ -2776,10 +2939,17 @@ function startBlockTextEdit(o, lines, ev) {
if (bgSwapped) img.src = prevSrc;
if (changed) {
$("editHint").textContent = "Đang áp dụng thay đổi…";
// Khối có TỪ 2 STYLE trở lên (thường + đậm/mono...) → gửi kèm đoạn
// style để engine giữ đúng font/màu từng phần; 1 style → đường cũ
// (ưu tiên dùng lại chính font object gốc).
const styleCount = new Set(
rich.flatMap((line) => line.map((g) => styleKeyOf(state.editObjects.find((x) => x.index === g.style) || {})))
).size;
stageEditOp({
op: "reflowText",
indices: allRuns.map((r) => r.index),
text,
richLines: styleCount > 1 ? rich : null,
});
} else {
$("editHint").textContent = "";
@@ -2845,7 +3015,13 @@ function fitEditLinesToPdf(ce, lines, s, page) {
const wantFs = targetPx / inkPerPx;
const ratio = wantFs / f.fs;
if (ratio < 0.88 || ratio > 1.12) {
divs[i].style.fontSize = f.fs * Math.max(0.3, Math.min(3, ratio)) + "px";
const k = Math.max(0.3, Math.min(3, ratio));
divs[i].style.fontSize = f.fs * k + "px";
// Dòng nhiều style: span có cỡ px riêng → scale theo cùng hệ số.
divs[i].querySelectorAll("span[data-style-run]").forEach((sp) => {
const cur = parseFloat(sp.style.fontSize) || f.fs;
sp.style.fontSize = cur * k + "px";
});
}
}
}
@@ -2879,10 +3055,13 @@ function fitEditLinesToPdf(ce, lines, s, page) {
}
// Bbox render thật của text trong div — đo bằng Range (chỉ tính chữ, không
// tính line-box nên không dính line-height); null nếu dòng rỗng.
// tính line-box nên không dính line-height); null nếu dòng rỗng. Text có
// thể nằm trong SPAN (dòng nhiều style) → tìm text node đầu tiên theo sâu.
function firstTextRect(div) {
const tn = div.firstChild;
if (!tn || tn.nodeType !== Node.TEXT_NODE) return null;
const walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT);
let tn = walker.nextNode();
while (tn && !(tn.textContent || "").trim()) tn = walker.nextNode();
if (!tn) return null;
const range = document.createRange();
range.selectNodeContents(tn);
const rects = range.getClientRects();
@@ -3649,8 +3828,19 @@ if (window.__TAURI__.event) {
loadDocument(pdf);
});
}
$("zoomIn").addEventListener("click", () => setZoom(state.zoom * 1.25));
$("zoomOut").addEventListener("click", () => setZoom(state.zoom / 1.25));
$("zoomIn").addEventListener("click", () => setZoom((state.editMode ? state.editZoom : state.zoom) * 1.25));
$("zoomOut").addEventListener("click", () => setZoom((state.editMode ? state.editZoom : state.zoom) / 1.25));
// Ctrl + lăn chuột để zoom trong chế độ Sửa nội dung (viewer đã có sẵn).
$("editStage").addEventListener(
"wheel",
(e) => {
if (!e.ctrlKey) return;
e.preventDefault();
setEditZoom(state.editZoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
},
{ passive: false }
);
$("zoomFit").addEventListener("click", fitWidth);
$("zoomFitPage").addEventListener("click", fitPage);
buildZoomSelect();
+3 -1
View File
@@ -385,7 +385,9 @@ body.tool-active .annotlayer * { pointer-events: none; }
overflow: hidden;
text-overflow: ellipsis;
}
.edit-page-wrap { position: relative; box-shadow: 0 2px 12px rgba(0,0,0,.5); background: #fff; }
/* margin auto (thay vì chỉ justify-content) để khi zoom to hơn khung, mép
trái không bị cắt mất — bug kinh điển của flex center + overflow. */
.edit-page-wrap { position: relative; box-shadow: 0 2px 12px rgba(0,0,0,.5); background: #fff; margin: auto; }
.edit-img { display: block; }
.edit-overlay { position: absolute; inset: 0; }
.edit-overlay.armed { cursor: crosshair; }
+318 -7
View File
@@ -142,7 +142,19 @@ pub enum EditOp {
/// trong `indices` bị thay bằng `text` mới, tự bẻ dòng theo bề rộng khối
/// (đo bằng hmtx của font), giữ baseline spacing + font/cỡ/màu của run
/// neo (run có baseline cao nhất). `\n` trong `text` = ngắt dòng cứng.
ReflowText { indices: Vec<u16>, text: String },
///
/// `rich` (tuỳ chọn): dòng lẫn NHIỀU STYLE (thường + đậm/mono…) — mỗi
/// dòng cứng là danh sách đoạn (text, run mang style); engine giữ đúng
/// font/cỡ/màu TỪNG ĐOẠN khi vẽ lại. None = cả khối 1 style như cũ.
ReflowText { indices: Vec<u16>, text: String, rich: Option<Vec<Vec<RichSeg>>> },
}
/// 1 đoạn chữ CÙNG STYLE trong 1 dòng cứng của reflow nhiều-style.
#[derive(Clone, Debug)]
pub struct RichSeg {
pub text: String,
/// Index (danh sách phẳng) của run mang style cho đoạn này.
pub style: u16,
}
fn quad_to_rect(q: &PdfQuadPoints) -> Rect {
@@ -651,6 +663,17 @@ struct ReflowPlan {
/// đú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,
/// Reflow NHIỀU STYLE: các dòng cứng theo đoạn + bảng style đã resolve.
rich: Option<(Vec<Vec<RichSeg>>, HashMap<u16, RichStyle>)>,
}
/// Style đã resolve cho 1 đoạn rich: font vẽ + bytes đo + màu + cỡ.
struct RichStyle {
draw: ReflowFont,
measure: Option<Vec<u8>>,
color: PdfColor,
tf: f32,
scaled: f32,
}
/// Probe: font `token` có ghi được `text` tròn trịa qua PDFium không?
@@ -853,7 +876,7 @@ pub fn apply_edits(
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
add_text_keys.insert(opi, key);
}
EditOp::ReflowText { indices, text } => {
EditOp::ReflowText { indices, text, rich } => {
// Run text hợp lệ của khối (sort + dedup).
let mut idxs: Vec<u16> = indices.iter().copied().filter(|i| valid(*i)).collect();
idxs.sort_unstable();
@@ -1066,11 +1089,13 @@ pub fn apply_edits(
let fam_key = fontmatch::normalize_key(&family);
let ascii_only =
text.chars().all(|c| c.is_control() || (' '..='~').contains(&c));
// Tầng 0 (dùng lại token font gốc) chỉ dùng được khi run neo
// ở CẤP TRANG: run trong form sẽ bị phẫu thuật xoá + document
// MỞ LẠI (token cũ vô hiệu) → đi tầng nhúng-lại-bytes/cùng-họ.
let anchor_nested = entries[anchor_idx as usize].path.len() > 1;
let original_ok = !anchor_nested
// Tầng 0 (dùng lại token font gốc) chỉ dùng được khi KHỐI
// không dính run trong form: có run nested → phẫu thuật xoá
// + document MỞ LẠI (mọi token cũ vô hiệu) → đi tầng
// nhúng-lại-bytes/cùng-họ.
let block_has_nested =
idxs.iter().any(|&i| entries[i as usize].path.len() > 1);
let original_ok = !block_has_nested
&& font_embedded
&& probe_font_roundtrip(&mut page, orig_token, text);
let embedded_bytes = if font_embedded {
@@ -1102,6 +1127,116 @@ pub fn apply_edits(
(ReflowFont::Loaded(key), Some(bytes))
};
// Reflow NHIỀU STYLE: resolve font/màu/cỡ TỪNG style-run
// (thang giữ-font như trên, tính theo text của style đó).
let rich_resolved: Option<(Vec<Vec<RichSeg>>, HashMap<u16, RichStyle>)> =
if let Some(rich_lines) = rich {
let mut style_ids: Vec<u16> =
rich_lines.iter().flatten().map(|s| s.style).collect();
style_ids.sort_unstable();
style_ids.dedup();
let mut table: HashMap<u16, RichStyle> = HashMap::new();
for sid in style_ids {
let style_text: String = rich_lines
.iter()
.flatten()
.filter(|s| s.style == sid)
.map(|s| s.text.as_str())
.collect();
let src = if valid(sid) { sid } else { anchor_idx };
let (sfam, sbold, sitalic, semb, sdata, stoken, stf, sscaled, scolor, snested) = {
let entry = &entries[src as usize];
let obj = object_at_path(&page, &entry.path)?;
match obj.as_text_object() {
Some(t) => {
let (f, b, i2) = text_object_style(t);
(
f,
b,
i2,
t.font().is_embedded().unwrap_or(false),
t.font().data().ok(),
t.font().token(),
t.unscaled_font_size().value.abs().max(1.0),
(t.scaled_font_size().value * mat_vscale(entry.acc)).abs().max(1.0),
t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)),
entry.path.len() > 1,
)
}
None => (
family.clone(),
bold,
italic,
false,
None,
orig_token,
unscaled.max(1.0),
scaled,
color,
true,
),
}
};
let s_ascii = style_text
.chars()
.all(|c| c.is_control() || (' '..='~').contains(&c));
let s_orig_ok = !block_has_nested
&& !snested
&& semb
&& probe_font_roundtrip(&mut page, stoken, &style_text);
let s_cover_bytes = if semb {
sdata.clone().filter(|b| fontmatch::coverage_ok(b, &style_text))
} else {
None
};
let skey = fontmatch::normalize_key(&sfam);
let (sdraw, smeasure) = if s_orig_ok {
(
ReflowFont::Original(stoken),
sdata.filter(|b| ttf_parser::Face::parse(b, 0).is_ok()),
)
} else if let Some(bytes) = s_cover_bytes {
let key = (format!("emb:{skey}:{src}"), sbold, sitalic);
font_needed
.entry(key.clone())
.or_insert(FontLoad::Bytes(bytes.clone()));
(ReflowFont::Loaded(key), Some(bytes))
} else if let (Some(builtin), true) =
(builtin_for(&skey, sbold, sitalic), s_ascii)
{
let measure =
fontmatch::find_family_font_bytes(&sfam, sbold, sitalic)
.or_else(|| find_font_bytes(sbold, sitalic));
let key = (format!("b14:{skey}"), sbold, sitalic);
font_needed
.entry(key.clone())
.or_insert(FontLoad::Builtin(builtin));
(ReflowFont::Loaded(key), measure)
} else {
let (key, bytes) = resolve_substitute_font(
&sfam, sbold, sitalic, &style_text,
)?;
font_needed
.entry(key.clone())
.or_insert(FontLoad::Bytes(bytes.clone()));
(ReflowFont::Loaded(key), Some(bytes))
};
table.insert(
sid,
RichStyle {
draw: sdraw,
measure: smeasure,
color: scolor,
tf: stf,
scaled: sscaled,
},
);
}
Some((rich_lines.clone(), table))
} else {
None
};
reflow_plans.insert(
opi,
ReflowPlan {
@@ -1118,6 +1253,7 @@ pub fn apply_edits(
scaled,
line_styles,
color,
rich: rich_resolved,
},
);
}
@@ -1493,6 +1629,181 @@ pub fn apply_edits(
};
let grow_limit = (plan.width * 1.35).min(avail.max(plan.width * 1.02));
// ---- Vẽ NHIỀU STYLE (dòng lẫn thường + đậm/mono…) ----
// Token hoá theo TỪ giữ style; bẻ dòng chung cả khối; mỗi
// cụm từ liền kề cùng style = 1 text object với đúng
// font/cỡ/màu của style đó.
if let Some((rich_lines, styles)) = &plan.rich {
struct Piece {
text: String,
style: u16,
lead: f32,
}
let faces: HashMap<u16, Option<ttf_parser::Face>> = styles
.iter()
.map(|(k, s)| {
(*k, s.measure.as_deref().and_then(|b| ttf_parser::Face::parse(b, 0).ok()))
})
.collect();
let measure_c = |sid: u16, c: char| -> f32 {
let sc = styles.get(&sid).map(|s| s.scaled).unwrap_or(plan.scaled);
match faces.get(&sid).and_then(|f| f.as_ref()) {
Some(f) => fontmatch::char_advance(f, c, sc),
None => sc * 0.5,
}
};
let measure_s = |sid: u16, s: &str| -> f32 {
s.chars().map(|c| measure_c(sid, c)).sum()
};
let (a, b, c2, d) = plan.linear;
let mut line_no = 0usize;
for segs in rich_lines {
// Token hoá: tách từ, giữ "glue" khi 2 đoạn dính liền giữa từ.
struct Tok {
text: String,
style: u16,
glue: bool,
}
let mut toks: Vec<Tok> = Vec::new();
let mut prev_ends_ws = true;
for seg in segs {
let starts_ws = seg.text.starts_with(char::is_whitespace);
let mut first = true;
for w in seg.text.split_whitespace() {
let glue = first && !starts_ws && !prev_ends_ws && !toks.is_empty();
toks.push(Tok { text: w.to_string(), style: seg.style, glue });
first = false;
}
prev_ends_ws = seg.text.trim().is_empty()
|| seg.text.ends_with(char::is_whitespace);
}
// Tổng bề rộng KHÔNG bẻ — xét ngưỡng nở như đường 1-style.
let hard_w: f32 = toks
.iter()
.enumerate()
.map(|(i, t)| {
let sp = if i == 0 || t.glue { 0.0 } else { measure_c(t.style, ' ') };
sp + measure_s(t.style, &t.text)
})
.sum();
let must_wrap = hard_w > grow_limit;
let mut out: Vec<Vec<Piece>> = Vec::new();
let mut cur: Vec<Piece> = Vec::new();
let mut cur_w = 0.0f32;
for t in toks {
let lead = if cur.is_empty() || t.glue { 0.0 } else { measure_c(t.style, ' ') };
let tw = measure_s(t.style, &t.text);
if must_wrap && !cur.is_empty() && cur_w + lead + tw > plan.width {
out.push(std::mem::take(&mut cur));
cur_w = tw;
cur.push(Piece { text: t.text, style: t.style, lead: 0.0 });
continue;
}
cur_w += lead + tw;
if let Some(last) = cur.last_mut() {
if last.style == t.style {
if lead > 0.0 {
last.text.push(' ');
}
last.text.push_str(&t.text);
continue;
}
}
cur.push(Piece { text: t.text, style: t.style, lead });
}
if !cur.is_empty() {
out.push(cur);
}
if out.is_empty() {
out.push(Vec::new()); // dòng trống vẫn chiếm 1 nhịp
}
for pieces in out {
let y = plan.first_baseline - (line_no as f32) * plan.line_advance;
line_no += 1;
if pieces.is_empty() {
continue;
}
let total: f32 = pieces
.iter()
.map(|p| p.lead + measure_s(p.style, &p.text))
.sum();
let mut x = if plan.centered {
(plan.left + plan.width / 2.0 - total / 2.0).max(8.0)
} else {
plan.left
};
for p in &pieces {
x += p.lead;
let st = &styles[&p.style];
let token = match &st.draw {
ReflowFont::Original(tok) => *tok,
ReflowFont::Loaded(key) => {
tokens.get(key).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font rich".into())
})?
}
};
// Ghi + tự kiểm; hỏng → ghi lại TỪNG TỪ tự đặt
// vị trí (không bao giờ ghi glyph dấu cách).
let wrote_ok = {
let mut obj = page
.objects_mut()
.create_text_object(
PdfPoints::ZERO,
PdfPoints::ZERO,
p.text.clone(),
token,
PdfPoints::new(st.tf),
)
.map_err(err)?;
let ok = obj
.as_text_object()
.map(|t| t.text() == p.text)
.unwrap_or(false);
if ok {
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
.map_err(err)?;
obj.set_fill_color(st.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_c(p.style, ' ');
let mut cx = x;
for word in p.text.split_whitespace() {
let mut obj = page
.objects_mut()
.create_text_object(
PdfPoints::ZERO,
PdfPoints::ZERO,
word,
token,
PdfPoints::new(st.tf),
)
.map_err(err)?;
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
.map_err(err)?;
obj.set_fill_color(st.color).map_err(err)?;
cx += measure_s(p.style, word) + space_w;
}
}
x += measure_s(p.style, &p.text);
}
}
}
reflow_out_lines.insert(opi, line_no);
continue;
}
// 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 };
+1 -1
View File
@@ -23,7 +23,7 @@ pub mod watermark;
pub use annot::{
apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec,
};
pub use edit::{apply_edits, flatten_form_xobjects, list_objects, EditOp, ObjectInfo, ObjectKind};
pub use edit::{apply_edits, flatten_form_xobjects, list_objects, EditOp, ObjectInfo, ObjectKind, RichSeg};
pub use meta::{outline, page_dims, strip_metadata, OutlineItem, PageDim};
pub use organize::{
build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages,
+118 -5
View File
@@ -635,7 +635,7 @@ của khối đoạn văn ban đầu, giữ nguyên phông chữ nhúng và kho
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: runs.iter().map(|r| r.index).collect(), text: long_text.into() }],
&[EditOp::ReflowText { indices: runs.iter().map(|r| r.index).collect(), text: long_text.into(), rich: None }],
&out,
None,
)
@@ -686,6 +686,7 @@ fn reflow_hard_break_creates_new_line() {
&[EditOp::ReflowText {
indices: runs.iter().map(|r| r.index).collect(),
text: "Đoạn một ngắn.\nĐoạn hai riêng.".into(),
rich: None,
}],
&out,
None,
@@ -718,7 +719,7 @@ the declared standard Helvetica base font untouched.";
&pdf,
&input,
0,
&[EditOp::ReflowText { indices: vec![idx], text: long_ascii.into() }],
&[EditOp::ReflowText { indices: vec![idx], text: long_ascii.into(), rich: None }],
&out,
None,
)
@@ -775,7 +776,7 @@ fn reflow_expands_indices_to_cover_block() {
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: vec![first, third], text: "Dòng đã thay hoàn toàn".into() }],
&[EditOp::ReflowText { indices: vec![first, third], text: "Dòng đã thay hoàn toàn".into(), rich: None }],
&out,
None,
)
@@ -853,6 +854,7 @@ fn reflow_preserves_centered_alignment() {
&[EditOp::ReflowText {
indices: idxs,
text: "TIÊU ĐỀ MỚI DÀI HƠN CHÚT\nNGẮN".into(),
rich: None,
}],
&out,
None,
@@ -910,7 +912,7 @@ fn reflow_keeps_per_line_font_sizes() {
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: idxs, text: "TIÊU ĐỀ MỚI\nphụ đề mới".into() }],
&[EditOp::ReflowText { indices: idxs, text: "TIÊU ĐỀ MỚI\nphụ đề mới".into(), rich: None }],
&out,
None,
)
@@ -964,7 +966,7 @@ fn reflow_hard_line_grows_without_rewrap() {
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: vec![idx], text: new_text.into() }],
&[EditOp::ReflowText { indices: vec![idx], text: new_text.into(), rich: None }],
&out,
None,
)
@@ -1165,6 +1167,7 @@ fn form_xobject_reflow_after_flatten_roundtrip() {
&[EditOp::ReflowText {
indices: vec![idx],
text: "Đoạn văn tiếng Việt thay thế trong form, đủ dài để chắc chắn phải bẻ xuống dòng mới".into(),
rich: None,
}],
&out,
None,
@@ -1275,6 +1278,7 @@ fn form_xobject_surgical_reflow() {
&[EditOp::ReflowText {
indices: vec![idx],
text: "Sửa trong form bằng phẫu thuật stream".into(),
rich: None,
}],
&out,
None,
@@ -1379,6 +1383,7 @@ fn flipped_form_surgical_reflow() {
&[EditOp::ReflowText {
indices: vec![idx],
text: "Đổi chữ trong form lật trục".into(),
rich: None,
}],
&out,
None,
@@ -1402,3 +1407,111 @@ fn flipped_form_surgical_reflow() {
new_run.rect
);
}
/// Fixture 1 dòng LẪN 2 STYLE trên cùng baseline: Helvetica thường +
/// Courier-Bold (mono đậm) — đúng kiểu "Trang quản trị hiển thị `code`".
fn build_mixed_style_pdf(path: &std::path::Path) {
use lopdf::{dictionary, Document, Object, Stream};
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let f1 = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let f2 = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Courier-Bold",
});
let content_id = doc.add_object(Stream::new(
dictionary! {},
b"BT /F1 12 Tf 72 700 Td (Trang quan tri hien thi ) Tj /F2 12 Tf (windowsInitialPassword) Tj ET".to_vec(),
));
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Resources" => dictionary! { "Font" => dictionary! { "F1" => f1, "F2" => f2 } },
"Contents" => content_id,
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}),
);
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
doc.save(path).expect("lưu fixture mixed style");
}
// Reflow NHIỀU STYLE: sửa phần chữ thường, phần mono-đậm GIỮ NGUYÊN FONT
// (BaseFont Courier-Bold còn nguyên), 2 đoạn nằm cùng dòng đúng thứ tự.
#[test]
fn rich_reflow_preserves_mixed_styles() {
use ff_engine::RichSeg;
let pdf = pdfium();
let input = tmp("ff_edit_rich_mixed.pdf");
build_mixed_style_pdf(&input);
let out = tmp("ff_edit_rich_mixed_out.pdf");
let idx_a = find_text_index(&pdf, &input, "Trang quan tri");
let idx_b = find_text_index(&pdf, &input, "windowsInitialPassword");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::ReflowText {
indices: vec![idx_a, idx_b],
text: "Trang quan tri hien thi MOI windowsInitialPassword".into(),
rich: Some(vec![vec![
RichSeg { text: "Trang quan tri hien thi MOI ".into(), style: idx_a },
RichSeg { text: "windowsInitialPassword".into(), style: idx_b },
]]),
}],
&out,
None,
)
.expect("apply_edits rich reflow");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
let norm = text.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(norm.contains("hien thi MOI"), "chữ sửa phải có mặt: {text:?}");
assert!(norm.contains("windowsInitialPassword"), "đoạn mono phải còn: {text:?}");
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let mono = objs
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("windowsInitialPassword")).unwrap_or(false))
.unwrap_or_else(|| panic!("phải có run mono: {objs:?}"));
let plain = objs
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("hien thi MOI")).unwrap_or(false))
.unwrap_or_else(|| panic!("phải có run thường: {objs:?}"));
assert!(
mono.font_name.as_deref().map(|f| f.contains("Courier")).unwrap_or(false),
"đoạn mono phải GIỮ font Courier: {:?}",
mono.font_name
);
assert!(
plain.font_name.as_deref().map(|f| f.contains("Helvetica")).unwrap_or(false),
"đoạn thường phải giữ Helvetica: {:?}",
plain.font_name
);
// Cùng dòng, mono nằm bên PHẢI đoạn thường.
assert!(
(mono.rect.bottom - plain.rect.bottom).abs() < 4.0,
"2 đoạn phải cùng baseline: {:?} vs {:?}",
mono.rect,
plain.rect
);
assert!(
mono.rect.left > plain.rect.left + 10.0,
"mono phải nằm sau đoạn thường: {:?} vs {:?}",
mono.rect,
plain.rect
);
}