ElyPDF: the document's font reads codes as text extraction does, partial ToUnicode included (R2.17)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 03:22:39 -04:00
1 parent 0ce468a3ad
commit 5a6c17757e
4 files changed
+74 -21

No files matched your search

+1 -1
View File
@@ -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),不再隐式扩展;单格开筛选时仍取整个数据区。代价:紧贴在筛选区下方追加的行不自动并入,需重新选定后再开筛选。
+16 -2
View File
@@ -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++;
}
+54 -18
View File
@@ -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<u8> {
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> <FFFF> endcodespacerange\n4 beginbfchar <0001> <0041> <0002> <0078> <0003> <0079> <0004> <0042> endbfchar\nendcmap CMapName currentdict /CMap defineresource pop end end";
let gids: Vec<u8> = [0u16, 36, 16, 16, 37].iter().flat_map(|g| g.to_be_bytes()).collect();
let objects: Vec<Vec<u8>> = vec![
b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Resources<</Font<</F1 4 0 R>>>>/Contents 5 0 R>>".to_vec(),
b"<</Type/Font/Subtype/Type0/BaseFont/GreatVibes/Encoding/Identity-H/DescendantFonts[6 0 R]/ToUnicode 7 0 R>>".to_vec(),
stream("", b"BT /F1 24 Tf 72 700 Td <000100030004> Tj ET"),
b"<</Type/Font/Subtype/CIDFontType2/BaseFont/GreatVibes/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>/FontDescriptor 8 0 R/CIDToGIDMap 9 0 R/DW 600>>".to_vec(),
stream("", unicode),
b"<</Type/FontDescriptor/FontName/GreatVibes/Flags 4/FontBBox[-200 -300 1200 1000]/ItalicAngle 0/Ascent 900/Descent -300/CapHeight 700/StemV 80/FontFile2 10 0 R>>".to_vec(),
stream("", &gids),
stream(&format!("/Length1 {}", font.len()), &font),
];
/// A PDF of `objects`, numbered from 1.
fn assembled(objects: &[Vec<u8>]) -> Vec<u8> {
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<u8> {
pdf
}
/// "AB" in WinAnsi Helvetica whose ToUnicode maps only "A".
fn partial_unicode_pdf() -> Vec<u8> {
let stream = |data: &[u8]| [format!("<</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 <00> <FF> endcodespacerange\n1 beginbfchar <41> <0041> endbfchar\nendcmap CMapName currentdict /CMap defineresource pop end end";
assembled(&[
b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Resources<</Font<</F1 4 0 R>>>>/Contents 5 0 R>>".to_vec(),
b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding/ToUnicode 6 0 R>>".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<u8> {
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> <FFFF> endcodespacerange\n4 beginbfchar <0001> <0041> <0002> <0078> <0003> <0079> <0004> <0042> endbfchar\nendcmap CMapName currentdict /CMap defineresource pop end end";
let gids: Vec<u8> = [0u16, 36, 16, 16, 37].iter().flat_map(|g| g.to_be_bytes()).collect();
let objects = [
b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Resources<</Font<</F1 4 0 R>>>>/Contents 5 0 R>>".to_vec(),
b"<</Type/Font/Subtype/Type0/BaseFont/GreatVibes/Encoding/Identity-H/DescendantFonts[6 0 R]/ToUnicode 7 0 R>>".to_vec(),
stream("", b"BT /F1 24 Tf 72 700 Td <000100030004> Tj ET"),
b"<</Type/Font/Subtype/CIDFontType2/BaseFont/GreatVibes/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>/FontDescriptor 8 0 R/CIDToGIDMap 9 0 R/DW 600>>".to_vec(),
stream("", unicode),
b"<</Type/FontDescriptor/FontName/GreatVibes/Flags 4/FontBBox[-200 -300 1200 1000]/ItalicAngle 0/Ascent 900/Descent -300/CapHeight 700/StemV 80/FontFile2 10 0 R>>".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");
}
+3
View File
@@ -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> <FF>`,唯一映射为 `<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`。