Form XObject vòng 2: rút object khỏi form thật sự — sửa trong form mới được LƯU
Build Windows portable / build (push) Canceled after 0s
Build Windows portable / build (push) Canceled after 0s
CI vòng 1 bắt đúng lỗi thiết kế: FPDFText_SetText trên con trong form KHÔNG đánh dấu dirty_streams của form (chỉ RemovePageObject/InsertPageObjectAtIndex mới làm — xem cpdf_pageobjectholder.cpp) nên mọi sửa in-place trong form không bao giờ được ghi ra file. Test canary fail chính xác ở điểm này. - Đổi feature pdfium-render pdfium_latest(7543) -> pdfium_7350 (cả ff-engine lẫn app): mở remove_object cho Form XObject qua blanket impl PdfPageObjectsCommon (wrapper 0.8.37 sót gate 7543 dù binding có đủ); API 7350 là subset của DLL bblanchon mới, đã quét 0 hàm 7543-only. - Mọi sửa/xoá con trong form = RÚT THẬT khỏi form (FPDFFormObj_RemoveObject, thứ tự path sâu trước + cùng cha con lớn trước) -> form dirty -> ProcessForm regenerate stream khi lưu. SetText con trong form = SetTextMode mới NestedRecreate: rút khỏi form + tạo lại ở cấp trang giữ font gốc qua token (probe như tầng 0 reflow; thiếu glyph -> nhúng lại bytes/cùng họ), matrix đã compose. - CI test thêm --test-threads=1: PDFium serialize qua 1 mutex toàn cục, 1 test panic khi giữ lock poison mutex làm 17 test song song chết oan. Docs cập nhật theo (12-phase4-summary.md Iteration 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,8 +43,10 @@ jobs:
|
||||
|
||||
# Máy dev không có Rust — CI là nơi duy nhất chạy test engine.
|
||||
# Fail test = fail build, không publish bản hỏng.
|
||||
# --test-threads=1: PDFium serialize qua 1 mutex toàn cục; 1 test panic
|
||||
# khi đang giữ lock sẽ poison mutex làm mọi test song song chết oan.
|
||||
- name: Run engine tests
|
||||
run: cargo test -p ff-engine
|
||||
run: cargo test -p ff-engine -- --test-threads=1
|
||||
|
||||
- name: Build release
|
||||
working-directory: app/src-tauri
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
- [x] **Iteration 2 — GIỮ FONT khi sửa (chuẩn Foxit)**: sửa tại chỗ giữ nguyên font gốc/nhúng (kể cả tiếng Việt); thiếu glyph mới thay font CÙNG HỌ (`fontmatch.rs`); đổi cỡ/màu không đụng font (fix bug phóng đại kép); sửa CẢ DÒNG (gộp run); WYSIWYG khi gõ; B/I + đổi font family; kéo-thả live; dọn file tạm
|
||||
- [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng
|
||||
- [x] **Đúp chuột từ viewer thường (chuẩn Foxit)**: đang xem, đúp vào chữ → tự vào chế độ sửa đúng trang + mở ngay ô sửa tại đoạn đó; đúp vùng trống không làm gì
|
||||
- [x] **Iteration 4 — Sửa text TRONG Form XObject**: file Canva/Illustrator gói cả trang vào form giờ sửa được (liệt kê đệ quy + quy toạ độ về trang; xoá trong form = set text rỗng; CI chạy `cargo test` trước khi build)
|
||||
- [x] **Iteration 4 — Sửa text TRONG Form XObject**: file Canva/Illustrator gói cả trang vào form giờ sửa được (liệt kê đệ quy + quy toạ độ về trang; sửa/xoá = rút object khỏi form — cách duy nhất làm form được ghi lại khi lưu; CI chạy `cargo test` trước khi build)
|
||||
- [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md)
|
||||
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30)
|
||||
- [x] **Phase 5 — Bảo mật** ✅ Iteration 1 + 2 hoàn tất (62/62 test engine xanh)
|
||||
|
||||
@@ -18,7 +18,8 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
image = "0.25"
|
||||
pdfium-render = "0.8"
|
||||
# Đồng bộ feature với ff-engine (xem chú thích ở crates/ff-engine/Cargo.toml).
|
||||
pdfium-render = { version = "0.8", default-features = false, features = ["pdfium_7350", "thread_safe", "image"] }
|
||||
lopdf = "0.36"
|
||||
ff-engine = { path = "../../crates/ff-engine" }
|
||||
|
||||
|
||||
@@ -10,7 +10,11 @@ ff-cos = { path = "../ff-cos" }
|
||||
ff-pdmodel = { path = "../ff-pdmodel" }
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
pdfium-render = "0.8"
|
||||
# default-features tắt để chọn pdfium_7350 thay vì pdfium_latest(7543):
|
||||
# wrapper 0.8.37 chỉ mở FPDFFormObj_RemoveObject (rút object khỏi Form
|
||||
# XObject — cần cho sửa file Canva) với gate 7350/future dù binding có cả
|
||||
# 7543; DLL bblanchon mới hơn là superset của API 7350 nên tương thích.
|
||||
pdfium-render = { version = "0.8", default-features = false, features = ["pdfium_7350", "thread_safe", "image"] }
|
||||
image = "0.25"
|
||||
lopdf = "0.36"
|
||||
ttf-parser = "0.25"
|
||||
|
||||
+139
-47
@@ -298,6 +298,29 @@ fn object_at_path<'a>(page: &PdfPage<'a>, path: &[u16]) -> Result<PdfPageObject<
|
||||
Ok(obj)
|
||||
}
|
||||
|
||||
/// RÚT object con ra khỏi form chứa nó (path đầy đủ của CON). Đây là cách
|
||||
/// DUY NHẤT làm thay đổi trong form được lưu ra file: chỉ RemovePageObject
|
||||
/// mới đánh dấu `dirty_streams_` của form để PDFium ghi lại stream khi
|
||||
/// GenerateContent (SetText trên con KHÔNG làm form dirty — xem
|
||||
/// cpdf_pageobjectholder.cpp). Cần feature pdfium_7350 của pdfium-render.
|
||||
fn remove_form_child(page: &PdfPage<'_>, child_path: &[u16]) -> Result<(), EngineError> {
|
||||
let err = |e: PdfiumError| EngineError::Pdfium(format!("rút object khỏi form: {e}"));
|
||||
debug_assert!(child_path.len() >= 2);
|
||||
let parent_path = &child_path[..child_path.len() - 1];
|
||||
let child_idx = *child_path.last().unwrap() as usize;
|
||||
let mut parent = object_at_path(page, parent_path)?;
|
||||
let form = match &mut parent {
|
||||
PdfPageObject::XObjectForm(f) => f,
|
||||
_ => return Err(EngineError::Pdfium("path cha không phải form".into())),
|
||||
};
|
||||
let child = form.get(child_idx).map_err(err)?;
|
||||
let removed = form.remove_object(child).map_err(err)?;
|
||||
// Cùng bẫy Drop như remove ở cấp trang: object đã tách, forget để khỏi
|
||||
// destroy nhầm (rò rỉ nhỏ, giải phóng khi đóng document).
|
||||
std::mem::forget(removed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kiểu chữ (đậm/nghiêng) của 1 text object, tổng hợp từ weight, italic-angle
|
||||
/// và tên font. Family lấy từ `name()` (BaseFont khai báo trong PDF) — với
|
||||
/// font KHÔNG nhúng, `family()` trả tên font stub nội bộ của PDFium ("Chrom
|
||||
@@ -541,6 +564,10 @@ enum SetTextMode {
|
||||
/// Phải thay font (glyph thiếu hoặc người dùng đổi family/kiểu) — xoá và
|
||||
/// tạo lại object với font `key`.
|
||||
Substitute(FontKey),
|
||||
/// Con TRONG Form XObject: sửa in-place không được LƯU (SetText trên con
|
||||
/// không làm form dirty) → rút khỏi form + tạo lại ở CẤP TRANG, ưu tiên
|
||||
/// giữ chính font object gốc (token) như tầng 0 của reflow.
|
||||
NestedRecreate(ReflowFont),
|
||||
}
|
||||
|
||||
/// Dữ liệu chụp lại từ 1 object trước khi xoá để tạo lại bản thay thế.
|
||||
@@ -620,23 +647,57 @@ pub fn apply_edits(
|
||||
if !valid(*index) {
|
||||
continue;
|
||||
}
|
||||
let nested_obj = entries[*index as usize].path.len() > 1;
|
||||
// Chụp mọi thứ cần thiết trong 1 block để NHẢ mượn trang
|
||||
// trước khi probe font (probe cần &mut page).
|
||||
struct SetTextInfo {
|
||||
family: String,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
old_text: String,
|
||||
non_embedded: bool,
|
||||
data_covers: bool,
|
||||
token: PdfFontToken,
|
||||
embedded_cover_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
let info = {
|
||||
let obj = object_at_path(&page, &entries[*index as usize].path)?;
|
||||
let t = match obj.as_text_object() {
|
||||
Some(t) => t,
|
||||
match obj.as_text_object() {
|
||||
Some(t) => {
|
||||
let (family, bold, italic) = text_object_style(t);
|
||||
let embedded = t.font().is_embedded().unwrap_or(false);
|
||||
let data = t.font().data().ok();
|
||||
let data_covers = data
|
||||
.as_deref()
|
||||
.map_or(false, |b| fontmatch::coverage_ok(b, text));
|
||||
SetTextInfo {
|
||||
family,
|
||||
bold,
|
||||
italic,
|
||||
old_text: t.text(),
|
||||
non_embedded: !embedded,
|
||||
data_covers,
|
||||
token: t.font().token(),
|
||||
embedded_cover_bytes: if embedded && data_covers {
|
||||
data
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
None => continue, // không phải text → bỏ qua op
|
||||
}
|
||||
};
|
||||
let (cur_family, cur_bold, cur_italic) = text_object_style(t);
|
||||
let target_bold = bold.unwrap_or(cur_bold);
|
||||
let target_italic = italic.unwrap_or(cur_italic);
|
||||
let style_change = target_bold != cur_bold || target_italic != cur_italic;
|
||||
let target_bold = bold.unwrap_or(info.bold);
|
||||
let target_italic = italic.unwrap_or(info.italic);
|
||||
let style_change = target_bold != info.bold || target_italic != info.italic;
|
||||
let family_change = font_family.as_deref().map_or(false, |f| {
|
||||
!f.trim().is_empty()
|
||||
&& fontmatch::normalize_key(f) != fontmatch::normalize_key(&cur_family)
|
||||
&& fontmatch::normalize_key(f) != fontmatch::normalize_key(&info.family)
|
||||
});
|
||||
|
||||
let mode = if !style_change && !family_change {
|
||||
let old_text = t.text();
|
||||
let old_chars: HashSet<char> = old_text.chars().collect();
|
||||
// Font gốc có giữ được không (không xét in-place hay recreate)?
|
||||
let old_chars: HashSet<char> = info.old_text.chars().collect();
|
||||
let new_covered_by_old = text
|
||||
.chars()
|
||||
.all(|c| c.is_control() || old_chars.contains(&c));
|
||||
@@ -644,40 +705,56 @@ pub fn apply_edits(
|
||||
// cũng tự thay bằng font hệ thống đủ Latin → re-encode text
|
||||
// ASCII tại chỗ chắc chắn an toàn, BaseFont khai báo giữ
|
||||
// nguyên trong file (đúng hành vi Foxit với base-14).
|
||||
let non_embedded = !t.font().is_embedded().unwrap_or(true);
|
||||
let ascii_only = text
|
||||
.chars()
|
||||
.all(|c| c.is_control() || (' '..='~').contains(&c));
|
||||
if *text == old_text || new_covered_by_old {
|
||||
SetTextMode::InPlace
|
||||
} else if non_embedded && ascii_only {
|
||||
SetTextMode::InPlace
|
||||
} else if t
|
||||
.font()
|
||||
.data()
|
||||
.ok()
|
||||
.map_or(false, |bytes| fontmatch::coverage_ok(&bytes, text))
|
||||
{
|
||||
let keep_font_ok = !style_change
|
||||
&& !family_change
|
||||
&& (*text == info.old_text
|
||||
|| new_covered_by_old
|
||||
|| (info.non_embedded && ascii_only)
|
||||
|| info.data_covers);
|
||||
|
||||
let mode = if keep_font_ok && !nested_obj {
|
||||
SetTextMode::InPlace
|
||||
} else if keep_font_ok && nested_obj {
|
||||
// Giữ font gốc nhưng phải TẠO LẠI ở cấp trang: dùng lại
|
||||
// chính font object (token) nếu probe ghi/đọc tròn trịa;
|
||||
// hỏng thì nhúng lại bytes font; cuối cùng mới thay họ.
|
||||
if probe_font_roundtrip(&mut page, info.token, text) {
|
||||
SetTextMode::NestedRecreate(ReflowFont::Original(info.token))
|
||||
} else if let Some(bytes) = info.embedded_cover_bytes {
|
||||
let key = (
|
||||
format!("emb:{}:{}", fontmatch::normalize_key(&info.family), index),
|
||||
info.bold,
|
||||
info.italic,
|
||||
);
|
||||
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
|
||||
SetTextMode::NestedRecreate(ReflowFont::Loaded(key))
|
||||
} else {
|
||||
let (key, bytes) = resolve_substitute_font(
|
||||
&cur_family,
|
||||
&info.family,
|
||||
target_bold,
|
||||
target_italic,
|
||||
text,
|
||||
)?;
|
||||
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
|
||||
SetTextMode::Substitute(key)
|
||||
SetTextMode::NestedRecreate(ReflowFont::Loaded(key))
|
||||
}
|
||||
} else {
|
||||
// Thiếu glyph hoặc chủ động đổi family/kiểu → font thay thế.
|
||||
let family = font_family
|
||||
.clone()
|
||||
.filter(|f| !f.trim().is_empty())
|
||||
.unwrap_or(cur_family);
|
||||
.unwrap_or(info.family);
|
||||
let (key, bytes) =
|
||||
resolve_substitute_font(&family, target_bold, target_italic, text)?;
|
||||
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes));
|
||||
if nested_obj {
|
||||
SetTextMode::NestedRecreate(ReflowFont::Loaded(key))
|
||||
} else {
|
||||
SetTextMode::Substitute(key)
|
||||
}
|
||||
};
|
||||
set_text_modes.insert(opi, mode);
|
||||
}
|
||||
@@ -1045,7 +1122,10 @@ pub fn apply_edits(
|
||||
for (opi, op) in ops.iter().enumerate() {
|
||||
let idx = match op {
|
||||
EditOp::SetText { index, .. }
|
||||
if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) =>
|
||||
if matches!(
|
||||
set_text_modes.get(&opi),
|
||||
Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_))
|
||||
) =>
|
||||
{
|
||||
*index
|
||||
}
|
||||
@@ -1086,7 +1166,10 @@ pub fn apply_edits(
|
||||
for (opi, op) in ops.iter().enumerate() {
|
||||
match op {
|
||||
EditOp::SetText { index, .. }
|
||||
if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) =>
|
||||
if matches!(
|
||||
set_text_modes.get(&opi),
|
||||
Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_))
|
||||
) =>
|
||||
{
|
||||
to_remove.push(*index)
|
||||
}
|
||||
@@ -1104,22 +1187,21 @@ pub fn apply_edits(
|
||||
to_remove.retain(|i| valid(*i));
|
||||
to_remove.sort_unstable();
|
||||
to_remove.dedup();
|
||||
// Con TRONG form: chưa rút object ra được (FPDFFormObj_RemoveObject
|
||||
// chưa mở ở wrapper) → "xoá" = set text RỖNG — glyph biến mất, PDFium
|
||||
// regenerate stream form khi lưu (ProcessForm → HasDirtyStreams).
|
||||
// Làm TRƯỚC khi xoá object cấp trang để path (nấc đầu = index trang)
|
||||
// còn đúng. Con không phải text (ảnh/path trong form): v1 bỏ qua.
|
||||
for &i in to_remove.iter().filter(|&&i| is_nested(i)) {
|
||||
let entry = &entries[i as usize];
|
||||
if entry.kind != ObjectKind::Text {
|
||||
continue;
|
||||
}
|
||||
let mut obj = object_at_path(&page, &entry.path)?;
|
||||
if let Some(t) = obj.as_text_object_mut() {
|
||||
if t.set_text("").is_err() {
|
||||
let _ = t.set_text(" ");
|
||||
}
|
||||
}
|
||||
// Con TRONG form: RÚT THẬT khỏi form (FPDFFormObj_RemoveObject) — cách
|
||||
// DUY NHẤT đánh dấu dirty_streams để stream form được ghi lại khi lưu
|
||||
// (SetText/Transform trên con không làm form dirty). Thứ tự: path SÂU
|
||||
// trước, cùng cha thì con GIẢM DẦN — rút 1 con làm các em sau nó trong
|
||||
// cùng form tụt index, và rút ở nấc nông làm lệch path xuyên qua form.
|
||||
// Làm TRƯỚC khi xoá cấp trang để nấc đầu path (index trang) còn đúng.
|
||||
// Con không phải text (ảnh/path trong form): v1 bỏ qua.
|
||||
let mut nested_paths: Vec<&Vec<u16>> = to_remove
|
||||
.iter()
|
||||
.filter(|&&i| is_nested(i) && entries[i as usize].kind == ObjectKind::Text)
|
||||
.map(|&i| &entries[i as usize].path)
|
||||
.collect();
|
||||
nested_paths.sort_by(|a, b| b.len().cmp(&a.len()).then(b.cmp(a)));
|
||||
for path in nested_paths {
|
||||
remove_form_child(&page, path)?;
|
||||
}
|
||||
// Cấp trang: xoá thật theo index trang GIẢM DẦN.
|
||||
let mut page_idxs: Vec<u16> = to_remove
|
||||
@@ -1142,17 +1224,27 @@ pub fn apply_edits(
|
||||
for (opi, op) in ops.iter().enumerate() {
|
||||
match op {
|
||||
EditOp::SetText { index, text, font_size, color, .. } => {
|
||||
let key = match set_text_modes.get(&opi) {
|
||||
Some(SetTextMode::Substitute(key)) => key,
|
||||
// Substitute (đổi font) hoặc NestedRecreate (con trong form,
|
||||
// giữ font gốc qua token) — đều tạo lại object ở cấp trang
|
||||
// với matrix ĐÃ COMPOSE từ pha chụp.
|
||||
let token = match set_text_modes.get(&opi) {
|
||||
Some(SetTextMode::Substitute(key)) => {
|
||||
tokens.get(key).copied().ok_or_else(|| {
|
||||
EngineError::Pdfium("thiếu token font cho SetText".into())
|
||||
})?
|
||||
}
|
||||
Some(SetTextMode::NestedRecreate(ReflowFont::Original(tok))) => *tok,
|
||||
Some(SetTextMode::NestedRecreate(ReflowFont::Loaded(key))) => {
|
||||
tokens.get(key).copied().ok_or_else(|| {
|
||||
EngineError::Pdfium("thiếu token font cho SetText".into())
|
||||
})?
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let cap = match captured.get(index) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
let token = tokens.get(key).copied().ok_or_else(|| {
|
||||
EngineError::Pdfium("thiếu token font cho SetText".into())
|
||||
})?;
|
||||
// Quy đổi cỡ hiển thị mong muốn → cỡ unscaled trước khi áp
|
||||
// matrix gốc (matrix có thể chứa scale — tránh phóng đại kép).
|
||||
let tf = match font_size {
|
||||
|
||||
+19
-12
@@ -23,18 +23,25 @@ Type0/CID nhúng), trong khi `list_objects` chỉ duyệt object cấp trang.
|
||||
thị nhân `mat_vscale(acc)` (độ dài ảnh vector đơn vị Y).
|
||||
- **ObjectInfo thêm `nested`** (con trong form — sửa text OK, kéo/resize chưa)
|
||||
**+ `expanded`** (form đã liệt kê con — UI bỏ khung form để khỏi che con).
|
||||
- **Áp op cho con trong form**: SetText in-place resolve theo path (đổi cỡ:
|
||||
so cỡ theo KHÔNG GIAN TRANG — target × vscale); SetText-substitute và
|
||||
ReflowText tạo object mới ở CẤP TRANG với matrix/toạ độ đã compose;
|
||||
**"xoá" con trong form = `set_text("")`** (glyph biến mất) vì
|
||||
`FPDFFormObj_RemoveObject` chưa mở ở wrapper cho pdfium_7543 — làm TRƯỚC
|
||||
khi xoá object cấp trang để path còn đúng. Transform (kéo/resize) con
|
||||
trong form: bỏ qua, UI chặn kéo.
|
||||
- **Điểm sống còn đã xác minh trước khi code**: PDFium (bản mới, bblanchon
|
||||
latest) REGENERATE content stream của form khi con bị sửa —
|
||||
`CPDF_PageContentGenerator::ProcessForm` → `HasDirtyStreams` → chạy
|
||||
generator đệ quy cho form. Không có cái này thì sửa xong lưu ra vẫn thấy
|
||||
chữ cũ.
|
||||
- **Áp op cho con trong form — bài học đắt giá từ CI vòng 1**: `dirty_streams_`
|
||||
của form CHỈ được đánh dấu bởi `RemovePageObject`/`InsertPageObjectAtIndex`
|
||||
trên CHÍNH form (xem cpdf_pageobjectholder.cpp) — **`FPDFText_SetText` trên
|
||||
con KHÔNG làm form dirty → sửa in-place trong form không bao giờ được ghi
|
||||
ra file** (test canary bắt được đúng lỗi này: extract sau lưu vẫn thấy text
|
||||
cũ). Cách làm đúng: mọi sửa/xoá con trong form = **RÚT THẬT khỏi form qua
|
||||
`FPDFFormObj_RemoveObject`** (đánh dấu dirty → `ProcessForm` regenerate
|
||||
stream form khi lưu) **+ với SetText thì tạo lại ở CẤP TRANG** giữ font gốc
|
||||
qua token (probe như tầng 0 reflow; thiếu glyph → nhúng lại bytes/cùng họ)
|
||||
với matrix đã compose (`SetTextMode::NestedRecreate`). Thứ tự rút: path sâu
|
||||
trước, cùng cha con lớn trước (rút làm tụt index các em). Transform
|
||||
(kéo/resize) con trong form: bỏ qua, UI chặn kéo.
|
||||
- **Đổi feature pdfium-render `pdfium_latest`(7543) → `pdfium_7350`** (cả
|
||||
ff-engine lẫn app): wrapper 0.8.37 gate `remove_object` của form chỉ mở cho
|
||||
7350/future dù binding có đủ cho 7543 (sót gate upstream); API 7350 là
|
||||
subset của DLL bblanchon mới → không mất gì (đã quét: 0 hàm 7543-only).
|
||||
- CI test chạy `--test-threads=1`: PDFium serialize qua 1 mutex toàn cục —
|
||||
1 test panic khi giữ lock sẽ poison mutex, 17 test song song chết oan
|
||||
(đã dính ở CI vòng 1).
|
||||
- Vá kèm: đọc source crate pdfium-render tải từ crates.io để chốt API vì máy
|
||||
dev không compile được Rust (xem mục CI bên dưới).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user