M2: Type 3 text in rendering mode 7 is not rendered and adds no clip (ISO 32000-2 9.3.6) (#4430)

ISO 32000-2 9.3.6: for a Type 3 font, modes 3 and 7 shall not be
rendered, the other modes render the glyph descriptions, and modes
4 to 7 add nothing to the clipping path. Vendored hayro-interpret
patch 42 drew every mode but 3 filled, so 7 Tr painted the glyphs
in the fill colour (black by default) wherever a following fill
did not cover them.

Patch 64: show_glyph keeps mode 7 for Type 3 glyphs and reports
them as clipping text (GlyphDrawMode::Clip, painting nothing, kept
by extraction), and clip_glyph's Type 3 arm is a deliberate no-op.
Modes 0-2 and 4-6 still render filled.


Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
mtlcl410
2026-10-24 10:34:36 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 9aef0dfb78
commit 6718f1f34e
7 changed files with 225 additions and 16 deletions
+12 -8
View File
@@ -8246,13 +8246,15 @@ trailer << /Root 1 0 R >>
}
}
/// ISO 32000-1 §9.3.6: only text rendering mode 3 has any effect on a Type 3 font (vendored
/// hayro-interpret patch in `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap
/// fonts, and the glyph procedure ran twice (fill, then stroke), so its anti-aliased edges
/// doubled up and the text looked bold; under `1 Tr` an uncoloured glyph took the stroking
/// colour, and under `7 Tr` it disappeared. Both glyph kinds have edges between pixels.
/// ISO 32000-2 §9.3.6: text in a Type 3 font is rendered from its glyph descriptions in every
/// text rendering mode but 3 and 7 (vendored hayro-interpret patches 42 and 64 in
/// `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap fonts, and the glyph
/// procedure ran twice (fill, then stroke), so its anti-aliased edges doubled up and the text
/// looked bold; under `1 Tr` an uncoloured glyph took the stroking colour. Modes 3 and 7
/// render nothing (`tests/it/type3_text_clip.rs` covers the clip). Both glyph kinds have
/// edges between pixels.
#[test]
fn text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled() {
fn rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled() {
let render = |glyph: &str, mode: u8| {
let content = format!("BT 1 0 0 RG 6 w 0 g /F1 20 Tf {mode} Tr 10.3 10.3 Td (A) Tj ET");
let pdf = format!(
@@ -8281,10 +8283,12 @@ trailer << /Root 1 0 R >>
for glyph in ["1 0 0 0 1 1 d1 0.12 0.12 0.71 0.71 re f", "1 0 d0 0.12 0.12 0.71 0.71 re f"] {
let filled = render(glyph, 0);
assert!(filled.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "{glyph}: the glyph paints");
for mode in [1, 2, 4, 5, 6, 7] {
for mode in [1, 2, 4, 5, 6] {
assert!(render(glyph, mode) == filled, "{glyph}: {mode} Tr draws as 0 Tr");
}
assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph");
for mode in [3, 7] {
assert!(render(glyph, mode).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: {mode} Tr hides the glyph");
}
}
}
+37
View File
@@ -2147,6 +2147,43 @@ mod tests {
}
}
/// ISO 32000-2 §9.3.6: Type 3 text under `7 Tr` is not rendered (vendored hayro-interpret
/// patch 64), but it is still clipping text, not an invisible text layer: the filter that
/// drops OCR layers keeps it, as it keeps clipping text in other fonts.
#[test]
fn clipping_mode_type3_text_is_kept_by_the_ocr_filter() {
let font = "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \
/CharProcs << /A 6 0 R >> /Encoding << /Type /Encoding /Differences [65 /A] >> \
/FirstChar 65 /LastChar 65 /Widths [1000] >>";
let glyph = "1000 0 100 0 900 800 d1 100 0 800 800 re f";
let content = "BT /T3 24 Tf 7 Tr 30 200 Td (AAA) Tj ET BT /T3 12 Tf 3 Tr 30 100 Td (A) Tj ET";
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /T3 5 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
font.to_string(),
format!("<< /Length {} >>\nstream\n{glyph}\nendstream", glyph.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
let all = extract_page_filtered(&pdf, 0, &InterpreterSettings::default(), TextFilter::default()).unwrap();
assert_eq!(all.plain_text().matches('A').count(), 4, "{:?}", all.plain_text());
let filtered = extract_page_filtered(&pdf, 0, &InterpreterSettings::default(), TextFilter { images: true, ..Default::default() }).unwrap();
assert_eq!(filtered.plain_text().trim(), "AAA", "the 7 Tr text stays, the 3 Tr layer goes");
}
/// One page per `(encoding, content)`, in Helvetica (`/F1`).
fn encoded_pages(pages: &[(lopdf::Object, &[u8])]) -> Pdf {
use lopdf::{Document, Object, Stream, dictionary};
+1
View File
@@ -6,4 +6,5 @@ mod jbig2_invariants;
mod named_form_properties;
mod search;
mod substitute_widths;
mod type3_text_clip;
mod tiling_cell_placement;
+163
View File
@@ -0,0 +1,163 @@
//! Contributor-original synthetic pages: text in a Type 3 font in the text rendering modes
//! (ISO 32000-2 §9.3.6, Table 104). For a Type 3 font, modes 3 and 7 render nothing, the other
//! modes render the glyph descriptions, and modes 4 to 7 add nothing to the clipping path
//! (vendored hayro-interpret patch 64).
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
use std::sync::Arc;
/// Glyph procedures in a 1000-unit glyph space (`/FontMatrix [0.001 0 0 0.001 0 0]`), named
/// by the letters that show them: the `/Differences` give them codes 65 (`A`) on, in order.
const GLYPHS: &[(&str, &[u8])] = &[
// A: a square, x 100..900, y 0..800.
("A", b"1000 0 100 0 900 800 d1 100 0 800 800 re f"),
// B: a triangle on the baseline with its apex at the top middle.
("B", b"1000 0 0 0 1000 1000 d1 0 0 m 1000 0 l 500 1000 l f"),
// C: a coloured glyph (`d0`): a blue square.
("C", b"1000 0 d0 0 0 1 rg 100 0 800 800 re f"),
// D: shows four of itself, nesting until the paint budget runs out.
("D", b"1000 0 0 0 1000 1000 d1 BT /T3 1000 Tf 0 0 Td (DDDD) Tj ET"),
];
fn pdf(content: &str) -> Vec<u8> {
let names: String = GLYPHS.iter().map(|(n, _)| format!("/{n} ")).collect();
let procs: String = GLYPHS.iter().enumerate().map(|(i, (n, _))| format!("/{n} {} 0 R ", 6 + i)).collect();
let widths = vec!["1000"; GLYPHS.len()].join(" ");
let mut 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 100 100] /Contents 4 0 R /Resources << /Font << /T3 5 0 R /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>".to_vec(),
stream(content.as_bytes()),
format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \
/CharProcs << {procs}>> /Encoding << /Type /Encoding /Differences [65 {names}] >> \
/FirstChar 65 /LastChar {} /Widths [{widths}] >>",
64 + GLYPHS.len()
)
.into_bytes(),
];
objects.extend(GLYPHS.iter().map(|(_, body)| stream(body)));
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(object);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
fn stream(body: &[u8]) -> Vec<u8> {
let mut s = format!("<< /Length {} >>\nstream\n", body.len()).into_bytes();
s.extend_from_slice(body);
s.extend_from_slice(b"\nendstream");
s
}
/// A 100 x 100 pixel rendering of a 100 x 100 pt page; `at(x, y)` reads the pixel at page
/// coordinates (y up).
struct Page(Vec<u8>);
impl Page {
fn render(content: &str) -> Self {
let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{content}: {:?}", page.error);
assert_eq!((page.width, page.height), (100, 100), "{content}");
Self(page.rgba.to_vec())
}
fn at(&self, x: usize, y: usize) -> [u8; 3] {
let i = ((99 - y) * 100 + x) * 4;
match self.0.get(i..i + 3) {
Some([r, g, b]) => [*r, *g, *b],
_ => panic!("pixel {x},{y} is off the page"),
}
}
/// Whether any pixel is dark in every channel: a glyph painted in the default black.
fn has_black(&self) -> bool {
self.0.as_chunks::<4>().0.iter().any(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)
}
}
const RED: [u8; 3] = [255, 0, 0];
const BLUE: [u8; 3] = [0, 0, 255];
const WHITE: [u8; 3] = [255, 255, 255];
/// `ABA` at 20 pt from (10, 40): squares x 12..28 and 52..68 (y 40..56), and a triangle from
/// (30, 40) and (50, 40) up to (40, 60).
const ABA: &str = "/T3 20 Tf 10 40 Td (ABA) Tj";
#[test]
fn clip_only_type3_text_is_not_rendered_and_leaves_no_clip() {
// The fill stops at x 60, inside the second square: a glyph painted under `7 Tr` would show
// past it as a bar beside the box.
let page = Page::render(&format!("BT 7 Tr {ABA} ET 1 0 0 rg 0 0 60 100 re f"));
assert_eq!(page.at(20, 48), RED, "the fill over the first square");
assert_eq!(page.at(5, 5), RED, "no clip: the fill covers its whole box");
assert_eq!(page.at(29, 45), RED, "no clip between the glyphs either");
assert_eq!(page.at(33, 57), RED, "no clip beside the triangle");
assert_eq!(page.at(64, 48), WHITE, "the second square past the fill is not rendered");
assert_eq!(page.at(80, 80), WHITE, "outside the fill");
assert!(!page.has_black(), "7 Tr renders no glyph");
// Alone, neither shape glyphs nor a coloured glyph leave a mark.
for text in [ABA, "/T3 20 Tf 10 40 Td (C) Tj"] {
let page = Page::render(&format!("BT 7 Tr {text} ET"));
assert!(page.0.as_chunks::<4>().0.iter().all(|p| p[..3] == WHITE), "7 Tr {text} renders nothing");
}
}
#[test]
fn invisible_type3_text_is_not_rendered() {
for text in [ABA, "/T3 20 Tf 10 40 Td (C) Tj"] {
let page = Page::render(&format!("BT 3 Tr {text} ET"));
assert!(page.0.as_chunks::<4>().0.iter().all(|p| p[..3] == WHITE), "3 Tr {text} renders nothing");
}
}
#[test]
fn other_modes_render_type3_glyphs_filled_and_never_clip() {
for mode in [0, 1, 2, 4, 5, 6] {
let page = Page::render(&format!("BT 0 0 1 rg 1 1 0 RG 6 w {mode} Tr {ABA} ET 1 0 0 rg 0 0 60 100 re f"));
assert_eq!(page.at(64, 48), BLUE, "{mode} Tr: the glyph is rendered, in the fill colour");
assert_eq!(page.at(70, 48), WHITE, "{mode} Tr: and not stroked");
assert_eq!(page.at(20, 48), RED, "{mode} Tr: the later fill over the glyph");
assert_eq!(page.at(5, 5), RED, "{mode} Tr: the later fill is not clipped");
assert_eq!(page.at(29, 45), RED, "{mode} Tr: not clipped between the glyphs");
assert!(!page.has_black(), "{mode} Tr");
}
// A coloured glyph keeps its own colour.
let page = Page::render("BT 1 0 0 rg 4 Tr /T3 20 Tf 10 40 Td (C) Tj ET");
assert_eq!(page.at(20, 48), BLUE);
}
#[test]
fn only_outline_glyphs_shape_a_clip_shared_with_type3_text() {
// `I` in Helvetica at 40 pt from (60, 10): its stem covers (65, 20). The square A from
// (10, 40) at 20 pt adds nothing to the clip and is not rendered.
let page = Page::render("BT 7 Tr /F1 40 Tf 60 10 Td (I) Tj /T3 20 Tf -50 30 Td (A) Tj ET 1 0 0 rg 0 0 100 100 re f");
assert_eq!(page.at(65, 20), RED, "inside the outline glyph's clip");
assert_eq!(page.at(20, 48), WHITE, "the Type 3 square neither clips nor paints");
assert_eq!(page.at(5, 5), WHITE, "outside the clip");
assert!(!page.has_black());
}
#[test]
fn self_nesting_type3_text_in_a_clip_mode_stays_bounded() {
// Every D shows four Ds: unbounded this is 4^n procedures. The paint nesting depth and the
// page's paint budget stop it; the page must still render.
for mode in [4, 6, 7] {
let content = format!("BT {mode} Tr /T3 20 Tf 10 40 Td (DD) Tj ET 1 0 0 rg 0 0 100 100 re f");
let page = Page::render(&content);
assert_eq!(page.at(5, 5), RED, "{content}: no clip");
assert!(!page.has_black(), "{content}");
}
}
+1 -1
View File
@@ -783,7 +783,7 @@ status = "partial"
commands = []
tools = []
evidence = []
notes = "hayro bootstrap with vendored patches"
notes = "hayro bootstrap with vendored patches. Text rendering modes follow ISO 32000-2 §9.3.6 for Type 3 fonts: 3 and 7 render nothing, the others render the glyph descriptions filled (never stroked), and 4 to 7 add nothing to the clipping path (hayro-interpret patches 42 and 64). Coverage is synthetic fixtures; no fidelity comparison across a Type 3 corpus yet"
[[feature]]
id = "view.render-cid-fonts"
+1 -1
View File
File diff suppressed because one or more lines are too long
+10 -6
View File
@@ -69,11 +69,15 @@ pub(crate) fn show_glyph<'a>(
device.set_blend_mode(ctx.get().graphics_state.blend_mode);
let stroke_props = ctx.stroke_props();
// PdfCraft patch: only mode 3 has any effect on text in a Type 3 font (ISO 32000-1
// 9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts
// look bold, and the clip modes have no outline to clip with.
// PdfCraft patch: text in a Type 3 font is rendered from its glyph descriptions in every mode
// but 3 and 7 (ISO 32000-2 §9.3.6). Stroking ran the glyph procedure a second time, so
// `2 Tr` made bitmap fonts look bold.
// PdfCraft patch (64): in modes 3 and 7 the text "shall not be rendered" (§9.3.6); `7 Tr`
// painted the glyphs in the fill colour. It is still reported as clipping text, but in
// modes 4 to 7 "nothing shall be added to the clipping path" (`clip_glyph`).
let render_mode = match (glyph, ctx.get().text_state.render_mode) {
(Glyph::Type3(_), TextRenderingMode::Invisible) => TextRenderingMode::Invisible,
(Glyph::Type3(_), TextRenderingMode::Clip) => TextRenderingMode::Clip,
(Glyph::Type3(_), _) => TextRenderingMode::Fill,
(_, mode) => mode,
};
@@ -187,9 +191,9 @@ pub(crate) fn clip_glyph(context: &mut Context<'_>, glyph: &Glyph<'_>, transform
context.get_mut().text_state.clip_paths.extend(outline);
}
}
Glyph::Type3(_) => {
warn!("text rendering mode clip not implemented for shape glyphs");
}
// PdfCraft patch (64): a Type 3 glyph adds nothing to the clipping path in modes 4 to 7
// (ISO 32000-2 §9.3.6), so text shown only in a Type 3 font leaves no clip at `ET`.
Glyph::Type3(_) => {}
}
}