mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 06:28:49 -06:00
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:
co-authored by
Claude Sonnet 5.5
parent
29278b1186
commit
57a16aea17
+1
-1
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user