diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index f278f76..b886945 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -741,6 +741,18 @@ struct EditOpDto { /// reflowText: index các run của khối đoạn văn. #[serde(default)] indices: Vec, + /// 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>>, +} + +/// 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 { 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, diff --git a/app/src/main.js b/app/src/main.js index 1bf1024..cdbedfb 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -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 = "
"; 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 = "
"; + } 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(); diff --git a/app/src/styles.css b/app/src/styles.css index 008176e..e9df964 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -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; } diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index beef0c6..98c2787 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -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, 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, text: String, rich: Option>> }, +} + +/// 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, color: PdfColor, + /// Reflow NHIỀU STYLE: các dòng cứng theo đoạn + bảng style đã resolve. + rich: Option<(Vec>, HashMap)>, +} + +/// Style đã resolve cho 1 đoạn rich: font vẽ + bytes đo + màu + cỡ. +struct RichStyle { + draw: ReflowFont, + measure: Option>, + 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 = 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>, HashMap)> = + if let Some(rich_lines) = rich { + let mut style_ids: Vec = + rich_lines.iter().flatten().map(|s| s.style).collect(); + style_ids.sort_unstable(); + style_ids.dedup(); + let mut table: HashMap = 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> = 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 = 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::new(); + let mut cur: Vec = 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 }; diff --git a/crates/ff-engine/src/lib.rs b/crates/ff-engine/src/lib.rs index 8b0ca2f..4b9e73f 100644 --- a/crates/ff-engine/src/lib.rs +++ b/crates/ff-engine/src/lib.rs @@ -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, diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index 67bb8c0..1c11dee 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -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::>().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 + ); +}