From ca4a32f2fac2ba126f53d806fa51fe49b4df553c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 10 Oct 2026 00:29:44 -0400 Subject: [PATCH] ElyPDF worker: text blocks are always valid JSON, whatever the text or font name holds (P-14) --- crates/ely-pdf-engine/shim/ely_annot.c | 30 ++++++++++++---- crates/ely-pdf-engine/shim/ely_text.c | 11 +++--- crates/ely-pdf-engine/tests/fonts.rs | 50 ++++++++++++++++++++++++++ tasks/BUGS.md | 2 +- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/crates/ely-pdf-engine/shim/ely_annot.c b/crates/ely-pdf-engine/shim/ely_annot.c index ea2c08c..9ec1ba0 100644 --- a/crates/ely-pdf-engine/shim/ely_annot.c +++ b/crates/ely-pdf-engine/shim/ely_annot.c @@ -9,18 +9,34 @@ static const char *stamps[] = { "Approved", "AsIs", "Confidential", "Departmenta "Expired", "Final", "ForComment", "ForPublicRelease", "NotApproved", "NotForPublicRelease", "Sold", "TopSecret", NULL }; +/* Valid UTF-8 always: a stray byte becomes U+FFFD. */ void ely_json_string(fz_context *ctx, fz_output *out, const char *s) { + char utf8[FZ_UTFMAX]; + int rune; fz_write_byte(ctx, out, '"'); - for (; *s; s++) + while (*s) { - unsigned char c = (unsigned char)*s; - if (c == '"' || c == '\\') - fz_write_printf(ctx, out, "\\%c", c); - else if (c < 32) - fz_write_printf(ctx, out, "\\u%04x", c); + s += fz_chartorune(&rune, s); + /* A surrogate pair joins; a lone one is no character. */ + if (rune >= 0xD800 && rune < 0xDC00) + { + int low; + int n = fz_chartorune(&low, s); + if (low >= 0xDC00 && low < 0xE000) + { + rune = 0x10000 + ((rune - 0xD800) << 10) + (low - 0xDC00); + s += n; + } + } + if ((rune >= 0xD800 && rune < 0xE000) || rune > 0x10FFFF) + rune = FZ_REPLACEMENT_CHARACTER; + if (rune == '"' || rune == '\\') + fz_write_printf(ctx, out, "\\%c", rune); + else if (rune < 32) + fz_write_printf(ctx, out, "\\u%04x", rune); else - fz_write_byte(ctx, out, c); + fz_write_data(ctx, out, utf8, fz_runetochar(utf8, rune)); } fz_write_byte(ctx, out, '"'); } diff --git a/crates/ely-pdf-engine/shim/ely_text.c b/crates/ely-pdf-engine/shim/ely_text.c index 9e75044..b09c236 100644 --- a/crates/ely-pdf-engine/shim/ely_text.c +++ b/crates/ely-pdf-engine/shim/ely_text.c @@ -313,10 +313,13 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char words = fz_new_buffer(ctx, 256); block_text(ctx, words, block); fz_terminate_buffer(ctx, words); - fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"text\":%q,\"font\":%q,\"size\":%g,\"color\":%d,\"editable\":%s}", - first ? "" : ",", block->bbox.x0, block->bbox.y0, block->bbox.x1, block->bbox.y1, - fz_string_from_buffer(ctx, words), fz_font_name(ctx, ch->font), ch->size, (int)(ch->argb & 0xffffff), - editable ? "true" : "false"); + fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"text\":", + first ? "" : ",", block->bbox.x0, block->bbox.y0, block->bbox.x1, block->bbox.y1); + ely_json_string(ctx, out, fz_string_from_buffer(ctx, words)); + fz_write_string(ctx, out, ",\"font\":"); + ely_json_string(ctx, out, fz_font_name(ctx, ch->font)); + fz_write_printf(ctx, out, ",\"size\":%g,\"color\":%d,\"editable\":%s}", + ch->size, (int)(ch->argb & 0xffffff), editable ? "true" : "false"); first = 0; } fz_write_string(ctx, out, "]"); diff --git a/crates/ely-pdf-engine/tests/fonts.rs b/crates/ely-pdf-engine/tests/fonts.rs index 9fdecb7..028863f 100644 --- a/crates/ely-pdf-engine/tests/fonts.rs +++ b/crates/ely-pdf-engine/tests/fonts.rs @@ -110,6 +110,56 @@ fn a_ligature_block_edits_like_any_other() { } } +#[test] +fn a_control_character_in_text_still_reads() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("control.pdf"); + // Code 1 names U+0001, as broken encodings do. + let content = "BT /F1 24 Tf 72 700 Td <41014243> Tj ET"; + let pdf = one_font_page(content, &[ + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 /uni0001] >> >>".to_string(), + ]); + std::fs::write(&path, pdf).expect("writes"); + let mut worker = worker(); + let doc = worker.open(&path).expect("opens").doc; + let text = page_text(&mut worker, doc); + let blocks = worker.text_blocks(doc, 0).expect("the blocks parse"); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].text, text, "the same text as the page reads"); +} + +#[test] +fn a_font_name_with_a_stray_byte_still_reads() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("name.pdf"); + // #E9 alone is not UTF-8. + let content = "BT /F1 24 Tf 72 700 Td (ABC) Tj ET"; + let pdf = one_font_page(content, &[ + "<< /Type /Font /Subtype /Type1 /BaseFont /Review#E9Font /Encoding /WinAnsiEncoding >>".to_string(), + ]); + std::fs::write(&path, pdf).expect("writes"); + let mut worker = worker(); + let doc = worker.open(&path).expect("opens").doc; + let blocks = worker.text_blocks(doc, 0).expect("the blocks parse"); + assert_eq!((blocks[0].text.as_str(), blocks[0].font.as_str()), ("ABC", "Review\u{FFFD}Font")); +} + +#[test] +fn a_font_name_in_surrogate_halves_still_reads() { + let dir = tempfile::tempdir().expect("a temp dir"); + let content = "BT /F1 24 Tf 72 700 Td (ABC) Tj ET"; + let mut worker = worker(); + // A pair joins; a lone half is no character. + for (base, want) in [("Review#ED#A0#BD#ED#B8#80Font", "Review\u{1F600}Font"), ("Lone#ED#A0#BDFont", "Lone\u{FFFD}Font")] { + let path = dir.path().join(format!("{want}.pdf")); + let font = format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} /Encoding /WinAnsiEncoding >>"); + std::fs::write(&path, one_font_page(content, &[font])).expect("writes"); + let doc = worker.open(&path).expect("opens").doc; + let blocks = worker.text_blocks(doc, 0).expect("the blocks parse"); + assert_eq!(blocks[0].font, want); + } +} + #[test] fn a_font_written_into_the_page_says_it_cannot_write() { let dir = tempfile::tempdir().expect("a temp dir"); diff --git a/tasks/BUGS.md b/tasks/BUGS.md index a6ed102..1e5d995 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -46,7 +46,7 @@ - [x] P-11 撤销或重做在 MuPDF 侧失败后 worker 的步数不再同步 — 撤销失败后重读历史标记 - [x] P-12 `unhook` 中途抛出时泄漏保留的对象 — 保留的链接随保留随计数 - [x] P-13 优化导出的“之前”大小取磁盘文件而非当前文档 — 有未保存编辑时不与磁盘文件比大小 -- [ ] P-14 文字块含控制字符时 JSON 回复不可解析 +- [x] P-14 文字块含控制字符时 JSON 回复不可解析 — 共用 JSON 写入:控制字符转义,非法字节与孤立代理为 U+FFFD - [x] D-01 导出或打印对话框开着时关闭其标签即崩溃 — Docs 实现 tab_closed,关标签即撤下其导出对话框;R1 PASS - [x] D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录 — 选数据时记下模板标签,预览副本不作模板;R1 测试绕过选 CSV 入口;R2 PASS - [ ] D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名)