M2: review of #1567: draw installed CJK fonts on screen only

FredApps: AGENTS.md §1.4 allows reading an installed font at runtime only as
the interface's last fallback and says nothing from it may enter a PDF or
package, while the renderer fallback also served exports and printing.

RenderConfig gains system_fonts, off by default. The engine sets it only on the
pool that draws the viewer (display_config); exports, printing, OCR and the
automation tools render with the document's own configuration, so nothing drawn
with an installed font is saved or printed. Tests: the render of a non-embedded
CNS1, GB1 and Korea1 font is blank without the flag and drawn with it; the
viewer's pool draws such a page and an export of it does not (it fails if the
flag is on everywhere). No Adobe-named font can be a candidate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Philip
2026-10-21 20:17:27 +01:00
co-authored by Claude Sonnet 5.5
parent 29278b1186
commit 57a16aea17
5 changed files with 95 additions and 18 deletions
+1 -1
View File
@@ -214,7 +214,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
- **2026-10-12 (M2, installed CJK fonts):** A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF doesn't embed, such as the Kozuka Gothic of an Acrobat text box or the SongtiTC of a Pages export, drew nothing unless craft-fonts had a Japanese face. After craft-fonts, it now draws with a face already installed on the machine (PingFang, Heiti, Microsoft JhengHei / YaHei, Malgun and the like; read at runtime, never bundled; Noto CJK and Source Han excluded; `PDFCRAFT_SYSTEM_FONTS=0` turns it off). The face of a collection with several regions (PingFang SC, TC) is chosen by region. Checked on macOS with a synthetic Traditional Chinese, Simplified Chinese and Korean page; the Windows and Linux font lists are untested, and a machine with none of the listed faces still draws nothing.
- **2026-10-12 (M2, installed CJK fonts):** A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF doesn't embed, such as the Kozuka Gothic of an Acrobat text box or the SongtiTC of a Pages export, drew nothing unless craft-fonts had a Japanese face. After craft-fonts, it now draws with a face already installed on the machine (PingFang, Heiti, Microsoft JhengHei / YaHei, Malgun and the like; read at runtime, never bundled, and used only for the on-screen viewer, never for exports, printing or OCR; Noto CJK and Source Han excluded; `PDFCRAFT_SYSTEM_FONTS=0` turns it off). The face of a collection with several regions (PingFang SC, TC) is chosen by region. Checked on macOS with a synthetic Traditional Chinese, Simplified Chinese and Korean page; the Windows and Linux font lists are untested, and a machine with none of the listed faces still draws nothing.
- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged.
- **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes.
+17 -9
View File
@@ -1009,11 +1009,19 @@ fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
cos.set(root_ref, Object::Dict(root));
}
/// The configuration of the pool that draws the viewer: the document's own, plus installed CJK
/// fonts for fonts a PDF does not embed (`RenderConfig::system_fonts`). Exports, printing, OCR and
/// the automation tools render with the document's configuration itself, so nothing drawn with an
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
fn display_config(config: &RenderConfig) -> RenderConfig {
RenderConfig { system_fonts: true, ..config.clone() }
}
/// Render `doc` with the layer visibility in its `info.layers`.
fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
doc.config.layers = Arc::new(overrides);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
@@ -3580,7 +3588,7 @@ impl Session {
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
let display = display_bytes(editor.as_mut(), &bytes);
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config));
let (form, adopted) = editor
.as_ref()
.map(|e| {
@@ -3962,7 +3970,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
Ok(())
}
@@ -4007,7 +4015,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
doc.fonts_stale = true;
Ok(())
@@ -4032,7 +4040,7 @@ impl Session {
/// After a refresh failed: draw the display bytes it left (the original, once released),
/// so the page still shows a document.
fn show_released(doc: &mut Document) {
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// Rebuild working bytes, inspection and renderer from the current edit state.
@@ -4082,7 +4090,7 @@ impl Session {
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
Ok(())
}
@@ -4795,7 +4803,7 @@ impl Session {
return false;
}
doc.config.hide_comments = hide;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.generation += 1;
true
}
@@ -4808,7 +4816,7 @@ impl Session {
return false;
}
doc.config.reading_colors = colors;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.generation += 1;
true
}
@@ -4844,7 +4852,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &doc.bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
}
+49
View File
@@ -385,6 +385,55 @@ fn edits_update_view_data_and_mark_dirty() {
assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed");
}
/// A font of an Adobe CJK collection that a PDF does not embed is drawn with an installed face on
/// the viewer's pool only. An export of the same page, which can be saved, has nothing drawn with
/// an installed font (AGENTS.md §1.2, §1.4). Skipped where no such face is installed.
#[test]
fn installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export() {
let content = "BT /F1 40 Tf 5 15 Td <7E419AD45B57> Tj ET";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /MSung-Light /Encoding /UniCNS-UCS2-H /DescendantFonts [6 0 R] >> endobj
6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /MSung-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (CNS1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >> endobj
7 0 obj << /Type /FontDescriptor /FontName /MSung-Light /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut s = Session::new();
let id = s.open("cns1.pdf", None, Arc::new(pdf.into_bytes()), None).unwrap();
let doc = s.get(id).unwrap();
doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest {
page: 0,
kind: pdfcraft_render::RequestKind::Pixels,
scale: 1.0,
..Default::default()
}]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let viewer = loop {
if let Some(page) = doc.renderer.try_recv() {
assert!(page.error.is_none(), "{:?}", page.error);
break page;
}
assert!(std::time::Instant::now() < deadline, "page render did not finish");
std::thread::yield_now();
};
let inked = |rgba: &[u8]| rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
if inked(&viewer.rgba) < 300 {
eprintln!("no installed Traditional Chinese face (or PDFCRAFT_SYSTEM_FONTS=0): skipped");
return;
}
let png = export::Exporter::new(doc).png(0, 72.0).unwrap();
let exported = image::load_from_memory(&png).unwrap().into_rgba8();
assert!(inked(exported.as_raw()) < 50, "an export draws nothing with an installed font ({} dark pixels)", inked(exported.as_raw()));
}
#[test]
fn metadata_edit_keeps_the_renderer_without_changing_page_content() {
let (mut s, id) = session_with(1);
+22 -6
View File
@@ -65,15 +65,23 @@ pub struct RenderConfig {
/// View ▸ Reading colours: solid text and line-art paints are remapped for readability and
/// the page clears to the setting's background. Display only; the document never changes.
pub reading_colors: ReadingColors,
/// Draw a CID font of an Adobe CJK collection that the PDF does not embed with one face already
/// installed on this machine (see `system_font`). Off by default and on only for the pool that
/// draws the viewer: exports, printing and OCR render without it, so nothing drawn with an
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
pub system_fonts: bool,
}
impl RenderConfig {
fn settings(&self) -> InterpreterSettings {
let standard = InterpreterSettings::default().font_resolver;
let system_fonts = self.system_fonts;
InterpreterSettings {
ocg_overrides: self.layers.clone(),
hide_comments: self.hide_comments,
font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| system_fallback(query)).or_else(|| standard(query))),
font_resolver: Arc::new(move |query| {
japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query))
}),
..InterpreterSettings::default()
}
}
@@ -6751,12 +6759,20 @@ trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{base_font}: {:?}", p.error);
let inked = p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
let pdf = Arc::new(pdf.into_bytes());
let ink = |config: RenderConfig| {
let mut r = PageRenderer::new(pdf.clone(), config);
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{base_font}: {:?}", p.error);
p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count()
};
// Two to four glyphs at 40 pt cover several hundred dark pixels; a blank run has none.
assert!(inked > 300, "{base_font}: the text is drawn ({inked} dark pixels)");
let shown = ink(RenderConfig { system_fonts: true, ..RenderConfig::default() });
assert!(shown > 300, "{base_font}: the text is drawn ({shown} dark pixels)");
// Off by default: exports, printing and OCR render without an installed font, so
// nothing drawn with one can be saved (AGENTS.md §1.2, §1.4).
let saved = ink(RenderConfig::default());
assert!(saved < 50, "{base_font}: not drawn without system_fonts ({saved} dark pixels)");
}
}
+6 -2
View File
@@ -5,7 +5,8 @@
//! `MSung-Light` without embedding the font. hayro maps those CIDs to Unicode itself, but it needs
//! a face to draw them with; without one the text is blank. The craft-fonts Japanese faces come
//! first (see `japanese_fallback` in `raster.rs`); this module is the last resort: one face
//! already installed on this machine. It is read at runtime and never embedded or shipped
//! already installed on this machine, used for the viewer only (`RenderConfig::system_fonts`, off
//! for exports, printing and OCR). It is read at runtime and never embedded or shipped
//! (AGENTS.md §1.4), Noto CJK and Source Han are left out on purpose (§1.1), and
//! `PDFCRAFT_SYSTEM_FONTS=0` turns it off, as for the interface font.
@@ -282,7 +283,10 @@ mod tests {
assert!(c.path.is_absolute(), "{}", c.path.display());
assert!(c.path.extension().is_some_and(|e| e == "ttf" || e == "ttc"), "{}", c.path.display());
let name = c.path.to_string_lossy().to_lowercase();
assert!(!["notosanscjk", "notoserifcjk", "sourcehan", "source-han"].iter().any(|b| name.contains(b)), "{name}");
assert!(
!["notosanscjk", "notoserifcjk", "sourcehan", "source-han", "adobe", "kozuka", "minion"].iter().any(|b| name.contains(b)),
"{name}"
);
}
}
}