Phase 4 Iteration 3: reflow đoạn văn 'như Word' (EditOp::ReflowText)
Engine:
- EditOp::ReflowText { indices, text }: engine tự suy hình học từ các run
(lề trái/bề rộng từ bounds, baseline từ matrix.f gom cụm ±1pt, khoảng cách
dòng = median hiệu baseline); bẻ dòng greedy đo bằng hmtx của font thật
(fontmatch::wrap_lines + char_advance, ttf-parser); \n = ngắt dòng cứng,
từ quá dài cắt theo ký tự, dòng rỗng giữ nhịp baseline
- Giữ font theo thang 4 mức nhất quán với iteration 2: (1) font nhúng parse
được + phủ đủ glyph → nhúng lại chính bytes đó; (2) family base-14 + ASCII
→ font chuẩn PDF qua FPDFText_LoadStandardFont (BaseFont giữ tên chuẩn,
không nhúng, file không phình; đo bằng font metric-compatible); (3) font
hệ thống cùng họ; (4) fallback. FontLoad enum Bytes/Builtin cho pha nạp font
- Dòng mới giữ phần tuyến tính matrix của run neo (scale/nghiêng), Tf =
unscaled run neo → cỡ hiển thị không đổi
UI + Tauri:
- paragraphLines: gom đoạn nhiều dòng (baseline cách đều ±25%, cỡ chữ tương
đồng, giao ngang ≥30%); double-click đoạn ≥2 dòng → textarea sửa CẢ ĐOẠN
WYSIWYG (đúng font/cỡ/màu/kiểu + line-height đúng nhịp baseline)
- Enter = xuống dòng cứng · Ctrl+Enter/bấm ra ngoài = áp dụng · Esc = huỷ;
1 commit = 1 op reflowText = 1 nấc undo
- DTO reflowText (indices) trong Tauri
Test: 3 integration mới (wrap giữ font nhúng + tiếng Việt + baseline đều;
hard break; base-14 giữ BaseFont Helvetica chuẩn) + 3 unit wrap_lines.
Toàn bộ suite engine ngoài qpdf: 52/52 xanh.
Docs: 12 (Iteration 3 + giới hạn v1), 13 (E26–E30), 14 (mục 3.1 → đã giải,
cập nhật thứ tự việc tiếp theo), README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
@@ -196,6 +196,61 @@ pub(crate) fn find_family_font_bytes(family: &str, bold: bool, italic: bool) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Bẻ dòng kiểu Word cho reflow đoạn (Phase 4 iteration 3): greedy theo TỪ,
|
||||
/// tôn trọng `\n` là ngắt dòng cứng; từ đơn dài hơn bề rộng thì cắt theo ký
|
||||
/// tự. `measure(c)` trả bề rộng 1 ký tự theo pt (đã nhân cỡ chữ). Dòng rỗng
|
||||
/// (từ `\n\n`) được GIỮ trong kết quả để caller giữ nhịp baseline.
|
||||
pub(crate) fn wrap_lines(text: &str, max_width: f32, measure: &dyn Fn(char) -> f32) -> Vec<String> {
|
||||
let width_of = |s: &str| s.chars().map(measure).sum::<f32>();
|
||||
// Nới 2% để tránh bẻ sớm vì sai số đo (không kerning).
|
||||
let limit = max_width.max(1.0) * 1.02;
|
||||
let mut out = Vec::new();
|
||||
for para in text.split('\n') {
|
||||
let words: Vec<&str> = para.split_whitespace().collect();
|
||||
if words.is_empty() {
|
||||
out.push(String::new());
|
||||
continue;
|
||||
}
|
||||
let mut line = String::new();
|
||||
for word in words {
|
||||
let candidate_extra = if line.is_empty() { width_of(word) } else { measure(' ') + width_of(word) };
|
||||
if !line.is_empty() && width_of(&line) + candidate_extra > limit {
|
||||
out.push(std::mem::take(&mut line));
|
||||
}
|
||||
if line.is_empty() && width_of(word) > limit {
|
||||
// Từ đơn quá dài: cắt theo ký tự.
|
||||
let mut piece = String::new();
|
||||
for ch in word.chars() {
|
||||
if !piece.is_empty() && width_of(&piece) + measure(ch) > limit {
|
||||
out.push(std::mem::take(&mut piece));
|
||||
}
|
||||
piece.push(ch);
|
||||
}
|
||||
line = piece;
|
||||
} else {
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
}
|
||||
}
|
||||
if !line.is_empty() {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bề rộng 1 ký tự (pt) theo hmtx của font tại cỡ `size_pt`. Glyph thiếu hoặc
|
||||
/// không đo được → xấp xỉ 0.5em (đủ tốt cho bẻ dòng, không dùng để vẽ).
|
||||
pub(crate) fn char_advance(face: &ttf_parser::Face<'_>, ch: char, size_pt: f32) -> f32 {
|
||||
let upem = face.units_per_em() as f32;
|
||||
face.glyph_index(ch)
|
||||
.and_then(|g| face.glyph_hor_advance(g))
|
||||
.map(|adv| adv as f32 / upem * size_pt)
|
||||
.unwrap_or(size_pt * 0.5)
|
||||
}
|
||||
|
||||
/// So khớp mềm 2 key đã chuẩn hoá: bằng nhau, hoặc chứa nhau (đủ dài để tránh nhiễu).
|
||||
fn keys_match(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
@@ -230,4 +285,28 @@ mod tests {
|
||||
assert!(!keys_match("arial", "georgia"));
|
||||
assert!(keys_match("arial", "arial"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_greedy_by_words() {
|
||||
// Mỗi ký tự rộng 10pt (kể cả space) → bề rộng 35pt chứa tối đa 3 ký tự/dòng…
|
||||
// dùng câu chữ 2-2-3 để kiểm greedy: "ab cd efg", limit 5 ký tự (50pt).
|
||||
let m = |_c: char| 10.0;
|
||||
let lines = wrap_lines("ab cd efg", 50.0, &m);
|
||||
assert_eq!(lines, vec!["ab cd".to_string(), "efg".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_respects_hard_breaks_and_empty_lines() {
|
||||
let m = |_c: char| 10.0;
|
||||
let lines = wrap_lines("mot\n\nhai ba", 100.0, &m);
|
||||
assert_eq!(lines, vec!["mot".to_string(), String::new(), "hai ba".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_splits_overlong_word() {
|
||||
let m = |_c: char| 10.0;
|
||||
let lines = wrap_lines("abcdefgh", 30.0, &m);
|
||||
// limit 30*1.02 → 3 ký tự/dòng
|
||||
assert_eq!(lines, vec!["abc".to_string(), "def".to_string(), "gh".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user