R7.4: an unembedded font is no damage (#5)

The warning watch skips MuPDF's non-embedded font notice and its
repeat counts. Text and search reads are watched too: damage they meet
is kept on the document, told by the next draw or picture check, and
fails image and text exports until then.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-16 13:44:17 -04:00
1 parent 969cb67c96
commit 6b676e06e6
9 files changed
+191 -9

No files matched your search

+1
View File
@@ -311,3 +311,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-16(用户定):合集安装包(R7.1)。macOS 一个公证的 `ElyOffice-<版本>-<架构>.dmg`,装首批六个已签名的 .app 与“应用程序”替身,用户拖入所要的(`release.sh` 的 `dmg` 与单个应用共用);无 Developer ID Installer 证书,不出 .pkg。Windows 一个 `ElyOffice-<版本>-windows-x86_64-setup.exe`(`packaging/windows/bundle.nsi`),组件页勾选应用,所选的各自的安装程序以 `/S` 运行,所以装法、位置、升级、卸载项与单装完全相同;某个退出码非零即停下并写明其名与退出码(2 为文件被占用或位置被占),之前装上的照旧。Linux 不出合集。单个应用的包、更新清单照旧。
- 2026-10-16(Gitea #6):设置窗口多出一行应用菜单(R7.2)。设置窗口沿用主窗口的外壳 `Shell`,Windows 与 Linux 上随之画出菜单栏(macOS 菜单在系统菜单栏,不受影响)与更新横幅。菜单栏与更新横幅只属主窗口:`Shell::secondary` 去掉两者,设置窗口用它;`settings/tests.rs` 量内容起点距标题栏底:只装菜单时主窗口大于零(macOS 为零),再有待下载的新版时更大,设置窗口为零。
- 2026-10-16(Gitea #4):装了宋体仍报缺少(R7.3)。内核按名找字体时比较 `GetEnglishSearchFontName`(小写 ASCII、去空格、中日韩本地名译为英文名,`宋体` 即 `simsun`),所以文档的 `宋体` 找到并用上 SimSun;缺字检查却比较原名与所用字体名,报为缺失。Linux 上内核以界面语言(英文)为 fontconfig 字体取名,文档里的本地名一律报缺。补丁 `0048-lok-fonts-missing-by-search-name.patch`:缺字检查同样比较检索名。实测(2026-10-16):Arch 容器装 ElyDoc 0.1.5 的 Arch 包与 simsun.ttc,40 行宋体五号、文档网格的 docx 报缺宋体,正文实为 SimSun(无此字体时为方框),与 Windows 同为一页;换上打过 0048 的内核后同一容器不再报缺、仍为一页。用户所说一页变两页未能复现,需其文件。缺字检查测试:`navigate/tests.rs` 以随包的 DejaVu Sans 写作 `DEJAVU SANS`,只报真缺的字体。另见:Linux 上属性栏的字体框为空(字体列表只有 SimSun,文档写宋体),未改。
- 2026-10-16(Gitea #5):每个 PDF 打开即报已损坏(R7.4)。C11.8 把画图块与导出时 MuPDF 的任何警告都算作损坏;读页面时载入未嵌入的字体也发警告(中文文件里写名而不嵌入的宋体等:`non-embedded font using identity encoding`),合法的文件因此转为修复副本、保存一律另存。现在警告监视(`keep_warning`,打开时的扫描、画图块、导出与 Markdown 的 PDF 共用)只略去这一句与紧随其后的 MuPDF 重复计数(`... repeated N times...`);其余照算损坏,含内容的语法错误、无字体写字、坏的嵌入字体与图片流。读文字与搜索也受监视(坏图片读过即被缓存,之后画图块、导出与检查都不再报警告):其间遇到的损坏记入报告与该文档,下一次画图块或检查图片报出,报出之前导出图片与文字一律拒绝。监视开始时,MuPDF 尚未报出的重复计数(来自其余未受监视的读取)按它所重复的那句归类(`ctx->warn.message`):字体提示的重复略去,其余照算。`tests/output.rs` 核对:两个同名未嵌入字体的文件先读文字再画图块、导出图片与文字都不算损坏;无字体写字与 `BT ] ET` 的图块为损坏;`[/FlateDecode /ASCIIHexDecode]` 下校验和错的图片使两种导出都被拒、图块为损坏。先读文字或搜索后,这样的两张图片、颜色键掩码无效的一张图片、以及它在两个未嵌入字体之前或之后画出,画图块、导出与检查图片仍报损坏。
+14 -1
View File
@@ -56,10 +56,21 @@ typedef struct
void *user;
} passing;
/* MuPDF draws an unembedded font with another. */
static const char notice[] = "non-embedded font using identity encoding:";
/* 1 while the last warning was that notice. */
static int substituted;
static void keep_warning(void *user, const char *message)
{
passing *p = user;
if (strlen(warned) + strlen(message) + 2 < sizeof warned)
/* An unembedded font drawn by another is no damage. */
if (!strncmp(message, notice, sizeof notice - 1))
substituted = 1;
else if (strncmp(message, "... repeated ", 13))
substituted = 0;
if (!substituted && strlen(warned) + strlen(message) + 2 < sizeof warned)
{
fz_strlcat(warned, message, sizeof warned);
fz_strlcat(warned, "\n", sizeof warned);
@@ -74,6 +85,8 @@ static int watched;
/* Collects MuPDF's warnings until `ely_watch_end`. */
void ely_watch_begin(fz_context *ctx)
{
/* A pending repeat is of MuPDF's last warning. */
substituted = !strncmp(ctx->warn.message, notice, sizeof notice - 1);
watching.next = fz_warning_callback(ctx, &watching.user);
warned[0] = 0;
watched = 1;
@@ -31,6 +31,8 @@ struct Doc {
/// What MuPDF warned at open; kept as the repair report.
warnings: String,
repair: Option<String>,
/// Damage a read met that no draw has told yet.
found: bool,
/// The repaired copy standing in; removed on close.
copy: Option<std::path::PathBuf>,
/// MuPDF's steps: an edit's id, or none for a save.
@@ -359,7 +361,7 @@ impl Worker {
let id = self.next;
self.next += 1;
let warnings = output::open_warnings();
let doc = Doc { open, password: String::new(), owner, warnings, repair: None, copy: None, steps: Vec::new(), at: 0 };
let doc = Doc { open, password: String::new(), owner, warnings, repair: None, found: false, copy: None, steps: Vec::new(), at: 0 };
self.docs.insert(id, doc);
if !locked && let Err(error) = self.repair(id) {
self.forget(id)?;
@@ -405,13 +407,12 @@ impl Worker {
self.drawn(id, page, (scale, rotate), tile, dark)
}
tag::TEXT => {
let doc = self.doc(ask.u64()?)?;
self.mupdf.text(doc, ask.u64()? as usize)
let (id, page) = (ask.u64()?, ask.u64()? as usize);
self.read(id, |mupdf, doc| mupdf.text(doc, page))
}
tag::SEARCH => {
let doc = self.doc(ask.u64()?)?;
let page = ask.u64()? as usize;
self.mupdf.search(doc, page, &ask.str()?)
let (id, page, needle) = (ask.u64()?, ask.u64()? as usize, ask.str()?);
self.read(id, |mupdf, doc| mupdf.search(doc, page, &needle))
}
tag::OUTLINE => self.mupdf.outline(self.doc(ask.u64()?)?),
tag::HELLO => Ok(Pack::new().str(ely_pdf_engine::proto::BUILD).bytes()),
@@ -82,6 +82,24 @@ impl crate::Worker {
Ok(())
}
/// A text read; damage it meets waits for a draw.
pub(crate) fn read(&mut self, id: u64, read: impl FnOnce(&Mupdf, &Open) -> io::Result<Vec<u8>>) -> io::Result<Vec<u8>> {
let doc = self.doc(id)?;
unsafe { ely_watch_begin(self.mupdf.ctx) };
let answer = read(&self.mupdf, doc);
if unsafe { ely_watch_end(self.mupdf.ctx) } != 0 {
self.note_damage(id)?;
self.docs.get_mut(&id).expect("just read").found = true;
}
answer
}
/// Takes damage a read found; the caller tells it.
fn found(&mut self, id: u64) -> io::Result<bool> {
let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
Ok(std::mem::take(&mut doc.found))
}
/// A tile, and whether drawing it met damage.
pub(crate) fn drawn(&mut self, id: u64, page: usize, view: (f32, i32), tile: [i32; 4], dark: bool) -> io::Result<Vec<u8>> {
unsafe { ely_watch_begin(self.mupdf.ctx) };
@@ -90,7 +108,9 @@ impl crate::Worker {
if warned {
self.note_damage(id)?;
}
Ok(Pack::new().u64(u64::from(warned)).blob(&pixels?).bytes())
let pixels = pixels?;
let damaged = warned | self.found(id)?;
Ok(Pack::new().u64(u64::from(damaged)).blob(&pixels).bytes())
}
/// Every picture decoded before an overwrite; true if any failed.
@@ -99,6 +119,7 @@ impl crate::Worker {
if failed {
self.note_damage(id)?;
}
let failed = failed | self.found(id)?;
Ok(Pack::new().u64(u64::from(failed)).bytes())
}
@@ -153,6 +174,12 @@ impl crate::Worker {
pub(crate) fn output(&mut self, kind: u8, id: u64, ask: &mut Unpack) -> io::Result<Vec<u8>> {
let has = self.allowed(id)?;
let doc = self.record(id)?;
// What a read found is no whole export.
if matches!(kind, tag::EXPORT_IMAGE | tag::EXPORT_TEXT) && doc.found {
refuse(may::COPY, has)?;
let report = doc.repair.as_deref().expect("noted with the damage");
return Err(io::Error::other(format!("the document did not read whole: {report}")));
}
match kind {
tag::EXPORT_IMAGE => {
refuse(may::COPY, has)?;
+129
View File
@@ -148,6 +148,7 @@ fn a_broken_stream_under_a_sound_table_is_damage_and_fails_exports() {
let report = worker.repair_report(doc).expect("asks").expect("damage is reported");
assert!(!report.trim().is_empty());
assert!(worker.export_text(doc, &dir.path().join("doc.txt")).is_err(), "partial text is no export");
assert!(worker.export_image(doc, 0, 72.0, &dir.path().join("page.png")).is_err(), "nor a partial page");
worker.rotate_pages(doc, &[0], 90).expect("edits the copy");
doc = worker.now(doc).expect("the state");
worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves the copy");
@@ -186,3 +187,131 @@ fn a_broken_picture_shows_when_drawn_or_checked() {
assert!(other.check_pictures(again).expect("checks"), "the check finds it undrawn");
assert_eq!(std::fs::read(&path).expect("reads"), bytes);
}
/// As many Chinese files name their fonts.
const SIMSUN: &str = "<< /Type /Font /Subtype /Type0 /BaseFont /SimSun /Encoding /Identity-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType2 /BaseFont /SimSun /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> /FontDescriptor << /Type /FontDescriptor /FontName /SimSun /Flags 6 /FontBBox [0 -141 996 855] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 859 /StemV 91 >> >>] >>";
/// Two runs, each in its own unembedded font.
const TWO_RUNS: &str = "BT /F1 24 Tf 72 700 Td <00410042> Tj /F2 24 Tf <0043> Tj ET";
#[test]
fn an_unembedded_font_is_no_damage() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("doc.pdf");
// Two fonts, one notice: a repeat is no damage either.
let fonts = " /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >>";
let bytes = one_page("", fonts, TWO_RUNS, &[SIMSUN.to_string(), SIMSUN.to_string()]);
std::fs::write(&path, &bytes).expect("writes");
// Each on a fresh worker: no font loaded before.
let (mut worker, doc) = opened(&path);
worker.export_text(doc, &dir.path().join("doc.txt")).expect("the text exports");
let (mut worker, doc) = opened(&path);
worker.export_image(doc, 0, 72.0, &dir.path().join("page.png")).expect("the page exports");
let (mut worker, doc) = opened(&path);
// Read unwatched first: its repeat is pending.
worker.text(doc, 0).expect("reads");
let tile = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws");
assert!(!tile.damaged, "a substituted font draws whole");
assert_eq!(worker.repair_report(doc).expect("asks"), None);
}
#[test]
fn unreadable_content_still_draws_as_damage() {
let dir = tempfile::tempdir().expect("a temp dir");
// Text with no font; a stray bracket.
for content in ["BT 72 700 Td (Missing text) Tj ET", "BT ] ET"] {
let path = dir.path().join("doc.pdf");
std::fs::write(&path, one_page("", "", content, &[])).expect("writes");
let (mut worker, doc) = opened(&path);
let tile = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws");
assert!(tile.damaged, "{content} draws as damage");
}
}
/// Two pictures whose zlib checksums are wrong.
fn broken_pictures(dir: &Path) -> std::path::PathBuf {
let hex = format!("{}>", "ff0000".repeat(16));
// One stored deflate block, its checksum wrong.
let mut zlib = vec![0x78, 0x01, 0x01];
zlib.extend_from_slice(&(hex.len() as u16).to_le_bytes());
zlib.extend_from_slice(&(!(hex.len() as u16)).to_le_bytes());
zlib.extend_from_slice(hex.as_bytes());
zlib.extend_from_slice(&[0, 0, 0, 0]);
let slot = "#".repeat(zlib.len());
let image = format!("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter [/FlateDecode /ASCIIHexDecode] /Length {} >>
stream
{slot}
endstream", zlib.len());
let names = " /Resources << /XObject << /Im1 5 0 R /Im2 6 0 R >> >>";
let mut bytes = one_page("", names, "q 100 0 0 100 72 600 cm /Im1 Do Q q 100 0 0 100 72 400 cm /Im2 Do Q", &[image.clone(), image]);
for _ in 0..2 {
let at = bytes.windows(slot.len()).position(|w| w == slot.as_bytes()).expect("the slot");
bytes[at..at + zlib.len()].copy_from_slice(&zlib);
}
let path = dir.join("doc.pdf");
std::fs::write(&path, &bytes).expect("writes");
path
}
/// A picture whose colour key mask is invalid.
fn masked() -> String {
let hex = format!("{}>", "ff0000".repeat(16));
format!("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Mask [null null null null null null] /Filter /ASCIIHexDecode /Length {} >>
stream
{hex}
endstream", hex.len())
}
/// The bad mask, alone or drawn beside two font notices.
fn bad_mask(dir: &Path, fonts: Option<bool>) -> std::path::PathBuf {
let picture = "q 100 0 0 100 72 600 cm /Im1 Do Q";
let (resources, content, objects) = match fonts {
None => (String::new(), picture.to_string(), vec![masked()]),
Some(first) => (
" /Font << /F1 6 0 R /F2 7 0 R >>".to_string(),
if first { format!("{picture} {TWO_RUNS}") } else { format!("{TWO_RUNS} {picture}") },
vec![masked(), SIMSUN.to_string(), SIMSUN.to_string()],
),
};
let names = format!(" /Resources << /XObject << /Im1 5 0 R >>{resources} >>");
let path = dir.join(format!("mask-{fonts:?}.pdf"));
std::fs::write(&path, one_page("", &names, &content, &objects)).expect("writes");
path
}
#[test]
fn a_broken_picture_under_two_filters_fails_exports() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = broken_pictures(dir.path());
let (mut worker, doc) = opened(&path);
assert!(worker.export_text(doc, &dir.path().join("doc.txt")).is_err(), "its text read whole");
let (mut worker, doc) = opened(&path);
assert!(worker.export_image(doc, 0, 72.0, &dir.path().join("page.png")).is_err(), "its page read whole");
let (mut worker, doc) = opened(&path);
let tile = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws");
assert!(tile.damaged, "the picture draws as damage");
}
#[test]
fn a_broken_picture_read_before_is_still_damage() {
let dir = tempfile::tempdir().expect("a temp dir");
let files = [broken_pictures(dir.path()), bad_mask(dir.path(), None), bad_mask(dir.path(), Some(true)), bad_mask(dir.path(), Some(false))];
// Reading caches the pictures; their repeat waits.
let read: [fn(&mut Worker, Doc); 2] = [|w, d| drop(w.text(d, 0).expect("reads")), |w, d| drop(w.search(d, 0, "x").expect("searches"))];
for (path, first) in files.iter().flat_map(|p| read.map(|r| (p, r))) {
let (mut worker, doc) = opened(&path);
first(&mut worker, doc);
let tile = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws");
assert!(tile.damaged, "the picture draws as damage");
assert!(worker.repair_report(doc).expect("asks").is_some());
let (mut worker, doc) = opened(&path);
first(&mut worker, doc);
assert!(worker.export_image(doc, 0, 72.0, &dir.path().join("page.png")).is_err(), "its page read whole");
let (mut worker, doc) = opened(&path);
first(&mut worker, doc);
assert!(worker.export_text(doc, &dir.path().join("doc.txt")).is_err(), "its text read whole");
let (mut worker, doc) = opened(&path);
first(&mut worker, doc);
assert!(worker.check_pictures(doc).expect("checks"), "the check finds it");
}
}
+1 -1
View File
@@ -279,7 +279,7 @@
- [x] R7.1 合集安装包(§4,用户定):macOS 一个公证的 dmg 装六个 .app 与“应用程序”替身,用户拖入所要的;Windows 一个安装程序,组件页勾选六个应用,每个与单独安装程序装得一样;Linux 不做合集;单个应用的包照旧(`release.sh` 的 `dmg` 单个与合集共用,Mac mini 上以两个假应用核对卷内内容;`bundle.nsi` 逐个以 `/S` 运行所选的安装程序,某个退出码非零即停下并写明;`windows-bundle.sh` 三个假应用:都装上,第二个被拒时退出码 2、第三个不运行;真包随 R7.7 建出 — 第 1 轮:被拒的是最后一个,去掉 `Abort` 测试仍过;加第三个应用后去掉即失败;第 2 轮 PASS)
- [x] R7.2 设置窗口多出一行应用菜单(#6,§5):设置窗口沿用主窗口的外壳 `Shell`,Windows 与 Linux 上随之画出菜单栏与更新横幅;设置窗口不画这两者(`Shell::secondary`;测试量内容起点距标题栏底,五种变体各自失败 — 第 1 轮:只查标记容器;第 2 轮:主窗口两者合计;第 3 轮 PASS)
- [x] R7.3 装了宋体仍报缺少(#4,§7、§8):内核按 `宋体` 找到 SimSun,缺字检查却比较原名与所用字体的英文名,报为缺失;查明 Linux 上的版面是否真用 SimSun,一页变两页的根因(补丁 0048:缺字检查比较 `GetEnglishSearchFontName`;Windows 与 WSL 内核已重建;`navigate/tests.rs` 的 `DEJAVU SANS` 用例修前失败、修后通过,ElyDoc 单线程 55 项通过;Arch 容器装 0.1.5 包与 simsun.ttc:修前报缺宋体,正文实为 SimSun,一页;换上新内核不再报缺,仍一页。一页变两页未能复现,需报告者的文件 — 第 1 轮 PASS)
- [ ] R7.4 打开 PDF 总说已损坏(#5,§11):画图块时 MuPDF 的任何警告都算损坏,未嵌入的中日韩字体(“non-embedded font using identity encoding”)即触发;损坏只认结构修复、读不出的对象与流、画不出的图片与内容
- [x] R7.4 打开 PDF 总说已损坏(#5,§11):画图块时 MuPDF 的任何警告都算损坏,未嵌入的中日韩字体(“non-embedded font using identity encoding”)即触发;损坏只认结构修复、读不出的对象与流、画不出的图片与内容
- [ ] R7.5 ElyMark 打开的正文块变成一块深色框(§14,用户定):按下的段落保持原样,不加底色与内边距,不挪位置;代码块仍有自己的底板
- [ ] R7.6 Windows 与 Linux 不能打印(#3,§19.3):`print_pdf` 只有 macOS;Windows 用系统打印对话框,Linux 经 xdg 桌面门户的打印接口
- [ ] R7.7 发布 0.1.6:版本号,三端建包、签名公证、上传,合集包一并上传,本机装上
+5
View File
@@ -0,0 +1,5 @@
FAIL
1. crates/ely-pdf-engine/shim/ely_mupdf.c:253 — Tile rendering discards content errors while building the display list. The opening scan decodes streams but does not interpret their drawing commands. With a sound cross-reference table and content `BT 72 700 Td (Missing text) Tj ET`, MuPDF warns that it cannot draw the text, yet this change returns an entirely white tile with `damaged = false`, no repair report, and `check_pictures = false`. HEAD reports damage for the same file. Thus unreadable content loses its warning and protection against overwriting the original, contrary to R7.4 and PDF-14. `BT ] ET` also reproduces this regression; the new assertion in agents.md:314 that syntax errors only reach the error callback is false. Preserve content failures during display-list construction while excluding the harmless font notices specifically, add regressions for these cases, and correct that assertion. Windows comparison evidence: `.work/R7.4-r1-compare.log`.
2. crates/ely-pdf-engine/shim/ely_mupdf.c:250; crates/ely-pdf-engine/shim/ely_output.c:60 — The unmonitored export pass caches images as well as fonts, so a second pass cannot reliably detect damage. Reproduction: a 4×4 RGB image with `/Filter [/FlateDecode /ASCIIHexDecode]`, containing zlib-compressed `ff0000` repeated 16 times followed by `>`, with its four checksum bytes replaced by zeros. MuPDF reports `ignoring zlib error: incorrect data check` during the first pass and caches the image; both PNG and text exports then return success without a repair report. Both exports reject this same fixture on HEAD. This silently accepts a failed stream read, contrary to R7.4 and the retained C11.8 export requirement; the new content-stream checksum test does not cover cached image streams. Retain and classify failures from the first pass instead of assuming every warning there is font substitution, and add a regression using this image filter chain. Windows comparison evidence: `.work/R7.4-r1-compare.log`.
+3
View File
@@ -0,0 +1,3 @@
FAIL
1. crates/ely-pdf-engine/shim/ely_mupdf.c:86 — Flushing before installing the warning collector discards genuine damage found by an earlier unwatched text/search read. Reproduction: a PDF with a sound cross-reference table and two distinct 4×4 RGB image objects with `/Filter [/FlateDecode /ASCIIHexDecode]`, each containing zlib-compressed `ff0000` repeated 16 times followed by `>`, with its checksum replaced by four zero bytes. Call `text` or `search` before rendering: MuPDF caches the images and leaves a repeated checksum warning pending; the new flush discards it, so rendering returns `damaged = false` and no repair report. Text extraction followed by either PNG or text export also succeeds. HEAD reports damage and rejects those exports. A single RGB image with `/Mask [null null null null null null]` reproduces the same loss; after the text read, even `check_pictures` returns false, removing the overwrite protection required by PDF-14. Capture and classify warnings during the earlier reads and retain damage for the affected document, excluding only the harmless font notice and its repeats; add regressions that read/search damaged images before rendering, exporting, or checking them. Windows comparison evidence: `.work/R7.4-r2-compare.log`.
+3
View File
@@ -0,0 +1,3 @@
FAIL
1. crates/ely-pdf-engine/shim/ely_mupdf.c:89 — Seeding from only the last warning still loses genuine damage when an unwatched read reaches fonts after a damaged image. Reproduction: combine the new bad-mask fixture (4×4 RGB, `/Mask [null null null null null null]`, `/ASCIIHexDecode`, `ff0000` repeated 16 times followed by `>`) with the two separate unembedded SimSun fonts from `an_unembedded_font_is_no_damage`; draw the image before the two text runs. On fresh workers, call `text()` or `search()` before rendering, exporting, or checking pictures. The mask warning occurs six times, but the first font notice flushes those warnings outside the collector; only the font repeat remains pending. The new filter ignores that repeat, and the cached image raises no further warning: rendering returns `damaged = false`, the repair report is `None`, PNG and text exports succeed, and `check_pictures` returns false. HEAD detects damage and rejects both exports on this same fixture. This removes the damaged original's overwrite protection required by R7.4 and PDF-14. Capture and classify warnings during text/search reads and retain genuine damage for the document; add a regression combining the bad image and font notices in both drawing orders. Windows comparison and exact reproduction: `.work/R7.4-r3-compare.log`.