ElyPDF: the edit tool checks a page's text blocks in one pass (P-04)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 13:16:01 -04:00
1 parent 4d8d5a6ef5
commit c9bfccc91c
5 files changed
+171 -13

No files matched your search

+102 -11
View File
@@ -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);
+62 -1
View File
@@ -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:?}");
}
+1 -1
View File
@@ -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 含连字的文字块永远不可编辑
+5
View File
@@ -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,同一探针通过;当前代码重复失败。修复方向:在一次整页干跑中保留各块独立的匹配语义,对同一字形匹配到的每个块分别计数,每个块每个字形至多计一次,并加入共享原点的重叠块回归测试。
+1
View File
@@ -0,0 +1 @@
PASS