From 5a6c17757e6d3265993b6e20a5c30cdba5abc878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 03:22:39 -0400 Subject: [PATCH] ElyPDF: the document's font reads codes as text extraction does, partial ToUnicode included (R2.17) --- agents.md | 2 +- crates/ely-pdf-engine/shim/ely_pen.c | 18 ++++++- crates/ely-pdf-engine/tests/content.rs | 72 +++++++++++++++++++------- tasks/reviews/R2.17-r2.md | 3 ++ 4 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 tasks/reviews/R2.17-r2.md diff --git a/agents.md b/agents.md index 978e60a..bdc7271 100644 --- a/agents.md +++ b/agents.md @@ -262,5 +262,5 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-06:ElyPDF 阅读(C11.1,PDF-01)。MuPDF 只链进 `ely-pdf-worker`(`crates/ely-pdf-engine`,build.rs 把 C 垫片 `shim/ely_mupdf.c` 与 `libmupdf.a`、`libmupdf-third.a` 只链给这个二进制),应用不链 MuPDF。build.rs 以协议、垫片与 worker 源码的散列为版本戳,应用启动 worker 先握手(`HELLO`),戳不同即拒绝(单测不重建 worker,旧二进制曾被误用)。worker 在打开第一个文件时才启动(PERF-01),`tests/scripts/pdf-idle.sh` 查空白窗口无子进程。垫片每个调用都在 `fz_try`/`fz_catch` 里,返回码加最后一条错误信息,MuPDF 的跳转从不越过 Rust。worker 与应用之间是 `ely-ipc` 的帧(从 ely-lok 抽出,两者共用):打开(锁定即报)、解锁、页数、页框、渲染一块(设备像素、缩放、旋转、深色)、按阅读顺序的字符与外框、页内搜索(每处命中及其全部四边形,跨行短语算一处,数量不设上限)、书签(JSON)、关闭;文件读不出只报错,worker 照常工作。查看器:打开时只读页数、前 100 页尺寸与书签,其余尺寸后台每批 100 页补齐;视图只排屏上的页(连续、单页、双页;适合宽度、适合页面、实际大小与缩放档;视图旋转 90°),按 512 设备像素一块请求,一次一块、由近及远,只留屏上的块(横纵两向),后台回来的块不在屏上即弃;画不出的块记下不再请求,换文档或重开前不重试;深色外观下页面按亮度反相(MuPDF `fz_invert_pixmap_luminance`,色相保留)。文字选择按字符、可跨页,复制按行换行,未读的页先读;查找每批 20 页在后台进行,命中逐个跳转并标出,Esc 关闭(组件库 617a8d6);书签栏用 `lists::Outline`,点即跳到页与位置。切换视图、缩放、旋转后仍停在正在读的那页。加密文件打开时问密码(外壳的密码框,见上一条),错了再问,取消即不打开。没有页的文件报错并关闭,不开标签。双页时同一行在视口内的两页都可为当前页;当前页只在横纵两向都可见的页里定,跳转在目标页左缘不在视口时横向移过去。图块按视口半开区间取。首次按下时文字未读,读到后照原按下处起选;查找结果按查找实例与所属标签核对,只在该标签为当前时跳转。书签为空时不留左栏。启动参数里的文件直接打开。 - 2026-10-09(用户定:“打开方式”里不得出现 LibreOffice):系统会登记磁盘上能找到的 .app、挂载镜像里的 .app 与运行过的包,构建目录里的 `LibreOffice.app` 与测试挂载的官方镜像曾因此成为 xlsx 等的默认程序。现构建后由 `scripts/inert-lo.sh` 去掉其全部文件与 URL 声明、快速查看与聚焦插件,注销它,并在 `engines/` 放 `.metadata_never_index`;Ely 只取其 Frameworks 与 Resources。任何脚本与测试都不挂载 LibreOffice 镜像:LOK 探测用本构建的内核,单测所需的官方内核由 `scripts/extract-official-lo.sh` 以 7zz 解成无包的文件夹。`tests/scripts/lo-unregistered.sh` 以 NSWorkspace 核对 docx、xlsx、pptx、odt、csv 的“打开方式”里没有 LibreOffice。 - 2026-10-09(用户报:ElyTable 点格跳到别处、拖不出选区):Calc 在 `sc_print_twips_msgs` 下以打印 twips(行高原值)报光标、选区与 `.uno:SheetGeometryData`,图块与鼠标却在图块 twips(每行按 `ScViewData::ToPixel` 截成整像素),两者随行号漂移(5000 行处约 140 行)。视图对外一律打印 twips;`LokView::set_geometry` 收下工作表几何后,`sheet::Axis::tile`、`print` 照内核的截断换算(每像素 twips 取图块的像素与 twips 之比),绘制、显示、跳转与冻结都先换成图块 twips,鼠标以图块 twips 发出(`tile_point`),拖动句柄仍为打印 twips;`.uno:` 读回与应用(表头、填充柄)照旧用打印 twips,表头的起点取 `LokView::scrolled_start`。内核读鼠标用的比例原取最后一块图块,worker 现于每次 `setClientVisibleArea` 前以 `setClientZoom` 告知图块比例,点击不再依赖先画过图块。 -- 2026-10-09(用户报:ElyPDF 改字报“这种字体画不出 V”):文档自带的子集字体常无可用的 Unicode 表(LibreOffice 导出的子集表为占位,0x00 起逐一对应字形号),按 Unicode 找字形一律失败。现以“文档自带字体”写字时直接用页面上的那个字体对象(`shim/ely_pen.c`;`pdf_load_font` 按对象缓存,与文字层的字体同一对象),以它自己的编码写:按文字提取的读法(ToUnicode 或 cid_to_ucs)倒出 Unicode 到编码的表,排版取它的宽度(`pdf_lookup_hmtx`),原有的 ToUnicode 与宽度不动,两个字共用一个字形时各写各的编码(原先按字形号写、重建 ToUnicode,共用字形的字被改写,且 CMap 漏写条目数,PDFKit 读不出字)。删去原文字块后页面资源里可能已无该字体,故按对象号以新名字重新引用。只支持编码即 CID 的字体(简单字体与 Identity-H),其余拒绝并说明;页面上没有的字仍报缺字。排版按同一套宽度量出的框,比较时留 0.01 点的余量(原块宽度即这些宽度之和,浮点误差曾使末字折行)。 +- 2026-10-09(用户报:ElyPDF 改字报“这种字体画不出 V”):文档自带的子集字体常无可用的 Unicode 表(LibreOffice 导出的子集表为占位,0x00 起逐一对应字形号),按 Unicode 找字形一律失败。现以“文档自带字体”写字时直接用页面上的那个字体对象(`shim/ely_pen.c`;`pdf_load_font` 按对象缓存,与文字层的字体同一对象),以它自己的编码写:按文字提取的读法(ToUnicode,缺项或坏值退回 cid_to_ucs,同 pdf-op-run.c)倒出 Unicode 到编码的表,排版取它的宽度(`pdf_lookup_hmtx`),原有的 ToUnicode 与宽度不动,两个字共用一个字形时各写各的编码(原先按字形号写、重建 ToUnicode,共用字形的字被改写,且 CMap 漏写条目数,PDFKit 读不出字)。删去原文字块后页面资源里可能已无该字体,故按对象号以新名字重新引用。只支持编码即 CID 的字体(简单字体与 Identity-H),其余拒绝并说明;页面上没有的字仍报缺字。排版按同一套宽度量出的框,比较时留 0.01 点的余量(原块宽度即这些宽度之和,浮点误差曾使末字折行)。 - 2026-10-09:ElyTable 的自动筛选(R2.9)。内核会重新推算未命名的自动筛选区:每次打开筛选列表时向下扩到紧邻的数据行(`ExtendDataArea`、`ExtendBackColorArea`,为接上后来追加的行),命令从单格找到它时按连续数据区重算(`ScDocShell::GetDBData`:应用条件、关闭筛选及其撤销);选区下方紧邻的合计行因此进了列表、随数据一起被隐藏,存后重开的筛选区同样会扩。补丁 `0040-lok-calc-autofilter-keeps-its-rows.patch`:LOK 下已有的自动筛选区保持设定时的行(同 Excel),不再隐式扩展;单格开筛选时仍取整个数据区。代价:紧贴在筛选区下方追加的行不自动并入,需重新选定后再开筛选。 diff --git a/crates/ely-pdf-engine/shim/ely_pen.c b/crates/ely-pdf-engine/shim/ely_pen.c index bd10477..6af5ccd 100644 --- a/crates/ely-pdf-engine/shim/ely_pen.c +++ b/crates/ely-pdf-engine/shim/ely_pen.c @@ -14,6 +14,21 @@ void ely_script_font(const unsigned char *data, size_t len) script_len = len; } +/* `cid` as text extraction reads it (pdf-op-run.c), or -1. */ +static int unicode_of(pdf_font_desc *desc, int cid) +{ + int ucs[PDF_MRANGE_CAP], n = 0; + if (desc->to_unicode) + n = pdf_lookup_cmap_full(desc->to_unicode, cid, ucs); + if (n == 1 && ucs[0] >= 8 && ucs[0] <= 13) + ucs[0] = ' '; + if (n == 1 && (ucs[0] < 32 || (ucs[0] >= 127 && ucs[0] < 160))) + n = 0; + if (n == 0 && cid < (int)desc->cid_to_ucs_len) + ucs[0] = desc->cid_to_ucs[cid], n = 1; + return n == 1 && ucs[0] > 0 && ucs[0] != FZ_REPLACEMENT_CHARACTER ? ucs[0] : -1; +} + /* Unicode and code pairs of `desc`; its codes are CIDs. */ static void own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p) { @@ -26,8 +41,7 @@ static void own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p) p->map = fz_malloc_array(ctx, 2 * end, int); for (cid = 0; cid < end; cid++) { - int u = desc->to_unicode ? pdf_lookup_cmap(desc->to_unicode, cid) - : cid < (int)desc->cid_to_ucs_len ? desc->cid_to_ucs[cid] : 0; + int u = unicode_of(desc, cid); if (u > 0 && pdf_font_cid_to_gid(ctx, desc, cid) > 0) p->map[2 * p->mapped] = u, p->map[2 * p->mapped + 1] = cid, p->mapped++; } diff --git a/crates/ely-pdf-engine/tests/content.rs b/crates/ely-pdf-engine/tests/content.rs index d221b7a..11e8a4c 100644 --- a/crates/ely-pdf-engine/tests/content.rs +++ b/crates/ely-pdf-engine/tests/content.rs @@ -292,24 +292,8 @@ fn a_subset_font_writes_with_its_own_glyphs_and_reads_back() { assert!(now.contains("TEXT BUNDLE"), "the new words read back: {now:?}"); } -/// "AyB", where "x" and "y" share one glyph. -fn shared_glyph_pdf() -> Vec { - let font = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts/GreatVibes-Regular.ttf")).expect("the font"); - let stream = |dict: &str, data: &[u8]| [format!("<<{dict}/Length {}>>stream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(); - let unicode = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /U def\n1 begincodespacerange <0000> endcodespacerange\n4 beginbfchar <0001> <0041> <0002> <0078> <0003> <0079> <0004> <0042> endbfchar\nendcmap CMapName currentdict /CMap defineresource pop end end"; - let gids: Vec = [0u16, 36, 16, 16, 37].iter().flat_map(|g| g.to_be_bytes()).collect(); - let objects: Vec> = vec![ - b"<>".to_vec(), - b"<>".to_vec(), - b"<>>>/Contents 5 0 R>>".to_vec(), - b"<>".to_vec(), - stream("", b"BT /F1 24 Tf 72 700 Td <000100030004> Tj ET"), - b"<>/FontDescriptor 8 0 R/CIDToGIDMap 9 0 R/DW 600>>".to_vec(), - stream("", unicode), - b"<>".to_vec(), - stream("", &gids), - stream(&format!("/Length1 {}", font.len()), &font), - ]; +/// A PDF of `objects`, numbered from 1. +fn assembled(objects: &[Vec]) -> Vec { let mut pdf = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (at, body) in objects.iter().enumerate() { @@ -323,6 +307,41 @@ fn shared_glyph_pdf() -> Vec { pdf } +/// "AB" in WinAnsi Helvetica whose ToUnicode maps only "A". +fn partial_unicode_pdf() -> Vec { + let stream = |data: &[u8]| [format!("<>stream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(); + let unicode = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /U def\n1 begincodespacerange <00> endcodespacerange\n1 beginbfchar <41> <0041> endbfchar\nendcmap CMapName currentdict /CMap defineresource pop end end"; + assembled(&[ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>>>/Contents 5 0 R>>".to_vec(), + b"<>".to_vec(), + stream(b"BT /F1 24 Tf 72 700 Td (AB) Tj ET"), + stream(unicode), + ]) +} + +/// "AyB", where "x" and "y" share one glyph. +fn shared_glyph_pdf() -> Vec { + let font = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts/GreatVibes-Regular.ttf")).expect("the font"); + let stream = |dict: &str, data: &[u8]| [format!("<<{dict}/Length {}>>stream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(); + let unicode = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /U def\n1 begincodespacerange <0000> endcodespacerange\n4 beginbfchar <0001> <0041> <0002> <0078> <0003> <0079> <0004> <0042> endbfchar\nendcmap CMapName currentdict /CMap defineresource pop end end"; + let gids: Vec = [0u16, 36, 16, 16, 37].iter().flat_map(|g| g.to_be_bytes()).collect(); + let objects = [ + b"<>".to_vec(), + b"<>".to_vec(), + b"<>>>/Contents 5 0 R>>".to_vec(), + b"<>".to_vec(), + stream("", b"BT /F1 24 Tf 72 700 Td <000100030004> Tj ET"), + b"<>/FontDescriptor 8 0 R/CIDToGIDMap 9 0 R/DW 600>>".to_vec(), + stream("", unicode), + b"<>".to_vec(), + stream("", &gids), + stream(&format!("/Length1 {}", font.len()), &font), + ]; + assembled(&objects) +} + #[test] fn a_letter_that_shares_its_glyph_keeps_its_own_code() { let dir = tempfile::tempdir().expect("a temp dir"); @@ -336,3 +355,20 @@ fn a_letter_that_shares_its_glyph_keeps_its_own_code() { doc = worker.now(doc).expect("the state"); assert_eq!(page_text(&mut worker, doc), "ByA", "y stays y, not the glyph's other letter"); } + +#[test] +fn a_partial_to_unicode_falls_back_as_reading_does() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("partial.pdf"); + std::fs::write(&path, partial_unicode_pdf()).expect("writes"); + let mut worker = worker(); + let mut doc = worker.open(&path).expect("opens").doc; + assert_eq!(page_text(&mut worker, doc), "AB", "the sample reads as written"); + let writing = rewrite(&mut worker, doc, 0, "BA", "original"); + assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("answers"), Wrote::Done(_)), "B is in the font"); + doc = worker.now(doc).expect("the state"); + let out = dir.path().join("saved.pdf"); + worker.save(doc, &out).expect("saves"); + let doc = worker.open(&out).expect("reopens").doc; + assert_eq!(page_text(&mut worker, doc), "BA", "saved and read again"); +} diff --git a/tasks/reviews/R2.17-r2.md b/tasks/reviews/R2.17-r2.md new file mode 100644 index 0000000..8252b61 --- /dev/null +++ b/tasks/reviews/R2.17-r2.md @@ -0,0 +1,3 @@ +FAIL + +crates/ely-pdf-engine/shim/ely_pen.c:29 — `own_codes` 在字体存在 ToUnicode 时只查该表,遗漏文字提取器对未映射字符使用的 `cid_to_ucs` 后备映射,导致面板可识别且字体已有的字被误报缺字,违反 R2.17 与 PDF-03。具体复现:构造一页 PDF,字体为 `/Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding /ToUnicode 6 0 R`,ToUnicode 的 codespace 为 `<00> `,唯一映射为 `<41> <0041>`,正文为 `BT /F1 24 Tf 72 700 Td (AB) Tj ET`。`text_blocks` 返回 `text="AB", editable=true`;保持字体、字号和框位置,将文字改为 `BA`,选择 `original`,`put_text` 返回 `Missing('B')`;同一请求显式选择 `helvetica` 返回 `Done("1")`。独立探针重复复现,现有 108 项测试全部通过,遗漏此编码路径。修复方向:按文字提取相同的逐字符优先级解析 Unicode,ToUnicode 未提供该 CID 的映射时继续查 `cid_to_ucs`,增加部分 ToUnicode 下原字体编辑、保存重开的回归。证据:`/private/tmp/ely-r217-r2-probes.log`、`/private/tmp/ely-r217-r2-partial-repeat.log`、`/private/tmp/ely-r217-r2-suite.log`。