ElyPDF: only content edits keep old tiles and known sizes; old tiles paint and free with their tab (R2.6)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 05:51:44 -04:00
1 parent 1bf693f7ab
commit 03bcbab5ce
11 files changed
+108 -26

No files matched your search

+1 -1
View File
@@ -267,5 +267,5 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-09:ElyTree 的差异按文件类型着色(R2.12)。组件库 7f2ac3e 与 5caf5aa:`DiffViewer` 按路径的扩展名判定散文与纯文本(md、markdown、txt、rst、adoc、org、csv、tsv、log 等,无扩展名同,不着色),代码按语言的行注释记号着色(`#`:py、toml、sh、yaml 等与 Makefile;`--`:sql、lua;`;`:ini、lisp;`%`:tex、erl;其余 `//`),关键字表各语言共用(原先一律套 `//` 的通用词法,Markdown 的 `#`、Python 的注释都被当作代码上色)。同批带入上游的块编辑器跨块选择、浮层滚轮、输入法候选框位置、插入点按设备像素对齐与格式修正。
- 2026-10-09:ElyMark 的表格(R2.13)。表格的空格与分隔行写法有许多种(`|a|b|`、`|---|`、对齐空格),逐字重现做不到,原先凡写不回原样的表格都成了原文块。现在一块的序列化与原文在把其中每张表格化成规范形式(各列对齐方式加各格的原文,`tables.js` 的 `canon`)后相同,即为可视块(`preserve.js` 的 `kept`),引用与列表里的表格同样;格内有 Milkdown 会改写的写法(`[[…]]`、引用式链接等)即不同,整块仍为原文块,与段落的规则一致。Milkdown 的列表项必须以段落开头,表格作列表项的第一块时无法放进编辑器,照原文块保留。未改的表格照旧逐字写回,改过的表格按文档多数表格的写法重写(`style.js` 按表格的行节点投票:单元格两侧有无空格、是否按列对齐,经 remark-gfm 的 `tableCellPadding` 与 `tablePipeAlign`;原先投票的解析器不认 GFM,表格从未计票),分隔行随之重写(如 `|---|` 成 `| - |`)。
- 2026-10-09:ElyTable 新插图表的位置(R2.10)。内核按“定位区域”把图表放在其旁的空处;带参数的请求(Ely 经 `FN_PARAM_5` 给区域)原把定位区域取为光标格,图表紧贴光标、压在数据上。补丁 `0041-lok-calc-chart-beside-its-data.patch`:定位区域取请求里的数据区域(多段取并集),同对话框路径取所选单元格。
- 2026-10-09:ElyPDF 编辑与缩放不闪白(R2.6)。旧图块不再随编辑或比例变化即刻丢弃:编辑落地时全部当前图块、比例变化时离开视图的同页图块,连同当时的页面尺寸进入“底图”(`apps/ely-pdf/src/backdrop.rs`),按其在页面中的比例位置画在该页新图块之下,直到该页在视口里的新图块到齐、或页面尺寸与旋转变了才释放;每页画什么由 `PdfView::pieces` 决定,绘制与测试同用它。原先比较整份尺寸表(超过 100 页的混合纸张文档尺寸表后段为占位,比较失败即全部清空)且只认同比例的图块(编辑后右栏收起、比例变化即清空),都会闪白。
- 2026-10-09:ElyPDF 编辑与缩放不闪白(R2.6)。旧图块不再随编辑或比例变化即刻丢弃:编辑落地时全部当前图块、比例变化时离开视图的同页图块,连同当时的页面尺寸进入“底图”(`apps/ely-pdf/src/backdrop.rs`),按其在页面中的比例位置画在该页新图块之下,直到该页在视口里的新图块到齐、或页面尺寸与旋转变了才释放;每页画什么由 `PdfView::pieces` 决定,绘制与测试同用它。只有改内容的编辑保留底图并沿用已测的页面尺寸(前 100 页以外不再被占位尺寸覆盖);增删、移动、旋转、裁剪页面与撤销重做(`PdfView::edit_pages`)清空底图,因同一序号可能已是另一页。关闭标签时底图随图块交给窗口释放。原先比较整份尺寸表(超过 100 页的混合纸张文档尺寸表后段为占位,比较失败即全部清空)且只认同比例的图块(编辑后右栏收起、比例变化即清空),都会闪白。
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
+1 -1
View File
@@ -72,7 +72,7 @@ impl Viewer {
let turned = unturn(margins, view.read(cx).rotate);
let points = turned.map(|side| side * MM);
view.update(cx, |view, cx| {
view.edit("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
view.edit_pages("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
});
cx.notify();
}
+31 -10
View File
@@ -17,6 +17,8 @@ pub struct Queued {
op: Op,
pick: Option<BTreeSet<usize>>,
doc: Doc,
/// The edit may add, drop, move or reshape pages.
pages: bool,
}
/// The document as an edit left it.
@@ -36,20 +38,31 @@ impl PdfView {
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
self.edit_picking(what, None, op, cx);
self.queue_edit(what, None, false, op, cx);
}
/// Queues an edit to the pages themselves.
pub fn edit_pages(
&mut self,
what: &'static str,
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
self.queue_edit(what, None, true, op, cx);
}
/// Queues `op`; once it lands, `pick` is picked.
pub fn edit_picking(
pub fn queue_edit(
&mut self,
what: &'static str,
pick: Option<BTreeSet<usize>>,
pages: bool,
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
log::info!("pdf: queues {what}");
// Aimed at what is shown now; later states refuse it.
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc });
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages });
self.next_edit(cx);
}
@@ -57,7 +70,7 @@ impl PdfView {
if self.editing {
return;
}
let Some(Queued { what, op, pick, doc }) = self.queue.pop_front() else {
let Some(Queued { what, op, pick, doc, pages }) = self.queue.pop_front() else {
return;
};
self.editing = true;
@@ -73,7 +86,7 @@ impl PdfView {
view.editing = false;
match fresh {
Ok(fresh) => {
view.refresh(fresh, cx);
view.refresh(fresh, pages, cx);
if let Some(pick) = pick {
view.anchor = pick.first().copied();
view.picked = pick;
@@ -93,13 +106,21 @@ impl PdfView {
}
/// Everything read before the edit goes.
fn refresh(&mut self, fresh: Fresh, cx: &mut Context<Self>) {
fn refresh(&mut self, fresh: Fresh, pages: bool, cx: &mut Context<Self>) {
self.version += 1;
self.edited += 1;
(self.doc, self.mark) = (fresh.doc, fresh.mark);
// Old tiles stay under unchanged pages; nothing flashes.
self.keep_all();
self.sizes = sizes(fresh.pages, &fresh.first);
let measured = sizes(fresh.pages, &fresh.first);
if pages || fresh.pages != self.sizes.len() {
// A page may now stand where another stood.
self.retire_tiles();
self.sizes = measured;
} else {
// Same pages: old tiles stay under them until repainted.
self.keep_all();
let read = fresh.first.len();
self.sizes[..read].copy_from_slice(&measured[..read]);
}
self.marks = fresh.marks;
self.failed.clear();
self.text.clear();
@@ -145,6 +166,6 @@ impl PdfView {
pub fn undo(&mut self, redo: bool, cx: &mut Context<Self>) {
let what = if redo { "redo" } else { "undo" };
self.edit(what, move |worker, doc| worker.undo(doc, redo), cx);
self.edit_pages(what, move |worker, doc| worker.undo(doc, redo), cx);
}
}
+5 -5
View File
@@ -81,7 +81,7 @@ impl PdfView {
let pages = self.chosen_pages();
let before = pages.iter().filter(|page| **page < to).count();
let landed = (to - before..to - before + pages.len()).collect();
self.edit_picking("moving pages", Some(landed), move |worker, doc| worker.move_pages(doc, &pages, to), cx);
self.queue_edit("moving pages", Some(landed), true, move |worker, doc| worker.move_pages(doc, &pages, to), cx);
}
}
@@ -105,14 +105,14 @@ impl Viewer {
}
view.update(cx, |view, cx| {
view.picked.clear();
view.edit("deleting pages", move |worker, doc| worker.delete_pages(doc, &pages), cx);
view.edit_pages("deleting pages", move |worker, doc| worker.delete_pages(doc, &pages), cx);
});
}
fn turn_pages(&mut self, by: i32, cx: &mut Context<Self>) {
self.on_pages(cx, |view, cx| {
let pages = view.chosen_pages();
view.edit("turning pages", move |worker, doc| worker.rotate_pages(doc, &pages, by), cx);
view.edit_pages("turning pages", move |worker, doc| worker.rotate_pages(doc, &pages, by), cx);
});
}
@@ -136,7 +136,7 @@ impl Viewer {
self.on_pages(cx, |view, cx| {
let at = view.chosen_pages().last().copied().expect("one page at least");
let size = view.sizes[at];
view.edit("inserting a page", move |worker, doc| worker.insert_blank(doc, at + 1, size), cx);
view.edit_pages("inserting a page", move |worker, doc| worker.insert_blank(doc, at + 1, size), cx);
});
}
@@ -148,7 +148,7 @@ impl Viewer {
self.pick(false, cx, |viewer, path: PathBuf, cx| {
viewer.on_pages(cx, |view, cx| {
let at = view.chosen_pages().last().copied().expect("one page at least") + 1;
view.edit(
view.edit_pages(
"inserting pages",
move |worker, doc| worker.insert_file(doc, at, &path).map(|(_, mark)| mark),
cx,
+6 -5
View File
@@ -69,11 +69,12 @@ fn drawn_tile(image: Arc<RenderImage>, page: usize, view: gpui::Entity<PdfView>)
canvas(
|_, _, _| {},
move |bounds, _, window, cx| match window.paint_image(bounds, bounds, Default::default(), image, 0, false) {
Ok(()) if !view.read(cx).drawn => {
view.update(cx, |view, _| view.drawn = true);
log::info!("pdf: first tile drawn, page {page}");
}
Ok(()) => {}
Ok(()) => view.update(cx, |view, _| {
view.painted += 1;
if view.painted == 1 {
log::info!("pdf: first tile drawn, page {page}");
}
}),
Err(error) => log::error!("pdf: drawing a page failed: {error}"),
},
)
+43
View File
@@ -272,3 +272,46 @@ fn an_edit_past_the_first_hundred_sizes_keeps_the_page_drawn() {
settle(cx, |app| view.read(app).edited == 1 && repainted(view.read(app)));
assert!(!bare.get(), "the page never goes blank");
}
#[test]
fn an_edit_on_page_101_keeps_its_measured_size_and_its_tiles() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut pages: Vec<(f32, f32, &[&str])> = vec![(612.0, 792.0, &["Page"]); 100];
pages.push((612.0, 900.0, &["Taller"]));
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "mixed.pdf", &pages)], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).sizes[100] == (612.0, 900.0));
cx.update(|_, app| view.update(app, |view, cx| view.show(100, 0.0, cx)));
settle(cx, |app| view.read(app).current == 100 && repainted(view.read(app)));
let (_watch, bare) = never_bare(cx, &view);
let late = ely_pdf_engine::NewAnnot { page: 100, ..note() };
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![late], cx)));
settle(cx, |app| view.read(app).edited == 1 && repainted(view.read(app)));
assert!(!bare.get(), "page 101 never goes blank");
assert_eq!(cx.update(|_, app| view.read(app).sizes[100]), (612.0, 900.0), "its measured size holds");
}
#[test]
fn old_tiles_are_painted_and_freed_with_their_tab() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
let view = shown(cx, &viewer);
settle(cx, |app| repainted(view.read(app)));
// Nothing new is asked; only old tiles can paint.
let before = cx.update(|_, app| {
view.update(app, |view, cx| {
let keys: Vec<_> = view.tiles.keys().copied().collect();
view.failed.extend(keys);
view.keep_all();
cx.notify();
view.painted
})
});
cx.run_until_parked();
let (painted, kept) = cx.update(|_, app| (view.read(app).painted, view.read(app).backdrop.len()));
assert!(kept > 0 && painted >= before + kept as u64, "{kept} old tiles, {} paints", painted - before);
cx.update(|_, app| view.update(app, |view, _| view.retire_all()));
assert!(cx.update(|_, app| view.read(app).backdrop.is_empty()), "closing hands every old tile over");
}
+5 -4
View File
@@ -37,8 +37,8 @@ pub struct PdfView {
pub tiles: HashMap<TileKey, Arc<RenderImage>>,
/// Images no longer drawn, freed at the next render.
pub retired: ely_gpui_component::primitives::Retired,
/// A tile reached the screen; logged once (PERF-05).
pub drawn: bool,
/// Tile images painted so far; the first is logged (PERF-05).
pub painted: u64,
/// Tiles the worker could not draw; not asked again.
pub failed: HashSet<TileKey>,
/// Old tiles shown under their page until covered.
@@ -145,7 +145,8 @@ impl PdfView {
pub fn retire_all(&mut self) -> ely_gpui_component::primitives::Retired {
let mut images = std::mem::take(&mut self.retired);
let tiles = self.tiles.drain().map(|(_, image)| image);
for image in tiles.chain(self.thumbs.drain().map(|(_, image)| image)) {
let old = self.backdrop.drain(..).map(|old| old.image);
for image in tiles.chain(old).chain(self.thumbs.drain().map(|(_, image)| image)) {
images.push(image);
}
images
@@ -166,7 +167,7 @@ impl PdfView {
scroll: ScrollHandle::new(),
tiles: HashMap::new(),
retired: Default::default(),
drawn: false,
painted: 0,
failed: HashSet::new(),
backdrop: Vec::new(),
text: HashMap::new(),
+3
View File
@@ -0,0 +1,3 @@
FAIL
crates/ely-lok/src/client/tests/motion.rs:255 — 大图层分块回归仅断言像素长度,重复首块、错序等内容损坏仍被判为完整传输,属于本次范围要求检查的弱测试。具体复现:在隔离源码副本中,将 worker/slides.rs:83 的 `pixels[from..to].to_vec()` 改为 `pixels[..to - from].to_vec()`,每次返回首段像素、保持块长度与总长度;独立构建该 worker 后,现有全部 6 项 motion 测试仍通过,包括新增的单层超过 64 MiB 用例。真实 DECK 以 6000 × 4500 请求、实际 6000 × 3375 输出时,图层 4 的 81,000,000 字节被分为 5 块;原版像素 SHA-256 为 `919a1d5c78f38941cb99f339dead3a362af619c8d8c381a1665fb1236c640483`,变异版为 `67082a91680256378fa8406d2024b5c5547e8f51a8e02af10b84874ed4efadbb`,确认图像内容已损坏。修复方向:用跨越多个分块边界、各段内容有差异的图层核对完整像素或独立预期像素位置,并要求重复首块的变异使测试失败。证据:`/tmp/ely-r220-r2-mutant-motion.log`、`/tmp/ely-r220-r2-wire-baseline.log`、`/tmp/ely-r220-r2-wire-mutant.log`。上一轮两项运行时缺陷已复核修复:原版长页面与 81 MB 单层传输通过,关闭及取层失败会释放暂存批次,另一文档继续可读;移除关闭清理会使对应回归失败。独立 worker 构建、原版 6 项 motion、3 项 playlist、注释检查通过。探针副本及其独立构建目录已删除,审查范围内源码散列保持一致。
+7
View File
@@ -0,0 +1,7 @@
FAIL
apps/ely-pdf/src/edits.rs:102 — 上一轮混合尺寸缺陷仍可在第 101 页自身可见时复现:refresh() 将已测量的后续页尺寸重置为第一页尺寸,backdrop.rs:84 因尺寸不符跳过旧底图,:111 随即释放它。生成 101 页 PDF,前 100 页为 612×792,第 101 页为 612×900;等待尺寸补读完成,以单页模式显示第 101 页并等待绘制完成,然后在该页添加便签。真实 worker 探针连续两次捕获 edited=1、tiles=0、backdrop=28、当前尺寸变成 612×792,随后尺寸恢复 612×900 时 backdrop 已为 0,页面出现空白,违反 R2.6。现有新增测试只在第一页添加便签。修复方向:依据页面身份和真实几何更新尺寸,批注等保持页面结构的操作保留已知实测尺寸,并覆盖第 101 页实际可见时的编辑路径。证据:/tmp/ely-r26-r2-probes.log、/tmp/ely-r26-r2-probes-repeat.log。
apps/ely-pdf/src/view.rs:145 — retire_all() 遗漏新增的 backdrop,关闭标签时这些已绘制图像无法进入窗口的释放队列,违反 PERF-08。复现:等待单页 PDF 绘制完成,添加便签,在编辑完成、旧底图等待重绘时通过实际 release_tab() 路径关闭标签;连续两次探针均记录关闭前后 backdrop=28,丢弃 PdfView 引用并推进后续帧后,window.has_image_atlas_entry(old) 仍为 true。每次在此阶段关闭标签都会将该批旧图块留在仍打开的窗口图像缓存中。修复方向:将 backdrop 中的全部图像随其他图块一起移交关闭时的 Retired 队列,增加关闭后核对图像缓存释放的回归。证据:/tmp/ely-r26-r2-probes.log、/tmp/ely-r26-r2-probes-repeat.log。
apps/ely-pdf/src/tests/input.rs:197 — 上一轮实际绘制验证缺陷仍存在:never_bare() 仅检查 pieces() 返回列表,:234 仅检查 Arc 地址更新,无法验证旧图块被画到窗口及编辑后的画面内容。隔离副本中让 pages.rs 的 drawn_tile() 对 backdrop 图像直接跳过 window.paint_image();该漏绘分支执行 109,864 次,包含三项新增回归在内的原有 118 项测试仍全部通过,重绘等待期间旧页面图像可直接漏绘。修复方向:断言等待新图块期间实际帧中的页面覆盖及原有内容,并核对落地后的批注或可视签名区域像素,使跳过旧图块绘制的变异触发失败。证据:/tmp/ely-r26-r2-mutation.log。独立基线应用与 worker 构建、118 项测试、注释检查均通过;上一轮属性栏提交便签导致缩放变化的复现场景通过,比例 2.2026143→2.72549 全程保留可绘制底图。写入本结论前已核对原有受跟踪文件散列一致,删除探针源码副本、独立 CARGO_TARGET_DIR 和增量缓存,仅保留小型日志。
+3
View File
@@ -0,0 +1,3 @@
FAIL
crates/ely-lok/src/view/zoom.rs:51(关联 render.rs:272、view/tests/fit.rs:148)— 连续缩放读取上一帧的居中偏移,导致指针下的页面锚点漂移,违反 R2.7 与 PRD LOK-04。`self.spare` 仅由 `prepare` 更新;第一次缩放已经修改 `geom.zoom`,同帧后续事件仍减去旧偏移。复现:固定 LOK 构建打开现有 FODT,视口 1200×900、100%,指针位于 (600,400),同帧依次缩放到 125%、150%、200%,随后重绘;指针下的文档横坐标从 6404 变成 4500 twips,原锚点画到 x=853.87 px,漂移约 254 px。真实窗口连续派发三个 PinchEvent 同样复现,漂移 250.87 px,截图显示左侧多出约 38 px 灰区。每次缩放后重绘的对照通过;副本中在每次缩放前按当前几何读取 `slack()` 后,连续事件探针通过,真实窗口误差降至 0.07 px。修复方向:让每次缩放使用与当前缩放、滚动一致的居中偏移,并增加同帧连续滚轮/捏合事件的锚点回归。上一轮的按键左页边与单次指针锚定场景已修复,分别恢复旧错误时现有回归测试均失败;现有测试只覆盖重绘之间的一次指针缩放,遗漏本场景。已独立构建 worker,运行 `cargo test -p ely-lok --features view --locked --offline --lib view::tests -- --test-threads=1 --nocapture`:34 passed,其中两个剪贴板用例按开关跳过;注释与文件行数检查通过。证据:`/tmp/ely-r27-r2-probes.log`、`/tmp/ely-r27-r2-native.log`、`/tmp/ely-r27-r2-with-fresh-slack.log`、`/tmp/ely-r27-r2-pixels.log`;复现代码:`/tmp/ely-r27-r2-reproducer.log`、`/tmp/ely-r27-r2-native-reproducer.log`。审查副本、独立构建目录及增量缓存已删除,1236 个原有跟踪文件散列一致。
+3
View File
@@ -0,0 +1,3 @@
FAIL
crates/ely-lok/src/client/tests/formula.rs:365 — 新增回归仅检查 worker 的 `.uno:StateTableCell`,绕过本次修复的 `figures::label`;既有界面测试(apps/ely-table/src/figures.rs:80)仅使用整数 `21`、`42`,上一轮数值失真的回归覆盖仍缺失。具体复现:在独立源码副本中恢复上一轮的 `tidy` 函数及其调用,`selection_figures_follow_the_cells_number_format` 和 `kernel_status_takes_the_interface_language` 仍全部通过;实际调用变异后的 `label`,`8128.00` 显示为 `8128`,`0.0000000001` 显示为 `0`,`1234567890.25` 显示为 `1234567890`。补充的数值保留断言在当前实现通过、恢复旧逻辑后失败。修复方向:在 ElyTable 的界面测试中断言这些真实内核统计值经 `figures::label` 后完整保留,使恢复旧逻辑时测试失败。上一轮行为缺陷已复测修复:真实内核的两位小数、尾零、微小值和大数小数输出以及当前界面转换均通过;独立构建 worker 与 ElyTable 成功,公式测试 8/8、ElyTable 测试 43/43、注释规则检查通过。变异证据见 `/tmp/ely-r28-r2-mutant-existing.log`、`/tmp/ely-r28-r2-probe-kernel.log`、`/tmp/ely-r28-r2-mutant-regression.log`;临时源码副本、独立构建目录和增量缓存已在写入结论前清理。