diff --git a/crates/ely-pdf-engine/shim/ely_text.c b/crates/ely-pdf-engine/shim/ely_text.c index 8882a0c..e1cd458 100644 --- a/crates/ely-pdf-engine/shim/ely_text.c +++ b/crates/ely-pdf-engine/shim/ely_text.c @@ -29,6 +29,12 @@ typedef struct int *c; int n; int hit; + /* Page-wide: each target's block, and hits per block. */ + int *owner; + int *hits; + /* The glyph each block last counted, so once each. */ + int *seen; + int glyph; } targets; /* The glyphs of `block` with their user-space origins. */ @@ -58,19 +64,32 @@ static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_ma { targets *t = opaque; fz_point at = fz_transform_point(fz_transform_point(fz_make_point(0, 0), trm), ctm); - int i, k; + int i, k, found = 0; + t->glyph++; for (i = 0; i < t->n; i++) { + int match = 0; if (fabsf(at.x - t->at[i].x) > 0.5f || fabsf(at.y - t->at[i].y) > 0.5f) continue; - for (k = 0; k < ucslen; k++) - if (ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER) - { - t->hit++; - return 1; - } + for (k = 0; k < ucslen && !match; k++) + match = ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER; + if (!match) + continue; + if (!t->owner) + { + t->hit++; + return 1; + } + /* Every block this glyph matches counts it once. */ + if (t->seen[t->owner[i]] != t->glyph) + { + t->seen[t->owner[i]] = t->glyph; + t->hits[t->owner[i]]++; + } + found = 1; } - return 0; + t->hit += found; + return found; } /* Runs the glyph filter over the page; `dry` changes nothing. */ @@ -99,7 +118,7 @@ fz_matrix ely_to_user(fz_context *ctx, pdf_page *page) /* Whether all of `block` sits in the page's own content. */ static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_block *block) { - targets t = { NULL, NULL, 0, 0 }; + targets t = { NULL, NULL, 0, 0, NULL, NULL, NULL, 0 }; int ok = 0; fz_var(t.at); fz_var(t.c); @@ -122,6 +141,70 @@ static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stex return ok; } +/* Each text block's glyphs found in page content, one pass. */ +static void reachable_all(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_page *text, int n, int *ok) +{ + targets t = { NULL, NULL, 0, 0, NULL, NULL, NULL, 0 }; + int *count = NULL, all = 0, b; + fz_matrix to_user = ely_to_user(ctx, page); + fz_stext_block *block; + fz_stext_line *line; + fz_stext_char *ch; + fz_var(t.at); + fz_var(t.c); + fz_var(t.owner); + fz_var(t.hits); + fz_var(t.seen); + fz_var(count); + pdf_begin_operation(ctx, pdf, "Look at text"); + fz_try(ctx) + { + for (block = text->first_block; block; block = block->next) + if (block->type == FZ_STEXT_BLOCK_TEXT) + for (line = block->u.t.first_line; line; line = line->next) + for (ch = line->first_char; ch; ch = ch->next) + all++; + t.at = fz_malloc_array(ctx, all + 1, fz_point); + t.c = fz_malloc_array(ctx, all + 1, int); + t.owner = fz_malloc_array(ctx, all + 1, int); + t.hits = fz_calloc(ctx, n + 1, sizeof(int)); + t.seen = fz_calloc(ctx, n + 1, sizeof(int)); + count = fz_calloc(ctx, n + 1, sizeof(int)); + b = 0; + for (block = text->first_block; block; block = block->next) + { + if (block->type != FZ_STEXT_BLOCK_TEXT) + continue; + for (line = block->u.t.first_line; line; line = line->next) + for (ch = line->first_char; ch; ch = ch->next) + if (!(ch->flags & FZ_STEXT_SYNTHETIC)) + { + t.at[t.n] = fz_transform_point(ch->origin, to_user); + t.c[t.n] = ch->c; + t.owner[t.n++] = b; + count[b]++; + } + b++; + } + filter_glyphs(ctx, pdf, page, &t, 1); + for (b = 0; b < n; b++) + ok[b] = count[b] > 0 && t.hits[b] == count[b]; + } + fz_always(ctx) + { + pdf_abandon_operation(ctx, pdf); + ely_forget_resources(ctx, pdf); + fz_free(ctx, t.at); + fz_free(ctx, t.c); + fz_free(ctx, t.owner); + fz_free(ctx, t.hits); + fz_free(ctx, t.seen); + fz_free(ctx, count); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + /* The text of `block`, lines on lines. */ static void block_text(fz_context *ctx, fz_buffer *buf, fz_stext_block *block) { @@ -153,18 +236,24 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char pdf_page *page = NULL; fz_buffer *buf = NULL, *words = NULL; fz_output *out = NULL; + int *reached = NULL; fz_var(text); fz_var(page); fz_var(buf); fz_var(words); fz_var(out); + fz_var(reached); fz_try(ctx) { pdf_document *pdf = ely_pdf_of(ctx, doc); fz_stext_block *block; - int first = 1; + int first = 1, n = 0, at = 0; text = read_text(ctx, doc, number); page = pdf_load_page(ctx, pdf, number); + for (block = text->first_block; block; block = block->next) + n += block->type == FZ_STEXT_BLOCK_TEXT; + reached = fz_calloc(ctx, n + 1, sizeof(int)); + reachable_all(ctx, pdf, page, text, n, reached); buf = fz_new_buffer(ctx, 1024); out = fz_new_output_with_buffer(ctx, buf); fz_write_string(ctx, out, "["); @@ -175,7 +264,8 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char if (block->type != FZ_STEXT_BLOCK_TEXT) continue; ch = block->u.t.first_line->first_char; - editable = upright(block) && reachable(ctx, pdf, page, block); + editable = reached[at++]; + editable = editable && upright(block); fz_drop_buffer(ctx, words); words = NULL; words = fz_new_buffer(ctx, 256); @@ -198,6 +288,7 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char fz_drop_buffer(ctx, words); fz_drop_page(ctx, (fz_page *)page); fz_drop_stext_page(ctx, text); + fz_free(ctx, reached); } fz_catch(ctx) return ely_fail(ctx); diff --git a/crates/ely-pdf-engine/tests/content.rs b/crates/ely-pdf-engine/tests/content.rs index 11e8a4c..85e0231 100644 --- a/crates/ely-pdf-engine/tests/content.rs +++ b/crates/ely-pdf-engine/tests/content.rs @@ -4,7 +4,7 @@ use std::path::Path; use ely_pdf_engine::{ Doc, Worker, Writing, Wrote, - sample::{Extras, pdf_with}, + sample::{Extras, one_page, pdf_with}, }; fn worker() -> Worker { @@ -372,3 +372,64 @@ fn a_partial_to_unicode_falls_back_as_reading_does() { let doc = worker.open(&out).expect("reopens").doc; assert_eq!(page_text(&mut worker, doc), "BA", "saved and read again"); } + +#[test] +fn a_dense_page_lists_its_blocks_in_one_pass() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut content = String::new(); + for col in 0..10 { + for row in 0..100 { + content.push_str(&format!("BT /F1 2 Tf {} {} Td (Cell {col} {row}) Tj ET\n", 20 + col * 58, 780 - row * 7)); + } + } + let font = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(); + let path = dir.path().join("dense.pdf"); + std::fs::write(&path, one_page("", " /Resources << /Font << /F1 5 0 R >> >>", &content, &[font])).expect("writes"); + let mut worker = worker(); + let doc = worker.open(&path).expect("opens").doc; + let started = std::time::Instant::now(); + let blocks = worker.text_blocks(doc, 0).expect("blocks"); + let took = started.elapsed(); + assert_eq!((blocks.len(), blocks.iter().filter(|b| b.editable).count()), (1000, 1000)); + // Per block took 1.5 s here; one pass 0.1 s. + assert!(took < std::time::Duration::from_millis(750), "{took:?}"); +} + +/// One page: `content`, font F1, and form Fm1 drawing `form`. +fn layered(dir: &Path, content: &str, form: &str) -> (Worker, Doc) { + let objects = [ + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Length {} >>\nstream\n{form}\nendstream", form.len()), + ]; + let page = " /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >>"; + let path = dir.join("layered.pdf"); + std::fs::write(&path, one_page("", page, content, &objects)).expect("writes"); + let mut worker = worker(); + let doc = worker.open(&path).expect("opens").doc; + (worker, doc) +} + +fn editable(worker: &mut Worker, doc: Doc) -> Vec<(String, bool)> { + let blocks = worker.text_blocks(doc, 0).expect("blocks"); + blocks.into_iter().map(|b| (b.text, b.editable)).collect() +} + +#[test] +fn turned_text_first_leaves_the_later_blocks_their_own_answer() { + let dir = tempfile::tempdir().expect("a temp dir"); + let content = "BT /F1 18 Tf 0 1 -1 0 50 500 Tm (ROTATED) Tj ET q 1 0 0 1 300 400 cm /Fm1 Do Q BT /F1 18 Tf 72 200 Td (OWN) Tj ET"; + let (mut worker, doc) = layered(dir.path(), content, "BT /F1 12 Tf 0 10 Td (STAMP) Tj ET"); + let mut got = editable(&mut worker, doc); + got.sort(); + assert_eq!(got, [("OWN".into(), true), ("ROTATED".into(), false), ("STAMP".into(), false)]); +} + +#[test] +fn blocks_that_share_a_glyph_origin_each_count_it() { + let dir = tempfile::tempdir().expect("a temp dir"); + let content = "q /Fm1 Do Q BT /F1 18 Tf 72 300 Td (BREAK) Tj ET BT /F1 18 Tf 72 700 Td (AB) Tj ET"; + let (mut worker, doc) = layered(dir.path(), content, "BT /F1 36 Tf 72 700 Td (AX) Tj ET"); + let got = editable(&mut worker, doc); + assert!(got.contains(&("AB".into(), true)), "{got:?}"); + assert!(got.contains(&("AX".into(), false)), "{got:?}"); +} diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 2b0a130..f8ec420 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -36,7 +36,7 @@ - [x] P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃 — worker 的拒绝改为自有错误类型 Refused;R1 测试不跨平台、不看提示;组件库 f834f55 加 Toaster::shown;R2 PASS - [x] P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失 — 图块随 R2.6;缩略图在编辑后留到重画;R1、R2 测试未证实际绘制;R3 PASS - [x] P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图 — 包住文字块的对象让位于文字,移动须过 3 个窗口像素;R1 漏掉同点的小图;R2 PASS -- [ ] P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应 +- [x] P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应 — 整页一遍干跑判定全部文字块(1000 块 1.5 s→0.1 s);R1 转向文字后错位、共用原点只计一块;R2 PASS - [ ] P-05 连续快画或快点被拒:“文档在此间已改动,未执行” - [ ] P-06 默认字体“原字体”在 Type 1/Type 3 字体的文件上失败,只给引擎原话 - [ ] P-07 含连字的文字块永远不可编辑 diff --git a/tasks/reviews/P-04-r1.md b/tasks/reviews/P-04-r1.md new file mode 100644 index 0000000..afe3051 --- /dev/null +++ b/tasks/reviews/P-04-r1.md @@ -0,0 +1,5 @@ +FAIL + +crates/ely-pdf-engine/shim/ely_text.c:250 — `at++` 位于 `&&` 右侧,旋转文字让 `upright(block)` 返回 0 后,后续文字块的 `reached` 索引错位,违反 PDF-03 的编辑要求。实测单页依次绘制 18 pt `ROTATED`(矩阵 `0 1 -1 0 50 500`)、位于 `(300,400)` 的 Form XObject 文字 `STAMP`、位于 `(72,200)` 的页面自身横排文字 `OWN`,返回的 editable 为 `[false,true,false]`;界面因此允许选择 STAMP,应用时引擎报 `this text is not in the page's own content`,可正常预览修改的 OWN 则无法选择。改动前返回 `[false,false,true]`,同一探针通过;当前代码重复失败。修复方向:每处理一个文字块都递增索引,使递增独立于横排判定,并加入旋转文字后混排 Form 与页面文字的回归测试。 + +crates/ely-pdf-engine/shim/ely_text.c:74 — 页级匹配把一次字形命中只计入首个匹配目标的 owner,随后 `return 1`;不同文字块共享原点和字符时,较早块抢占较晚块的计数,使可编辑文字被标为不可编辑。实测先以 Form XObject 在 `(72,700)` 绘制 36 pt `AX`,再在页面 `(72,300)` 绘制 18 pt `BREAK`,最后在页面 `(72,700)` 绘制 18 pt `AB`;三块均横排,AB 的 A 被计入 AX,最终 AB 的 editable 为 false,界面无法选中编辑,违反 PDF-03。改动前同一文件的 AB 为 true,同一探针通过;当前代码重复失败。修复方向:在一次整页干跑中保留各块独立的匹配语义,对同一字形匹配到的每个块分别计数,每个块每个字形至多计一次,并加入共享原点的重叠块回归测试。 diff --git a/tasks/reviews/P-04-r2.md b/tasks/reviews/P-04-r2.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/P-04-r2.md @@ -0,0 +1 @@ +PASS